@alfe.ai/openclaw-identity 0.1.27 → 0.1.28

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
@@ -13,7 +13,7 @@ let _auriclabs_roles = require("@auriclabs/roles");
13
13
  * Permissions narrow which tools an identity may exec via sift
14
14
  * conditions on the `tool` field. `args` is its own field — never
15
15
  * spread — so a tool whose args legitimately include a `tool` key
16
- * can't override the gate's value (closes the round-5 toolArgs.tool
16
+ * can't override the gate's value (closes the round-5 parameter-name
17
17
  * collision).
18
18
  *
19
19
  * The function is a thin wrapper around `createAbility(...).has(...)`
@@ -23,12 +23,10 @@ let _auriclabs_roles = require("@auriclabs/roles");
23
23
  * silently loses `cannot` semantics — the wrapper makes the contract
24
24
  * explicit and testable.
25
25
  *
26
- * Today this function is wired into `before_tool_call` only for the
27
- * TOKEN path (B.0.7 atomic flip carries the token's `Permission[]` in
28
- * `ctx.tokenPermissions`). The JWT path still falls back to permissive
29
- * because the agent runtime can't yet fetch the resolved permission
30
- * set for `ctx.actingIdentityId` (needs an AgentApiClient endpoint
31
- * that doesn't exist yet — landing alongside Stage D's full rollout).
26
+ * The live `before_tool_call` hook uses this evaluator for both an optional
27
+ * Alfe-augmented token-permissions path and the resolved permission set cached
28
+ * from `message_received`. Stock OpenClaw does not expose auth-method or token
29
+ * permissions in its tool context, so the cache path is the production path.
32
30
  */
33
31
  /**
34
32
  * Evaluate the round-6 strict-namespacing gate. Single
@@ -74,51 +72,28 @@ function applyGateMode(decision, mode, toolName) {
74
72
  };
75
73
  }
76
74
  /**
77
- * Resolve the kill-switch from process.env. Defaults to "fail-closed"
78
- * in production and "fail-open" elsewhere Stage H flips production
79
- * to fail-closed once metrics confirm a stable deny rate.
75
+ * Resolve the kill-switch from process.env. It currently defaults to
76
+ * "fail-open" in every environment; rollout wiring must set "fail-closed"
77
+ * explicitly once metrics confirm a stable deny rate.
80
78
  */
81
79
  function resolveToolGatingMode() {
82
80
  const raw = process.env.OPENCLAW_TOOL_GATING;
83
81
  if (raw === "fail-open" || raw === "fail-closed") return raw;
84
82
  return "fail-open";
85
83
  }
86
- /**
87
- * Evaluate `agent:chat` — the inbound conversational gate (Layer 2 of the
88
- * default-deny chat path). Mirrors `evaluateAgentExec` but with no
89
- * tool/args context — chat admission only depends on whether the
90
- * resolved identity holds `agent:chat` at the agent's scope.
91
- *
92
- * Layer 1 (`services/chat`) already gates this for chat WS + channel
93
- * adapter inject. The OpenClaw plugin's `message_received` hook is
94
- * defense-in-depth — it catches any path that reaches the agent
95
- * runtime without going through Layer 1.
96
- */
97
- function evaluateAgentChat(permissions, args) {
98
- if (!args.agentId) return { allowed: false };
99
- const ability = (0, _auriclabs_roles.createAbility)(permissions);
100
- const gatePermission = {
101
- subject: "agent",
102
- action: "chat",
103
- scope: `agent:${args.agentId}`
104
- };
105
- return { allowed: ability.has(gatePermission, {}) };
106
- }
107
84
  //#endregion
108
85
  //#region src/plugin.ts
109
86
  /**
110
87
  * @alfe.ai/openclaw-identity — OpenClaw native plugin
111
88
  *
112
- * HTTP-based identity resolution + role-based admission gate. Installed as
89
+ * HTTP-based identity resolution + role-based tool gate. Installed as
113
90
  * part of the core alfe integration on every agent.
114
91
  *
115
92
  * Hooks:
116
- * - message_received → resolves the sender via the agent API and gates
117
- * on `agent:chat` at the agent's scope using the
118
- * resolved identity's permission set
119
- * (`evaluateAgentChat`). Blocks unknown senders
120
- * and any identity without the permission. Layer 2
121
- * of the default-deny chat path.
93
+ * - message_received → resolves and caches the sender via the agent API.
94
+ * OpenClaw runs this hook fire-and-forget and ignores
95
+ * return values, so it is context preparation, not an
96
+ * admission boundary.
122
97
  * - before_tool_call → typed `(event: ToolCallEvent, ctx: ToolCallContext)`
123
98
  * hook (Phase 1 Stage A.0). Gates `agent:exec`
124
99
  * via `evaluateAgentExec` against the token's
@@ -132,18 +107,26 @@ function evaluateAgentChat(permissions, args) {
132
107
  */
133
108
  const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
134
109
  const RESOLVE_CACHE_TTL_MS = 6e4;
110
+ const MAX_RESOLVE_CACHE_ENTRIES = 1e3;
135
111
  const resolveCache = /* @__PURE__ */ new Map();
136
112
  let lastInboundConversationId;
137
113
  function getCachedResolve(key) {
138
114
  const entry = resolveCache.get(key);
139
115
  if (!entry) return null;
140
- if (Date.now() > entry.expiresAt) {
116
+ if (Date.now() >= entry.expiresAt) {
141
117
  resolveCache.delete(key);
142
118
  return null;
143
119
  }
144
120
  return entry;
145
121
  }
146
122
  function setCachedResolve(key, value) {
123
+ const now = Date.now();
124
+ for (const [cachedKey, entry] of resolveCache) if (entry.expiresAt <= now) resolveCache.delete(cachedKey);
125
+ while (resolveCache.size >= MAX_RESOLVE_CACHE_ENTRIES) {
126
+ const oldest = resolveCache.keys().next();
127
+ if (oldest.done) break;
128
+ resolveCache.delete(oldest.value);
129
+ }
147
130
  resolveCache.set(key, {
148
131
  ...value,
149
132
  expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
@@ -160,8 +143,11 @@ function findMostRelevantCacheEntry(probeKeys) {
160
143
  }
161
144
  let mostRecent = null;
162
145
  const now = Date.now();
163
- for (const entry of resolveCache.values()) {
164
- if (entry.expiresAt < now) continue;
146
+ for (const [key, entry] of resolveCache) {
147
+ if (entry.expiresAt <= now) {
148
+ resolveCache.delete(key);
149
+ continue;
150
+ }
165
151
  if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
166
152
  }
167
153
  return mostRecent;
@@ -177,17 +163,14 @@ function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
177
163
  return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
178
164
  }
179
165
  /**
180
- * Resolve the inbound session identity. Reads from `lastInboundConversationId`
181
- * (set by `message_received`) the LLM-supplied `params.conversationId` is
182
- * ignored because in practice the LLM copies the visible-metadata
183
- * `conversationId` field, which is currently the WS request envelope id
184
- * (`req_…`), not the real session key the cache is keyed on. Falls back to
185
- * `params.conversationId` only when no inbound message has been observed
186
- * this process (autonomous / scheduled triggers).
166
+ * Resolve the inbound session identity from the conversation value overwritten
167
+ * by `before_tool_call`. The hook replaces model-controlled input with the
168
+ * message-to-run bridge before execution. Autonomous and scheduled triggers
169
+ * receive an empty value and therefore have no fallback to a prior sender.
187
170
  */
188
171
  function resolveSessionIdentity(params) {
189
- const conversationId = lastInboundConversationId ?? params.conversationId;
190
- if (!conversationId) return {
172
+ const conversationId = params.conversationId;
173
+ if (typeof conversationId !== "string" || conversationId.length === 0) return {
191
174
  ok: false,
192
175
  error: "no_inbound_session: identity verification can only be initiated from a user-message turn. The verify tools auto-bind to the inbound sender's session and cannot be called from autonomous / scheduled / tool-chain triggers."
193
176
  };
@@ -230,29 +213,54 @@ function getClient() {
230
213
  }
231
214
  let cachedAgentContext = null;
232
215
  let inflightWhoami = null;
216
+ let lifecycleGeneration = 0;
217
+ let whoamiRetryAfter = 0;
218
+ const WHOAMI_RETRY_COOLDOWN_MS = 5e3;
219
+ const conversationByRunId = /* @__PURE__ */ new Map();
233
220
  async function getAgentContext(client) {
234
221
  if (cachedAgentContext) return cachedAgentContext;
235
222
  if (inflightWhoami) return inflightWhoami;
236
- inflightWhoami = (async () => {
223
+ if (Date.now() < whoamiRetryAfter) return null;
224
+ const generation = lifecycleGeneration;
225
+ const request = (async () => {
237
226
  try {
238
227
  const r = await client.whoami();
228
+ if (generation !== lifecycleGeneration) return null;
229
+ whoamiRetryAfter = 0;
239
230
  cachedAgentContext = {
240
231
  agentId: r.agentId,
241
232
  tenantId: r.tenantId
242
233
  };
243
234
  return cachedAgentContext;
244
235
  } catch {
236
+ if (generation === lifecycleGeneration) whoamiRetryAfter = Date.now() + WHOAMI_RETRY_COOLDOWN_MS;
245
237
  return null;
246
- } finally {
247
- inflightWhoami = null;
248
238
  }
249
239
  })();
250
- return inflightWhoami;
240
+ inflightWhoami = request;
241
+ request.finally(() => {
242
+ if (inflightWhoami === request) inflightWhoami = null;
243
+ });
244
+ return request;
245
+ }
246
+ function resolveToolConversation(ctx) {
247
+ if (ctx.runId) {
248
+ const runConversation = conversationByRunId.get(ctx.runId);
249
+ if (runConversation) return runConversation;
250
+ }
251
+ return lastInboundConversationId ?? ctx.conversationId ?? ctx.sessionKey ?? null;
252
+ }
253
+ function resolveInboundConversation(ctx) {
254
+ if (ctx.runId) {
255
+ const runConversation = conversationByRunId.get(ctx.runId);
256
+ if (runConversation) return runConversation;
257
+ }
258
+ return lastInboundConversationId ?? null;
251
259
  }
252
260
  const plugin = {
253
261
  id: "@alfe.ai/openclaw-identity",
254
262
  name: "Alfe Identity",
255
- description: "Identity resolution and access gating for inbound messages",
263
+ description: "Identity resolution and role-based tool gating",
256
264
  version: pkg.version,
257
265
  activate(api) {
258
266
  (0, _alfe_ai_agent_api_client.installToolErrorCapture)(api, { plugin: "openclaw-identity" });
@@ -283,8 +291,16 @@ const plugin = {
283
291
  name: "lookup_identity",
284
292
  description: "Search identities by name. Returns multiple matches.",
285
293
  parameters: _sinclair_typebox.Type.Object({
286
- query: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Text search query" })),
287
- status: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String())
294
+ query: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({
295
+ description: "Text search query",
296
+ maxLength: 256
297
+ })),
298
+ status: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([
299
+ _sinclair_typebox.Type.Literal("anonymous"),
300
+ _sinclair_typebox.Type.Literal("partial"),
301
+ _sinclair_typebox.Type.Literal("identified"),
302
+ _sinclair_typebox.Type.Literal("verified")
303
+ ]))
288
304
  }),
289
305
  handler: (params) => client.searchIdentities({
290
306
  q: params.query,
@@ -298,22 +314,13 @@ const plugin = {
298
314
  survivorId: _sinclair_typebox.Type.String({ description: "Identity to keep" }),
299
315
  mergedId: _sinclair_typebox.Type.String({ description: "Identity to merge into survivor" })
300
316
  }),
301
- handler: (params) => client.mergeIdentities(params.survivorId, {
302
- mergedId: params.mergedId,
303
- changedBy: {
304
- type: "agent",
305
- id: "plugin"
306
- }
307
- })
317
+ handler: (params) => client.mergeIdentities(params.survivorId, { mergedId: params.mergedId })
308
318
  }),
309
319
  (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
310
320
  name: "unmerge_identities",
311
321
  description: "Reverse a merge — restore previously merged identity.",
312
322
  parameters: _sinclair_typebox.Type.Object({ mergedId: _sinclair_typebox.Type.String({ description: "Identity that was merged (has mergedInto pointer)" }) }),
313
- handler: (params) => client.unmergeIdentity(params.mergedId, { changedBy: {
314
- type: "agent",
315
- id: "plugin"
316
- } })
323
+ handler: (params) => client.unmergeIdentity(params.mergedId)
317
324
  }),
318
325
  (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
319
326
  name: "add_identity_note",
@@ -321,15 +328,17 @@ const plugin = {
321
328
  parameters: _sinclair_typebox.Type.Object({
322
329
  identityId: _sinclair_typebox.Type.String(),
323
330
  content: _sinclair_typebox.Type.String(),
324
- category: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "observation, preference, relationship, context, or warning" }))
331
+ category: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([
332
+ _sinclair_typebox.Type.Literal("observation"),
333
+ _sinclair_typebox.Type.Literal("preference"),
334
+ _sinclair_typebox.Type.Literal("relationship"),
335
+ _sinclair_typebox.Type.Literal("context"),
336
+ _sinclair_typebox.Type.Literal("warning")
337
+ ]))
325
338
  }),
326
339
  handler: (params) => client.addIdentityNote(params.identityId, {
327
340
  content: params.content,
328
- category: params.category,
329
- changedBy: {
330
- type: "agent",
331
- id: "plugin"
332
- }
341
+ category: params.category
333
342
  })
334
343
  }),
335
344
  (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
@@ -337,16 +346,15 @@ const plugin = {
337
346
  description: "Add or remove a tag on an identity.",
338
347
  parameters: _sinclair_typebox.Type.Object({
339
348
  identityId: _sinclair_typebox.Type.String(),
340
- tag: _sinclair_typebox.Type.String(),
341
- action: _sinclair_typebox.Type.String({ description: "'add' or 'remove'" })
349
+ tag: _sinclair_typebox.Type.String({
350
+ minLength: 1,
351
+ maxLength: 128
352
+ }),
353
+ action: _sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("add"), _sinclair_typebox.Type.Literal("remove")])
342
354
  }),
343
355
  handler: (params) => client.tagIdentity(params.identityId, {
344
356
  tag: params.tag,
345
- action: params.action,
346
- changedBy: {
347
- type: "agent",
348
- id: "plugin"
349
- }
357
+ action: params.action
350
358
  })
351
359
  }),
352
360
  (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
@@ -354,7 +362,10 @@ const plugin = {
354
362
  description: "Get the changelog for an identity — versions, diffs, who changed what.",
355
363
  parameters: _sinclair_typebox.Type.Object({
356
364
  identityId: _sinclair_typebox.Type.String(),
357
- limit: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Number())
365
+ limit: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Integer({
366
+ minimum: 1,
367
+ maximum: 200
368
+ }))
358
369
  }),
359
370
  handler: (params) => client.getIdentityChangelog(params.identityId, { limit: params.limit })
360
371
  }),
@@ -363,15 +374,9 @@ const plugin = {
363
374
  description: "Revert an identity to a previous version.",
364
375
  parameters: _sinclair_typebox.Type.Object({
365
376
  identityId: _sinclair_typebox.Type.String(),
366
- targetVersion: _sinclair_typebox.Type.Number()
377
+ targetVersion: _sinclair_typebox.Type.Integer({ minimum: 0 })
367
378
  }),
368
- handler: (params) => client.rollbackIdentity(params.identityId, {
369
- targetVersion: params.targetVersion,
370
- changedBy: {
371
- type: "agent",
372
- id: "plugin"
373
- }
374
- })
379
+ handler: (params) => client.rollbackIdentity(params.identityId, { targetVersion: params.targetVersion })
375
380
  }),
376
381
  (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
377
382
  name: "update_identity",
@@ -385,7 +390,7 @@ const plugin = {
385
390
  }),
386
391
  handler: (params) => {
387
392
  const { identityId, ...rest } = params;
388
- if (!identityId.startsWith("idn_")) throw new Error(`"${identityId}" is not a contact identity (those start with "idn_"). update_identity edits OTHER people you know — NOT your own agent profile. To set YOUR OWN profile picture, call generate_avatar (from a prompt) or set_avatar (with the image url you already generated). To change your own voice, call set_voice.`);
393
+ if (!identityId.startsWith("idn_")) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)(`"${identityId}" is not a contact identity (those start with "idn_"). update_identity edits OTHER people you know — NOT your own agent profile. To set YOUR OWN profile picture, call generate_avatar (from a prompt) or set_avatar (with the image url you already generated). To change your own voice, call set_voice.`);
389
394
  return client.updateIdentity(identityId, rest);
390
395
  }
391
396
  }),
@@ -396,12 +401,15 @@ const plugin = {
396
401
  conversationId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Deprecated and ignored — the tool auto-binds to the current inbound session. Retained as optional for back-compat with older agent prompts." })),
397
402
  contactEmail: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
398
403
  contactMobile: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
399
- 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'" }))
404
+ preferredChannel: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("mobile"), _sinclair_typebox.Type.Literal("email")], { description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to" }))
400
405
  }),
401
406
  handler: (params) => {
402
407
  const contactEmail = params.contactEmail;
403
408
  const contactMobile = params.contactMobile;
404
- if (contactEmail && contactMobile) return Promise.resolve({ error: "Specify exactly one of contactEmail or contactMobile, not both" });
409
+ if (contactEmail && contactMobile) return Promise.resolve({
410
+ status: "error",
411
+ error: "Specify exactly one of contactEmail or contactMobile, not both"
412
+ });
405
413
  const contact = contactEmail ? {
406
414
  channel: "email",
407
415
  value: contactEmail
@@ -410,7 +418,10 @@ const plugin = {
410
418
  value: contactMobile
411
419
  } : void 0;
412
420
  const resolved = resolveRequesterContext(params);
413
- if ("error" in resolved) return Promise.resolve({ error: resolved.error });
421
+ if ("error" in resolved) return Promise.resolve({
422
+ status: "error",
423
+ error: resolved.error
424
+ });
414
425
  return client.requestIdentityVerification({
415
426
  claimedIdentityId: resolved.claimedIdentityId,
416
427
  requestingIdentityId: resolved.requestingIdentityId,
@@ -431,15 +442,18 @@ const plugin = {
431
442
  }),
432
443
  handler: async (params) => {
433
444
  const resolved = resolveClaimedIdentity(params);
434
- if ("error" in resolved) return { error: resolved.error };
445
+ if ("error" in resolved) return {
446
+ status: "error",
447
+ error: resolved.error
448
+ };
435
449
  const result = await client.confirmIdentityVerification({
436
450
  claimedIdentityId: resolved.claimedIdentityId,
437
451
  verificationId: params.verificationId,
438
452
  phrase: params.phrase
439
453
  });
440
454
  if (result.verified === true) {
441
- const conversationId = lastInboundConversationId ?? params.conversationId;
442
- if (conversationId) invalidateCachedResolve(conversationId);
455
+ const conversationId = params.conversationId;
456
+ if (typeof conversationId === "string" && conversationId.length > 0) invalidateCachedResolve(conversationId);
443
457
  }
444
458
  return result;
445
459
  }
@@ -450,34 +464,18 @@ const plugin = {
450
464
  api.on("message_received", async (...args) => {
451
465
  const event = args[0];
452
466
  const ctx = args[1];
453
- const provider = ctx.channelId ?? "unknown";
454
- const senderId = event.metadata?.UserId ?? event.from;
467
+ const provider = event.metadata?.provider ?? ctx.channelId ?? "unknown";
468
+ const senderId = event.metadata?.senderId ?? event.metadata?.UserId ?? event.from;
455
469
  if (!senderId) return;
456
470
  if (ctx.conversationId) lastInboundConversationId = ctx.conversationId;
457
- let agentId = ctx.agentId;
458
- if (!agentId) {
459
- const fallback = await getAgentContext(client);
460
- if (fallback) agentId = fallback.agentId;
461
- else {
462
- log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
463
- return {
464
- block: true,
465
- blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
466
- };
467
- }
468
- }
469
471
  const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
470
472
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
471
473
  const cached = getCachedResolve(cacheKey);
472
474
  if (cached) {
473
- if (cached.identityId == null) return {
474
- block: true,
475
- blockReason: "Identity: identity not provisioned for this channel"
476
- };
477
- if (!evaluateAgentChat([...cached.permissions, ...forwarded], { agentId }).allowed) return {
478
- block: true,
479
- blockReason: "Identity: chat not permitted for this sender"
480
- };
475
+ if (forwarded.length > 0) setCachedResolve(cacheKey, {
476
+ ...cached,
477
+ permissions: [...new Set([...cached.permissions, ...forwarded])]
478
+ });
481
479
  return;
482
480
  }
483
481
  try {
@@ -492,32 +490,19 @@ const plugin = {
492
490
  senderProvider: provider,
493
491
  senderPlatformId: senderId
494
492
  });
495
- if (r.identityId == null) {
496
- log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
497
- return {
498
- block: true,
499
- blockReason: "Identity: identity not provisioned for this channel"
500
- };
501
- }
502
- const cachedAfterFill = getCachedResolve(cacheKey);
503
- if (!evaluateAgentChat(cachedAfterFill ? [...cachedAfterFill.permissions] : [...r.permissions, ...forwarded], { agentId }).allowed) return {
504
- block: true,
505
- blockReason: "Identity: chat not permitted for this sender"
506
- };
507
- log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
493
+ log.debug(`Identity context cached for ${provider} (${r.status})`);
508
494
  } catch (e) {
509
- log.error(`Identity resolution failed for ${senderId}: ${e.message}`);
510
- return {
511
- block: true,
512
- blockReason: "Identity: identity service unavailable — access denied (fail-closed)"
513
- };
495
+ log.error(`Identity context resolution failed: ${e.message}`);
514
496
  }
515
497
  }, { priority: 100 });
516
- const beforeToolCallStub = async (event, ctx) => {
517
- if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
498
+ const beforeToolCall = async (event, ctx) => {
499
+ if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return { params: {
500
+ ...event.params,
501
+ conversationId: resolveInboundConversation(ctx) ?? ""
502
+ } };
518
503
  if (event.toolName === "who_is_this") {
519
- const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
520
- if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
504
+ const cacheKey = resolveToolConversation(ctx);
505
+ if (isSelfScopedWhoIsThis(event.params, cacheKey)) return;
521
506
  }
522
507
  let agentId = ctx.agentId;
523
508
  if (!agentId) {
@@ -525,28 +510,32 @@ const plugin = {
525
510
  if (fallback) agentId = fallback.agentId;
526
511
  }
527
512
  if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
528
- agentId,
513
+ agentId: agentId ?? "",
529
514
  toolName: event.toolName,
530
- toolArgs: event.toolArgs
515
+ toolArgs: event.params
531
516
  }), resolveToolGatingMode(), event.toolName);
532
- const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
517
+ const cacheKey = resolveToolConversation(ctx);
533
518
  if (cacheKey) {
534
519
  const cached = getCachedResolve(cacheKey);
535
520
  if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
536
- agentId,
521
+ agentId: agentId ?? "",
537
522
  toolName: event.toolName,
538
- toolArgs: event.toolArgs
523
+ toolArgs: event.params
539
524
  }), resolveToolGatingMode(), event.toolName);
540
525
  }
541
526
  return applyGateMode(evaluateAgentExec([], {
542
- agentId,
527
+ agentId: agentId ?? "",
543
528
  toolName: event.toolName,
544
- toolArgs: event.toolArgs
529
+ toolArgs: event.params
545
530
  }), resolveToolGatingMode(), event.toolName);
546
531
  };
547
- api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
532
+ api.on("before_tool_call", (...args) => beforeToolCall(args[0], args[1]), { priority: 100 });
548
533
  api.on("before_agent_start", (...args) => {
549
534
  const ctx = args[1];
535
+ if (ctx.trigger && ctx.trigger !== "user") {
536
+ lastInboundConversationId = void 0;
537
+ return Promise.resolve(void 0);
538
+ } else if (ctx.runId && lastInboundConversationId) conversationByRunId.set(ctx.runId, lastInboundConversationId);
550
539
  const cached = findMostRelevantCacheEntry([
551
540
  ctx.conversationId,
552
541
  ctx.sessionKey,
@@ -565,6 +554,12 @@ const plugin = {
565
554
  "verification flow."
566
555
  ].join(" ") });
567
556
  }, { priority: 100 });
557
+ api.on("agent_end", (...args) => {
558
+ const ctx = args[1];
559
+ if (ctx.runId) conversationByRunId.delete(ctx.runId);
560
+ lastInboundConversationId = void 0;
561
+ return Promise.resolve();
562
+ }, { priority: 100 });
568
563
  if (!cachedAgentContext) getAgentContext(client).then((c) => {
569
564
  if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
570
565
  else log.warn("Plugin context warm failed; will retry on next hook fire");
@@ -572,9 +567,14 @@ const plugin = {
572
567
  log.info("Alfe Identity plugin activated");
573
568
  },
574
569
  deactivate(api) {
570
+ lifecycleGeneration += 1;
575
571
  cachedClient = null;
576
572
  cachedAgentContext = null;
577
573
  inflightWhoami = null;
574
+ whoamiRetryAfter = 0;
575
+ resolveCache.clear();
576
+ conversationByRunId.clear();
577
+ lastInboundConversationId = void 0;
578
578
  api.logger.info("Alfe Identity plugin deactivated");
579
579
  }
580
580
  };