@alfe.ai/openclaw-identity 0.1.0 → 0.1.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.
package/dist/plugin2.cjs CHANGED
@@ -147,74 +147,70 @@ function setCachedResolve(key, value) {
147
147
  expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
148
148
  });
149
149
  }
150
- function resolveCachedSession(params) {
150
+ function invalidateCachedResolve(key) {
151
+ resolveCache.delete(key);
152
+ }
153
+ function findMostRelevantCacheEntry(probeKeys) {
154
+ for (const k of probeKeys) {
155
+ if (!k) continue;
156
+ const hit = getCachedResolve(k);
157
+ if (hit) return hit;
158
+ }
159
+ let mostRecent = null;
160
+ const now = Date.now();
161
+ for (const entry of resolveCache.values()) {
162
+ if (entry.expiresAt < now) continue;
163
+ if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
164
+ }
165
+ return mostRecent;
166
+ }
167
+ const UNCONDITIONAL_SELF_SERVICE_TOOLS = new Set(["request_identity_verification", "confirm_identity_verification"]);
168
+ function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
169
+ if (!cacheKey) return false;
170
+ const cached = getCachedResolve(cacheKey);
171
+ if (!cached) return false;
172
+ const argProvider = toolArgs?.provider;
173
+ const argPlatformId = toolArgs?.platformId;
174
+ if (typeof argProvider !== "string" || typeof argPlatformId !== "string") return false;
175
+ return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
176
+ }
177
+ /**
178
+ * Resolve the inbound session identity from `conversationId`. Returns
179
+ * `{ error }` on cache miss / missing param — never falls back to LLM-
180
+ * supplied identity (that's the security property — see header).
181
+ */
182
+ function resolveSessionIdentity(params) {
151
183
  const conversationId = params.conversationId;
152
- const explicitClaimed = params.claimedIdentityId;
153
184
  if (!conversationId) return {
154
- hit: false,
155
- conversationId: null
185
+ ok: false,
186
+ error: "missing_conversation_id: pass conversationId from the inbound message metadata. The verify tools auto-bind to the inbound sender's identity — there is no manual override."
156
187
  };
157
188
  const cached = getCachedResolve(conversationId);
158
189
  if (cached?.identityId == null) return {
159
- hit: false,
160
- conversationId,
161
- error: "session_identity_unavailable: inbound identity cache miss for this conversationId. Pass claimedIdentityId explicitly, or wait for the next inbound message."
162
- };
163
- if (explicitClaimed && explicitClaimed !== cached.identityId) return {
164
- hit: false,
165
- conversationId,
166
- error: "claimedIdentityId conflicts with the inbound session identity. Either omit claimedIdentityId (recommended) or remove conversationId."
190
+ ok: false,
191
+ error: "session_identity_unavailable: inbound identity cache miss for this conversationId. The plugin populates the cache from the message_received hook on every inbound message; if this fires the inbound channel either hasn't sent a message recently (>60s) or the daemon failed to plumb the hook context."
167
192
  };
168
193
  return {
169
- hit: true,
170
- cached: {
171
- ...cached,
172
- identityId: cached.identityId
173
- }
194
+ ok: true,
195
+ identityId: cached.identityId,
196
+ senderProvider: cached.senderProvider,
197
+ senderPlatformId: cached.senderPlatformId
174
198
  };
175
199
  }
176
- /**
177
- * Resolution order for the request flow (needs the full requester tuple):
178
- * 1. conversationId hits cache → derive all four from cached entry.
179
- * 2. conversationId set but cache miss → reject session_identity_unavailable.
180
- * 3. No conversationId AND no claimedIdentityId → reject missing_identity.
181
- * 4. claimedIdentityId + full requester tuple supplied → legacy explicit
182
- * flow. Layer 1's server-side `no_op_already_verified` guard catches
183
- * the common misuse case (looking up an existing identity by name).
184
- */
185
200
  function resolveRequesterContext(params) {
186
- const session = resolveCachedSession(params);
187
- if (session.hit) return {
188
- claimedIdentityId: session.cached.identityId,
189
- requestingIdentityId: session.cached.identityId,
190
- requestingProvider: session.cached.senderProvider,
191
- requestingPlatformId: session.cached.senderPlatformId
192
- };
193
- if (session.conversationId) return { error: session.error };
194
- const claimedIdentityId = params.claimedIdentityId;
195
- const requestingIdentityId = params.requestingIdentityId;
196
- const requestingProvider = params.requestingProvider;
197
- const requestingPlatformId = params.requestingPlatformId;
198
- if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId + requestingIdentityId + requestingProvider + requestingPlatformId explicitly." };
199
- if (!requestingIdentityId || !requestingProvider || !requestingPlatformId) return { error: "missing_identity: when claimedIdentityId is supplied without conversationId, requestingIdentityId / requestingProvider / requestingPlatformId are also required. Prefer passing conversationId so all four are auto-derived." };
201
+ const session = resolveSessionIdentity(params);
202
+ if (!session.ok) return { error: session.error };
200
203
  return {
201
- claimedIdentityId,
202
- requestingIdentityId,
203
- requestingProvider,
204
- requestingPlatformId
204
+ claimedIdentityId: session.identityId,
205
+ requestingIdentityId: session.identityId,
206
+ requestingProvider: session.senderProvider,
207
+ requestingPlatformId: session.senderPlatformId
205
208
  };
206
209
  }
207
- /**
208
- * Confirm flow only needs `claimedIdentityId` — same conflict / cache-miss
209
- * branches as the requester resolver, but no requester tuple.
210
- */
211
210
  function resolveClaimedIdentity(params) {
212
- const session = resolveCachedSession(params);
213
- if (session.hit) return { claimedIdentityId: session.cached.identityId };
214
- if (session.conversationId) return { error: session.error };
215
- const claimedIdentityId = params.claimedIdentityId;
216
- if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId explicitly." };
217
- return { claimedIdentityId };
211
+ const session = resolveSessionIdentity(params);
212
+ if (!session.ok) return { error: session.error };
213
+ return { claimedIdentityId: session.identityId };
218
214
  }
219
215
  function ok(data) {
220
216
  return {
@@ -259,6 +255,27 @@ function getClient() {
259
255
  });
260
256
  return cachedClient;
261
257
  }
258
+ let cachedAgentContext = null;
259
+ let inflightWhoami = null;
260
+ async function getAgentContext(client) {
261
+ if (cachedAgentContext) return cachedAgentContext;
262
+ if (inflightWhoami) return inflightWhoami;
263
+ inflightWhoami = (async () => {
264
+ try {
265
+ const r = await client.whoami();
266
+ cachedAgentContext = {
267
+ agentId: r.agentId,
268
+ tenantId: r.tenantId
269
+ };
270
+ return cachedAgentContext;
271
+ } catch {
272
+ return null;
273
+ } finally {
274
+ inflightWhoami = null;
275
+ }
276
+ })();
277
+ return inflightWhoami;
278
+ }
262
279
  const plugin = {
263
280
  id: "@alfe.ai/openclaw-identity",
264
281
  name: "Alfe Identity",
@@ -399,13 +416,9 @@ const plugin = {
399
416
  }),
400
417
  defineTool({
401
418
  name: "request_identity_verification",
402
- description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the user has just told you their email or mobile number and you want them to confirm it. PASS conversationId (from the inbound message metadata)that auto-binds the verification to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat user who just spoke). Any merge into a pre-existing account with the same contact happens automatically. Do NOT pass claimedIdentityId yourself unless you have a specific reason to verify a different identity than the inbound sender; looking up an identity by name and passing it here is a footgun and the server will reject with no_op_already_verified.",
419
+ description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the inbound user has just told you their email or mobile number and you want them to confirm it. PASS conversationId from the inbound message metadata — the verification ALWAYS binds to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat / chat-web user who just spoke). There is no way to verify a different identity via this tool — identity is server-authoritative, resolved at session-start by the chat service. If the contact you supply already lives on another existing identity (e.g. the user's Clerk web account), the merge happens automatically on confirm. To add a new contact to an already-verified canonical identity (e.g. add a new email to a Clerk account), the user must be talking from a session that already resolves to that canonical identity (post-merge, or chat-web logged in as Clerk).",
403
420
  parameters: _sinclair_typebox.Type.Object({
404
- conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Conversation ID from the inbound message — the daemon attaches this to every message. Pass it to auto-bind the verification to the inbound sender's identity. When set you should NOT also pass claimedIdentityId / requestingIdentityId / requestingProvider / requestingPlatformId." })),
405
- claimedIdentityId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Override for the inbound sender's identity. NEVER set this from a lookup_identity result — that's a footgun (the verification will no-op). Leave unset and pass conversationId so the inbound identity is used automatically. Only set this for explicit non-inbound verification flows (rare — dashboard / admin only)." })),
406
- requestingIdentityId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Identity of the person making the claim. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
407
- requestingProvider: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Provider the requester is on (discord, slack, whatsapp, etc.). Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
408
- requestingPlatformId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Requester's provider-specific user ID. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
421
+ conversationId: _sinclair_typebox.Type.String({ description: "Conversation ID from the inbound message — the daemon attaches this to every message. REQUIRED there is no manual override path." }),
409
422
  contactEmail: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
410
423
  contactMobile: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
411
424
  preferredChannel: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
@@ -435,21 +448,25 @@ const plugin = {
435
448
  }),
436
449
  defineTool({
437
450
  name: "confirm_identity_verification",
438
- description: "Confirm a verification by submitting the phrase the user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor) or 'contact_verified' (the contact was attached to the claimed identity). PASS the same conversationId you used on request_identity_verification — claimedIdentityId is auto-derived from it.",
451
+ description: "Confirm a verification by submitting the phrase the inbound user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor — the inbound identity merged into it) or 'contact_verified' (the contact was attached to the inbound identity). PASS the same conversationId you used on request_identity_verification — identity is auto-derived from it, no override.",
439
452
  parameters: _sinclair_typebox.Type.Object({
440
- conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Conversation ID from the inbound message — same one passed to request_identity_verification. Auto-binds claimedIdentityId to the inbound sender." })),
441
- claimedIdentityId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Override for the inbound sender's identity. Must match the value used on the request. Leave unset and pass conversationId." })),
453
+ conversationId: _sinclair_typebox.Type.String({ description: "Conversation ID from the inbound message — same one passed to request_identity_verification. REQUIRED." }),
442
454
  verificationId: _sinclair_typebox.Type.String({ description: "Verification ID returned from request_identity_verification" }),
443
- phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the person received via mobile or email" })
455
+ phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the inbound user received via mobile or email" })
444
456
  }),
445
- handler: (params) => {
457
+ handler: async (params) => {
446
458
  const resolved = resolveClaimedIdentity(params);
447
- if ("error" in resolved) return Promise.resolve({ error: resolved.error });
448
- return client.confirmIdentityVerification({
459
+ if ("error" in resolved) return { error: resolved.error };
460
+ const result = await client.confirmIdentityVerification({
449
461
  claimedIdentityId: resolved.claimedIdentityId,
450
462
  verificationId: params.verificationId,
451
463
  phrase: params.phrase
452
464
  });
465
+ if (result.verified === true) {
466
+ const conversationId = params.conversationId;
467
+ invalidateCachedResolve(conversationId);
468
+ }
469
+ return result;
453
470
  }
454
471
  })
455
472
  ];
@@ -461,13 +478,17 @@ const plugin = {
461
478
  const provider = ctx.channelId ?? "unknown";
462
479
  const senderId = event.metadata?.UserId ?? event.from;
463
480
  if (!senderId) return;
464
- const agentId = ctx.agentId;
481
+ let agentId = ctx.agentId;
465
482
  if (!agentId) {
466
- log.warn(`message_received without ctx.agentId fail-closed`);
467
- return {
468
- block: true,
469
- blockReason: "Identity: agentId missing on message context"
470
- };
483
+ const fallback = await getAgentContext(client);
484
+ if (fallback) agentId = fallback.agentId;
485
+ else {
486
+ log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
487
+ return {
488
+ block: true,
489
+ blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
490
+ };
491
+ }
471
492
  }
472
493
  const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
473
494
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
@@ -516,40 +537,68 @@ const plugin = {
516
537
  };
517
538
  }
518
539
  }, { priority: 100 });
519
- const beforeToolCallStub = (event, ctx) => {
520
- if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) {
521
- const blocked = applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
522
- agentId: ctx.agentId,
523
- toolName: event.toolName,
524
- toolArgs: event.toolArgs
525
- }), resolveToolGatingMode(), event.toolName);
526
- return Promise.resolve(blocked);
540
+ const beforeToolCallStub = async (event, ctx) => {
541
+ if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
542
+ if (event.toolName === "who_is_this") {
543
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
544
+ if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
527
545
  }
546
+ let agentId = ctx.agentId;
547
+ if (!agentId) {
548
+ const fallback = await getAgentContext(client);
549
+ if (fallback) agentId = fallback.agentId;
550
+ }
551
+ if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
552
+ agentId,
553
+ toolName: event.toolName,
554
+ toolArgs: event.toolArgs
555
+ }), resolveToolGatingMode(), event.toolName);
528
556
  const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
529
557
  if (cacheKey) {
530
558
  const cached = getCachedResolve(cacheKey);
531
- if (cached && cached.permissions.length > 0) {
532
- const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
533
- agentId: ctx.agentId,
534
- toolName: event.toolName,
535
- toolArgs: event.toolArgs
536
- }), resolveToolGatingMode(), event.toolName);
537
- return Promise.resolve(blocked);
538
- }
559
+ if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
560
+ agentId,
561
+ toolName: event.toolName,
562
+ toolArgs: event.toolArgs
563
+ }), resolveToolGatingMode(), event.toolName);
539
564
  }
540
- const fallback = evaluateAgentExec([], {
541
- agentId: ctx.agentId,
565
+ return applyGateMode(evaluateAgentExec([], {
566
+ agentId,
542
567
  toolName: event.toolName,
543
568
  toolArgs: event.toolArgs
544
- });
545
- const mode = resolveToolGatingMode();
546
- return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
569
+ }), resolveToolGatingMode(), event.toolName);
547
570
  };
548
571
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
572
+ api.on("before_agent_start", (...args) => {
573
+ const ctx = args[1];
574
+ const cached = findMostRelevantCacheEntry([
575
+ ctx.conversationId,
576
+ ctx.sessionKey,
577
+ ctx.sessionId,
578
+ ctx.channelId
579
+ ]);
580
+ if (!cached) return Promise.resolve(void 0);
581
+ if (cached.status !== "anonymous" && cached.status !== "partial") return Promise.resolve(void 0);
582
+ return Promise.resolve({ prependSystemContext: [
583
+ "Identity status: the user has not yet verified their identity.",
584
+ "Tools available now: request_identity_verification,",
585
+ "confirm_identity_verification, who_is_this.",
586
+ "All other tools are restricted until the user verifies.",
587
+ "If the user asks for a capability you cannot perform, briefly",
588
+ "explain that they need to verify first and offer to start the",
589
+ "verification flow."
590
+ ].join(" ") });
591
+ }, { priority: 100 });
592
+ if (!cachedAgentContext) getAgentContext(client).then((c) => {
593
+ if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
594
+ else log.warn("Plugin context warm failed; will retry on next hook fire");
595
+ });
549
596
  log.info("Alfe Identity plugin activated");
550
597
  },
551
598
  deactivate(api) {
552
599
  cachedClient = null;
600
+ cachedAgentContext = null;
601
+ inflightWhoami = null;
553
602
  api.logger.info("Alfe Identity plugin deactivated");
554
603
  }
555
604
  };
package/dist/plugin2.js CHANGED
@@ -147,74 +147,70 @@ function setCachedResolve(key, value) {
147
147
  expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
148
148
  });
149
149
  }
150
- function resolveCachedSession(params) {
150
+ function invalidateCachedResolve(key) {
151
+ resolveCache.delete(key);
152
+ }
153
+ function findMostRelevantCacheEntry(probeKeys) {
154
+ for (const k of probeKeys) {
155
+ if (!k) continue;
156
+ const hit = getCachedResolve(k);
157
+ if (hit) return hit;
158
+ }
159
+ let mostRecent = null;
160
+ const now = Date.now();
161
+ for (const entry of resolveCache.values()) {
162
+ if (entry.expiresAt < now) continue;
163
+ if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
164
+ }
165
+ return mostRecent;
166
+ }
167
+ const UNCONDITIONAL_SELF_SERVICE_TOOLS = new Set(["request_identity_verification", "confirm_identity_verification"]);
168
+ function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
169
+ if (!cacheKey) return false;
170
+ const cached = getCachedResolve(cacheKey);
171
+ if (!cached) return false;
172
+ const argProvider = toolArgs?.provider;
173
+ const argPlatformId = toolArgs?.platformId;
174
+ if (typeof argProvider !== "string" || typeof argPlatformId !== "string") return false;
175
+ return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
176
+ }
177
+ /**
178
+ * Resolve the inbound session identity from `conversationId`. Returns
179
+ * `{ error }` on cache miss / missing param — never falls back to LLM-
180
+ * supplied identity (that's the security property — see header).
181
+ */
182
+ function resolveSessionIdentity(params) {
151
183
  const conversationId = params.conversationId;
152
- const explicitClaimed = params.claimedIdentityId;
153
184
  if (!conversationId) return {
154
- hit: false,
155
- conversationId: null
185
+ ok: false,
186
+ error: "missing_conversation_id: pass conversationId from the inbound message metadata. The verify tools auto-bind to the inbound sender's identity — there is no manual override."
156
187
  };
157
188
  const cached = getCachedResolve(conversationId);
158
189
  if (cached?.identityId == null) return {
159
- hit: false,
160
- conversationId,
161
- error: "session_identity_unavailable: inbound identity cache miss for this conversationId. Pass claimedIdentityId explicitly, or wait for the next inbound message."
162
- };
163
- if (explicitClaimed && explicitClaimed !== cached.identityId) return {
164
- hit: false,
165
- conversationId,
166
- error: "claimedIdentityId conflicts with the inbound session identity. Either omit claimedIdentityId (recommended) or remove conversationId."
190
+ ok: false,
191
+ error: "session_identity_unavailable: inbound identity cache miss for this conversationId. The plugin populates the cache from the message_received hook on every inbound message; if this fires the inbound channel either hasn't sent a message recently (>60s) or the daemon failed to plumb the hook context."
167
192
  };
168
193
  return {
169
- hit: true,
170
- cached: {
171
- ...cached,
172
- identityId: cached.identityId
173
- }
194
+ ok: true,
195
+ identityId: cached.identityId,
196
+ senderProvider: cached.senderProvider,
197
+ senderPlatformId: cached.senderPlatformId
174
198
  };
175
199
  }
176
- /**
177
- * Resolution order for the request flow (needs the full requester tuple):
178
- * 1. conversationId hits cache → derive all four from cached entry.
179
- * 2. conversationId set but cache miss → reject session_identity_unavailable.
180
- * 3. No conversationId AND no claimedIdentityId → reject missing_identity.
181
- * 4. claimedIdentityId + full requester tuple supplied → legacy explicit
182
- * flow. Layer 1's server-side `no_op_already_verified` guard catches
183
- * the common misuse case (looking up an existing identity by name).
184
- */
185
200
  function resolveRequesterContext(params) {
186
- const session = resolveCachedSession(params);
187
- if (session.hit) return {
188
- claimedIdentityId: session.cached.identityId,
189
- requestingIdentityId: session.cached.identityId,
190
- requestingProvider: session.cached.senderProvider,
191
- requestingPlatformId: session.cached.senderPlatformId
192
- };
193
- if (session.conversationId) return { error: session.error };
194
- const claimedIdentityId = params.claimedIdentityId;
195
- const requestingIdentityId = params.requestingIdentityId;
196
- const requestingProvider = params.requestingProvider;
197
- const requestingPlatformId = params.requestingPlatformId;
198
- if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId + requestingIdentityId + requestingProvider + requestingPlatformId explicitly." };
199
- if (!requestingIdentityId || !requestingProvider || !requestingPlatformId) return { error: "missing_identity: when claimedIdentityId is supplied without conversationId, requestingIdentityId / requestingProvider / requestingPlatformId are also required. Prefer passing conversationId so all four are auto-derived." };
201
+ const session = resolveSessionIdentity(params);
202
+ if (!session.ok) return { error: session.error };
200
203
  return {
201
- claimedIdentityId,
202
- requestingIdentityId,
203
- requestingProvider,
204
- requestingPlatformId
204
+ claimedIdentityId: session.identityId,
205
+ requestingIdentityId: session.identityId,
206
+ requestingProvider: session.senderProvider,
207
+ requestingPlatformId: session.senderPlatformId
205
208
  };
206
209
  }
207
- /**
208
- * Confirm flow only needs `claimedIdentityId` — same conflict / cache-miss
209
- * branches as the requester resolver, but no requester tuple.
210
- */
211
210
  function resolveClaimedIdentity(params) {
212
- const session = resolveCachedSession(params);
213
- if (session.hit) return { claimedIdentityId: session.cached.identityId };
214
- if (session.conversationId) return { error: session.error };
215
- const claimedIdentityId = params.claimedIdentityId;
216
- if (!claimedIdentityId) return { error: "missing_identity: pass conversationId (recommended — auto-binds to the inbound sender) or claimedIdentityId explicitly." };
217
- return { claimedIdentityId };
211
+ const session = resolveSessionIdentity(params);
212
+ if (!session.ok) return { error: session.error };
213
+ return { claimedIdentityId: session.identityId };
218
214
  }
219
215
  function ok(data) {
220
216
  return {
@@ -259,6 +255,27 @@ function getClient() {
259
255
  });
260
256
  return cachedClient;
261
257
  }
258
+ let cachedAgentContext = null;
259
+ let inflightWhoami = null;
260
+ async function getAgentContext(client) {
261
+ if (cachedAgentContext) return cachedAgentContext;
262
+ if (inflightWhoami) return inflightWhoami;
263
+ inflightWhoami = (async () => {
264
+ try {
265
+ const r = await client.whoami();
266
+ cachedAgentContext = {
267
+ agentId: r.agentId,
268
+ tenantId: r.tenantId
269
+ };
270
+ return cachedAgentContext;
271
+ } catch {
272
+ return null;
273
+ } finally {
274
+ inflightWhoami = null;
275
+ }
276
+ })();
277
+ return inflightWhoami;
278
+ }
262
279
  const plugin = {
263
280
  id: "@alfe.ai/openclaw-identity",
264
281
  name: "Alfe Identity",
@@ -399,13 +416,9 @@ const plugin = {
399
416
  }),
400
417
  defineTool({
401
418
  name: "request_identity_verification",
402
- description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the user has just told you their email or mobile number and you want them to confirm it. PASS conversationId (from the inbound message metadata)that auto-binds the verification to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat user who just spoke). Any merge into a pre-existing account with the same contact happens automatically. Do NOT pass claimedIdentityId yourself unless you have a specific reason to verify a different identity than the inbound sender; looking up an identity by name and passing it here is a footgun and the server will reject with no_op_already_verified.",
419
+ description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the inbound user has just told you their email or mobile number and you want them to confirm it. PASS conversationId from the inbound message metadata — the verification ALWAYS binds to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat / chat-web user who just spoke). There is no way to verify a different identity via this tool — identity is server-authoritative, resolved at session-start by the chat service. If the contact you supply already lives on another existing identity (e.g. the user's Clerk web account), the merge happens automatically on confirm. To add a new contact to an already-verified canonical identity (e.g. add a new email to a Clerk account), the user must be talking from a session that already resolves to that canonical identity (post-merge, or chat-web logged in as Clerk).",
403
420
  parameters: Type.Object({
404
- conversationId: Type.Optional(Type.String({ description: "Conversation ID from the inbound message — the daemon attaches this to every message. Pass it to auto-bind the verification to the inbound sender's identity. When set you should NOT also pass claimedIdentityId / requestingIdentityId / requestingProvider / requestingPlatformId." })),
405
- claimedIdentityId: Type.Optional(Type.String({ description: "Override for the inbound sender's identity. NEVER set this from a lookup_identity result — that's a footgun (the verification will no-op). Leave unset and pass conversationId so the inbound identity is used automatically. Only set this for explicit non-inbound verification flows (rare — dashboard / admin only)." })),
406
- requestingIdentityId: Type.Optional(Type.String({ description: "Identity of the person making the claim. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
407
- requestingProvider: Type.Optional(Type.String({ description: "Provider the requester is on (discord, slack, whatsapp, etc.). Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
408
- requestingPlatformId: Type.Optional(Type.String({ description: "Requester's provider-specific user ID. Auto-derived from conversationId; only set explicitly when conversationId is omitted." })),
421
+ conversationId: Type.String({ description: "Conversation ID from the inbound message — the daemon attaches this to every message. REQUIRED there is no manual override path." }),
409
422
  contactEmail: Type.Optional(Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
410
423
  contactMobile: Type.Optional(Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
411
424
  preferredChannel: Type.Optional(Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
@@ -435,21 +448,25 @@ const plugin = {
435
448
  }),
436
449
  defineTool({
437
450
  name: "confirm_identity_verification",
438
- description: "Confirm a verification by submitting the phrase the user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor) or 'contact_verified' (the contact was attached to the claimed identity). PASS the same conversationId you used on request_identity_verification — claimedIdentityId is auto-derived from it.",
451
+ description: "Confirm a verification by submitting the phrase the inbound user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor — the inbound identity merged into it) or 'contact_verified' (the contact was attached to the inbound identity). PASS the same conversationId you used on request_identity_verification — identity is auto-derived from it, no override.",
439
452
  parameters: Type.Object({
440
- conversationId: Type.Optional(Type.String({ description: "Conversation ID from the inbound message — same one passed to request_identity_verification. Auto-binds claimedIdentityId to the inbound sender." })),
441
- claimedIdentityId: Type.Optional(Type.String({ description: "Override for the inbound sender's identity. Must match the value used on the request. Leave unset and pass conversationId." })),
453
+ conversationId: Type.String({ description: "Conversation ID from the inbound message — same one passed to request_identity_verification. REQUIRED." }),
442
454
  verificationId: Type.String({ description: "Verification ID returned from request_identity_verification" }),
443
- phrase: Type.String({ description: "Three-word phrase the person received via mobile or email" })
455
+ phrase: Type.String({ description: "Three-word phrase the inbound user received via mobile or email" })
444
456
  }),
445
- handler: (params) => {
457
+ handler: async (params) => {
446
458
  const resolved = resolveClaimedIdentity(params);
447
- if ("error" in resolved) return Promise.resolve({ error: resolved.error });
448
- return client.confirmIdentityVerification({
459
+ if ("error" in resolved) return { error: resolved.error };
460
+ const result = await client.confirmIdentityVerification({
449
461
  claimedIdentityId: resolved.claimedIdentityId,
450
462
  verificationId: params.verificationId,
451
463
  phrase: params.phrase
452
464
  });
465
+ if (result.verified === true) {
466
+ const conversationId = params.conversationId;
467
+ invalidateCachedResolve(conversationId);
468
+ }
469
+ return result;
453
470
  }
454
471
  })
455
472
  ];
@@ -461,13 +478,17 @@ const plugin = {
461
478
  const provider = ctx.channelId ?? "unknown";
462
479
  const senderId = event.metadata?.UserId ?? event.from;
463
480
  if (!senderId) return;
464
- const agentId = ctx.agentId;
481
+ let agentId = ctx.agentId;
465
482
  if (!agentId) {
466
- log.warn(`message_received without ctx.agentId fail-closed`);
467
- return {
468
- block: true,
469
- blockReason: "Identity: agentId missing on message context"
470
- };
483
+ const fallback = await getAgentContext(client);
484
+ if (fallback) agentId = fallback.agentId;
485
+ else {
486
+ log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
487
+ return {
488
+ block: true,
489
+ blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
490
+ };
491
+ }
471
492
  }
472
493
  const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
473
494
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
@@ -516,40 +537,68 @@ const plugin = {
516
537
  };
517
538
  }
518
539
  }, { priority: 100 });
519
- const beforeToolCallStub = (event, ctx) => {
520
- if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) {
521
- const blocked = applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
522
- agentId: ctx.agentId,
523
- toolName: event.toolName,
524
- toolArgs: event.toolArgs
525
- }), resolveToolGatingMode(), event.toolName);
526
- return Promise.resolve(blocked);
540
+ const beforeToolCallStub = async (event, ctx) => {
541
+ if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
542
+ if (event.toolName === "who_is_this") {
543
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
544
+ if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
527
545
  }
546
+ let agentId = ctx.agentId;
547
+ if (!agentId) {
548
+ const fallback = await getAgentContext(client);
549
+ if (fallback) agentId = fallback.agentId;
550
+ }
551
+ if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
552
+ agentId,
553
+ toolName: event.toolName,
554
+ toolArgs: event.toolArgs
555
+ }), resolveToolGatingMode(), event.toolName);
528
556
  const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
529
557
  if (cacheKey) {
530
558
  const cached = getCachedResolve(cacheKey);
531
- if (cached && cached.permissions.length > 0) {
532
- const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
533
- agentId: ctx.agentId,
534
- toolName: event.toolName,
535
- toolArgs: event.toolArgs
536
- }), resolveToolGatingMode(), event.toolName);
537
- return Promise.resolve(blocked);
538
- }
559
+ if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
560
+ agentId,
561
+ toolName: event.toolName,
562
+ toolArgs: event.toolArgs
563
+ }), resolveToolGatingMode(), event.toolName);
539
564
  }
540
- const fallback = evaluateAgentExec([], {
541
- agentId: ctx.agentId,
565
+ return applyGateMode(evaluateAgentExec([], {
566
+ agentId,
542
567
  toolName: event.toolName,
543
568
  toolArgs: event.toolArgs
544
- });
545
- const mode = resolveToolGatingMode();
546
- return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
569
+ }), resolveToolGatingMode(), event.toolName);
547
570
  };
548
571
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
572
+ api.on("before_agent_start", (...args) => {
573
+ const ctx = args[1];
574
+ const cached = findMostRelevantCacheEntry([
575
+ ctx.conversationId,
576
+ ctx.sessionKey,
577
+ ctx.sessionId,
578
+ ctx.channelId
579
+ ]);
580
+ if (!cached) return Promise.resolve(void 0);
581
+ if (cached.status !== "anonymous" && cached.status !== "partial") return Promise.resolve(void 0);
582
+ return Promise.resolve({ prependSystemContext: [
583
+ "Identity status: the user has not yet verified their identity.",
584
+ "Tools available now: request_identity_verification,",
585
+ "confirm_identity_verification, who_is_this.",
586
+ "All other tools are restricted until the user verifies.",
587
+ "If the user asks for a capability you cannot perform, briefly",
588
+ "explain that they need to verify first and offer to start the",
589
+ "verification flow."
590
+ ].join(" ") });
591
+ }, { priority: 100 });
592
+ if (!cachedAgentContext) getAgentContext(client).then((c) => {
593
+ if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
594
+ else log.warn("Plugin context warm failed; will retry on next hook fire");
595
+ });
549
596
  log.info("Alfe Identity plugin activated");
550
597
  },
551
598
  deactivate(api) {
552
599
  cachedClient = null;
600
+ cachedAgentContext = null;
601
+ inflightWhoami = null;
553
602
  api.logger.info("Alfe Identity plugin deactivated");
554
603
  }
555
604
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-identity",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenClaw identity plugin — identity resolution, access gating, permission enforcement",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@auriclabs/roles": "0.1.1",
31
31
  "@sinclair/typebox": "^0.34.48",
32
- "@alfe.ai/config": "0.0.8",
33
- "@alfe.ai/agent-api-client": "0.1.2"
32
+ "@alfe.ai/agent-api-client": "0.1.3",
33
+ "@alfe.ai/config": "0.0.8"
34
34
  },
35
35
  "license": "UNLICENSED",
36
36
  "scripts": {