@paperclipai/server 0.2.7 → 0.3.0-canary.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/cursor-models.d.ts +13 -0
  3. package/dist/adapters/cursor-models.d.ts.map +1 -0
  4. package/dist/adapters/cursor-models.js +148 -0
  5. package/dist/adapters/cursor-models.js.map +1 -0
  6. package/dist/adapters/registry.d.ts.map +1 -1
  7. package/dist/adapters/registry.js +55 -9
  8. package/dist/adapters/registry.js.map +1 -1
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +5 -1
  11. package/dist/app.js.map +1 -1
  12. package/dist/auth/better-auth.d.ts +2 -1
  13. package/dist/auth/better-auth.d.ts.map +1 -1
  14. package/dist/auth/better-auth.js +29 -1
  15. package/dist/auth/better-auth.js.map +1 -1
  16. package/dist/config.d.ts +5 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +42 -2
  19. package/dist/config.js.map +1 -1
  20. package/dist/home-paths.d.ts +1 -0
  21. package/dist/home-paths.d.ts.map +1 -1
  22. package/dist/home-paths.js +3 -0
  23. package/dist/home-paths.js.map +1 -1
  24. package/dist/index.d.ts +9 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +468 -370
  27. package/dist/index.js.map +1 -1
  28. package/dist/middleware/error-handler.d.ts +14 -0
  29. package/dist/middleware/error-handler.d.ts.map +1 -1
  30. package/dist/middleware/error-handler.js +19 -4
  31. package/dist/middleware/error-handler.js.map +1 -1
  32. package/dist/middleware/logger.d.ts.map +1 -1
  33. package/dist/middleware/logger.js +52 -3
  34. package/dist/middleware/logger.js.map +1 -1
  35. package/dist/realtime/live-events-ws.d.ts +20 -2
  36. package/dist/realtime/live-events-ws.d.ts.map +1 -1
  37. package/dist/realtime/live-events-ws.js +3 -1
  38. package/dist/realtime/live-events-ws.js.map +1 -1
  39. package/dist/routes/access.d.ts +47 -0
  40. package/dist/routes/access.d.ts.map +1 -1
  41. package/dist/routes/access.js +1340 -193
  42. package/dist/routes/access.js.map +1 -1
  43. package/dist/routes/agents.d.ts.map +1 -1
  44. package/dist/routes/agents.js +106 -17
  45. package/dist/routes/agents.js.map +1 -1
  46. package/dist/routes/companies.d.ts.map +1 -1
  47. package/dist/routes/companies.js +6 -0
  48. package/dist/routes/companies.js.map +1 -1
  49. package/dist/routes/issues-checkout-wakeup.d.ts +9 -0
  50. package/dist/routes/issues-checkout-wakeup.d.ts.map +1 -0
  51. package/dist/routes/issues-checkout-wakeup.js +12 -0
  52. package/dist/routes/issues-checkout-wakeup.js.map +1 -0
  53. package/dist/routes/issues.d.ts.map +1 -1
  54. package/dist/routes/issues.js +122 -15
  55. package/dist/routes/issues.js.map +1 -1
  56. package/dist/routes/sidebar-badges.d.ts.map +1 -1
  57. package/dist/routes/sidebar-badges.js +12 -11
  58. package/dist/routes/sidebar-badges.js.map +1 -1
  59. package/dist/services/agents.d.ts +13 -1
  60. package/dist/services/agents.d.ts.map +1 -1
  61. package/dist/services/agents.js +65 -5
  62. package/dist/services/agents.js.map +1 -1
  63. package/dist/services/approvals.d.ts.map +1 -1
  64. package/dist/services/approvals.js +14 -1
  65. package/dist/services/approvals.js.map +1 -1
  66. package/dist/services/company-portability.d.ts.map +1 -1
  67. package/dist/services/company-portability.js +16 -4
  68. package/dist/services/company-portability.js.map +1 -1
  69. package/dist/services/heartbeat.d.ts +24 -0
  70. package/dist/services/heartbeat.d.ts.map +1 -1
  71. package/dist/services/heartbeat.js +152 -9
  72. package/dist/services/heartbeat.js.map +1 -1
  73. package/dist/services/hire-hook.d.ts +14 -0
  74. package/dist/services/hire-hook.d.ts.map +1 -0
  75. package/dist/services/hire-hook.js +85 -0
  76. package/dist/services/hire-hook.js.map +1 -0
  77. package/dist/services/index.d.ts +2 -1
  78. package/dist/services/index.d.ts.map +1 -1
  79. package/dist/services/index.js +2 -1
  80. package/dist/services/index.js.map +1 -1
  81. package/dist/services/issues.d.ts +37 -0
  82. package/dist/services/issues.d.ts.map +1 -1
  83. package/dist/services/issues.js +199 -2
  84. package/dist/services/issues.js.map +1 -1
  85. package/dist/services/projects.d.ts +8 -0
  86. package/dist/services/projects.d.ts.map +1 -1
  87. package/dist/services/projects.js +45 -0
  88. package/dist/services/projects.js.map +1 -1
  89. package/dist/services/run-log-store.d.ts.map +1 -1
  90. package/dist/services/run-log-store.js +4 -7
  91. package/dist/services/run-log-store.js.map +1 -1
  92. package/dist/services/secrets.d.ts +6 -2
  93. package/dist/services/secrets.d.ts.map +1 -1
  94. package/dist/services/secrets.js +9 -5
  95. package/dist/services/secrets.js.map +1 -1
  96. package/dist/services/sidebar-badges.d.ts +1 -1
  97. package/dist/services/sidebar-badges.d.ts.map +1 -1
  98. package/dist/services/sidebar-badges.js +2 -2
  99. package/dist/services/sidebar-badges.js.map +1 -1
  100. package/dist/startup-banner.d.ts +4 -0
  101. package/dist/startup-banner.d.ts.map +1 -1
  102. package/dist/startup-banner.js +5 -0
  103. package/dist/startup-banner.js.map +1 -1
  104. package/package.json +15 -8
  105. package/skills/paperclip/SKILL.md +77 -10
  106. package/skills/paperclip/references/api-reference.md +24 -3
  107. package/skills/release/SKILL.md +261 -0
  108. package/skills/release-changelog/SKILL.md +178 -0
  109. package/ui-dist/assets/_basePickBy-uTypp8IS.js +1 -0
  110. package/ui-dist/assets/_baseUniq-Br5ginSL.js +1 -0
  111. package/ui-dist/assets/arc-CL_yTLb7.js +1 -0
  112. package/ui-dist/assets/architectureDiagram-VXUJARFQ-QDWenfc8.js +36 -0
  113. package/ui-dist/assets/blockDiagram-VD42YOAC-Cx5v00JC.js +122 -0
  114. package/ui-dist/assets/c4Diagram-YG6GDRKO-w1jXPcld.js +10 -0
  115. package/ui-dist/assets/channel-Cgg5Zy18.js +1 -0
  116. package/ui-dist/assets/chunk-4BX2VUAB-IP20JmJc.js +1 -0
  117. package/ui-dist/assets/chunk-55IACEB6-DnoDQzkr.js +1 -0
  118. package/ui-dist/assets/chunk-B4BG7PRW-B0oMPqWG.js +165 -0
  119. package/ui-dist/assets/chunk-DI55MBZ5-XP2Bv90U.js +220 -0
  120. package/ui-dist/assets/chunk-FMBD7UC4-BTXZhgrQ.js +15 -0
  121. package/ui-dist/assets/chunk-QN33PNHL-CV1_kZb0.js +1 -0
  122. package/ui-dist/assets/chunk-QZHKN3VN-DRQRsvpN.js +1 -0
  123. package/ui-dist/assets/chunk-TZMSLE5B-CyRhnMtV.js +1 -0
  124. package/ui-dist/assets/classDiagram-2ON5EDUG-C7fyGn2Z.js +1 -0
  125. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-C7fyGn2Z.js +1 -0
  126. package/ui-dist/assets/clone-C-dKMXxT.js +1 -0
  127. package/ui-dist/assets/cose-bilkent-S5V4N54A-C3zcDRja.js +1 -0
  128. package/ui-dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  129. package/ui-dist/assets/dagre-6UL2VRFP-B2DRzkCD.js +4 -0
  130. package/ui-dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  131. package/ui-dist/assets/diagram-PSM6KHXK-CxoAcsrX.js +24 -0
  132. package/ui-dist/assets/diagram-QEK2KX5R-DE0jOoKl.js +43 -0
  133. package/ui-dist/assets/diagram-S2PKOQOG-nZkpfo7y.js +24 -0
  134. package/ui-dist/assets/erDiagram-Q2GNP2WA-Bl8ZbdFt.js +60 -0
  135. package/ui-dist/assets/flowDiagram-NV44I4VS-Bets3UXj.js +162 -0
  136. package/ui-dist/assets/ganttDiagram-JELNMOA3-CzdlM4Q4.js +267 -0
  137. package/ui-dist/assets/gitGraphDiagram-V2S2FVAM-0znuWjdT.js +65 -0
  138. package/ui-dist/assets/graph-CqfQ17fA.js +1 -0
  139. package/ui-dist/assets/{index-pu8fzL7q.js → index-7nNiKIK4.js} +2 -2
  140. package/ui-dist/assets/{index-CF8nX1vG.js → index-9_NCEtxm.js} +1 -1
  141. package/ui-dist/assets/{index-kYgLZCCi.js → index-B98qVBYv.js} +1 -1
  142. package/ui-dist/assets/{index-CdX_ZDP5.js → index-BGTZFghP.js} +1 -1
  143. package/ui-dist/assets/{index-tZqt0pgN.js → index-BLzuntY1.js} +5 -5
  144. package/ui-dist/assets/index-BZyxDGuR.js +1 -0
  145. package/ui-dist/assets/{index-NaKAsGKV.js → index-B_BkZUzM.js} +1 -1
  146. package/ui-dist/assets/{index-Dg1rynkq.js → index-Bgr40E5T.js} +1 -1
  147. package/ui-dist/assets/{index-B-OLFaqv.js → index-BrUI189T.js} +2 -2
  148. package/ui-dist/assets/{index-C9NgpAmN.js → index-BwywxTyu.js} +1 -1
  149. package/ui-dist/assets/{index-CgAKYlm4.js → index-C0rbEf43.js} +1 -1
  150. package/ui-dist/assets/{index-D-j8BRho.js → index-CEG1A6xN.js} +1 -1
  151. package/ui-dist/assets/{index-DYE3wRax.js → index-CZlgUP5H.js} +1 -1
  152. package/ui-dist/assets/{index-C3Iv_Fqi.js → index-CnZTY1ys.js} +1 -1
  153. package/ui-dist/assets/index-DN1_92Qm.js +900 -0
  154. package/ui-dist/assets/{index-COdba80T.js → index-DYxzi5jO.js} +1 -1
  155. package/ui-dist/assets/{index-BIEaSsd6.js → index-DZTlxw-9.js} +1 -1
  156. package/ui-dist/assets/{index-CKNdC7J5.js → index-DZYyOzky.js} +1 -1
  157. package/ui-dist/assets/{index-COhpE0rU.js → index-Dsg_WOwh.js} +1 -1
  158. package/ui-dist/assets/{index-Btnrtcrc.js → index-DskiIzZI.js} +1 -1
  159. package/ui-dist/assets/{index-jVC6cnfD.js → index-Lr8m8V8u.js} +1 -1
  160. package/ui-dist/assets/{index-BTMR1V3x.js → index-O7wFYmP6.js} +1 -1
  161. package/ui-dist/assets/index-nfAtmpEH.css +1 -0
  162. package/ui-dist/assets/{index-DVW9_Opq.js → index-rYTF_5JN.js} +1 -1
  163. package/ui-dist/assets/infoDiagram-HS3SLOUP-CoLHBo93.js +2 -0
  164. package/ui-dist/assets/init-Gi6I4Gst.js +1 -0
  165. package/ui-dist/assets/journeyDiagram-XKPGCS4Q-B-juOJt7.js +139 -0
  166. package/ui-dist/assets/kanban-definition-3W4ZIXB7-CRhCTAGl.js +89 -0
  167. package/ui-dist/assets/katex-O9d3_IXG.js +261 -0
  168. package/ui-dist/assets/layout-0_5Wah8d.js +1 -0
  169. package/ui-dist/assets/linear-rXBW0wlX.js +1 -0
  170. package/ui-dist/assets/mermaid.core-CZKXdC_e.js +256 -0
  171. package/ui-dist/assets/mindmap-definition-VGOIOE7T-CcsJM-Ch.js +68 -0
  172. package/ui-dist/assets/ordinal-Cboi1Yqb.js +1 -0
  173. package/ui-dist/assets/pieDiagram-ADFJNKIX-Ci6R42sU.js +30 -0
  174. package/ui-dist/assets/quadrantDiagram-AYHSOK5B-B6xrkCsR.js +7 -0
  175. package/ui-dist/assets/requirementDiagram-UZGBJVZJ-4V-WQI-N.js +64 -0
  176. package/ui-dist/assets/sankeyDiagram-TZEHDZUN-D4NrQ_iq.js +10 -0
  177. package/ui-dist/assets/sequenceDiagram-WL72ISMW-C1V8T_WD.js +145 -0
  178. package/ui-dist/assets/stateDiagram-FKZM4ZOC-B6msJabL.js +1 -0
  179. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-BbXAbFox.js +1 -0
  180. package/ui-dist/assets/timeline-definition-IT6M3QCI-D82F3vGD.js +61 -0
  181. package/ui-dist/assets/treemap-GDKQZRPO-CN1NndeP.js +162 -0
  182. package/ui-dist/assets/xychartDiagram-PRI3JC2R-BrHVvbst.js +7 -0
  183. package/ui-dist/brands/opencode-logo-dark-square.svg +18 -0
  184. package/ui-dist/brands/opencode-logo-light-square.svg +18 -0
  185. package/ui-dist/index.html +2 -2
  186. package/ui-dist/site.webmanifest +15 -4
  187. package/ui-dist/sw.js +42 -0
  188. package/ui-dist/assets/index-B6IJ7rtH.css +0 -1
  189. package/ui-dist/assets/index-CNeWfnNw.js +0 -856
  190. package/ui-dist/assets/index-D7c99xP8.js +0 -1
@@ -1,29 +1,44 @@
1
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
1
+ import { createHash, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Router } from "express";
6
6
  import { and, eq, isNull, desc } from "drizzle-orm";
7
- import { agentApiKeys, authUsers, invites, joinRequests, } from "@paperclipai/db";
8
- import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS, } from "@paperclipai/shared";
7
+ import { agentApiKeys, authUsers, invites, joinRequests } from "@paperclipai/db";
8
+ import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared";
9
9
  import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
10
+ import { logger } from "../middleware/logger.js";
10
11
  import { validate } from "../middleware/validate.js";
11
- import { accessService, agentService, logActivity } from "../services/index.js";
12
+ import { accessService, agentService, deduplicateAgentName, logActivity, notifyHireApproved } from "../services/index.js";
12
13
  import { assertCompanyAccess } from "./authz.js";
13
14
  import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js";
14
15
  function hashToken(token) {
15
16
  return createHash("sha256").update(token).digest("hex");
16
17
  }
18
+ const INVITE_TOKEN_PREFIX = "pcp_invite_";
19
+ const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
20
+ const INVITE_TOKEN_SUFFIX_LENGTH = 8;
21
+ const INVITE_TOKEN_MAX_RETRIES = 5;
22
+ const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000;
17
23
  function createInviteToken() {
18
- return `pcp_invite_${randomBytes(24).toString("hex")}`;
24
+ const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
25
+ let suffix = "";
26
+ for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
27
+ suffix += INVITE_TOKEN_ALPHABET[bytes[idx] % INVITE_TOKEN_ALPHABET.length];
28
+ }
29
+ return `${INVITE_TOKEN_PREFIX}${suffix}`;
19
30
  }
20
31
  function createClaimSecret() {
21
32
  return `pcp_claim_${randomBytes(24).toString("hex")}`;
22
33
  }
34
+ export function companyInviteExpiresAt(nowMs = Date.now()) {
35
+ return new Date(nowMs + COMPANY_INVITE_TTL_MS);
36
+ }
23
37
  function tokenHashesMatch(left, right) {
24
38
  const leftBytes = Buffer.from(left, "utf8");
25
39
  const rightBytes = Buffer.from(right, "utf8");
26
- return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
40
+ return (leftBytes.length === rightBytes.length &&
41
+ timingSafeEqual(leftBytes, rightBytes));
27
42
  }
28
43
  function requestBaseUrl(req) {
29
44
  const forwardedProto = req.header("x-forwarded-proto");
@@ -41,7 +56,7 @@ function readSkillMarkdown(skillName) {
41
56
  const candidates = [
42
57
  path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> <pkg>/skills/
43
58
  path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root)
44
- path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md"), // dev: src/routes/ -> repo root/skills/
59
+ path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md") // dev: src/routes/ -> repo root/skills/
45
60
  ];
46
61
  for (const skillPath of candidates) {
47
62
  try {
@@ -72,170 +87,502 @@ function normalizeHostname(value) {
72
87
  return null;
73
88
  if (trimmed.startsWith("[")) {
74
89
  const end = trimmed.indexOf("]");
75
- return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
90
+ return end > 1
91
+ ? trimmed.slice(1, end).toLowerCase()
92
+ : trimmed.toLowerCase();
76
93
  }
77
94
  const firstColon = trimmed.indexOf(":");
78
95
  if (firstColon > -1)
79
96
  return trimmed.slice(0, firstColon).toLowerCase();
80
97
  return trimmed.toLowerCase();
81
98
  }
99
+ function normalizeHeaderValue(value, depth = 0) {
100
+ const direct = nonEmptyTrimmedString(value);
101
+ if (direct)
102
+ return direct;
103
+ if (!isPlainObject(value) || depth >= 3)
104
+ return null;
105
+ const candidateKeys = [
106
+ "value",
107
+ "token",
108
+ "secret",
109
+ "apiKey",
110
+ "api_key",
111
+ "auth",
112
+ "authToken",
113
+ "auth_token",
114
+ "accessToken",
115
+ "access_token",
116
+ "authorization",
117
+ "bearer",
118
+ "header",
119
+ "raw",
120
+ "text",
121
+ "string"
122
+ ];
123
+ for (const key of candidateKeys) {
124
+ if (!Object.prototype.hasOwnProperty.call(value, key))
125
+ continue;
126
+ const normalized = normalizeHeaderValue(value[key], depth + 1);
127
+ if (normalized)
128
+ return normalized;
129
+ }
130
+ const entries = Object.entries(value);
131
+ if (entries.length === 1) {
132
+ const [singleKey, singleValue] = entries[0];
133
+ const normalizedKey = singleKey.trim().toLowerCase();
134
+ if (normalizedKey !== "type" &&
135
+ normalizedKey !== "version" &&
136
+ normalizedKey !== "secretid" &&
137
+ normalizedKey !== "secret_id") {
138
+ const normalized = normalizeHeaderValue(singleValue, depth + 1);
139
+ if (normalized)
140
+ return normalized;
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+ function extractHeaderEntries(input) {
146
+ if (isPlainObject(input)) {
147
+ return Object.entries(input);
148
+ }
149
+ if (!Array.isArray(input)) {
150
+ return [];
151
+ }
152
+ const entries = [];
153
+ for (const item of input) {
154
+ if (Array.isArray(item)) {
155
+ const key = nonEmptyTrimmedString(item[0]);
156
+ if (!key)
157
+ continue;
158
+ entries.push([key, item[1]]);
159
+ continue;
160
+ }
161
+ if (!isPlainObject(item))
162
+ continue;
163
+ const mapped = item;
164
+ const explicitKey = nonEmptyTrimmedString(mapped.key) ??
165
+ nonEmptyTrimmedString(mapped.name) ??
166
+ nonEmptyTrimmedString(mapped.header);
167
+ if (explicitKey) {
168
+ const explicitValue = Object.prototype.hasOwnProperty.call(mapped, "value")
169
+ ? mapped.value
170
+ : Object.prototype.hasOwnProperty.call(mapped, "token")
171
+ ? mapped.token
172
+ : Object.prototype.hasOwnProperty.call(mapped, "secret")
173
+ ? mapped.secret
174
+ : mapped;
175
+ entries.push([explicitKey, explicitValue]);
176
+ continue;
177
+ }
178
+ const singleEntry = Object.entries(mapped);
179
+ if (singleEntry.length === 1) {
180
+ entries.push(singleEntry[0]);
181
+ }
182
+ }
183
+ return entries;
184
+ }
82
185
  function normalizeHeaderMap(input) {
83
- if (!isPlainObject(input))
186
+ const entries = extractHeaderEntries(input);
187
+ if (entries.length === 0)
84
188
  return undefined;
85
189
  const out = {};
86
- for (const [key, value] of Object.entries(input)) {
87
- if (typeof value !== "string")
190
+ for (const [key, value] of entries) {
191
+ const normalizedValue = normalizeHeaderValue(value);
192
+ if (!normalizedValue)
88
193
  continue;
89
194
  const trimmedKey = key.trim();
90
- const trimmedValue = value.trim();
195
+ const trimmedValue = normalizedValue.trim();
91
196
  if (!trimmedKey || !trimmedValue)
92
197
  continue;
93
198
  out[trimmedKey] = trimmedValue;
94
199
  }
95
200
  return Object.keys(out).length > 0 ? out : undefined;
96
201
  }
97
- function buildJoinConnectivityDiagnostics(input) {
98
- const diagnostics = [];
99
- const bindHost = normalizeHostname(input.bindHost);
100
- const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null;
101
- const allowSet = new Set(input.allowedHostnames
102
- .map((entry) => normalizeHostname(entry))
103
- .filter((entry) => Boolean(entry)));
104
- diagnostics.push({
105
- code: "openclaw_deployment_context",
106
- level: "info",
107
- message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`,
108
- });
109
- if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") {
110
- if (!bindHost || isLoopbackHost(bindHost)) {
111
- diagnostics.push({
112
- code: "openclaw_private_bind_loopback",
113
- level: "warn",
114
- message: "Paperclip is bound to loopback in authenticated/private mode.",
115
- hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.",
116
- });
117
- }
118
- if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
119
- diagnostics.push({
120
- code: "openclaw_private_bind_not_allowed",
121
- level: "warn",
122
- message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`,
123
- hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`,
124
- });
125
- }
126
- if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) {
127
- diagnostics.push({
128
- code: "openclaw_private_allowed_hostnames_empty",
129
- level: "warn",
130
- message: "No explicit allowed hostnames are configured for authenticated/private mode.",
131
- hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs off-host.",
132
- });
133
- }
202
+ function nonEmptyTrimmedString(value) {
203
+ if (typeof value !== "string")
204
+ return null;
205
+ const trimmed = value.trim();
206
+ return trimmed.length > 0 ? trimmed : null;
207
+ }
208
+ function headerMapHasKeyIgnoreCase(headers, targetKey) {
209
+ const normalizedTarget = targetKey.trim().toLowerCase();
210
+ return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget);
211
+ }
212
+ function headerMapGetIgnoreCase(headers, targetKey) {
213
+ const normalizedTarget = targetKey.trim().toLowerCase();
214
+ const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget);
215
+ if (!key)
216
+ return null;
217
+ const value = headers[key];
218
+ return typeof value === "string" ? value : null;
219
+ }
220
+ function tokenFromAuthorizationHeader(rawHeader) {
221
+ const trimmed = nonEmptyTrimmedString(rawHeader);
222
+ if (!trimmed)
223
+ return null;
224
+ const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
225
+ if (bearerMatch?.[1]) {
226
+ return nonEmptyTrimmedString(bearerMatch[1]);
134
227
  }
135
- if (input.deploymentMode === "authenticated" &&
136
- input.deploymentExposure === "public" &&
137
- input.callbackUrl &&
138
- input.callbackUrl.protocol !== "https:") {
139
- diagnostics.push({
140
- code: "openclaw_public_http_callback",
141
- level: "warn",
142
- message: "OpenClaw callback URL uses HTTP in authenticated/public mode.",
143
- hint: "Prefer HTTPS for public deployments.",
144
- });
228
+ return trimmed;
229
+ }
230
+ function parseBooleanLike(value) {
231
+ if (typeof value === "boolean")
232
+ return value;
233
+ if (typeof value !== "string")
234
+ return null;
235
+ const normalized = value.trim().toLowerCase();
236
+ if (normalized === "true" || normalized === "1")
237
+ return true;
238
+ if (normalized === "false" || normalized === "0")
239
+ return false;
240
+ return null;
241
+ }
242
+ function generateEd25519PrivateKeyPem() {
243
+ const generated = generateKeyPairSync("ed25519");
244
+ return generated.privateKey
245
+ .export({ type: "pkcs8", format: "pem" })
246
+ .toString();
247
+ }
248
+ export function buildJoinDefaultsPayloadForAccept(input) {
249
+ if (input.adapterType !== "openclaw_gateway") {
250
+ return input.defaultsPayload;
145
251
  }
146
- return diagnostics;
252
+ const merged = isPlainObject(input.defaultsPayload)
253
+ ? { ...input.defaultsPayload }
254
+ : {};
255
+ if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
256
+ const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
257
+ if (legacyPaperclipApiUrl)
258
+ merged.paperclipApiUrl = legacyPaperclipApiUrl;
259
+ }
260
+ const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
261
+ const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader);
262
+ const inboundOpenClawTokenHeader = nonEmptyTrimmedString(input.inboundOpenClawTokenHeader);
263
+ if (inboundOpenClawTokenHeader &&
264
+ !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")) {
265
+ mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
266
+ }
267
+ if (inboundOpenClawAuthHeader &&
268
+ !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) {
269
+ mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
270
+ }
271
+ if (Object.keys(mergedHeaders).length > 0) {
272
+ merged.headers = mergedHeaders;
273
+ }
274
+ else {
275
+ delete merged.headers;
276
+ }
277
+ const discoveredToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
278
+ headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
279
+ tokenFromAuthorizationHeader(headerMapGetIgnoreCase(mergedHeaders, "authorization"));
280
+ if (discoveredToken &&
281
+ !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")) {
282
+ mergedHeaders["x-openclaw-token"] = discoveredToken;
283
+ }
284
+ return Object.keys(merged).length > 0 ? merged : null;
285
+ }
286
+ export function mergeJoinDefaultsPayloadForReplay(existingDefaultsPayload, nextDefaultsPayload) {
287
+ if (!isPlainObject(existingDefaultsPayload) &&
288
+ !isPlainObject(nextDefaultsPayload)) {
289
+ return nextDefaultsPayload ?? existingDefaultsPayload;
290
+ }
291
+ if (!isPlainObject(existingDefaultsPayload)) {
292
+ return nextDefaultsPayload;
293
+ }
294
+ if (!isPlainObject(nextDefaultsPayload)) {
295
+ return existingDefaultsPayload;
296
+ }
297
+ const merged = {
298
+ ...existingDefaultsPayload,
299
+ ...nextDefaultsPayload
300
+ };
301
+ const existingHeaders = normalizeHeaderMap(existingDefaultsPayload.headers);
302
+ const nextHeaders = normalizeHeaderMap(nextDefaultsPayload.headers);
303
+ if (existingHeaders || nextHeaders) {
304
+ merged.headers = {
305
+ ...(existingHeaders ?? {}),
306
+ ...(nextHeaders ?? {})
307
+ };
308
+ }
309
+ else if (Object.prototype.hasOwnProperty.call(merged, "headers")) {
310
+ delete merged.headers;
311
+ }
312
+ return merged;
313
+ }
314
+ export function canReplayOpenClawGatewayInviteAccept(input) {
315
+ if (input.requestType !== "agent" ||
316
+ input.adapterType !== "openclaw_gateway") {
317
+ return false;
318
+ }
319
+ if (!input.existingJoinRequest) {
320
+ return false;
321
+ }
322
+ if (input.existingJoinRequest.requestType !== "agent" ||
323
+ input.existingJoinRequest.adapterType !== "openclaw_gateway") {
324
+ return false;
325
+ }
326
+ return (input.existingJoinRequest.status === "pending_approval" ||
327
+ input.existingJoinRequest.status === "approved");
328
+ }
329
+ function summarizeSecretForLog(value) {
330
+ const trimmed = nonEmptyTrimmedString(value);
331
+ if (!trimmed)
332
+ return null;
333
+ return {
334
+ present: true,
335
+ length: trimmed.length,
336
+ sha256Prefix: hashToken(trimmed).slice(0, 12)
337
+ };
338
+ }
339
+ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload) {
340
+ const defaults = isPlainObject(defaultsPayload)
341
+ ? defaultsPayload
342
+ : null;
343
+ const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
344
+ const gatewayTokenValue = headers
345
+ ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
346
+ headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
347
+ tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"))
348
+ : null;
349
+ return {
350
+ present: Boolean(defaults),
351
+ keys: defaults ? Object.keys(defaults).sort() : [],
352
+ url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
353
+ paperclipApiUrl: defaults
354
+ ? nonEmptyTrimmedString(defaults.paperclipApiUrl)
355
+ : null,
356
+ headerKeys: headers ? Object.keys(headers).sort() : [],
357
+ sessionKeyStrategy: defaults
358
+ ? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
359
+ : null,
360
+ disableDeviceAuth: defaults
361
+ ? parseBooleanLike(defaults.disableDeviceAuth)
362
+ : null,
363
+ waitTimeoutMs: defaults && typeof defaults.waitTimeoutMs === "number"
364
+ ? defaults.waitTimeoutMs
365
+ : null,
366
+ devicePrivateKeyPem: defaults
367
+ ? summarizeSecretForLog(defaults.devicePrivateKeyPem)
368
+ : null,
369
+ gatewayToken: summarizeSecretForLog(gatewayTokenValue)
370
+ };
147
371
  }
148
- function normalizeAgentDefaultsForJoin(input) {
372
+ export function normalizeAgentDefaultsForJoin(input) {
373
+ const fatalErrors = [];
149
374
  const diagnostics = [];
150
- if (input.adapterType !== "openclaw") {
375
+ if (input.adapterType !== "openclaw_gateway") {
151
376
  const normalized = isPlainObject(input.defaultsPayload)
152
377
  ? input.defaultsPayload
153
378
  : null;
154
- return { normalized, diagnostics };
379
+ return { normalized, diagnostics, fatalErrors };
155
380
  }
156
381
  if (!isPlainObject(input.defaultsPayload)) {
157
382
  diagnostics.push({
158
- code: "openclaw_callback_config_missing",
383
+ code: "openclaw_gateway_defaults_missing",
159
384
  level: "warn",
160
- message: "No OpenClaw callback config was provided in agentDefaultsPayload.",
161
- hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw webhook immediately after approval.",
385
+ message: "No OpenClaw gateway config was provided in agentDefaultsPayload.",
386
+ hint: "Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
162
387
  });
163
- return { normalized: null, diagnostics };
388
+ fatalErrors.push("agentDefaultsPayload is required for adapterType=openclaw_gateway");
389
+ return {
390
+ normalized: null,
391
+ diagnostics,
392
+ fatalErrors
393
+ };
164
394
  }
165
395
  const defaults = input.defaultsPayload;
166
396
  const normalized = {};
167
- let callbackUrl = null;
168
- const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
169
- if (!rawUrl) {
397
+ let gatewayUrl = null;
398
+ const rawGatewayUrl = nonEmptyTrimmedString(defaults.url);
399
+ if (!rawGatewayUrl) {
170
400
  diagnostics.push({
171
- code: "openclaw_callback_url_missing",
401
+ code: "openclaw_gateway_url_missing",
172
402
  level: "warn",
173
- message: "OpenClaw callback URL is missing.",
174
- hint: "Set agentDefaultsPayload.url to your OpenClaw webhook endpoint.",
403
+ message: "OpenClaw gateway URL is missing.",
404
+ hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
175
405
  });
406
+ fatalErrors.push("agentDefaultsPayload.url is required");
176
407
  }
177
408
  else {
178
409
  try {
179
- callbackUrl = new URL(rawUrl);
180
- if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") {
410
+ gatewayUrl = new URL(rawGatewayUrl);
411
+ if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") {
181
412
  diagnostics.push({
182
- code: "openclaw_callback_url_protocol",
413
+ code: "openclaw_gateway_url_protocol",
183
414
  level: "warn",
184
- message: `Unsupported callback protocol: ${callbackUrl.protocol}`,
185
- hint: "Use http:// or https://.",
415
+ message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).`
186
416
  });
417
+ fatalErrors.push("agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway");
187
418
  }
188
419
  else {
189
- normalized.url = callbackUrl.toString();
420
+ normalized.url = gatewayUrl.toString();
190
421
  diagnostics.push({
191
- code: "openclaw_callback_url_configured",
422
+ code: "openclaw_gateway_url_configured",
192
423
  level: "info",
193
- message: `Callback endpoint set to ${callbackUrl.toString()}`,
194
- });
195
- }
196
- if (isLoopbackHost(callbackUrl.hostname)) {
197
- diagnostics.push({
198
- code: "openclaw_callback_loopback",
199
- level: "warn",
200
- message: "OpenClaw callback endpoint uses loopback hostname.",
201
- hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.",
424
+ message: `Gateway endpoint set to ${gatewayUrl.toString()}`
202
425
  });
203
426
  }
204
427
  }
205
428
  catch {
206
429
  diagnostics.push({
207
- code: "openclaw_callback_url_invalid",
430
+ code: "openclaw_gateway_url_invalid",
208
431
  level: "warn",
209
- message: `Invalid callback URL: ${rawUrl}`,
432
+ message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}`
210
433
  });
434
+ fatalErrors.push("agentDefaultsPayload.url is not a valid URL");
211
435
  }
212
436
  }
213
- const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : "";
214
- normalized.method = rawMethod || "POST";
215
- if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) {
216
- normalized.timeoutSec = Math.max(1, Math.min(120, Math.floor(defaults.timeoutSec)));
437
+ const headers = normalizeHeaderMap(defaults.headers) ?? {};
438
+ const gatewayToken = headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
439
+ headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
440
+ tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
441
+ if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) {
442
+ headers["x-openclaw-token"] = gatewayToken;
217
443
  }
218
- const headers = normalizeHeaderMap(defaults.headers);
219
- if (headers)
444
+ if (Object.keys(headers).length > 0) {
220
445
  normalized.headers = headers;
221
- if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) {
222
- normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim();
446
+ }
447
+ if (!gatewayToken) {
448
+ diagnostics.push({
449
+ code: "openclaw_gateway_auth_header_missing",
450
+ level: "warn",
451
+ message: "Gateway auth token is missing from agent defaults.",
452
+ hint: "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
453
+ });
454
+ fatalErrors.push("agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required");
455
+ }
456
+ else if (gatewayToken.trim().length < 16) {
457
+ diagnostics.push({
458
+ code: "openclaw_gateway_auth_header_too_short",
459
+ level: "warn",
460
+ message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`,
461
+ hint: "Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
462
+ });
463
+ fatalErrors.push("agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token");
464
+ }
465
+ else {
466
+ diagnostics.push({
467
+ code: "openclaw_gateway_auth_header_configured",
468
+ level: "info",
469
+ message: "Gateway auth token configured."
470
+ });
223
471
  }
224
472
  if (isPlainObject(defaults.payloadTemplate)) {
225
473
  normalized.payloadTemplate = defaults.payloadTemplate;
226
474
  }
227
- diagnostics.push(...buildJoinConnectivityDiagnostics({
228
- deploymentMode: input.deploymentMode,
229
- deploymentExposure: input.deploymentExposure,
230
- bindHost: input.bindHost,
231
- allowedHostnames: input.allowedHostnames,
232
- callbackUrl,
233
- }));
234
- return { normalized, diagnostics };
475
+ const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
476
+ const disableDeviceAuth = parsedDisableDeviceAuth === true;
477
+ if (parsedDisableDeviceAuth !== null) {
478
+ normalized.disableDeviceAuth = parsedDisableDeviceAuth;
479
+ }
480
+ const configuredDevicePrivateKeyPem = nonEmptyTrimmedString(defaults.devicePrivateKeyPem);
481
+ if (configuredDevicePrivateKeyPem) {
482
+ normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem;
483
+ diagnostics.push({
484
+ code: "openclaw_gateway_device_key_configured",
485
+ level: "info",
486
+ message: "Gateway device key configured. Pairing approvals should persist for this agent."
487
+ });
488
+ }
489
+ else if (!disableDeviceAuth) {
490
+ try {
491
+ normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem();
492
+ diagnostics.push({
493
+ code: "openclaw_gateway_device_key_generated",
494
+ level: "info",
495
+ message: "Generated persistent gateway device key for this join. Pairing approvals should persist for this agent."
496
+ });
497
+ }
498
+ catch (err) {
499
+ diagnostics.push({
500
+ code: "openclaw_gateway_device_key_generate_failed",
501
+ level: "warn",
502
+ message: `Failed to generate gateway device key: ${err instanceof Error ? err.message : String(err)}`,
503
+ hint: "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true."
504
+ });
505
+ fatalErrors.push("Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true.");
506
+ }
507
+ }
508
+ const waitTimeoutMs = typeof defaults.waitTimeoutMs === "number" &&
509
+ Number.isFinite(defaults.waitTimeoutMs)
510
+ ? Math.floor(defaults.waitTimeoutMs)
511
+ : typeof defaults.waitTimeoutMs === "string"
512
+ ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10)
513
+ : NaN;
514
+ if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) {
515
+ normalized.waitTimeoutMs = waitTimeoutMs;
516
+ }
517
+ const timeoutSec = typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)
518
+ ? Math.floor(defaults.timeoutSec)
519
+ : typeof defaults.timeoutSec === "string"
520
+ ? Number.parseInt(defaults.timeoutSec.trim(), 10)
521
+ : NaN;
522
+ if (Number.isFinite(timeoutSec) && timeoutSec > 0) {
523
+ normalized.timeoutSec = timeoutSec;
524
+ }
525
+ const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy);
526
+ if (sessionKeyStrategy === "fixed" ||
527
+ sessionKeyStrategy === "issue" ||
528
+ sessionKeyStrategy === "run") {
529
+ normalized.sessionKeyStrategy = sessionKeyStrategy;
530
+ }
531
+ const sessionKey = nonEmptyTrimmedString(defaults.sessionKey);
532
+ if (sessionKey) {
533
+ normalized.sessionKey = sessionKey;
534
+ }
535
+ const role = nonEmptyTrimmedString(defaults.role);
536
+ if (role) {
537
+ normalized.role = role;
538
+ }
539
+ if (Array.isArray(defaults.scopes)) {
540
+ const scopes = defaults.scopes
541
+ .filter((entry) => typeof entry === "string")
542
+ .map((entry) => entry.trim())
543
+ .filter(Boolean);
544
+ if (scopes.length > 0) {
545
+ normalized.scopes = scopes;
546
+ }
547
+ }
548
+ const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string"
549
+ ? defaults.paperclipApiUrl.trim()
550
+ : "";
551
+ if (rawPaperclipApiUrl) {
552
+ try {
553
+ const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
554
+ if (parsedPaperclipApiUrl.protocol !== "http:" &&
555
+ parsedPaperclipApiUrl.protocol !== "https:") {
556
+ diagnostics.push({
557
+ code: "openclaw_gateway_paperclip_api_url_protocol",
558
+ level: "warn",
559
+ message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`
560
+ });
561
+ }
562
+ else {
563
+ normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
564
+ diagnostics.push({
565
+ code: "openclaw_gateway_paperclip_api_url_configured",
566
+ level: "info",
567
+ message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
568
+ });
569
+ }
570
+ }
571
+ catch {
572
+ diagnostics.push({
573
+ code: "openclaw_gateway_paperclip_api_url_invalid",
574
+ level: "warn",
575
+ message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
576
+ });
577
+ }
578
+ }
579
+ return { normalized, diagnostics, fatalErrors };
235
580
  }
236
581
  function toInviteSummaryResponse(req, token, invite) {
237
582
  const baseUrl = requestBaseUrl(req);
238
583
  const onboardingPath = `/api/invites/${token}/onboarding`;
584
+ const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
585
+ const inviteMessage = extractInviteMessage(invite);
239
586
  return {
240
587
  id: invite.id,
241
588
  companyId: invite.companyId,
@@ -244,57 +591,386 @@ function toInviteSummaryResponse(req, token, invite) {
244
591
  expiresAt: invite.expiresAt,
245
592
  onboardingPath,
246
593
  onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
594
+ onboardingTextPath,
595
+ onboardingTextUrl: baseUrl
596
+ ? `${baseUrl}${onboardingTextPath}`
597
+ : onboardingTextPath,
247
598
  skillIndexPath: "/api/skills/index",
248
- skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
599
+ skillIndexUrl: baseUrl
600
+ ? `${baseUrl}/api/skills/index`
601
+ : "/api/skills/index",
602
+ inviteMessage
249
603
  };
250
604
  }
605
+ function buildOnboardingDiscoveryDiagnostics(input) {
606
+ const diagnostics = [];
607
+ let apiHost = null;
608
+ if (input.apiBaseUrl) {
609
+ try {
610
+ apiHost = normalizeHostname(new URL(input.apiBaseUrl).hostname);
611
+ }
612
+ catch {
613
+ apiHost = null;
614
+ }
615
+ }
616
+ const bindHost = normalizeHostname(input.bindHost);
617
+ const allowSet = new Set(input.allowedHostnames
618
+ .map((entry) => normalizeHostname(entry))
619
+ .filter((entry) => Boolean(entry)));
620
+ if (apiHost && isLoopbackHost(apiHost)) {
621
+ diagnostics.push({
622
+ code: "openclaw_onboarding_api_loopback",
623
+ level: "warn",
624
+ message: "Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host.",
625
+ hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain)."
626
+ });
627
+ }
628
+ if (input.deploymentMode === "authenticated" &&
629
+ input.deploymentExposure === "private" &&
630
+ (!bindHost || isLoopbackHost(bindHost))) {
631
+ diagnostics.push({
632
+ code: "openclaw_onboarding_private_loopback_bind",
633
+ level: "warn",
634
+ message: "Paperclip is bound to loopback in authenticated/private mode.",
635
+ hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding."
636
+ });
637
+ }
638
+ if (input.deploymentMode === "authenticated" &&
639
+ input.deploymentExposure === "private" &&
640
+ apiHost &&
641
+ !isLoopbackHost(apiHost) &&
642
+ allowSet.size > 0 &&
643
+ !allowSet.has(apiHost)) {
644
+ diagnostics.push({
645
+ code: "openclaw_onboarding_private_host_not_allowed",
646
+ level: "warn",
647
+ message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`,
648
+ hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`
649
+ });
650
+ }
651
+ return diagnostics;
652
+ }
653
+ function buildOnboardingConnectionCandidates(input) {
654
+ let base = null;
655
+ try {
656
+ if (input.apiBaseUrl) {
657
+ base = new URL(input.apiBaseUrl);
658
+ }
659
+ }
660
+ catch {
661
+ base = null;
662
+ }
663
+ const protocol = base?.protocol ?? "http:";
664
+ const port = base?.port ? `:${base.port}` : "";
665
+ const candidates = new Set();
666
+ if (base) {
667
+ candidates.add(base.origin);
668
+ }
669
+ const bindHost = normalizeHostname(input.bindHost);
670
+ if (bindHost && !isLoopbackHost(bindHost)) {
671
+ candidates.add(`${protocol}//${bindHost}${port}`);
672
+ }
673
+ for (const rawHost of input.allowedHostnames) {
674
+ const host = normalizeHostname(rawHost);
675
+ if (!host)
676
+ continue;
677
+ candidates.add(`${protocol}//${host}${port}`);
678
+ }
679
+ if (base && isLoopbackHost(base.hostname)) {
680
+ candidates.add(`${protocol}//host.docker.internal${port}`);
681
+ }
682
+ return Array.from(candidates);
683
+ }
251
684
  function buildInviteOnboardingManifest(req, token, invite, opts) {
252
685
  const baseUrl = requestBaseUrl(req);
253
686
  const skillPath = "/api/skills/paperclip";
254
687
  const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
255
688
  const registrationEndpointPath = `/api/invites/${token}/accept`;
256
- const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
689
+ const registrationEndpointUrl = baseUrl
690
+ ? `${baseUrl}${registrationEndpointPath}`
691
+ : registrationEndpointPath;
692
+ const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
693
+ const onboardingTextUrl = baseUrl
694
+ ? `${baseUrl}${onboardingTextPath}`
695
+ : onboardingTextPath;
696
+ const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
697
+ apiBaseUrl: baseUrl,
698
+ deploymentMode: opts.deploymentMode,
699
+ deploymentExposure: opts.deploymentExposure,
700
+ bindHost: opts.bindHost,
701
+ allowedHostnames: opts.allowedHostnames
702
+ });
703
+ const connectionCandidates = buildOnboardingConnectionCandidates({
704
+ apiBaseUrl: baseUrl,
705
+ bindHost: opts.bindHost,
706
+ allowedHostnames: opts.allowedHostnames
707
+ });
257
708
  return {
258
709
  invite: toInviteSummaryResponse(req, token, invite),
259
710
  onboarding: {
260
- instructions: "Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.",
261
- recommendedAdapterType: "openclaw",
711
+ instructions: "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
712
+ inviteMessage: extractInviteMessage(invite),
713
+ recommendedAdapterType: "openclaw_gateway",
262
714
  requiredFields: {
263
715
  requestType: "agent",
264
716
  agentName: "Display name for this agent",
265
- adapterType: "Use 'openclaw' for OpenClaw webhook-based agents",
717
+ adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
266
718
  capabilities: "Optional capability summary",
267
- agentDefaultsPayload: "Optional adapter config such as url/method/headers/webhookAuthHeader for OpenClaw callback endpoint",
719
+ agentDefaultsPayload: "Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem."
268
720
  },
269
721
  registrationEndpoint: {
270
722
  method: "POST",
271
723
  path: registrationEndpointPath,
272
- url: registrationEndpointUrl,
724
+ url: registrationEndpointUrl
273
725
  },
274
726
  claimEndpointTemplate: {
275
727
  method: "POST",
276
728
  path: "/api/join-requests/{requestId}/claim-api-key",
277
729
  body: {
278
- claimSecret: "one-time claim secret returned when the join request is created",
279
- },
730
+ claimSecret: "one-time claim secret returned when the join request is created"
731
+ }
280
732
  },
281
733
  connectivity: {
282
734
  deploymentMode: opts.deploymentMode,
283
735
  deploymentExposure: opts.deploymentExposure,
284
736
  bindHost: opts.bindHost,
285
737
  allowedHostnames: opts.allowedHostnames,
286
- guidance: opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
738
+ connectionCandidates,
739
+ diagnostics: discoveryDiagnostics,
740
+ guidance: opts.deploymentMode === "authenticated" &&
741
+ opts.deploymentExposure === "private"
287
742
  ? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
288
- : "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
743
+ : "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
744
+ },
745
+ textInstructions: {
746
+ path: onboardingTextPath,
747
+ url: onboardingTextUrl,
748
+ contentType: "text/plain"
289
749
  },
290
750
  skill: {
291
751
  name: "paperclip",
292
752
  path: skillPath,
293
753
  url: skillUrl,
294
- installPath: "~/.openclaw/skills/paperclip/SKILL.md",
295
- },
296
- },
754
+ installPath: "~/.openclaw/skills/paperclip/SKILL.md"
755
+ }
756
+ }
757
+ };
758
+ }
759
+ export function buildInviteOnboardingTextDocument(req, token, invite, opts) {
760
+ const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
761
+ const onboarding = manifest.onboarding;
762
+ const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
763
+ ? onboarding.connectivity.diagnostics
764
+ : [];
765
+ const lines = [];
766
+ const appendBlock = (block) => {
767
+ const trimmed = block.replace(/^\n/, "").replace(/\n\s*$/, "");
768
+ const lineIndentation = trimmed
769
+ .split("\n")
770
+ .filter((line) => line.trim().length > 0)
771
+ .map((line) => line.match(/^(\s*)/)?.[0].length ?? 0);
772
+ const minIndent = lineIndentation.length > 0 ? Math.min(...lineIndentation) : 0;
773
+ for (const line of trimmed.split("\n")) {
774
+ lines.push(line.slice(minIndent));
775
+ }
297
776
  };
777
+ appendBlock(`
778
+ # Paperclip OpenClaw Gateway Onboarding
779
+
780
+ This document is meant to be readable by both humans and agents.
781
+
782
+ ## Invite
783
+ - inviteType: ${invite.inviteType}
784
+ - allowedJoinTypes: ${invite.allowedJoinTypes}
785
+ - expiresAt: ${invite.expiresAt.toISOString()}
786
+ `);
787
+ if (onboarding.inviteMessage) {
788
+ appendBlock(`
789
+ ## Message from inviter
790
+ ${onboarding.inviteMessage}
791
+ `);
792
+ }
793
+ appendBlock(`
794
+ ## Step 0
795
+
796
+ Get the OpenClaw gateway auth token (THIS MUST BE SENT)
797
+ Token lives in:
798
+
799
+ ~/.openclaw/openclaw.json -> gateway.auth.token
800
+ Extract:
801
+
802
+ TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
803
+ test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
804
+ test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
805
+
806
+ 3) IMPORTANT: Don't accidentally drop the token when generating JSON
807
+ If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
808
+
809
+ Safe payload build looks sort of like this (substitute where necessary):
810
+
811
+ BODY="$(node -e '
812
+ const token = process.argv[1];
813
+ if (!token) process.exit(2);
814
+ const body = {
815
+ requestType: "agent",
816
+ agentName: "OpenClaw",
817
+ adapterType: "openclaw_gateway",
818
+ capabilities: "OpenClaw agent adapter",
819
+ agentDefaultsPayload: {
820
+ url: "ws://127.0.0.1:18789",
821
+ paperclipApiUrl: "http://host.docker.internal:3100",
822
+ headers: { "x-openclaw-token": token },
823
+ waitTimeoutMs: 120000,
824
+ sessionKeyStrategy: "issue",
825
+ role: "operator",
826
+ scopes: ["operator.admin"]
827
+ }
828
+ };
829
+ process.stdout.write(JSON.stringify(body));
830
+ ' "$TOKEN")"
831
+
832
+ ## Step 1: Submit agent join request
833
+ ${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}
834
+
835
+ IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token.
836
+ Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred.
837
+ Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL.
838
+ Pairing mode requirement:
839
+ - Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable.
840
+ - You may set disableDeviceAuth=true only for special environments that cannot support pairing.
841
+ - First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry.
842
+ Do NOT use /v1/responses or /hooks/* in this gateway join flow.
843
+
844
+ Body (JSON):
845
+ {
846
+ "requestType": "agent",
847
+ "agentName": "My OpenClaw Agent",
848
+ "adapterType": "openclaw_gateway",
849
+ "capabilities": "Optional summary",
850
+ "agentDefaultsPayload": {
851
+ "url": "wss://your-openclaw-gateway.example",
852
+ "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
853
+ "headers": { "x-openclaw-token": "replace-me" },
854
+ "waitTimeoutMs": 120000,
855
+ "sessionKeyStrategy": "issue",
856
+ "role": "operator",
857
+ "scopes": ["operator.admin"]
858
+ }
859
+ }
860
+
861
+ Expected response includes:
862
+ - request id
863
+ - one-time claimSecret
864
+ - claimApiKeyPath
865
+
866
+ ## Step 2: Wait for board approval
867
+ The board approves the join request in Paperclip before key claim is allowed.
868
+
869
+ ## Step 3: Claim API key (one-time)
870
+ ${onboarding.claimEndpointTemplate.method} /api/join-requests/{requestId}/claim-api-key
871
+
872
+ Body (JSON):
873
+ {
874
+ "claimSecret": "<one-time-claim-secret>"
875
+ }
876
+
877
+ On successful claim, save the full JSON response to:
878
+
879
+ - ~/.openclaw/workspace/paperclip-claimed-api-key.json
880
+ chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json
881
+
882
+ And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here:
883
+ https://docs.openclaw.ai/help/environment
884
+
885
+ e.g.
886
+
887
+ {
888
+ env: {
889
+ PAPERCLIP_API_KEY: "...",
890
+ PAPERCLIP_API_URL: "...",
891
+ },
892
+ }
893
+
894
+ Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run.
895
+
896
+ Important:
897
+ - claim secrets expire
898
+ - claim secrets are single-use
899
+ - claim fails before board approval
900
+
901
+ ## Step 4: Install Paperclip skill in OpenClaw
902
+ GET ${onboarding.skill.url}
903
+ Install path: ${onboarding.skill.installPath}
904
+
905
+ Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL
906
+
907
+ ## Text onboarding URL
908
+ ${onboarding.textInstructions.url}
909
+
910
+ ## Connectivity guidance
911
+ ${onboarding.connectivity?.guidance ??
912
+ "Ensure Paperclip is reachable from your OpenClaw runtime."}
913
+ `);
914
+ const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates)
915
+ ? onboarding.connectivity.connectionCandidates.filter((entry) => Boolean(entry))
916
+ : [];
917
+ if (connectionCandidates.length > 0) {
918
+ lines.push("## Suggested Paperclip base URLs to try");
919
+ for (const candidate of connectionCandidates) {
920
+ lines.push(`- ${candidate}`);
921
+ }
922
+ appendBlock(`
923
+
924
+ Test each candidate with:
925
+ - GET <candidate>/api/health
926
+ - set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request
927
+
928
+ If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.
929
+ For authenticated/private mode, they may need:
930
+ - pnpm paperclipai allowed-hostname <host>
931
+ - then restart Paperclip and retry onboarding.
932
+ `);
933
+ }
934
+ if (diagnostics.length > 0) {
935
+ lines.push("## Connectivity diagnostics");
936
+ for (const diag of diagnostics) {
937
+ lines.push(`- [${diag.level}] ${diag.message}`);
938
+ if (diag.hint)
939
+ lines.push(` hint: ${diag.hint}`);
940
+ }
941
+ }
942
+ appendBlock(`
943
+
944
+ ## Helpful endpoints
945
+ ${onboarding.registrationEndpoint.path}
946
+ ${onboarding.claimEndpointTemplate.path}
947
+ ${onboarding.skill.path}
948
+ ${manifest.invite.onboardingPath}
949
+ `);
950
+ return `${lines.join("\n")}\n`;
951
+ }
952
+ function extractInviteMessage(invite) {
953
+ const rawDefaults = invite.defaultsPayload;
954
+ if (!rawDefaults ||
955
+ typeof rawDefaults !== "object" ||
956
+ Array.isArray(rawDefaults)) {
957
+ return null;
958
+ }
959
+ const rawMessage = rawDefaults.agentMessage;
960
+ if (typeof rawMessage !== "string") {
961
+ return null;
962
+ }
963
+ const trimmed = rawMessage.trim();
964
+ return trimmed.length ? trimmed : null;
965
+ }
966
+ function mergeInviteDefaults(defaultsPayload, agentMessage) {
967
+ const merged = defaultsPayload && typeof defaultsPayload === "object"
968
+ ? { ...defaultsPayload }
969
+ : {};
970
+ if (agentMessage) {
971
+ merged.agentMessage = agentMessage;
972
+ }
973
+ return Object.keys(merged).length ? merged : null;
298
974
  }
299
975
  function requestIp(req) {
300
976
  const forwarded = req.header("x-forwarded-for");
@@ -345,13 +1021,111 @@ function grantsFromDefaults(defaultsPayload, key) {
345
1021
  continue;
346
1022
  result.push({
347
1023
  permissionKey: record.permissionKey,
348
- scope: record.scope && typeof record.scope === "object" && !Array.isArray(record.scope)
1024
+ scope: record.scope &&
1025
+ typeof record.scope === "object" &&
1026
+ !Array.isArray(record.scope)
349
1027
  ? record.scope
350
- : null,
1028
+ : null
351
1029
  });
352
1030
  }
353
1031
  return result;
354
1032
  }
1033
+ export function resolveJoinRequestAgentManagerId(candidates) {
1034
+ const ceoCandidates = candidates.filter((candidate) => candidate.role === "ceo");
1035
+ if (ceoCandidates.length === 0)
1036
+ return null;
1037
+ const rootCeo = ceoCandidates.find((candidate) => candidate.reportsTo === null);
1038
+ return (rootCeo ?? ceoCandidates[0] ?? null)?.id ?? null;
1039
+ }
1040
+ function isInviteTokenHashCollisionError(error) {
1041
+ const candidates = [
1042
+ error,
1043
+ error?.cause ?? null
1044
+ ];
1045
+ for (const candidate of candidates) {
1046
+ if (!candidate || typeof candidate !== "object")
1047
+ continue;
1048
+ const code = "code" in candidate && typeof candidate.code === "string"
1049
+ ? candidate.code
1050
+ : null;
1051
+ const message = "message" in candidate && typeof candidate.message === "string"
1052
+ ? candidate.message
1053
+ : "";
1054
+ const constraint = "constraint" in candidate && typeof candidate.constraint === "string"
1055
+ ? candidate.constraint
1056
+ : null;
1057
+ if (code !== "23505")
1058
+ continue;
1059
+ if (constraint === "invites_token_hash_unique_idx")
1060
+ return true;
1061
+ if (message.includes("invites_token_hash_unique_idx"))
1062
+ return true;
1063
+ }
1064
+ return false;
1065
+ }
1066
+ function isAbortError(error) {
1067
+ return error instanceof Error && error.name === "AbortError";
1068
+ }
1069
+ async function probeInviteResolutionTarget(url, timeoutMs) {
1070
+ const startedAt = Date.now();
1071
+ const controller = new AbortController();
1072
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1073
+ try {
1074
+ const response = await fetch(url, {
1075
+ method: "HEAD",
1076
+ redirect: "manual",
1077
+ signal: controller.signal
1078
+ });
1079
+ const durationMs = Date.now() - startedAt;
1080
+ if (response.ok ||
1081
+ response.status === 401 ||
1082
+ response.status === 403 ||
1083
+ response.status === 404 ||
1084
+ response.status === 405 ||
1085
+ response.status === 422 ||
1086
+ response.status === 500 ||
1087
+ response.status === 501) {
1088
+ return {
1089
+ status: "reachable",
1090
+ method: "HEAD",
1091
+ durationMs,
1092
+ httpStatus: response.status,
1093
+ message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`
1094
+ };
1095
+ }
1096
+ return {
1097
+ status: "unreachable",
1098
+ method: "HEAD",
1099
+ durationMs,
1100
+ httpStatus: response.status,
1101
+ message: `Webhook endpoint probe returned HTTP ${response.status}.`
1102
+ };
1103
+ }
1104
+ catch (error) {
1105
+ const durationMs = Date.now() - startedAt;
1106
+ if (isAbortError(error)) {
1107
+ return {
1108
+ status: "timeout",
1109
+ method: "HEAD",
1110
+ durationMs,
1111
+ httpStatus: null,
1112
+ message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`
1113
+ };
1114
+ }
1115
+ return {
1116
+ status: "unreachable",
1117
+ method: "HEAD",
1118
+ durationMs,
1119
+ httpStatus: null,
1120
+ message: error instanceof Error
1121
+ ? error.message
1122
+ : "Webhook endpoint probe failed."
1123
+ };
1124
+ }
1125
+ finally {
1126
+ clearTimeout(timeout);
1127
+ }
1128
+ }
355
1129
  export function accessRoutes(db, opts) {
356
1130
  const router = Router();
357
1131
  const access = accessService(db);
@@ -382,20 +1156,25 @@ export function accessRoutes(db, opts) {
382
1156
  throw notFound("Board claim challenge not found");
383
1157
  if (!code)
384
1158
  throw badRequest("Claim code is required");
385
- if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId) {
1159
+ if (req.actor.type !== "board" ||
1160
+ req.actor.source !== "session" ||
1161
+ !req.actor.userId) {
386
1162
  throw unauthorized("Sign in before claiming board ownership");
387
1163
  }
388
1164
  const claimed = await claimBoardOwnership(db, {
389
1165
  token,
390
1166
  code,
391
- userId: req.actor.userId,
1167
+ userId: req.actor.userId
392
1168
  });
393
1169
  if (claimed.status === "invalid")
394
1170
  throw notFound("Board claim challenge not found");
395
1171
  if (claimed.status === "expired")
396
1172
  throw conflict("Board claim challenge expired. Restart server to generate a new one.");
397
1173
  if (claimed.status === "claimed") {
398
- res.json({ claimed: true, userId: claimed.claimedByUserId ?? req.actor.userId });
1174
+ res.json({
1175
+ claimed: true,
1176
+ userId: claimed.claimedByUserId ?? req.actor.userId
1177
+ });
399
1178
  return;
400
1179
  }
401
1180
  throw conflict("Board claim challenge is no longer available");
@@ -418,12 +1197,77 @@ export function accessRoutes(db, opts) {
418
1197
  if (!allowed)
419
1198
  throw forbidden("Permission denied");
420
1199
  }
1200
+ async function assertCanGenerateOpenClawInvitePrompt(req, companyId) {
1201
+ assertCompanyAccess(req, companyId);
1202
+ if (req.actor.type === "agent") {
1203
+ if (!req.actor.agentId)
1204
+ throw forbidden("Agent authentication required");
1205
+ const actorAgent = await agents.getById(req.actor.agentId);
1206
+ if (!actorAgent || actorAgent.companyId !== companyId) {
1207
+ throw forbidden("Agent key cannot access another company");
1208
+ }
1209
+ if (actorAgent.role !== "ceo") {
1210
+ throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
1211
+ }
1212
+ return;
1213
+ }
1214
+ if (req.actor.type !== "board")
1215
+ throw unauthorized();
1216
+ if (isLocalImplicit(req))
1217
+ return;
1218
+ const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
1219
+ if (!allowed)
1220
+ throw forbidden("Permission denied");
1221
+ }
1222
+ async function createCompanyInviteForCompany(input) {
1223
+ const normalizedAgentMessage = typeof input.agentMessage === "string"
1224
+ ? input.agentMessage.trim() || null
1225
+ : null;
1226
+ const insertValues = {
1227
+ companyId: input.companyId,
1228
+ inviteType: "company_join",
1229
+ allowedJoinTypes: input.allowedJoinTypes,
1230
+ defaultsPayload: mergeInviteDefaults(input.defaultsPayload ?? null, normalizedAgentMessage),
1231
+ expiresAt: companyInviteExpiresAt(),
1232
+ invitedByUserId: input.req.actor.userId ?? null
1233
+ };
1234
+ let token = null;
1235
+ let created = null;
1236
+ for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
1237
+ const candidateToken = createInviteToken();
1238
+ try {
1239
+ const row = await db
1240
+ .insert(invites)
1241
+ .values({
1242
+ ...insertValues,
1243
+ tokenHash: hashToken(candidateToken)
1244
+ })
1245
+ .returning()
1246
+ .then((rows) => rows[0]);
1247
+ token = candidateToken;
1248
+ created = row;
1249
+ break;
1250
+ }
1251
+ catch (error) {
1252
+ if (!isInviteTokenHashCollisionError(error)) {
1253
+ throw error;
1254
+ }
1255
+ }
1256
+ }
1257
+ if (!token || !created) {
1258
+ throw conflict("Failed to generate a unique invite token. Please retry.");
1259
+ }
1260
+ return { token, created, normalizedAgentMessage };
1261
+ }
421
1262
  router.get("/skills/index", (_req, res) => {
422
1263
  res.json({
423
1264
  skills: [
424
1265
  { name: "paperclip", path: "/api/skills/paperclip" },
425
- { name: "paperclip-create-agent", path: "/api/skills/paperclip-create-agent" },
426
- ],
1266
+ {
1267
+ name: "paperclip-create-agent",
1268
+ path: "/api/skills/paperclip-create-agent"
1269
+ }
1270
+ ]
427
1271
  });
428
1272
  });
429
1273
  router.get("/skills/:skillName", (req, res) => {
@@ -436,24 +1280,19 @@ export function accessRoutes(db, opts) {
436
1280
  router.post("/companies/:companyId/invites", validate(createCompanyInviteSchema), async (req, res) => {
437
1281
  const companyId = req.params.companyId;
438
1282
  await assertCompanyPermission(req, companyId, "users:invite");
439
- const token = createInviteToken();
440
- const created = await db
441
- .insert(invites)
442
- .values({
1283
+ const { token, created, normalizedAgentMessage } = await createCompanyInviteForCompany({
1284
+ req,
443
1285
  companyId,
444
- inviteType: "company_join",
445
- tokenHash: hashToken(token),
446
1286
  allowedJoinTypes: req.body.allowedJoinTypes,
447
1287
  defaultsPayload: req.body.defaultsPayload ?? null,
448
- expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
449
- invitedByUserId: req.actor.userId ?? null,
450
- })
451
- .returning()
452
- .then((rows) => rows[0]);
1288
+ agentMessage: req.body.agentMessage ?? null
1289
+ });
453
1290
  await logActivity(db, {
454
1291
  companyId,
455
1292
  actorType: req.actor.type === "agent" ? "agent" : "user",
456
- actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
1293
+ actorId: req.actor.type === "agent"
1294
+ ? req.actor.agentId ?? "unknown-agent"
1295
+ : req.actor.userId ?? "board",
457
1296
  action: "invite.created",
458
1297
  entityType: "invite",
459
1298
  entityId: created.id,
@@ -461,12 +1300,53 @@ export function accessRoutes(db, opts) {
461
1300
  inviteType: created.inviteType,
462
1301
  allowedJoinTypes: created.allowedJoinTypes,
463
1302
  expiresAt: created.expiresAt.toISOString(),
464
- },
1303
+ hasAgentMessage: Boolean(normalizedAgentMessage)
1304
+ }
465
1305
  });
1306
+ const inviteSummary = toInviteSummaryResponse(req, token, created);
466
1307
  res.status(201).json({
467
1308
  ...created,
468
1309
  token,
469
1310
  inviteUrl: `/invite/${token}`,
1311
+ onboardingTextPath: inviteSummary.onboardingTextPath,
1312
+ onboardingTextUrl: inviteSummary.onboardingTextUrl,
1313
+ inviteMessage: inviteSummary.inviteMessage
1314
+ });
1315
+ });
1316
+ router.post("/companies/:companyId/openclaw/invite-prompt", validate(createOpenClawInvitePromptSchema), async (req, res) => {
1317
+ const companyId = req.params.companyId;
1318
+ await assertCanGenerateOpenClawInvitePrompt(req, companyId);
1319
+ const { token, created, normalizedAgentMessage } = await createCompanyInviteForCompany({
1320
+ req,
1321
+ companyId,
1322
+ allowedJoinTypes: "agent",
1323
+ defaultsPayload: null,
1324
+ agentMessage: req.body.agentMessage ?? null
1325
+ });
1326
+ await logActivity(db, {
1327
+ companyId,
1328
+ actorType: req.actor.type === "agent" ? "agent" : "user",
1329
+ actorId: req.actor.type === "agent"
1330
+ ? req.actor.agentId ?? "unknown-agent"
1331
+ : req.actor.userId ?? "board",
1332
+ action: "invite.openclaw_prompt_created",
1333
+ entityType: "invite",
1334
+ entityId: created.id,
1335
+ details: {
1336
+ inviteType: created.inviteType,
1337
+ allowedJoinTypes: created.allowedJoinTypes,
1338
+ expiresAt: created.expiresAt.toISOString(),
1339
+ hasAgentMessage: Boolean(normalizedAgentMessage)
1340
+ }
1341
+ });
1342
+ const inviteSummary = toInviteSummaryResponse(req, token, created);
1343
+ res.status(201).json({
1344
+ ...created,
1345
+ token,
1346
+ inviteUrl: `/invite/${token}`,
1347
+ onboardingTextPath: inviteSummary.onboardingTextPath,
1348
+ onboardingTextUrl: inviteSummary.onboardingTextUrl,
1349
+ inviteMessage: inviteSummary.inviteMessage
470
1350
  });
471
1351
  });
472
1352
  router.get("/invites/:token", async (req, res) => {
@@ -478,7 +1358,10 @@ export function accessRoutes(db, opts) {
478
1358
  .from(invites)
479
1359
  .where(eq(invites.tokenHash, hashToken(token)))
480
1360
  .then((rows) => rows[0] ?? null);
481
- if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
1361
+ if (!invite ||
1362
+ invite.revokedAt ||
1363
+ invite.acceptedAt ||
1364
+ inviteExpired(invite)) {
482
1365
  throw notFound("Invite not found");
483
1366
  }
484
1367
  res.json(toInviteSummaryResponse(req, token, invite));
@@ -497,6 +1380,62 @@ export function accessRoutes(db, opts) {
497
1380
  }
498
1381
  res.json(buildInviteOnboardingManifest(req, token, invite, opts));
499
1382
  });
1383
+ router.get("/invites/:token/onboarding.txt", async (req, res) => {
1384
+ const token = req.params.token.trim();
1385
+ if (!token)
1386
+ throw notFound("Invite not found");
1387
+ const invite = await db
1388
+ .select()
1389
+ .from(invites)
1390
+ .where(eq(invites.tokenHash, hashToken(token)))
1391
+ .then((rows) => rows[0] ?? null);
1392
+ if (!invite || invite.revokedAt || inviteExpired(invite)) {
1393
+ throw notFound("Invite not found");
1394
+ }
1395
+ res
1396
+ .type("text/plain; charset=utf-8")
1397
+ .send(buildInviteOnboardingTextDocument(req, token, invite, opts));
1398
+ });
1399
+ router.get("/invites/:token/test-resolution", async (req, res) => {
1400
+ const token = req.params.token.trim();
1401
+ if (!token)
1402
+ throw notFound("Invite not found");
1403
+ const invite = await db
1404
+ .select()
1405
+ .from(invites)
1406
+ .where(eq(invites.tokenHash, hashToken(token)))
1407
+ .then((rows) => rows[0] ?? null);
1408
+ if (!invite || invite.revokedAt || inviteExpired(invite)) {
1409
+ throw notFound("Invite not found");
1410
+ }
1411
+ const rawUrl = typeof req.query.url === "string" ? req.query.url.trim() : "";
1412
+ if (!rawUrl)
1413
+ throw badRequest("url query parameter is required");
1414
+ let target;
1415
+ try {
1416
+ target = new URL(rawUrl);
1417
+ }
1418
+ catch {
1419
+ throw badRequest("url must be an absolute http(s) URL");
1420
+ }
1421
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
1422
+ throw badRequest("url must use http or https");
1423
+ }
1424
+ const parsedTimeoutMs = typeof req.query.timeoutMs === "string"
1425
+ ? Number(req.query.timeoutMs)
1426
+ : NaN;
1427
+ const timeoutMs = Number.isFinite(parsedTimeoutMs)
1428
+ ? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
1429
+ : 5000;
1430
+ const probe = await probeInviteResolutionTarget(target, timeoutMs);
1431
+ res.json({
1432
+ inviteId: invite.id,
1433
+ testResolutionPath: `/api/invites/${token}/test-resolution`,
1434
+ requestedUrl: target.toString(),
1435
+ timeoutMs,
1436
+ ...probe
1437
+ });
1438
+ });
500
1439
  router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
501
1440
  const token = req.params.token.trim();
502
1441
  if (!token)
@@ -506,14 +1445,25 @@ export function accessRoutes(db, opts) {
506
1445
  .from(invites)
507
1446
  .where(eq(invites.tokenHash, hashToken(token)))
508
1447
  .then((rows) => rows[0] ?? null);
509
- if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
1448
+ if (!invite || invite.revokedAt || inviteExpired(invite)) {
510
1449
  throw notFound("Invite not found");
511
1450
  }
1451
+ const inviteAlreadyAccepted = Boolean(invite.acceptedAt);
1452
+ const existingJoinRequestForInvite = inviteAlreadyAccepted
1453
+ ? await db
1454
+ .select()
1455
+ .from(joinRequests)
1456
+ .where(eq(joinRequests.inviteId, invite.id))
1457
+ .then((rows) => rows[0] ?? null)
1458
+ : null;
512
1459
  if (invite.inviteType === "bootstrap_ceo") {
1460
+ if (inviteAlreadyAccepted)
1461
+ throw notFound("Invite not found");
513
1462
  if (req.body.requestType !== "human") {
514
1463
  throw badRequest("Bootstrap invite requires human request type");
515
1464
  }
516
- if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) {
1465
+ if (req.actor.type !== "board" ||
1466
+ (!req.actor.userId && !isLocalImplicit(req))) {
517
1467
  throw unauthorized("Authenticated user required for bootstrap acceptance");
518
1468
  }
519
1469
  const userId = req.actor.userId ?? "local-board";
@@ -531,7 +1481,7 @@ export function accessRoutes(db, opts) {
531
1481
  inviteId: updatedInvite.id,
532
1482
  inviteType: updatedInvite.inviteType,
533
1483
  bootstrapAccepted: true,
534
- userId,
1484
+ userId
535
1485
  });
536
1486
  return;
537
1487
  }
@@ -539,70 +1489,235 @@ export function accessRoutes(db, opts) {
539
1489
  const companyId = invite.companyId;
540
1490
  if (!companyId)
541
1491
  throw conflict("Invite is missing company scope");
542
- if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) {
1492
+ if (invite.allowedJoinTypes !== "both" &&
1493
+ invite.allowedJoinTypes !== requestType) {
543
1494
  throw badRequest(`Invite does not allow ${requestType} joins`);
544
1495
  }
545
1496
  if (requestType === "human" && req.actor.type !== "board") {
546
1497
  throw unauthorized("Human invite acceptance requires authenticated user");
547
1498
  }
548
- if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) {
1499
+ if (requestType === "human" &&
1500
+ !req.actor.userId &&
1501
+ !isLocalImplicit(req)) {
549
1502
  throw unauthorized("Authenticated user is required");
550
1503
  }
551
1504
  if (requestType === "agent" && !req.body.agentName) {
552
- throw badRequest("agentName is required for agent join requests");
1505
+ if (!inviteAlreadyAccepted ||
1506
+ !existingJoinRequestForInvite?.agentName) {
1507
+ throw badRequest("agentName is required for agent join requests");
1508
+ }
1509
+ }
1510
+ const adapterType = req.body.adapterType ?? null;
1511
+ if (inviteAlreadyAccepted &&
1512
+ !canReplayOpenClawGatewayInviteAccept({
1513
+ requestType,
1514
+ adapterType,
1515
+ existingJoinRequest: existingJoinRequestForInvite
1516
+ })) {
1517
+ throw notFound("Invite not found");
1518
+ }
1519
+ const replayJoinRequestId = inviteAlreadyAccepted
1520
+ ? existingJoinRequestForInvite?.id ?? null
1521
+ : null;
1522
+ if (inviteAlreadyAccepted && !replayJoinRequestId) {
1523
+ throw conflict("Join request not found");
553
1524
  }
1525
+ const replayMergedDefaults = inviteAlreadyAccepted
1526
+ ? mergeJoinDefaultsPayloadForReplay(existingJoinRequestForInvite?.agentDefaultsPayload ?? null, req.body.agentDefaultsPayload ?? null)
1527
+ : req.body.agentDefaultsPayload ?? null;
1528
+ const gatewayDefaultsPayload = requestType === "agent"
1529
+ ? buildJoinDefaultsPayloadForAccept({
1530
+ adapterType,
1531
+ defaultsPayload: replayMergedDefaults,
1532
+ paperclipApiUrl: req.body.paperclipApiUrl ?? null,
1533
+ inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
1534
+ inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
1535
+ })
1536
+ : null;
554
1537
  const joinDefaults = requestType === "agent"
555
1538
  ? normalizeAgentDefaultsForJoin({
556
- adapterType: req.body.adapterType ?? null,
557
- defaultsPayload: req.body.agentDefaultsPayload ?? null,
1539
+ adapterType,
1540
+ defaultsPayload: gatewayDefaultsPayload,
558
1541
  deploymentMode: opts.deploymentMode,
559
1542
  deploymentExposure: opts.deploymentExposure,
560
1543
  bindHost: opts.bindHost,
561
- allowedHostnames: opts.allowedHostnames,
1544
+ allowedHostnames: opts.allowedHostnames
562
1545
  })
563
- : { normalized: null, diagnostics: [] };
564
- const claimSecret = requestType === "agent" ? createClaimSecret() : null;
1546
+ : {
1547
+ normalized: null,
1548
+ diagnostics: [],
1549
+ fatalErrors: []
1550
+ };
1551
+ if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
1552
+ throw badRequest(joinDefaults.fatalErrors.join("; "));
1553
+ }
1554
+ if (requestType === "agent" && adapterType === "openclaw_gateway") {
1555
+ logger.info({
1556
+ inviteId: invite.id,
1557
+ joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
1558
+ code: diag.code,
1559
+ level: diag.level
1560
+ })),
1561
+ normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog(joinDefaults.normalized)
1562
+ }, "invite accept normalized OpenClaw gateway defaults");
1563
+ }
1564
+ const claimSecret = requestType === "agent" && !inviteAlreadyAccepted
1565
+ ? createClaimSecret()
1566
+ : null;
565
1567
  const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
566
1568
  const claimSecretExpiresAt = claimSecret
567
1569
  ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
568
1570
  : null;
569
1571
  const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null;
570
- const created = await db.transaction(async (tx) => {
571
- await tx
572
- .update(invites)
573
- .set({ acceptedAt: new Date(), updatedAt: new Date() })
574
- .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt)));
575
- const row = await tx
576
- .insert(joinRequests)
577
- .values({
578
- inviteId: invite.id,
579
- companyId,
580
- requestType,
581
- status: "pending_approval",
1572
+ const created = !inviteAlreadyAccepted
1573
+ ? await db.transaction(async (tx) => {
1574
+ await tx
1575
+ .update(invites)
1576
+ .set({ acceptedAt: new Date(), updatedAt: new Date() })
1577
+ .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt)));
1578
+ const row = await tx
1579
+ .insert(joinRequests)
1580
+ .values({
1581
+ inviteId: invite.id,
1582
+ companyId,
1583
+ requestType,
1584
+ status: "pending_approval",
1585
+ requestIp: requestIp(req),
1586
+ requestingUserId: requestType === "human"
1587
+ ? req.actor.userId ?? "local-board"
1588
+ : null,
1589
+ requestEmailSnapshot: requestType === "human" ? actorEmail : null,
1590
+ agentName: requestType === "agent" ? req.body.agentName : null,
1591
+ adapterType: requestType === "agent" ? adapterType : null,
1592
+ capabilities: requestType === "agent"
1593
+ ? req.body.capabilities ?? null
1594
+ : null,
1595
+ agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
1596
+ claimSecretHash,
1597
+ claimSecretExpiresAt
1598
+ })
1599
+ .returning()
1600
+ .then((rows) => rows[0]);
1601
+ return row;
1602
+ })
1603
+ : await db
1604
+ .update(joinRequests)
1605
+ .set({
582
1606
  requestIp: requestIp(req),
583
- requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null,
584
- requestEmailSnapshot: requestType === "human" ? actorEmail : null,
585
- agentName: requestType === "agent" ? req.body.agentName : null,
586
- adapterType: requestType === "agent" ? req.body.adapterType ?? null : null,
587
- capabilities: requestType === "agent" ? req.body.capabilities ?? null : null,
1607
+ agentName: requestType === "agent"
1608
+ ? req.body.agentName ??
1609
+ existingJoinRequestForInvite?.agentName ??
1610
+ null
1611
+ : null,
1612
+ capabilities: requestType === "agent"
1613
+ ? req.body.capabilities ??
1614
+ existingJoinRequestForInvite?.capabilities ??
1615
+ null
1616
+ : null,
1617
+ adapterType: requestType === "agent" ? adapterType : null,
588
1618
  agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
589
- claimSecretHash,
590
- claimSecretExpiresAt,
1619
+ updatedAt: new Date()
591
1620
  })
1621
+ .where(eq(joinRequests.id, replayJoinRequestId))
592
1622
  .returning()
593
1623
  .then((rows) => rows[0]);
594
- return row;
595
- });
1624
+ if (!created) {
1625
+ throw conflict("Join request not found");
1626
+ }
1627
+ if (inviteAlreadyAccepted &&
1628
+ requestType === "agent" &&
1629
+ adapterType === "openclaw_gateway" &&
1630
+ created.status === "approved" &&
1631
+ created.createdAgentId) {
1632
+ const existingAgent = await agents.getById(created.createdAgentId);
1633
+ if (!existingAgent) {
1634
+ throw conflict("Approved join request agent not found");
1635
+ }
1636
+ const existingAdapterConfig = isPlainObject(existingAgent.adapterConfig)
1637
+ ? existingAgent.adapterConfig
1638
+ : {};
1639
+ const nextAdapterConfig = {
1640
+ ...existingAdapterConfig,
1641
+ ...(joinDefaults.normalized ?? {})
1642
+ };
1643
+ const updatedAgent = await agents.update(created.createdAgentId, {
1644
+ adapterType,
1645
+ adapterConfig: nextAdapterConfig
1646
+ });
1647
+ if (!updatedAgent) {
1648
+ throw conflict("Approved join request agent not found");
1649
+ }
1650
+ await logActivity(db, {
1651
+ companyId,
1652
+ actorType: req.actor.type === "agent" ? "agent" : "user",
1653
+ actorId: req.actor.type === "agent"
1654
+ ? req.actor.agentId ?? "invite-agent"
1655
+ : req.actor.userId ?? "board",
1656
+ action: "agent.updated_from_join_replay",
1657
+ entityType: "agent",
1658
+ entityId: updatedAgent.id,
1659
+ details: { inviteId: invite.id, joinRequestId: created.id }
1660
+ });
1661
+ }
1662
+ if (requestType === "agent" && adapterType === "openclaw_gateway") {
1663
+ const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog(joinDefaults.normalized);
1664
+ const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog(created.agentDefaultsPayload);
1665
+ const missingPersistedFields = [];
1666
+ if (expectedDefaults.url && !persistedDefaults.url)
1667
+ missingPersistedFields.push("url");
1668
+ if (expectedDefaults.paperclipApiUrl &&
1669
+ !persistedDefaults.paperclipApiUrl) {
1670
+ missingPersistedFields.push("paperclipApiUrl");
1671
+ }
1672
+ if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) {
1673
+ missingPersistedFields.push("headers.x-openclaw-token");
1674
+ }
1675
+ if (expectedDefaults.devicePrivateKeyPem &&
1676
+ !persistedDefaults.devicePrivateKeyPem) {
1677
+ missingPersistedFields.push("devicePrivateKeyPem");
1678
+ }
1679
+ if (expectedDefaults.headerKeys.length > 0 &&
1680
+ persistedDefaults.headerKeys.length === 0) {
1681
+ missingPersistedFields.push("headers");
1682
+ }
1683
+ logger.info({
1684
+ inviteId: invite.id,
1685
+ joinRequestId: created.id,
1686
+ joinRequestStatus: created.status,
1687
+ expectedDefaults,
1688
+ persistedDefaults,
1689
+ diagnostics: joinDefaults.diagnostics.map((diag) => ({
1690
+ code: diag.code,
1691
+ level: diag.level,
1692
+ message: diag.message,
1693
+ hint: diag.hint ?? null
1694
+ }))
1695
+ }, "invite accept persisted OpenClaw gateway join request");
1696
+ if (missingPersistedFields.length > 0) {
1697
+ logger.warn({
1698
+ inviteId: invite.id,
1699
+ joinRequestId: created.id,
1700
+ missingPersistedFields
1701
+ }, "invite accept detected missing persisted OpenClaw gateway defaults");
1702
+ }
1703
+ }
596
1704
  await logActivity(db, {
597
1705
  companyId,
598
1706
  actorType: req.actor.type === "agent" ? "agent" : "user",
599
1707
  actorId: req.actor.type === "agent"
600
1708
  ? req.actor.agentId ?? "invite-agent"
601
- : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"),
602
- action: "join.requested",
1709
+ : req.actor.userId ??
1710
+ (requestType === "agent" ? "invite-anon" : "board"),
1711
+ action: inviteAlreadyAccepted
1712
+ ? "join.request_replayed"
1713
+ : "join.requested",
603
1714
  entityType: "join_request",
604
1715
  entityId: created.id,
605
- details: { requestType, requestIp: created.requestIp },
1716
+ details: {
1717
+ requestType,
1718
+ requestIp: created.requestIp,
1719
+ inviteReplay: inviteAlreadyAccepted
1720
+ }
606
1721
  });
607
1722
  const response = toJoinRequestResponse(created);
608
1723
  if (claimSecret) {
@@ -612,18 +1727,24 @@ export function accessRoutes(db, opts) {
612
1727
  claimSecret,
613
1728
  claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`,
614
1729
  onboarding: onboardingManifest.onboarding,
615
- diagnostics: joinDefaults.diagnostics,
1730
+ diagnostics: joinDefaults.diagnostics
616
1731
  });
617
1732
  return;
618
1733
  }
619
1734
  res.status(202).json({
620
1735
  ...response,
621
- ...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}),
1736
+ ...(joinDefaults.diagnostics.length > 0
1737
+ ? { diagnostics: joinDefaults.diagnostics }
1738
+ : {})
622
1739
  });
623
1740
  });
624
1741
  router.post("/invites/:inviteId/revoke", async (req, res) => {
625
1742
  const id = req.params.inviteId;
626
- const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null);
1743
+ const invite = await db
1744
+ .select()
1745
+ .from(invites)
1746
+ .where(eq(invites.id, id))
1747
+ .then((rows) => rows[0] ?? null);
627
1748
  if (!invite)
628
1749
  throw notFound("Invite not found");
629
1750
  if (invite.inviteType === "bootstrap_ceo") {
@@ -648,10 +1769,12 @@ export function accessRoutes(db, opts) {
648
1769
  await logActivity(db, {
649
1770
  companyId: invite.companyId,
650
1771
  actorType: req.actor.type === "agent" ? "agent" : "user",
651
- actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
1772
+ actorId: req.actor.type === "agent"
1773
+ ? req.actor.agentId ?? "unknown-agent"
1774
+ : req.actor.userId ?? "board",
652
1775
  action: "invite.revoked",
653
1776
  entityType: "invite",
654
- entityId: id,
1777
+ entityId: id
655
1778
  });
656
1779
  }
657
1780
  res.json(revoked);
@@ -703,15 +1826,26 @@ export function accessRoutes(db, opts) {
703
1826
  await access.setPrincipalGrants(companyId, "user", existing.requestingUserId, grants, req.actor.userId ?? null);
704
1827
  }
705
1828
  else {
1829
+ const existingAgents = await agents.list(companyId);
1830
+ const managerId = resolveJoinRequestAgentManagerId(existingAgents);
1831
+ if (!managerId) {
1832
+ throw conflict("Join request cannot be approved because this company has no active CEO");
1833
+ }
1834
+ const agentName = deduplicateAgentName(existing.agentName ?? "New Agent", existingAgents.map((a) => ({
1835
+ id: a.id,
1836
+ name: a.name,
1837
+ status: a.status
1838
+ })));
706
1839
  const created = await agents.create(companyId, {
707
- name: existing.agentName ?? "New Agent",
1840
+ name: agentName,
708
1841
  role: "general",
709
1842
  title: null,
710
1843
  status: "idle",
711
- reportsTo: null,
1844
+ reportsTo: managerId,
712
1845
  capabilities: existing.capabilities ?? null,
713
1846
  adapterType: existing.adapterType ?? "process",
714
- adapterConfig: existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object"
1847
+ adapterConfig: existing.agentDefaultsPayload &&
1848
+ typeof existing.agentDefaultsPayload === "object"
715
1849
  ? existing.agentDefaultsPayload
716
1850
  : {},
717
1851
  runtimeConfig: {},
@@ -719,7 +1853,7 @@ export function accessRoutes(db, opts) {
719
1853
  spentMonthlyCents: 0,
720
1854
  permissions: {},
721
1855
  lastHeartbeatAt: null,
722
- metadata: null,
1856
+ metadata: null
723
1857
  });
724
1858
  createdAgentId = created.id;
725
1859
  await access.ensureMembership(companyId, "agent", created.id, "member", "active");
@@ -733,7 +1867,7 @@ export function accessRoutes(db, opts) {
733
1867
  approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
734
1868
  approvedAt: new Date(),
735
1869
  createdAgentId,
736
- updatedAt: new Date(),
1870
+ updatedAt: new Date()
737
1871
  })
738
1872
  .where(eq(joinRequests.id, requestId))
739
1873
  .returning()
@@ -745,8 +1879,17 @@ export function accessRoutes(db, opts) {
745
1879
  action: "join.approved",
746
1880
  entityType: "join_request",
747
1881
  entityId: requestId,
748
- details: { requestType: existing.requestType, createdAgentId },
1882
+ details: { requestType: existing.requestType, createdAgentId }
749
1883
  });
1884
+ if (createdAgentId) {
1885
+ void notifyHireApproved(db, {
1886
+ companyId,
1887
+ agentId: createdAgentId,
1888
+ source: "join_request",
1889
+ sourceId: requestId,
1890
+ approvedAt: new Date()
1891
+ }).catch(() => { });
1892
+ }
750
1893
  res.json(toJoinRequestResponse(approved));
751
1894
  });
752
1895
  router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => {
@@ -768,7 +1911,7 @@ export function accessRoutes(db, opts) {
768
1911
  status: "rejected",
769
1912
  rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
770
1913
  rejectedAt: new Date(),
771
- updatedAt: new Date(),
1914
+ updatedAt: new Date()
772
1915
  })
773
1916
  .where(eq(joinRequests.id, requestId))
774
1917
  .returning()
@@ -780,7 +1923,7 @@ export function accessRoutes(db, opts) {
780
1923
  action: "join.rejected",
781
1924
  entityType: "join_request",
782
1925
  entityId: requestId,
783
- details: { requestType: existing.requestType },
1926
+ details: { requestType: existing.requestType }
784
1927
  });
785
1928
  res.json(toJoinRequestResponse(rejected));
786
1929
  });
@@ -805,7 +1948,8 @@ export function accessRoutes(db, opts) {
805
1948
  if (!tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash)) {
806
1949
  throw forbidden("Invalid claim secret");
807
1950
  }
808
- if (joinRequest.claimSecretExpiresAt && joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) {
1951
+ if (joinRequest.claimSecretExpiresAt &&
1952
+ joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) {
809
1953
  throw conflict("Claim secret expired");
810
1954
  }
811
1955
  if (joinRequest.claimSecretConsumedAt)
@@ -833,13 +1977,16 @@ export function accessRoutes(db, opts) {
833
1977
  action: "agent_api_key.claimed",
834
1978
  entityType: "agent_api_key",
835
1979
  entityId: created.id,
836
- details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId },
1980
+ details: {
1981
+ agentId: joinRequest.createdAgentId,
1982
+ joinRequestId: requestId
1983
+ }
837
1984
  });
838
1985
  res.status(201).json({
839
1986
  keyId: created.id,
840
1987
  token: created.token,
841
1988
  agentId: joinRequest.createdAgentId,
842
- createdAt: created.createdAt,
1989
+ createdAt: created.createdAt
843
1990
  });
844
1991
  });
845
1992
  router.get("/companies/:companyId/members", async (req, res) => {