From 0b420450532eae7339df9ce529ae65eae7310dfa Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 13:54:58 -0600 Subject: [PATCH] Auto-deduplicate agent shortname on join request approval When approving an agent join request with a shortname already in use, append a numeric suffix (e.g. "openclaw-2") instead of returning an error. Co-Authored-By: Claude Opus 4.6 --- .../agent-shortname-collision.test.ts | 34 +- server/src/routes/access.ts | 2019 ++++++++++------- server/src/services/agents.ts | 16 + server/src/services/index.ts | 2 +- 4 files changed, 1305 insertions(+), 766 deletions(-) diff --git a/server/src/__tests__/agent-shortname-collision.test.ts b/server/src/__tests__/agent-shortname-collision.test.ts index 69ef427a..396152cb 100644 --- a/server/src/__tests__/agent-shortname-collision.test.ts +++ b/server/src/__tests__/agent-shortname-collision.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { hasAgentShortnameCollision } from "../services/agents.ts"; +import { hasAgentShortnameCollision, deduplicateAgentName } from "../services/agents.ts"; describe("hasAgentShortnameCollision", () => { it("detects collisions by normalized shortname", () => { @@ -35,3 +35,35 @@ describe("hasAgentShortnameCollision", () => { expect(collision).toBe(false); }); }); + +describe("deduplicateAgentName", () => { + it("returns original name when no collision", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "other-agent", status: "idle" }, + ]); + expect(name).toBe("OpenClaw"); + }); + + it("appends suffix when name collides", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "openclaw", status: "idle" }, + ]); + expect(name).toBe("OpenClaw 2"); + }); + + it("increments suffix until unique", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "openclaw", status: "idle" }, + { id: "a2", name: "openclaw-2", status: "idle" }, + { id: "a3", name: "openclaw-3", status: "idle" }, + ]); + expect(name).toBe("OpenClaw 4"); + }); + + it("ignores terminated agents for collision", () => { + const name = deduplicateAgentName("OpenClaw", [ + { id: "a1", name: "openclaw", status: "terminated" }, + ]); + expect(name).toBe("OpenClaw"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index f6fd1664..3fba5208 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -10,7 +10,7 @@ import { agentApiKeys, authUsers, invites, - joinRequests, + joinRequests } from "@paperclipai/db"; import { acceptInviteSchema, @@ -19,15 +19,30 @@ import { listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, - PERMISSION_KEYS, + PERMISSION_KEYS } from "@paperclipai/shared"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; -import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js"; +import { + forbidden, + conflict, + notFound, + unauthorized, + badRequest +} from "../errors.js"; import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; -import { accessService, agentService, logActivity, notifyHireApproved } from "../services/index.js"; +import { + accessService, + agentService, + deduplicateAgentName, + logActivity, + notifyHireApproved +} from "../services/index.js"; import { assertCompanyAccess } from "./authz.js"; -import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; +import { + claimBoardOwnership, + inspectBoardClaimChallenge +} from "../board-claim.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -59,25 +74,30 @@ export function companyInviteExpiresAt(nowMs: number = Date.now()) { function tokenHashesMatch(left: string, right: string) { const leftBytes = Buffer.from(left, "utf8"); const rightBytes = Buffer.from(right, "utf8"); - return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); + return ( + leftBytes.length === rightBytes.length && + timingSafeEqual(leftBytes, rightBytes) + ); } function requestBaseUrl(req: Request) { const forwardedProto = req.header("x-forwarded-proto"); const proto = forwardedProto?.split(",")[0]?.trim() || req.protocol || "http"; - const host = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.header("host"); + const host = + req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.header("host"); if (!host) return ""; return `${proto}://${host}`; } function readSkillMarkdown(skillName: string): string | null { const normalized = skillName.trim().toLowerCase(); - if (normalized !== "paperclip" && normalized !== "paperclip-create-agent") return null; + if (normalized !== "paperclip" && normalized !== "paperclip-create-agent") + return null; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ - path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> /skills/ - path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root) - path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md"), // dev: src/routes/ -> repo root/skills/ + path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> /skills/ + path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root) + path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md") // dev: src/routes/ -> repo root/skills/ ]; for (const skillPath of candidates) { try { @@ -121,7 +141,9 @@ function normalizeHostname(value: string | null | undefined): string | null { if (!trimmed) return null; if (trimmed.startsWith("[")) { const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); + return end > 1 + ? trimmed.slice(1, end).toLowerCase() + : trimmed.toLowerCase(); } const firstColon = trimmed.indexOf(":"); if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); @@ -130,7 +152,7 @@ function normalizeHostname(value: string | null | undefined): string | null { function normalizeHeaderValue( value: unknown, - depth: number = 0, + depth: number = 0 ): string | null { const direct = nonEmptyTrimmedString(value); if (direct) return direct; @@ -152,11 +174,14 @@ function normalizeHeaderValue( "header", "raw", "text", - "string", + "string" ]; for (const key of candidateKeys) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; - const normalized = normalizeHeaderValue((value as Record)[key], depth + 1); + const normalized = normalizeHeaderValue( + (value as Record)[key], + depth + 1 + ); if (normalized) return normalized; } @@ -202,13 +227,16 @@ function extractHeaderEntries(input: unknown): Array<[string, unknown]> { nonEmptyTrimmedString(mapped.name) ?? nonEmptyTrimmedString(mapped.header); if (explicitKey) { - const explicitValue = Object.prototype.hasOwnProperty.call(mapped, "value") + const explicitValue = Object.prototype.hasOwnProperty.call( + mapped, + "value" + ) ? mapped.value : Object.prototype.hasOwnProperty.call(mapped, "token") - ? mapped.token - : Object.prototype.hasOwnProperty.call(mapped, "secret") - ? mapped.secret - : mapped; + ? mapped.token + : Object.prototype.hasOwnProperty.call(mapped, "secret") + ? mapped.secret + : mapped; entries.push([explicitKey, explicitValue]); continue; } @@ -222,7 +250,9 @@ function extractHeaderEntries(input: unknown): Array<[string, unknown]> { return entries; } -function normalizeHeaderMap(input: unknown): Record | undefined { +function normalizeHeaderMap( + input: unknown +): Record | undefined { const entries = extractHeaderEntries(input); if (entries.length === 0) return undefined; @@ -244,14 +274,24 @@ function nonEmptyTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } -function headerMapHasKeyIgnoreCase(headers: Record, targetKey: string): boolean { +function headerMapHasKeyIgnoreCase( + headers: Record, + targetKey: string +): boolean { const normalizedTarget = targetKey.trim().toLowerCase(); - return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget); + return Object.keys(headers).some( + (key) => key.trim().toLowerCase() === normalizedTarget + ); } -function headerMapGetIgnoreCase(headers: Record, targetKey: string): string | null { +function headerMapGetIgnoreCase( + headers: Record, + targetKey: string +): string | null { const normalizedTarget = targetKey.trim().toLowerCase(); - const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget); + const key = Object.keys(headers).find( + (candidate) => candidate.trim().toLowerCase() === normalizedTarget + ); if (!key) return null; const value = headers[key]; return typeof value === "string" ? value : null; @@ -279,7 +319,7 @@ export function buildJoinDefaultsPayloadForAccept(input: { const merged = isPlainObject(input.defaultsPayload) ? { ...(input.defaultsPayload as Record) } - : {} as Record; + : ({} as Record); if (!nonEmptyTrimmedString(merged.url)) { const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl); @@ -297,12 +337,17 @@ export function buildJoinDefaultsPayloadForAccept(input: { } if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) { - const providedWebhookAuthHeader = nonEmptyTrimmedString(input.webhookAuthHeader); - if (providedWebhookAuthHeader) merged.webhookAuthHeader = providedWebhookAuthHeader; + const providedWebhookAuthHeader = nonEmptyTrimmedString( + input.webhookAuthHeader + ); + if (providedWebhookAuthHeader) + merged.webhookAuthHeader = providedWebhookAuthHeader; } const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; - const compatibilityHeaders = normalizeHeaderMap(input.responsesWebhookHeaders); + const compatibilityHeaders = normalizeHeaderMap( + input.responsesWebhookHeaders + ); if (compatibilityHeaders) { for (const [key, value] of Object.entries(compatibilityHeaders)) { if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) { @@ -311,8 +356,13 @@ export function buildJoinDefaultsPayloadForAccept(input: { } } - const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader); - if (inboundOpenClawAuthHeader && !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) { + const inboundOpenClawAuthHeader = nonEmptyTrimmedString( + input.inboundOpenClawAuthHeader + ); + if ( + inboundOpenClawAuthHeader && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth") + ) { mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader; } @@ -322,10 +372,18 @@ export function buildJoinDefaultsPayloadForAccept(input: { delete merged.headers; } - const hasAuthorizationHeader = headerMapHasKeyIgnoreCase(mergedHeaders, "authorization"); - const hasWebhookAuthHeader = Boolean(nonEmptyTrimmedString(merged.webhookAuthHeader)); + const hasAuthorizationHeader = headerMapHasKeyIgnoreCase( + mergedHeaders, + "authorization" + ); + const hasWebhookAuthHeader = Boolean( + nonEmptyTrimmedString(merged.webhookAuthHeader) + ); if (!hasAuthorizationHeader && !hasWebhookAuthHeader) { - const openClawAuthToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth"); + const openClawAuthToken = headerMapGetIgnoreCase( + mergedHeaders, + "x-openclaw-auth" + ); if (openClawAuthToken) { merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken); } @@ -334,8 +392,14 @@ 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)) { +export function mergeJoinDefaultsPayloadForReplay( + existingDefaultsPayload: unknown, + nextDefaultsPayload: unknown +): unknown { + if ( + !isPlainObject(existingDefaultsPayload) && + !isPlainObject(nextDefaultsPayload) + ) { return nextDefaultsPayload ?? existingDefaultsPayload; } if (!isPlainObject(existingDefaultsPayload)) { @@ -347,15 +411,19 @@ export function mergeJoinDefaultsPayloadForReplay(existingDefaultsPayload: unkno const merged: Record = { ...(existingDefaultsPayload as Record), - ...(nextDefaultsPayload as Record), + ...(nextDefaultsPayload as Record) }; - const existingHeaders = normalizeHeaderMap((existingDefaultsPayload as Record).headers); - const nextHeaders = normalizeHeaderMap((nextDefaultsPayload as Record).headers); + const existingHeaders = normalizeHeaderMap( + (existingDefaultsPayload as Record).headers + ); + const nextHeaders = normalizeHeaderMap( + (nextDefaultsPayload as Record).headers + ); if (existingHeaders || nextHeaders) { merged.headers = { ...(existingHeaders ?? {}), - ...(nextHeaders ?? {}), + ...(nextHeaders ?? {}) }; } else if (Object.prototype.hasOwnProperty.call(merged, "headers")) { delete merged.headers; @@ -367,7 +435,10 @@ export function mergeJoinDefaultsPayloadForReplay(existingDefaultsPayload: unkno export function canReplayOpenClawInviteAccept(input: { requestType: "human" | "agent"; adapterType: string | null; - existingJoinRequest: Pick | null; + existingJoinRequest: Pick< + typeof joinRequests.$inferSelect, + "requestType" | "adapterType" | "status" + > | null; }): boolean { if (input.requestType !== "agent" || input.adapterType !== "openclaw") { return false; @@ -375,27 +446,39 @@ export function canReplayOpenClawInviteAccept(input: { if (!input.existingJoinRequest) { return false; } - if (input.existingJoinRequest.requestType !== "agent" || input.existingJoinRequest.adapterType !== "openclaw") { + if ( + input.existingJoinRequest.requestType !== "agent" || + input.existingJoinRequest.adapterType !== "openclaw" + ) { return false; } - return input.existingJoinRequest.status === "pending_approval" || input.existingJoinRequest.status === "approved"; + return ( + input.existingJoinRequest.status === "pending_approval" || + input.existingJoinRequest.status === "approved" + ); } -function summarizeSecretForLog(value: unknown): { present: true; length: number; sha256Prefix: string } | null { +function summarizeSecretForLog( + value: unknown +): { present: true; length: number; sha256Prefix: string } | null { const trimmed = nonEmptyTrimmedString(value); if (!trimmed) return null; return { present: true, length: trimmed.length, - sha256Prefix: hashToken(trimmed).slice(0, 12), + sha256Prefix: hashToken(trimmed).slice(0, 12) }; } function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { - const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record) : null; + const defaults = isPlainObject(defaultsPayload) + ? (defaultsPayload as Record) + : null; const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; const openClawAuthHeaderValue = headers - ? Object.entries(headers).find(([key]) => key.trim().toLowerCase() === "x-openclaw-auth")?.[1] ?? null + ? Object.entries(headers).find( + ([key]) => key.trim().toLowerCase() === "x-openclaw-auth" + )?.[1] ?? null : null; return { @@ -403,10 +486,14 @@ function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { keys: defaults ? Object.keys(defaults).sort() : [], url: defaults ? nonEmptyTrimmedString(defaults.url) : null, method: defaults ? nonEmptyTrimmedString(defaults.method) : null, - paperclipApiUrl: defaults ? nonEmptyTrimmedString(defaults.paperclipApiUrl) : null, + paperclipApiUrl: defaults + ? nonEmptyTrimmedString(defaults.paperclipApiUrl) + : null, headerKeys: headers ? Object.keys(headers).sort() : [], - webhookAuthHeader: defaults ? summarizeSecretForLog(defaults.webhookAuthHeader) : null, - openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue), + webhookAuthHeader: defaults + ? summarizeSecretForLog(defaults.webhookAuthHeader) + : null, + openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue) }; } @@ -419,26 +506,32 @@ function buildJoinConnectivityDiagnostics(input: { }): JoinDiagnostic[] { const diagnostics: JoinDiagnostic[] = []; const bindHost = normalizeHostname(input.bindHost); - const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null; + const callbackHost = input.callbackUrl + ? normalizeHostname(input.callbackUrl.hostname) + : null; const allowSet = new Set( input.allowedHostnames .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), + .filter((entry): entry is string => Boolean(entry)) ); diagnostics.push({ code: "openclaw_deployment_context", level: "info", - message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`, + message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.` }); - if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") { + if ( + input.deploymentMode === "authenticated" && + input.deploymentExposure === "private" + ) { if (!bindHost || isLoopbackHost(bindHost)) { diagnostics.push({ code: "openclaw_private_bind_loopback", level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.", + message: + "Paperclip is bound to loopback in authenticated/private mode.", + hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks." }); } if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { @@ -446,15 +539,16 @@ function buildJoinConnectivityDiagnostics(input: { code: "openclaw_private_bind_not_allowed", level: "warn", message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`, + hint: `Run pnpm paperclipai allowed-hostname ${bindHost}` }); } if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) { diagnostics.push({ code: "openclaw_private_allowed_hostnames_empty", level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs off-host.", + message: + "No explicit allowed hostnames are configured for authenticated/private mode.", + hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs off-host." }); } } @@ -469,7 +563,7 @@ function buildJoinConnectivityDiagnostics(input: { code: "openclaw_public_http_callback", level: "warn", message: "OpenClaw callback URL uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments.", + hint: "Prefer HTTPS for public deployments." }); } @@ -496,8 +590,9 @@ function normalizeAgentDefaultsForJoin(input: { diagnostics.push({ code: "openclaw_callback_config_missing", level: "warn", - message: "No OpenClaw callback config was provided in agentDefaultsPayload.", - hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw SSE endpoint immediately after approval.", + message: + "No OpenClaw callback config was provided in agentDefaultsPayload.", + hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw SSE endpoint immediately after approval." }); return { normalized: null as Record | null, diagnostics }; } @@ -512,32 +607,36 @@ function normalizeAgentDefaultsForJoin(input: { code: "openclaw_callback_url_missing", level: "warn", message: "OpenClaw callback URL is missing.", - hint: "Set agentDefaultsPayload.url to your OpenClaw SSE endpoint.", + hint: "Set agentDefaultsPayload.url to your OpenClaw SSE endpoint." }); } else { try { callbackUrl = new URL(rawUrl); - if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") { + if ( + callbackUrl.protocol !== "http:" && + callbackUrl.protocol !== "https:" + ) { diagnostics.push({ code: "openclaw_callback_url_protocol", level: "warn", message: `Unsupported callback protocol: ${callbackUrl.protocol}`, - hint: "Use http:// or https://.", + hint: "Use http:// or https://." }); } else { normalized.url = callbackUrl.toString(); diagnostics.push({ code: "openclaw_callback_url_configured", level: "info", - message: `Callback endpoint set to ${callbackUrl.toString()}`, + message: `Callback endpoint set to ${callbackUrl.toString()}` }); } if (isWakePath(callbackUrl.pathname)) { diagnostics.push({ code: "openclaw_callback_wake_path_incompatible", level: "warn", - message: "Configured callback path targets /hooks/wake, which is not stream-capable for strict SSE mode.", - hint: "Use an endpoint that returns text/event-stream for the full run duration.", + message: + "Configured callback path targets /hooks/wake, which is not stream-capable for strict SSE mode.", + hint: "Use an endpoint that returns text/event-stream for the full run duration." }); } if (isLoopbackHost(callbackUrl.hostname)) { @@ -545,45 +644,59 @@ function normalizeAgentDefaultsForJoin(input: { code: "openclaw_callback_loopback", level: "warn", message: "OpenClaw callback endpoint uses loopback hostname.", - hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.", + hint: "Use a reachable hostname/IP when OpenClaw runs on another machine." }); } } catch { diagnostics.push({ code: "openclaw_callback_url_invalid", level: "warn", - message: `Invalid callback URL: ${rawUrl}`, + message: `Invalid callback URL: ${rawUrl}` }); } } - const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : ""; + const rawMethod = + typeof defaults.method === "string" + ? defaults.method.trim().toUpperCase() + : ""; normalized.method = rawMethod || "POST"; - if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) { - normalized.timeoutSec = Math.max(0, Math.min(7200, Math.floor(defaults.timeoutSec))); + if ( + typeof defaults.timeoutSec === "number" && + Number.isFinite(defaults.timeoutSec) + ) { + normalized.timeoutSec = Math.max( + 0, + Math.min(7200, Math.floor(defaults.timeoutSec)) + ); } const headers = normalizeHeaderMap(defaults.headers); if (headers) normalized.headers = headers; - if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) { + if ( + typeof defaults.webhookAuthHeader === "string" && + defaults.webhookAuthHeader.trim() + ) { normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim(); } - const openClawAuthHeader = headers ? headerMapGetIgnoreCase(headers, "x-openclaw-auth") : null; + const openClawAuthHeader = headers + ? headerMapGetIgnoreCase(headers, "x-openclaw-auth") + : null; if (openClawAuthHeader) { diagnostics.push({ code: "openclaw_auth_header_configured", level: "info", - message: "Gateway auth token received via headers.x-openclaw-auth.", + message: "Gateway auth token received via headers.x-openclaw-auth." }); } else { diagnostics.push({ code: "openclaw_auth_header_missing", level: "warn", message: "Gateway auth token is missing from agent defaults.", - hint: "Set agentDefaultsPayload.headers.x-openclaw-auth to the token your OpenClaw /v1/responses endpoint requires.", + hint: "Set agentDefaultsPayload.headers.x-openclaw-auth to the token your OpenClaw /v1/responses endpoint requires." }); } @@ -591,24 +704,28 @@ function normalizeAgentDefaultsForJoin(input: { normalized.payloadTemplate = defaults.payloadTemplate; } - const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string" - ? defaults.paperclipApiUrl.trim() - : ""; + const rawPaperclipApiUrl = + typeof defaults.paperclipApiUrl === "string" + ? defaults.paperclipApiUrl.trim() + : ""; if (rawPaperclipApiUrl) { try { const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl); - if (parsedPaperclipApiUrl.protocol !== "http:" && parsedPaperclipApiUrl.protocol !== "https:") { + if ( + parsedPaperclipApiUrl.protocol !== "http:" && + parsedPaperclipApiUrl.protocol !== "https:" + ) { diagnostics.push({ code: "openclaw_paperclip_api_url_protocol", level: "warn", - message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`, + message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).` }); } else { normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString(); diagnostics.push({ code: "openclaw_paperclip_api_url_configured", level: "info", - message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`, + message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}` }); if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) { diagnostics.push({ @@ -616,7 +733,7 @@ function normalizeAgentDefaultsForJoin(input: { level: "warn", message: "paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments.", + hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments." }); } } @@ -624,7 +741,7 @@ function normalizeAgentDefaultsForJoin(input: { diagnostics.push({ code: "openclaw_paperclip_api_url_invalid", level: "warn", - message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`, + message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}` }); } } @@ -635,14 +752,18 @@ function normalizeAgentDefaultsForJoin(input: { deploymentExposure: input.deploymentExposure, bindHost: input.bindHost, allowedHostnames: input.allowedHostnames, - callbackUrl, - }), + callbackUrl + }) ); return { normalized, diagnostics }; } -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 onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; @@ -656,10 +777,14 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv onboardingPath, onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath, onboardingTextPath, - onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, + onboardingTextUrl: baseUrl + ? `${baseUrl}${onboardingTextPath}` + : onboardingTextPath, skillIndexPath: "/api/skills/index", - skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index", - inviteMessage, + skillIndexUrl: baseUrl + ? `${baseUrl}/api/skills/index` + : "/api/skills/index", + inviteMessage }; } @@ -684,7 +809,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { const allowSet = new Set( input.allowedHostnames .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), + .filter((entry): entry is string => Boolean(entry)) ); if (apiHost && isLoopbackHost(apiHost)) { @@ -693,7 +818,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { 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).", + hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain)." }); } @@ -706,7 +831,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { 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.", + hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding." }); } @@ -722,7 +847,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { 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}`, + hint: `Run pnpm paperclipai allowed-hostname ${apiHost}` }); } @@ -778,28 +903,34 @@ function buildInviteOnboardingManifest( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, + } ) { const baseUrl = requestBaseUrl(req); const skillPath = "/api/skills/paperclip"; const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath; 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 onboardingTextUrl = baseUrl + ? `${baseUrl}${onboardingTextPath}` + : onboardingTextPath; const testResolutionPath = `/api/invites/${token}/test-resolution`; - const testResolutionUrl = baseUrl ? `${baseUrl}${testResolutionPath}` : testResolutionPath; + const testResolutionUrl = baseUrl + ? `${baseUrl}${testResolutionPath}` + : testResolutionPath; const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({ apiBaseUrl: baseUrl, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, - allowedHostnames: opts.allowedHostnames, + allowedHostnames: opts.allowedHostnames }); const connectionCandidates = buildOnboardingConnectionCandidates({ apiBaseUrl: baseUrl, bindHost: opts.bindHost, - allowedHostnames: opts.allowedHostnames, + allowedHostnames: opts.allowedHostnames }); return { @@ -815,19 +946,20 @@ function buildInviteOnboardingManifest( adapterType: "Use 'openclaw' for OpenClaw streaming agents", capabilities: "Optional capability summary", agentDefaultsPayload: - "Adapter config for OpenClaw SSE endpoint. MUST include headers.x-openclaw-auth; also include url/method/paperclipApiUrl (and optional webhookAuthHeader/timeoutSec/payloadTemplate).", + "Adapter config for OpenClaw SSE endpoint. MUST include headers.x-openclaw-auth; also include url/method/paperclipApiUrl (and optional webhookAuthHeader/timeoutSec/payloadTemplate)." }, registrationEndpoint: { method: "POST", path: registrationEndpointPath, - url: registrationEndpointUrl, + url: registrationEndpointUrl }, claimEndpointTemplate: { method: "POST", path: "/api/join-requests/{requestId}/claim-api-key", body: { - claimSecret: "one-time claim secret returned when the join request is created", - }, + claimSecret: + "one-time claim secret returned when the join request is created" + } }, connectivity: { deploymentMode: opts.deploymentMode, @@ -841,27 +973,28 @@ function buildInviteOnboardingManifest( url: testResolutionUrl, query: { url: "https://your-openclaw-agent.example/v1/responses", - timeoutMs: 5000, - }, + timeoutMs: 5000 + } }, diagnostics: discoveryDiagnostics, 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 `." - : "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", + contentType: "text/plain" }, skill: { name: "paperclip", path: skillPath, url: skillUrl, - installPath: "~/.openclaw/skills/paperclip/SKILL.md", - }, - }, + installPath: "~/.openclaw/skills/paperclip/SKILL.md" + } + } }; } @@ -874,7 +1007,7 @@ export function buildInviteOnboardingTextDocument( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, + } ) { const manifest = buildInviteOnboardingManifest(req, token, invite, opts); const onboarding = manifest.onboarding as { @@ -894,129 +1027,213 @@ export function buildInviteOnboardingTextDocument( ? 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()}`, - "", - ]; + const lines: string[] = []; + const appendBlock = (block: string) => { + const trimmed = block.replace(/^\n/, "").replace(/\n\s*$/, ""); + const lineIndentation = trimmed + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^(\s*)/)?.[0].length ?? 0); + const minIndent = + lineIndentation.length > 0 ? Math.min(...lineIndentation) : 0; + for (const line of trimmed.split("\n")) { + lines.push(line.slice(minIndent)); + } + }; + + appendBlock(` + # 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()} + `); if (onboarding.inviteMessage) { - lines.push("## Message from inviter", onboarding.inviteMessage, ""); + appendBlock(` + ## Message from inviter + ${onboarding.inviteMessage} + `); } - lines.push( - "## Step 1: Submit agent join request", - `${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`, - "", - "IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-auth with your gateway token.", - "Without this token, Paperclip callback requests to your OpenClaw endpoint will fail with 401 Unauthorized.", - "", - "Body (JSON):", - "{", - ' "requestType": "agent",', - ' "agentName": "My OpenClaw Agent",', - ' "adapterType": "openclaw",', - ' "capabilities": "Optional summary",', - ' "agentDefaultsPayload": {', - ' "url": "https://your-openclaw-agent.example/v1/responses",', - ' "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",', - ' "streamTransport": "sse",', - ' "method": "POST",', - ' "headers": { "x-openclaw-auth": "replace-me" },', - ' "timeoutSec": 0', - " }", - "}", - "", - "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": ""', - "}", - "", - "On successful claim, save the full JSON response to:", - "- ~/.openclaw/workspace/paperclip-claimed-api-key.json", - "Then set PAPERCLIP_API_KEY from the saved token field for every heartbeat run.", - "", - "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.", - ); + appendBlock(` + ## Step 0 + + Get the OpenClaw gateway auth token (THIS MUST BE SENT) + Token lives in: + + ~/.openclaw/openclaw.json -> gateway.auth.token + Extract: + + TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')" + test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1) + + 3) IMPORTANT: Don't accidentally drop the token when generating JSON + If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var. + + Safe payload build looks sort of like this (substitute where necessary): + + BODY="$(node -e ' + const token = process.argv[1]; + if (!token) process.exit(2); + const body = { + requestType: "agent", + agentName: "OpenClaw", + adapterType: "openclaw", + capabilities: "OpenClaw agent adapter", + agentDefaultsPayload: { + url: "http://127.0.0.1:18789/v1/responses", + paperclipApiUrl: "http://host.docker.internal:3100", + streamTransport: "sse", + method: "POST", + headers: { "x-openclaw-auth": token }, + timeoutSec: 0 + } + }; + process.stdout.write(JSON.stringify(body)); + ' "$TOKEN")" + + ## Step 1: Submit agent join request + ${onboarding.registrationEndpoint.method} ${ + onboarding.registrationEndpoint.url + } + + IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-auth with your gateway token. + Without this token, Paperclip callback requests to your OpenClaw endpoint will fail with 401 Unauthorized. + + Body (JSON): + { + "requestType": "agent", + "agentName": "My OpenClaw Agent", + "adapterType": "openclaw", + "capabilities": "Optional summary", + "agentDefaultsPayload": { + "url": "https://your-openclaw-agent.example/v1/responses", + "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", + "streamTransport": "sse", + "method": "POST", + "headers": { "x-openclaw-auth": "replace-me" }, + "timeoutSec": 0 + } + } + + Expected response includes: + - request id + - one-time claimSecret + - claimApiKeyPath + + Verify the response diagnostics include: + 'openclaw_auth_header_configured' + and do not include: + 'openclaw_auth_header_missing' + + ## 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": "" + } + + On successful claim, save the full JSON response to: + + - ~/.openclaw/workspace/paperclip-claimed-api-key.json + chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json + + And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here: + https://docs.openclaw.ai/help/environment + + e.g. + + { + env: { + PAPERCLIP_API_KEY: "...", + PAPERCLIP_API_URL: "...", + }, + } + + Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run. + + 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 (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-agent.example%2Fv1%2Fresponses`, - "", - "This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable.", - ); + appendBlock(` + ## Optional: test callback resolution from Paperclip + ${onboarding.connectivity.testResolutionEndpoint.method ?? "GET"} ${ + onboarding.connectivity.testResolutionEndpoint.url + }?url=https%3A%2F%2Fyour-openclaw-agent.example%2Fv1%2Fresponses + + This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable. + `); } - const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates) - ? onboarding.connectivity.connectionCandidates.filter((entry): entry is string => Boolean(entry)) + const connectionCandidates = Array.isArray( + onboarding.connectivity?.connectionCandidates + ) + ? onboarding.connectivity.connectionCandidates.filter( + (entry): entry is string => Boolean(entry) + ) : []; if (connectionCandidates.length > 0) { - lines.push("", "## Suggested Paperclip base URLs to try"); + lines.push("## Suggested Paperclip base URLs to try"); for (const candidate of connectionCandidates) { lines.push(`- ${candidate}`); } - lines.push( - "", - "Test each candidate with:", - "- GET /api/health", - "- set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request", - "", - "If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.", - "For authenticated/private mode, they may need:", - "- pnpm paperclipai allowed-hostname ", - "- then restart Paperclip and retry onboarding.", - ); + appendBlock(` + + Test each candidate with: + - GET /api/health + - set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request + + If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration. + For authenticated/private mode, they may need: + - pnpm paperclipai allowed-hostname + - then restart Paperclip and retry onboarding. + `); } if (diagnostics.length > 0) { - lines.push("", "## Connectivity diagnostics"); + 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, - ); + appendBlock(` + + ## Helpful endpoints + ${onboarding.registrationEndpoint.path} + ${onboarding.claimEndpointTemplate.path} + ${onboarding.skill.path} + ${manifest.invite.onboardingPath} + `); if (onboarding.connectivity?.testResolutionEndpoint?.path) { lines.push(`${onboarding.connectivity.testResolutionEndpoint.path}`); } @@ -1024,9 +1241,15 @@ export function buildInviteOnboardingTextDocument( return `${lines.join("\n")}\n`; } -function extractInviteMessage(invite: typeof invites.$inferSelect): string | null { +function extractInviteMessage( + invite: typeof invites.$inferSelect +): string | null { const rawDefaults = invite.defaultsPayload; - if (!rawDefaults || typeof rawDefaults !== "object" || Array.isArray(rawDefaults)) { + if ( + !rawDefaults || + typeof rawDefaults !== "object" || + Array.isArray(rawDefaults) + ) { return null; } const rawMessage = (rawDefaults as Record).agentMessage; @@ -1039,11 +1262,12 @@ function extractInviteMessage(invite: typeof invites.$inferSelect): string | nul function mergeInviteDefaults( defaultsPayload: Record | null | undefined, - agentMessage: string | null, + agentMessage: string | null ): Record | null { - const merged = defaultsPayload && typeof defaultsPayload === "object" - ? { ...defaultsPayload } - : {}; + const merged = + defaultsPayload && typeof defaultsPayload === "object" + ? { ...defaultsPayload } + : {}; if (agentMessage) { merged.agentMessage = agentMessage; } @@ -1081,8 +1305,11 @@ async function resolveActorEmail(db: Db, req: Request): Promise { function grantsFromDefaults( defaultsPayload: Record | null | undefined, - key: "human" | "agent", -): Array<{ permissionKey: (typeof PERMISSION_KEYS)[number]; scope: Record | null }> { + key: "human" | "agent" +): Array<{ + permissionKey: (typeof PERMISSION_KEYS)[number]; + scope: Record | null; +}> { if (!defaultsPayload || typeof defaultsPayload !== "object") return []; const scoped = defaultsPayload[key]; if (!scoped || typeof scoped !== "object") return []; @@ -1101,9 +1328,11 @@ function grantsFromDefaults( result.push({ permissionKey: record.permissionKey as (typeof PERMISSION_KEYS)[number], scope: - record.scope && typeof record.scope === "object" && !Array.isArray(record.scope) + record.scope && + typeof record.scope === "object" && + !Array.isArray(record.scope) ? (record.scope as Record) - : null, + : null }); } return result; @@ -1116,26 +1345,37 @@ type JoinRequestManagerCandidate = { }; export function resolveJoinRequestAgentManagerId( - candidates: JoinRequestManagerCandidate[], + candidates: JoinRequestManagerCandidate[] ): string | null { - const ceoCandidates = candidates.filter((candidate) => candidate.role === "ceo"); + const ceoCandidates = candidates.filter( + (candidate) => candidate.role === "ceo" + ); if (ceoCandidates.length === 0) return null; - const rootCeo = ceoCandidates.find((candidate) => candidate.reportsTo === null); + const rootCeo = ceoCandidates.find( + (candidate) => candidate.reportsTo === null + ); return (rootCeo ?? ceoCandidates[0] ?? null)?.id ?? null; } function isInviteTokenHashCollisionError(error: unknown) { const candidates = [ error, - (error as { cause?: unknown } | null)?.cause ?? null, + (error as { cause?: unknown } | null)?.cause ?? null ]; for (const candidate of candidates) { if (!candidate || typeof candidate !== "object") continue; - const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null; - const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : ""; - const constraint = "constraint" in candidate && typeof candidate.constraint === "string" - ? candidate.constraint - : null; + const code = + "code" in candidate && typeof candidate.code === "string" + ? candidate.code + : null; + const message = + "message" in candidate && typeof candidate.message === "string" + ? candidate.message + : ""; + const constraint = + "constraint" in candidate && typeof candidate.constraint === "string" + ? candidate.constraint + : null; if (code !== "23505") continue; if (constraint === "invites_token_hash_unique_idx") return true; if (message.includes("invites_token_hash_unique_idx")) return true; @@ -1155,7 +1395,10 @@ type InviteResolutionProbe = { message: string; }; -async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise { +async function probeInviteResolutionTarget( + url: URL, + timeoutMs: number +): Promise { const startedAt = Date.now(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -1163,7 +1406,7 @@ async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise const response = await fetch(url, { method: "HEAD", redirect: "manual", - signal: controller.signal, + signal: controller.signal }); const durationMs = Date.now() - startedAt; if ( @@ -1181,7 +1424,7 @@ async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise method: "HEAD", durationMs, httpStatus: response.status, - message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`, + message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.` }; } return { @@ -1189,7 +1432,7 @@ async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise method: "HEAD", durationMs, httpStatus: response.status, - message: `Webhook endpoint probe returned HTTP ${response.status}.`, + message: `Webhook endpoint probe returned HTTP ${response.status}.` }; } catch (error) { const durationMs = Date.now() - startedAt; @@ -1199,7 +1442,7 @@ async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise method: "HEAD", durationMs, httpStatus: null, - message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`, + message: `Webhook endpoint probe timed out after ${timeoutMs}ms.` }; } return { @@ -1207,7 +1450,10 @@ async function probeInviteResolutionTarget(url: URL, timeoutMs: number): Promise method: "HEAD", durationMs, httpStatus: null, - message: error instanceof Error ? error.message : "Webhook endpoint probe failed.", + message: + error instanceof Error + ? error.message + : "Webhook endpoint probe failed." }; } finally { clearTimeout(timeout); @@ -1221,7 +1467,7 @@ export function accessRoutes( deploymentExposure: DeploymentExposure; bindHost: string; allowedHostnames: string[]; - }, + } ) { const router = Router(); const access = accessService(db); @@ -1236,49 +1482,76 @@ export function accessRoutes( router.get("/board-claim/:token", async (req, res) => { const token = (req.params.token as string).trim(); - const code = typeof req.query.code === "string" ? req.query.code.trim() : undefined; + const code = + typeof req.query.code === "string" ? req.query.code.trim() : undefined; if (!token) throw notFound("Board claim challenge not found"); const challenge = inspectBoardClaimChallenge(token, code); - if (challenge.status === "invalid") throw notFound("Board claim challenge not found"); + if (challenge.status === "invalid") + throw notFound("Board claim challenge not found"); res.json(challenge); }); router.post("/board-claim/:token/claim", async (req, res) => { const token = (req.params.token as string).trim(); - const code = typeof req.body?.code === "string" ? req.body.code.trim() : undefined; + const code = + typeof req.body?.code === "string" ? req.body.code.trim() : undefined; if (!token) throw notFound("Board claim challenge not found"); if (!code) throw badRequest("Claim code is required"); - if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId) { + if ( + req.actor.type !== "board" || + req.actor.source !== "session" || + !req.actor.userId + ) { throw unauthorized("Sign in before claiming board ownership"); } const claimed = await claimBoardOwnership(db, { token, code, - userId: req.actor.userId, + userId: req.actor.userId }); - if (claimed.status === "invalid") throw notFound("Board claim challenge not found"); - if (claimed.status === "expired") throw conflict("Board claim challenge expired. Restart server to generate a new one."); + if (claimed.status === "invalid") + throw notFound("Board claim challenge not found"); + if (claimed.status === "expired") + throw conflict( + "Board claim challenge expired. Restart server to generate a new one." + ); if (claimed.status === "claimed") { - res.json({ claimed: true, userId: claimed.claimedByUserId ?? req.actor.userId }); + res.json({ + claimed: true, + userId: claimed.claimedByUserId ?? req.actor.userId + }); return; } throw conflict("Board claim challenge is no longer available"); }); - async function assertCompanyPermission(req: Request, companyId: string, permissionKey: any) { + async function assertCompanyPermission( + req: Request, + companyId: string, + permissionKey: any + ) { assertCompanyAccess(req, companyId); if (req.actor.type === "agent") { if (!req.actor.agentId) throw forbidden(); - const allowed = await access.hasPermission(companyId, "agent", req.actor.agentId, permissionKey); + const allowed = await access.hasPermission( + companyId, + "agent", + req.actor.agentId, + permissionKey + ); if (!allowed) throw forbidden("Permission denied"); return; } if (req.actor.type !== "board") throw unauthorized(); if (isLocalImplicit(req)) return; - const allowed = await access.canUser(companyId, req.actor.userId, permissionKey); + const allowed = await access.canUser( + companyId, + req.actor.userId, + permissionKey + ); if (!allowed) throw forbidden("Permission denied"); } @@ -1286,8 +1559,11 @@ export function accessRoutes( res.json({ skills: [ { name: "paperclip", path: "/api/skills/paperclip" }, - { name: "paperclip-create-agent", path: "/api/skills/paperclip-create-agent" }, - ], + { + name: "paperclip-create-agent", + path: "/api/skills/paperclip-create-agent" + } + ] }); }); @@ -1304,16 +1580,20 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - const normalizedAgentMessage = typeof req.body.agentMessage === "string" - ? req.body.agentMessage.trim() || null - : null; + const normalizedAgentMessage = + typeof req.body.agentMessage === "string" + ? req.body.agentMessage.trim() || null + : null; const insertValues = { companyId, inviteType: "company_join" as const, allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage), + defaultsPayload: mergeInviteDefaults( + req.body.defaultsPayload ?? null, + normalizedAgentMessage + ), expiresAt: companyInviteExpiresAt(), - invitedByUserId: req.actor.userId ?? null, + invitedByUserId: req.actor.userId ?? null }; let token: string | null = null; @@ -1325,7 +1605,7 @@ export function accessRoutes( .insert(invites) .values({ ...insertValues, - tokenHash: hashToken(candidateToken), + tokenHash: hashToken(candidateToken) }) .returning() .then((rows) => rows[0]); @@ -1339,13 +1619,18 @@ export function accessRoutes( } } if (!token || !created) { - throw conflict("Failed to generate a unique invite token. Please retry."); + throw conflict( + "Failed to generate a unique invite token. Please retry." + ); } await logActivity(db, { companyId, actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", action: "invite.created", entityType: "invite", entityId: created.id, @@ -1353,8 +1638,8 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), - hasAgentMessage: Boolean(normalizedAgentMessage), - }, + hasAgentMessage: Boolean(normalizedAgentMessage) + } }); const inviteSummary = toInviteSummaryResponse(req, token, created); @@ -1364,9 +1649,9 @@ export function accessRoutes( inviteUrl: `/invite/${token}`, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, - inviteMessage: inviteSummary.inviteMessage, + inviteMessage: inviteSummary.inviteMessage }); - }, + } ); router.get("/invites/:token", async (req, res) => { @@ -1377,7 +1662,12 @@ 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 || + invite.acceptedAt || + inviteExpired(invite) + ) { throw notFound("Invite not found"); } @@ -1411,7 +1701,9 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.type("text/plain; charset=utf-8").send(buildInviteOnboardingTextDocument(req, token, invite, opts)); + res + .type("text/plain; charset=utf-8") + .send(buildInviteOnboardingTextDocument(req, token, invite, opts)); }); router.get("/invites/:token/test-resolution", async (req, res) => { @@ -1426,7 +1718,8 @@ export function accessRoutes( throw notFound("Invite not found"); } - const rawUrl = typeof req.query.url === "string" ? req.query.url.trim() : ""; + const rawUrl = + typeof req.query.url === "string" ? req.query.url.trim() : ""; if (!rawUrl) throw badRequest("url query parameter is required"); let target: URL; try { @@ -1438,7 +1731,10 @@ export function accessRoutes( throw badRequest("url must use http or https"); } - const parsedTimeoutMs = typeof req.query.timeoutMs === "string" ? Number(req.query.timeoutMs) : NaN; + 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; @@ -1448,334 +1744,437 @@ export function accessRoutes( testResolutionPath: `/api/invites/${token}/test-resolution`, requestedUrl: target.toString(), timeoutMs, - ...probe, + ...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"); + 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"); - 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 inviteAlreadyAccepted = Boolean(invite.acceptedAt); - const existingJoinRequestForInvite = inviteAlreadyAccepted - ? await db + const invite = 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"); + .from(invites) + .where(eq(invites.tokenHash, hashToken(token))) + .then((rows) => rows[0] ?? null); + if (!invite || invite.revokedAt || inviteExpired(invite)) { + throw notFound("Invite not found"); } - if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) { - throw unauthorized("Authenticated user required for bootstrap acceptance"); - } - const userId = req.actor.userId ?? "local-board"; - const existingAdmin = await access.isInstanceAdmin(userId); - if (!existingAdmin) { - await access.promoteInstanceAdmin(userId); - } - const updatedInvite = await db - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where(eq(invites.id, invite.id)) - .returning() - .then((rows) => rows[0] ?? invite); - res.status(202).json({ - inviteId: updatedInvite.id, - inviteType: updatedInvite.inviteType, - bootstrapAccepted: true, - userId, - }); - return; - } + 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; - const requestType = req.body.requestType as "human" | "agent"; - const companyId = invite.companyId; - if (!companyId) throw conflict("Invite is missing company scope"); - if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) { - throw badRequest(`Invite does not allow ${requestType} joins`); - } - - if (requestType === "human" && req.actor.type !== "board") { - throw unauthorized("Human invite acceptance requires authenticated user"); - } - if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) { - throw unauthorized("Authenticated user is required"); - } - if (requestType === "agent" && !req.body.agentName) { - 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, - defaultsPayload: replayMergedDefaults, - responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, - responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, - responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, - paperclipApiUrl: req.body.paperclipApiUrl ?? null, - webhookAuthHeader: req.body.webhookAuthHeader ?? null, - inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, - }) - : null; - - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - requestType, - adapterType, - bodyKeys: isPlainObject(req.body) ? Object.keys(req.body).sort() : [], - responsesWebhookUrl: nonEmptyTrimmedString(req.body.responsesWebhookUrl), - paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), - webhookAuthHeader: summarizeSecretForLog(req.body.webhookAuthHeader), - inboundOpenClawAuthHeader: summarizeSecretForLog(req.header("x-openclaw-auth") ?? null), - rawAgentDefaults: summarizeOpenClawDefaultsForLog(req.body.agentDefaultsPayload ?? null), - mergedAgentDefaults: summarizeOpenClawDefaultsForLog(openClawDefaultsPayload), - }, - "invite accept received OpenClaw join payload", - ); - } - - const joinDefaults = requestType === "agent" - ? normalizeAgentDefaultsForJoin({ - adapterType, - defaultsPayload: openClawDefaultsPayload, - deploymentMode: opts.deploymentMode, - deploymentExposure: opts.deploymentExposure, - bindHost: opts.bindHost, - allowedHostnames: opts.allowedHostnames, - }) - : { normalized: null as Record | null, diagnostics: [] as JoinDiagnostic[] }; - - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({ - code: diag.code, - level: diag.level, - })), - normalizedAgentDefaults: summarizeOpenClawDefaultsForLog(joinDefaults.normalized), - }, - "invite accept normalized OpenClaw defaults", - ); - } - - 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 = !inviteAlreadyAccepted - ? await db.transaction(async (tx) => { - await tx + 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"); + } + if ( + req.actor.type !== "board" || + (!req.actor.userId && !isLocalImplicit(req)) + ) { + throw unauthorized( + "Authenticated user required for bootstrap acceptance" + ); + } + const userId = req.actor.userId ?? "local-board"; + const existingAdmin = await access.isInstanceAdmin(userId); + if (!existingAdmin) { + await access.promoteInstanceAdmin(userId); + } + const updatedInvite = await db .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", - 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, - }) + .where(eq(invites.id, invite.id)) .returning() - .then((rows) => rows[0]); - return row; - }) - : await db - .update(joinRequests) - .set({ - requestIp: requestIp(req), - 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, - updatedAt: new Date(), + .then((rows) => rows[0] ?? invite); + res.status(202).json({ + inviteId: updatedInvite.id, + inviteType: updatedInvite.inviteType, + bootstrapAccepted: true, + userId + }); + return; + } + + const requestType = req.body.requestType as "human" | "agent"; + const companyId = invite.companyId; + if (!companyId) throw conflict("Invite is missing company scope"); + if ( + invite.allowedJoinTypes !== "both" && + invite.allowedJoinTypes !== requestType + ) { + throw badRequest(`Invite does not allow ${requestType} joins`); + } + + if (requestType === "human" && req.actor.type !== "board") { + throw unauthorized( + "Human invite acceptance requires authenticated user" + ); + } + if ( + requestType === "human" && + !req.actor.userId && + !isLocalImplicit(req) + ) { + throw unauthorized("Authenticated user is required"); + } + if (requestType === "agent" && !req.body.agentName) { + 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 }) - .where(eq(joinRequests.id, replayJoinRequestId as string)) - .returning() - .then((rows) => rows[0]); - - 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"); + ) { + throw notFound("Invite 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"); + 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, + defaultsPayload: replayMergedDefaults, + responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, + responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, + responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, + paperclipApiUrl: req.body.paperclipApiUrl ?? null, + webhookAuthHeader: req.body.webhookAuthHeader ?? null, + inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null + }) + : null; + + if (requestType === "agent" && adapterType === "openclaw") { + logger.info( + { + inviteId: invite.id, + requestType, + adapterType, + bodyKeys: isPlainObject(req.body) + ? Object.keys(req.body).sort() + : [], + responsesWebhookUrl: nonEmptyTrimmedString( + req.body.responsesWebhookUrl + ), + paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), + webhookAuthHeader: summarizeSecretForLog( + req.body.webhookAuthHeader + ), + inboundOpenClawAuthHeader: summarizeSecretForLog( + req.header("x-openclaw-auth") ?? null + ), + rawAgentDefaults: summarizeOpenClawDefaultsForLog( + req.body.agentDefaultsPayload ?? null + ), + mergedAgentDefaults: summarizeOpenClawDefaultsForLog( + openClawDefaultsPayload + ) + }, + "invite accept received OpenClaw join payload" + ); + } + + const joinDefaults = + requestType === "agent" + ? normalizeAgentDefaultsForJoin({ + adapterType, + defaultsPayload: openClawDefaultsPayload, + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames + }) + : { + normalized: null as Record | null, + diagnostics: [] as JoinDiagnostic[] + }; + + if (requestType === "agent" && adapterType === "openclaw") { + logger.info( + { + inviteId: invite.id, + joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({ + code: diag.code, + level: diag.level + })), + normalizedAgentDefaults: summarizeOpenClawDefaultsForLog( + joinDefaults.normalized + ) + }, + "invite accept normalized OpenClaw defaults" + ); + } + + 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 = !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", + 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), + 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, + updatedAt: new Date() + }) + .where(eq(joinRequests.id, replayJoinRequestId as string)) + .returning() + .then((rows) => rows[0]); + + 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[] = []; + + if (expectedDefaults.url && !persistedDefaults.url) + missingPersistedFields.push("url"); + if ( + expectedDefaults.paperclipApiUrl && + !persistedDefaults.paperclipApiUrl + ) { + missingPersistedFields.push("paperclipApiUrl"); + } + if ( + expectedDefaults.webhookAuthHeader && + !persistedDefaults.webhookAuthHeader + ) { + missingPersistedFields.push("webhookAuthHeader"); + } + if ( + expectedDefaults.openClawAuthHeader && + !persistedDefaults.openClawAuthHeader + ) { + missingPersistedFields.push("headers.x-openclaw-auth"); + } + if ( + expectedDefaults.headerKeys.length > 0 && + persistedDefaults.headerKeys.length === 0 + ) { + missingPersistedFields.push("headers"); + } + + logger.info( + { + inviteId: invite.id, + joinRequestId: created.id, + joinRequestStatus: created.status, + expectedDefaults, + persistedDefaults, + diagnostics: joinDefaults.diagnostics.map((diag) => ({ + code: diag.code, + level: diag.level, + message: diag.message, + hint: diag.hint ?? null + })) + }, + "invite accept persisted OpenClaw join request" + ); + + if (missingPersistedFields.length > 0) { + logger.warn( + { + inviteId: invite.id, + joinRequestId: created.id, + missingPersistedFields + }, + "invite accept detected missing persisted OpenClaw defaults" + ); + } + } + 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 }, + : req.actor.userId ?? + (requestType === "agent" ? "invite-anon" : "board"), + action: inviteAlreadyAccepted + ? "join.request_replayed" + : "join.requested", + entityType: "join_request", + entityId: created.id, + details: { + requestType, + requestIp: created.requestIp, + inviteReplay: inviteAlreadyAccepted + } }); - } - if (requestType === "agent" && adapterType === "openclaw") { - const expectedDefaults = summarizeOpenClawDefaultsForLog(joinDefaults.normalized); - const persistedDefaults = summarizeOpenClawDefaultsForLog(created.agentDefaultsPayload); - const missingPersistedFields: string[] = []; - - if (expectedDefaults.url && !persistedDefaults.url) missingPersistedFields.push("url"); - if (expectedDefaults.paperclipApiUrl && !persistedDefaults.paperclipApiUrl) { - missingPersistedFields.push("paperclipApiUrl"); - } - if (expectedDefaults.webhookAuthHeader && !persistedDefaults.webhookAuthHeader) { - missingPersistedFields.push("webhookAuthHeader"); - } - if (expectedDefaults.openClawAuthHeader && !persistedDefaults.openClawAuthHeader) { - missingPersistedFields.push("headers.x-openclaw-auth"); - } - if (expectedDefaults.headerKeys.length > 0 && persistedDefaults.headerKeys.length === 0) { - missingPersistedFields.push("headers"); - } - - logger.info( - { - inviteId: invite.id, - joinRequestId: created.id, - joinRequestStatus: created.status, - expectedDefaults, - persistedDefaults, - diagnostics: joinDefaults.diagnostics.map((diag) => ({ - code: diag.code, - level: diag.level, - message: diag.message, - hint: diag.hint ?? null, - })), - }, - "invite accept persisted OpenClaw join request", - ); - - if (missingPersistedFields.length > 0) { - logger.warn( - { - inviteId: invite.id, - joinRequestId: created.id, - missingPersistedFields, - }, - "invite accept detected missing persisted OpenClaw defaults", + const response = toJoinRequestResponse(created); + if (claimSecret) { + const onboardingManifest = buildInviteOnboardingManifest( + req, + token, + invite, + opts ); + res.status(202).json({ + ...response, + claimSecret, + claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`, + onboarding: onboardingManifest.onboarding, + diagnostics: joinDefaults.diagnostics + }); + return; } - } - - await logActivity(db, { - companyId, - actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: - req.actor.type === "agent" - ? req.actor.agentId ?? "invite-agent" - : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"), - action: inviteAlreadyAccepted ? "join.request_replayed" : "join.requested", - entityType: "join_request", - entityId: created.id, - details: { requestType, requestIp: created.requestIp, inviteReplay: inviteAlreadyAccepted }, - }); - - const response = toJoinRequestResponse(created); - if (claimSecret) { - const onboardingManifest = buildInviteOnboardingManifest(req, token, invite, opts); res.status(202).json({ ...response, - claimSecret, - claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`, - onboarding: onboardingManifest.onboarding, - diagnostics: joinDefaults.diagnostics, + ...(joinDefaults.diagnostics.length > 0 + ? { diagnostics: joinDefaults.diagnostics } + : {}) }); - return; } - res.status(202).json({ - ...response, - ...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}), - }); - }); + ); router.post("/invites/:inviteId/revoke", async (req, res) => { const id = req.params.inviteId as string; - const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null); + const invite = await db + .select() + .from(invites) + .where(eq(invites.id, id)) + .then((rows) => rows[0] ?? null); if (!invite) throw notFound("Invite not found"); if (invite.inviteType === "bootstrap_ceo") { await assertInstanceAdmin(req); @@ -1797,10 +2196,13 @@ export function accessRoutes( await logActivity(db, { companyId: invite.companyId, actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", action: "invite.revoked", entityType: "invite", - entityId: id, + entityId: id }); } @@ -1818,204 +2220,284 @@ export function accessRoutes( .orderBy(desc(joinRequests.createdAt)); const filtered = all.filter((row) => { if (query.status && row.status !== query.status) return false; - if (query.requestType && row.requestType !== query.requestType) return false; + if (query.requestType && row.requestType !== query.requestType) + return false; return true; }); res.json(filtered.map(toJoinRequestResponse)); }); - router.post("/companies/:companyId/join-requests/:requestId/approve", async (req, res) => { - const companyId = req.params.companyId as string; - const requestId = req.params.requestId as string; - await assertCompanyPermission(req, companyId, "joins:approve"); + router.post( + "/companies/:companyId/join-requests/:requestId/approve", + async (req, res) => { + const companyId = req.params.companyId as string; + const requestId = req.params.requestId as string; + await assertCompanyPermission(req, companyId, "joins:approve"); - const existing = await db - .select() - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId))) - .then((rows) => rows[0] ?? null); - if (!existing) throw notFound("Join request not found"); - if (existing.status !== "pending_approval") throw conflict("Join request is not pending"); + const existing = await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.id, requestId) + ) + ) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Join request not found"); + if (existing.status !== "pending_approval") + throw conflict("Join request is not pending"); - const invite = await db - .select() - .from(invites) - .where(eq(invites.id, existing.inviteId)) - .then((rows) => rows[0] ?? null); - if (!invite) throw notFound("Invite not found"); + const invite = await db + .select() + .from(invites) + .where(eq(invites.id, existing.inviteId)) + .then((rows) => rows[0] ?? null); + if (!invite) throw notFound("Invite not found"); - let createdAgentId: string | null = existing.createdAgentId ?? null; - if (existing.requestType === "human") { - if (!existing.requestingUserId) throw conflict("Join request missing user identity"); - await access.ensureMembership(companyId, "user", existing.requestingUserId, "member", "active"); - const grants = grantsFromDefaults(invite.defaultsPayload as Record | null, "human"); - await access.setPrincipalGrants( - companyId, - "user", - existing.requestingUserId, - grants, - req.actor.userId ?? null, - ); - } else { - const managerId = resolveJoinRequestAgentManagerId(await agents.list(companyId)); - if (!managerId) { - throw conflict("Join request cannot be approved because this company has no active CEO"); + let createdAgentId: string | null = existing.createdAgentId ?? null; + if (existing.requestType === "human") { + if (!existing.requestingUserId) + throw conflict("Join request missing user identity"); + await access.ensureMembership( + companyId, + "user", + existing.requestingUserId, + "member", + "active" + ); + const grants = grantsFromDefaults( + invite.defaultsPayload as Record | null, + "human" + ); + await access.setPrincipalGrants( + companyId, + "user", + existing.requestingUserId, + grants, + req.actor.userId ?? null + ); + } else { + const existingAgents = await agents.list(companyId); + const managerId = resolveJoinRequestAgentManagerId(existingAgents); + if (!managerId) { + throw conflict( + "Join request cannot be approved because this company has no active CEO" + ); + } + + const agentName = deduplicateAgentName( + existing.agentName ?? "New Agent", + existingAgents.map((a) => ({ id: a.id, name: a.name, status: a.status })), + ); + + const created = await agents.create(companyId, { + name: agentName, + role: "general", + title: null, + status: "idle", + reportsTo: managerId, + capabilities: existing.capabilities ?? null, + adapterType: existing.adapterType ?? "process", + adapterConfig: + existing.agentDefaultsPayload && + typeof existing.agentDefaultsPayload === "object" + ? (existing.agentDefaultsPayload as Record) + : {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null + }); + createdAgentId = created.id; + await access.ensureMembership( + companyId, + "agent", + created.id, + "member", + "active" + ); + const grants = grantsFromDefaults( + invite.defaultsPayload as Record | null, + "agent" + ); + await access.setPrincipalGrants( + companyId, + "agent", + created.id, + grants, + req.actor.userId ?? null + ); } - const created = await agents.create(companyId, { - name: existing.agentName ?? "New Agent", - role: "general", - title: null, - status: "idle", - reportsTo: managerId, - capabilities: existing.capabilities ?? null, - adapterType: existing.adapterType ?? "process", - adapterConfig: - existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object" - ? (existing.agentDefaultsPayload as Record) - : {}, - runtimeConfig: {}, - budgetMonthlyCents: 0, - spentMonthlyCents: 0, - permissions: {}, - lastHeartbeatAt: null, - metadata: null, - }); - createdAgentId = created.id; - await access.ensureMembership(companyId, "agent", created.id, "member", "active"); - const grants = grantsFromDefaults(invite.defaultsPayload as Record | null, "agent"); - await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null); - } + const approved = await db + .update(joinRequests) + .set({ + status: "approved", + approvedByUserId: + req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), + approvedAt: new Date(), + createdAgentId, + updatedAt: new Date() + }) + .where(eq(joinRequests.id, requestId)) + .returning() + .then((rows) => rows[0]); - const approved = await db - .update(joinRequests) - .set({ - status: "approved", - approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), - approvedAt: new Date(), - createdAgentId, - updatedAt: new Date(), - }) - .where(eq(joinRequests.id, requestId)) - .returning() - .then((rows) => rows[0]); - - await logActivity(db, { - companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "join.approved", - entityType: "join_request", - entityId: requestId, - details: { requestType: existing.requestType, createdAgentId }, - }); - - if (createdAgentId) { - void notifyHireApproved(db, { + await logActivity(db, { companyId, - agentId: createdAgentId, - source: "join_request", - sourceId: requestId, - approvedAt: new Date(), - }).catch(() => {}); + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "join.approved", + entityType: "join_request", + entityId: requestId, + details: { requestType: existing.requestType, createdAgentId } + }); + + if (createdAgentId) { + void notifyHireApproved(db, { + companyId, + agentId: createdAgentId, + source: "join_request", + sourceId: requestId, + approvedAt: new Date() + }).catch(() => {}); + } + + res.json(toJoinRequestResponse(approved)); } + ); - res.json(toJoinRequestResponse(approved)); - }); + router.post( + "/companies/:companyId/join-requests/:requestId/reject", + async (req, res) => { + const companyId = req.params.companyId as string; + const requestId = req.params.requestId as string; + await assertCompanyPermission(req, companyId, "joins:approve"); - router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => { - const companyId = req.params.companyId as string; - const requestId = req.params.requestId as string; - await assertCompanyPermission(req, companyId, "joins:approve"); + const existing = await db + .select() + .from(joinRequests) + .where( + and( + eq(joinRequests.companyId, companyId), + eq(joinRequests.id, requestId) + ) + ) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Join request not found"); + if (existing.status !== "pending_approval") + throw conflict("Join request is not pending"); - const existing = await db - .select() - .from(joinRequests) - .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId))) - .then((rows) => rows[0] ?? null); - if (!existing) throw notFound("Join request not found"); - if (existing.status !== "pending_approval") throw conflict("Join request is not pending"); + const rejected = await db + .update(joinRequests) + .set({ + status: "rejected", + rejectedByUserId: + req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), + rejectedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(joinRequests.id, requestId)) + .returning() + .then((rows) => rows[0]); - const rejected = await db - .update(joinRequests) - .set({ - status: "rejected", - rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null), - rejectedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(joinRequests.id, requestId)) - .returning() - .then((rows) => rows[0]); + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "join.rejected", + entityType: "join_request", + entityId: requestId, + details: { requestType: existing.requestType } + }); - await logActivity(db, { - companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "join.rejected", - entityType: "join_request", - entityId: requestId, - details: { requestType: existing.requestType }, - }); - - res.json(toJoinRequestResponse(rejected)); - }); - - router.post("/join-requests/:requestId/claim-api-key", validate(claimJoinRequestApiKeySchema), async (req, res) => { - const requestId = req.params.requestId as string; - const presentedClaimSecretHash = hashToken(req.body.claimSecret); - const joinRequest = await db - .select() - .from(joinRequests) - .where(eq(joinRequests.id, requestId)) - .then((rows) => rows[0] ?? null); - if (!joinRequest) throw notFound("Join request not found"); - if (joinRequest.requestType !== "agent") throw badRequest("Only agent join requests can claim API keys"); - if (joinRequest.status !== "approved") throw conflict("Join request must be approved before key claim"); - if (!joinRequest.createdAgentId) throw conflict("Join request has no created agent"); - if (!joinRequest.claimSecretHash) throw conflict("Join request is missing claim secret metadata"); - if (!tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash)) { - throw forbidden("Invalid claim secret"); + res.json(toJoinRequestResponse(rejected)); } - if (joinRequest.claimSecretExpiresAt && joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) { - throw conflict("Claim secret expired"); + ); + + router.post( + "/join-requests/:requestId/claim-api-key", + validate(claimJoinRequestApiKeySchema), + async (req, res) => { + const requestId = req.params.requestId as string; + const presentedClaimSecretHash = hashToken(req.body.claimSecret); + const joinRequest = await db + .select() + .from(joinRequests) + .where(eq(joinRequests.id, requestId)) + .then((rows) => rows[0] ?? null); + if (!joinRequest) throw notFound("Join request not found"); + if (joinRequest.requestType !== "agent") + throw badRequest("Only agent join requests can claim API keys"); + if (joinRequest.status !== "approved") + throw conflict("Join request must be approved before key claim"); + if (!joinRequest.createdAgentId) + throw conflict("Join request has no created agent"); + if (!joinRequest.claimSecretHash) + throw conflict("Join request is missing claim secret metadata"); + if ( + !tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash) + ) { + throw forbidden("Invalid claim secret"); + } + if ( + joinRequest.claimSecretExpiresAt && + joinRequest.claimSecretExpiresAt.getTime() <= Date.now() + ) { + throw conflict("Claim secret expired"); + } + if (joinRequest.claimSecretConsumedAt) + throw conflict("Claim secret already used"); + + const existingKey = await db + .select({ id: agentApiKeys.id }) + .from(agentApiKeys) + .where(eq(agentApiKeys.agentId, joinRequest.createdAgentId)) + .then((rows) => rows[0] ?? null); + if (existingKey) throw conflict("API key already claimed"); + + const consumed = await db + .update(joinRequests) + .set({ claimSecretConsumedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(joinRequests.id, requestId), + isNull(joinRequests.claimSecretConsumedAt) + ) + ) + .returning({ id: joinRequests.id }) + .then((rows) => rows[0] ?? null); + if (!consumed) throw conflict("Claim secret already used"); + + const created = await agents.createApiKey( + joinRequest.createdAgentId, + "initial-join-key" + ); + + await logActivity(db, { + companyId: joinRequest.companyId, + actorType: "system", + actorId: "join-claim", + action: "agent_api_key.claimed", + entityType: "agent_api_key", + entityId: created.id, + details: { + agentId: joinRequest.createdAgentId, + joinRequestId: requestId + } + }); + + res.status(201).json({ + keyId: created.id, + token: created.token, + agentId: joinRequest.createdAgentId, + createdAt: created.createdAt + }); } - if (joinRequest.claimSecretConsumedAt) throw conflict("Claim secret already used"); - - const existingKey = await db - .select({ id: agentApiKeys.id }) - .from(agentApiKeys) - .where(eq(agentApiKeys.agentId, joinRequest.createdAgentId)) - .then((rows) => rows[0] ?? null); - if (existingKey) throw conflict("API key already claimed"); - - const consumed = await db - .update(joinRequests) - .set({ claimSecretConsumedAt: new Date(), updatedAt: new Date() }) - .where(and(eq(joinRequests.id, requestId), isNull(joinRequests.claimSecretConsumedAt))) - .returning({ id: joinRequests.id }) - .then((rows) => rows[0] ?? null); - if (!consumed) throw conflict("Claim secret already used"); - - const created = await agents.createApiKey(joinRequest.createdAgentId, "initial-join-key"); - - await logActivity(db, { - companyId: joinRequest.companyId, - actorType: "system", - actorId: "join-claim", - action: "agent_api_key.claimed", - entityType: "agent_api_key", - entityId: created.id, - details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId }, - }); - - res.status(201).json({ - keyId: created.id, - token: created.token, - agentId: joinRequest.createdAgentId, - createdAt: created.createdAt, - }); - }); + ); router.get("/companies/:companyId/members", async (req, res) => { const companyId = req.params.companyId as string; @@ -2035,27 +2517,33 @@ export function accessRoutes( companyId, memberId, req.body.grants ?? [], - req.actor.userId ?? null, + req.actor.userId ?? null ); if (!updated) throw notFound("Member not found"); res.json(updated); - }, + } ); - router.post("/admin/users/:userId/promote-instance-admin", async (req, res) => { - await assertInstanceAdmin(req); - const userId = req.params.userId as string; - const result = await access.promoteInstanceAdmin(userId); - res.status(201).json(result); - }); + router.post( + "/admin/users/:userId/promote-instance-admin", + async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const result = await access.promoteInstanceAdmin(userId); + res.status(201).json(result); + } + ); - router.post("/admin/users/:userId/demote-instance-admin", async (req, res) => { - await assertInstanceAdmin(req); - const userId = req.params.userId as string; - const removed = await access.demoteInstanceAdmin(userId); - if (!removed) throw notFound("Instance admin role not found"); - res.json(removed); - }); + router.post( + "/admin/users/:userId/demote-instance-admin", + async (req, res) => { + await assertInstanceAdmin(req); + const userId = req.params.userId as string; + const removed = await access.demoteInstanceAdmin(userId); + if (!removed) throw notFound("Instance admin role not found"); + res.json(removed); + } + ); router.get("/admin/users/:userId/company-access", async (req, res) => { await assertInstanceAdmin(req); @@ -2070,9 +2558,12 @@ export function accessRoutes( async (req, res) => { await assertInstanceAdmin(req); const userId = req.params.userId as string; - const memberships = await access.setUserCompanyAccess(userId, req.body.companyIds ?? []); + const memberships = await access.setUserCompanyAccess( + userId, + req.body.companyIds ?? [] + ); res.json(memberships); - }, + } ); return router; diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index dd72b86f..817a9e7b 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -165,6 +165,22 @@ export function hasAgentShortnameCollision( }); } +export function deduplicateAgentName( + candidateName: string, + existingAgents: AgentShortnameRow[], +): string { + if (!hasAgentShortnameCollision(candidateName, existingAgents)) { + return candidateName; + } + for (let i = 2; i <= 100; i++) { + const suffixed = `${candidateName} ${i}`; + if (!hasAgentShortnameCollision(suffixed, existingAgents)) { + return suffixed; + } + } + return `${candidateName} ${Date.now()}`; +} + export function agentService(db: Db) { function withUrlKey(row: T) { return { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index c88e5e68..0dfe46ab 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,5 +1,5 @@ export { companyService } from "./companies.js"; -export { agentService } from "./agents.js"; +export { agentService, deduplicateAgentName } from "./agents.js"; export { assetService } from "./assets.js"; export { projectService } from "./projects.js"; export { issueService, type IssueFilters } from "./issues.js";