multicorn-shield 0.1.15 → 0.1.16

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.
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFile, mkdir, writeFile } from 'fs/promises';
2
+ import { readFile, mkdir, writeFile, unlink } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { createInterface } from 'readline';
6
6
  import { spawn } from 'child_process';
7
+ import { createHash } from 'crypto';
7
8
  import 'stream';
8
9
 
9
10
  var CONFIG_DIR = join(homedir(), ".multicorn");
@@ -610,51 +611,53 @@ function capitalize(str) {
610
611
  }
611
612
  var MULTICORN_DIR = join(homedir(), ".multicorn");
612
613
  var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
613
- var CONSENT_POLL_INTERVAL_MS = 3e3;
614
- var CONSENT_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
615
- function deriveDashboardUrl(baseUrl) {
614
+ var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
615
+ function cacheKey(agentName, apiKey) {
616
+ return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
617
+ }
618
+ async function ensureCacheIdentity(apiKey) {
619
+ const currentHash = createHash("sha256").update(apiKey).digest("hex");
620
+ let storedHash = null;
616
621
  try {
617
- const url = new URL(baseUrl);
618
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
619
- url.port = "5173";
620
- url.protocol = "http:";
621
- return url.toString();
622
+ const raw = await readFile(CACHE_META_PATH, "utf8");
623
+ const meta = JSON.parse(raw);
624
+ if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
625
+ storedHash = meta.apiKeyHash;
622
626
  }
623
- if (url.hostname === "api.multicorn.ai") {
624
- url.hostname = "app.multicorn.ai";
625
- return url.toString();
626
- }
627
- if (url.hostname.includes("api")) {
628
- url.hostname = url.hostname.replace("api", "app");
629
- return url.toString();
630
- }
631
- if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
632
- return "https://app.multicorn.ai";
633
- }
634
- return "https://app.multicorn.ai";
635
627
  } catch {
636
- return "https://app.multicorn.ai";
637
628
  }
638
- }
639
- var ShieldAuthError = class _ShieldAuthError extends Error {
640
- constructor(message) {
641
- super(message);
642
- this.name = "ShieldAuthError";
643
- Object.setPrototypeOf(this, _ShieldAuthError.prototype);
629
+ if (storedHash === null || storedHash !== currentHash) {
630
+ try {
631
+ await unlink(SCOPES_PATH);
632
+ } catch {
633
+ }
644
634
  }
645
- };
646
- async function loadCachedScopes(agentName) {
635
+ if (storedHash !== currentHash) {
636
+ await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
637
+ await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
638
+ encoding: "utf8",
639
+ mode: 384
640
+ });
641
+ }
642
+ }
643
+ async function loadCachedScopes(agentName, apiKey) {
644
+ if (apiKey.length === 0) return null;
645
+ await ensureCacheIdentity(apiKey);
646
+ const key = cacheKey(agentName, apiKey);
647
647
  try {
648
648
  const raw = await readFile(SCOPES_PATH, "utf8");
649
649
  const parsed = JSON.parse(raw);
650
650
  if (!isScopesCacheFile(parsed)) return null;
651
- const entry = parsed[agentName];
651
+ const entry = parsed[key];
652
652
  return entry?.scopes ?? null;
653
653
  } catch {
654
654
  return null;
655
655
  }
656
656
  }
657
- async function saveCachedScopes(agentName, agentId, scopes) {
657
+ async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
658
+ if (apiKey.length === 0) return;
659
+ await ensureCacheIdentity(apiKey);
660
+ const key = cacheKey(agentName, apiKey);
658
661
  await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
659
662
  let existing = {};
660
663
  try {
@@ -665,7 +668,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
665
668
  }
666
669
  const updated = {
667
670
  ...existing,
668
- [agentName]: {
671
+ [key]: {
669
672
  agentId,
670
673
  scopes,
671
674
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -676,6 +679,44 @@ async function saveCachedScopes(agentName, agentId, scopes) {
676
679
  mode: 384
677
680
  });
678
681
  }
682
+ function isScopesCacheFile(value) {
683
+ return typeof value === "object" && value !== null;
684
+ }
685
+
686
+ // src/proxy/consent.ts
687
+ var CONSENT_POLL_INTERVAL_MS = 3e3;
688
+ var CONSENT_POLL_TIMEOUT_MS = 5 * 60 * 1e3;
689
+ function deriveDashboardUrl(baseUrl) {
690
+ try {
691
+ const url = new URL(baseUrl);
692
+ if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
693
+ url.port = "5173";
694
+ url.protocol = "http:";
695
+ return url.toString();
696
+ }
697
+ if (url.hostname === "api.multicorn.ai") {
698
+ url.hostname = "app.multicorn.ai";
699
+ return url.toString();
700
+ }
701
+ if (url.hostname.includes("api")) {
702
+ url.hostname = url.hostname.replace("api", "app");
703
+ return url.toString();
704
+ }
705
+ if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
706
+ return "https://app.multicorn.ai";
707
+ }
708
+ return "https://app.multicorn.ai";
709
+ } catch {
710
+ return "https://app.multicorn.ai";
711
+ }
712
+ }
713
+ var ShieldAuthError = class _ShieldAuthError extends Error {
714
+ constructor(message) {
715
+ super(message);
716
+ this.name = "ShieldAuthError";
717
+ Object.setPrototypeOf(this, _ShieldAuthError.prototype);
718
+ }
719
+ };
679
720
  async function findAgentByName(agentName, apiKey, baseUrl) {
680
721
  let response;
681
722
  try {
@@ -796,7 +837,7 @@ Waiting for you to grant access in the Multicorn dashboard...
796
837
  );
797
838
  }
798
839
  async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
799
- const cachedScopes = await loadCachedScopes(agentName);
840
+ const cachedScopes = await loadCachedScopes(agentName, apiKey);
800
841
  if (cachedScopes !== null && cachedScopes.length > 0) {
801
842
  logger.debug("Loaded scopes from cache.", { agent: agentName, count: cachedScopes.length });
802
843
  return { id: "", name: agentName, scopes: cachedScopes };
@@ -824,7 +865,7 @@ async function resolveAgentRecord(agentName, apiKey, baseUrl, logger) {
824
865
  }
825
866
  const scopes = await fetchGrantedScopes(agent.id, apiKey, baseUrl);
826
867
  if (scopes.length > 0) {
827
- await saveCachedScopes(agentName, agent.id, scopes);
868
+ await saveCachedScopes(agentName, agent.id, scopes, apiKey);
828
869
  }
829
870
  return { ...agent, scopes };
830
871
  }
@@ -862,9 +903,6 @@ function isPermissionShape(value) {
862
903
  const obj = value;
863
904
  return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || typeof obj["revoked_at"] === "string");
864
905
  }
865
- function isScopesCacheFile(value) {
866
- return typeof value === "object" && value !== null;
867
- }
868
906
 
869
907
  // src/proxy/index.ts
870
908
  var DEFAULT_SCOPE_REFRESH_INTERVAL_MS = 6e4;
@@ -892,7 +930,7 @@ function createProxyServer(config) {
892
930
  const scopes = await fetchGrantedScopes(agentId, config.apiKey, config.baseUrl);
893
931
  grantedScopes = scopes;
894
932
  if (scopes.length > 0) {
895
- await saveCachedScopes(config.agentName, agentId, scopes);
933
+ await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
896
934
  }
897
935
  config.logger.debug("Scopes refreshed.", { count: scopes.length });
898
936
  } catch (error) {
@@ -921,7 +959,7 @@ function createProxyServer(config) {
921
959
  scopeParam
922
960
  );
923
961
  grantedScopes = scopes;
924
- await saveCachedScopes(config.agentName, agentId, scopes);
962
+ await saveCachedScopes(config.agentName, agentId, scopes, config.apiKey);
925
963
  } finally {
926
964
  consentInProgress = false;
927
965
  }
@@ -1,4 +1,5 @@
1
- import { readFile, mkdir, writeFile } from 'fs/promises';
1
+ import { createHash } from 'crypto';
2
+ import { readFile, mkdir, writeFile, unlink } from 'fs/promises';
2
3
  import { join } from 'path';
3
4
  import { homedir } from 'os';
4
5
  import { spawn } from 'child_process';
@@ -83,18 +84,53 @@ function mapToolToScope(toolName, command) {
83
84
  }
84
85
  var MULTICORN_DIR = join(homedir(), ".multicorn");
85
86
  var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
86
- async function loadCachedScopes(agentName) {
87
+ var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
88
+ function cacheKey(agentName, apiKey) {
89
+ return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
90
+ }
91
+ async function ensureCacheIdentity(apiKey) {
92
+ const currentHash = createHash("sha256").update(apiKey).digest("hex");
93
+ let storedHash = null;
94
+ try {
95
+ const raw = await readFile(CACHE_META_PATH, "utf8");
96
+ const meta = JSON.parse(raw);
97
+ if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
98
+ storedHash = meta.apiKeyHash;
99
+ }
100
+ } catch {
101
+ }
102
+ if (storedHash === null || storedHash !== currentHash) {
103
+ try {
104
+ await unlink(SCOPES_PATH);
105
+ } catch {
106
+ }
107
+ }
108
+ if (storedHash !== currentHash) {
109
+ await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
110
+ await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
111
+ encoding: "utf8",
112
+ mode: 384
113
+ });
114
+ }
115
+ }
116
+ async function loadCachedScopes(agentName, apiKey) {
117
+ if (apiKey.length === 0) return null;
118
+ await ensureCacheIdentity(apiKey);
119
+ const key = cacheKey(agentName, apiKey);
87
120
  try {
88
121
  const raw = await readFile(SCOPES_PATH, "utf8");
89
122
  const parsed = JSON.parse(raw);
90
123
  if (!isScopesCacheFile(parsed)) return null;
91
- const entry = parsed[agentName];
124
+ const entry = parsed[key];
92
125
  return entry?.scopes ?? null;
93
126
  } catch {
94
127
  return null;
95
128
  }
96
129
  }
97
- async function saveCachedScopes(agentName, agentId, scopes) {
130
+ async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
131
+ if (apiKey.length === 0) return;
132
+ await ensureCacheIdentity(apiKey);
133
+ const key = cacheKey(agentName, apiKey);
98
134
  await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
99
135
  let existing = {};
100
136
  try {
@@ -105,7 +141,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
105
141
  }
106
142
  const updated = {
107
143
  ...existing,
108
- [agentName]: {
144
+ [key]: {
109
145
  agentId,
110
146
  scopes,
111
147
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -201,6 +237,13 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
201
237
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
202
238
  });
203
239
  if (!response.ok) {
240
+ const body2 = await response.json().catch(() => null);
241
+ if (response.status === 403) {
242
+ const msg = (body2?.error?.message ?? "").toLowerCase();
243
+ if (msg.includes("agent limit") || msg.includes("maximum")) {
244
+ throw new Error("Agent limit reached. Upgrade your plan at app.multicorn.ai/settings.");
245
+ }
246
+ }
204
247
  handleHttpError(response.status);
205
248
  throw new Error(
206
249
  `Failed to register agent "${agentName}": service returned ${String(response.status)}.`
@@ -212,15 +255,28 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
212
255
  }
213
256
  return body.data.id;
214
257
  }
258
+ var findOrRegisterInflight = /* @__PURE__ */ new Map();
215
259
  async function findOrRegisterAgent(agentName, apiKey, baseUrl, logger) {
216
- const existing = await findAgentByName(agentName, apiKey, baseUrl, logger);
217
- if (existing !== null) return existing;
218
- try {
219
- const id = await registerAgent(agentName, apiKey, baseUrl, logger);
220
- return { id, name: agentName };
221
- } catch {
222
- return null;
223
- }
260
+ const key = `${agentName}:${apiKey}:${baseUrl}`;
261
+ const existing = findOrRegisterInflight.get(key);
262
+ if (existing !== void 0) return existing;
263
+ const promise = (async () => {
264
+ const found = await findAgentByName(agentName, apiKey, baseUrl, logger);
265
+ if (found !== null) return found;
266
+ try {
267
+ const id = await registerAgent(agentName, apiKey, baseUrl, logger);
268
+ return { id, name: agentName };
269
+ } catch (err) {
270
+ if (err instanceof Error && err.message.includes("Agent limit reached")) {
271
+ throw err;
272
+ }
273
+ return null;
274
+ }
275
+ })().finally(() => {
276
+ findOrRegisterInflight.delete(key);
277
+ });
278
+ findOrRegisterInflight.set(key, promise);
279
+ return promise;
224
280
  }
225
281
  async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
226
282
  try {
@@ -366,6 +422,7 @@ var agentRecord = null;
366
422
  var grantedScopes = [];
367
423
  var consentInProgress = false;
368
424
  var lastScopeRefresh = 0;
425
+ var pinnedAgentName = null;
369
426
  var SCOPE_REFRESH_INTERVAL_MS = 6e4;
370
427
  function readConfig() {
371
428
  const apiKey = process.env["MULTICORN_API_KEY"] ?? "";
@@ -386,12 +443,20 @@ function resolveAgentName(sessionKey, envOverride) {
386
443
  }
387
444
  return "openclaw";
388
445
  }
446
+ function getAgentName(sessionKey, envOverride) {
447
+ if (pinnedAgentName !== null) return pinnedAgentName;
448
+ const resolved = resolveAgentName(sessionKey, envOverride);
449
+ if (resolved !== "openclaw") {
450
+ pinnedAgentName = resolved;
451
+ }
452
+ return resolved;
453
+ }
389
454
  async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
390
455
  if (agentRecord !== null && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
391
456
  return "ready";
392
457
  }
393
458
  if (agentRecord === null) {
394
- const cached = await loadCachedScopes(agentName);
459
+ const cached = await loadCachedScopes(agentName, apiKey);
395
460
  if (cached !== null && cached.length > 0) {
396
461
  grantedScopes = cached;
397
462
  void findOrRegisterAgent(agentName, apiKey, baseUrl).then((record) => {
@@ -418,7 +483,7 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
418
483
  grantedScopes = scopes;
419
484
  lastScopeRefresh = Date.now();
420
485
  if (scopes.length > 0) {
421
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
486
+ await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
422
487
  });
423
488
  }
424
489
  return "ready";
@@ -444,7 +509,7 @@ async function ensureConsent(agentName, apiKey, baseUrl, scope) {
444
509
  try {
445
510
  const scopes = await waitForConsent(agentRecord.id, agentName, apiKey, baseUrl, scope);
446
511
  grantedScopes = scopes;
447
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
512
+ await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
448
513
  });
449
514
  } finally {
450
515
  consentInProgress = false;
@@ -466,7 +531,10 @@ var handler = async (event) => {
466
531
  );
467
532
  return;
468
533
  }
469
- const agentName = resolveAgentName(event.sessionKey, config.agentName);
534
+ if (config.agentName !== null) {
535
+ pinnedAgentName = config.agentName;
536
+ }
537
+ const agentName = getAgentName(event.sessionKey, config.agentName);
470
538
  const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
471
539
  if (readiness === "block") {
472
540
  event.messages.push(
@@ -488,9 +556,10 @@ var handler = async (event) => {
488
556
  const permitted = isPermitted(event);
489
557
  if (!permitted) {
490
558
  const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
491
- const dashboardUrl = deriveDashboardUrl(config.baseUrl);
559
+ const base = deriveDashboardUrl(config.baseUrl).replace(/\/+$/, "");
492
560
  event.messages.push(
493
- `Permission denied: ${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at ${dashboardUrl}/approvals `
561
+ `Permission denied: ${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at:
562
+ ${base}/approvals`
494
563
  );
495
564
  void logAction(
496
565
  {
@@ -520,6 +589,7 @@ function resetState() {
520
589
  grantedScopes = [];
521
590
  consentInProgress = false;
522
591
  lastScopeRefresh = 0;
592
+ pinnedAgentName = null;
523
593
  }
524
594
 
525
595
  export { handler, readConfig, resetState, resolveAgentName };
@@ -3,7 +3,8 @@ import * as path from 'path';
3
3
  import { join } from 'path';
4
4
  import * as os from 'os';
5
5
  import { homedir } from 'os';
6
- import { mkdir, readFile, writeFile } from 'fs/promises';
6
+ import { createHash } from 'crypto';
7
+ import { mkdir, readFile, writeFile, unlink } from 'fs/promises';
7
8
  import { spawn } from 'child_process';
8
9
 
9
10
  // Multicorn Shield plugin for OpenClaw - https://multicorn.ai
@@ -88,18 +89,53 @@ function mapToolToScope(toolName, command) {
88
89
  }
89
90
  var MULTICORN_DIR = join(homedir(), ".multicorn");
90
91
  var SCOPES_PATH = join(MULTICORN_DIR, "scopes.json");
91
- async function loadCachedScopes(agentName) {
92
+ var CACHE_META_PATH = join(MULTICORN_DIR, "cache-meta.json");
93
+ function cacheKey(agentName, apiKey) {
94
+ return createHash("sha256").update(`${agentName}:${apiKey}`).digest("hex").slice(0, 16);
95
+ }
96
+ async function ensureCacheIdentity(apiKey) {
97
+ const currentHash = createHash("sha256").update(apiKey).digest("hex");
98
+ let storedHash = null;
99
+ try {
100
+ const raw = await readFile(CACHE_META_PATH, "utf8");
101
+ const meta = JSON.parse(raw);
102
+ if (typeof meta === "object" && meta !== null && "apiKeyHash" in meta) {
103
+ storedHash = meta.apiKeyHash;
104
+ }
105
+ } catch {
106
+ }
107
+ if (storedHash === null || storedHash !== currentHash) {
108
+ try {
109
+ await unlink(SCOPES_PATH);
110
+ } catch {
111
+ }
112
+ }
113
+ if (storedHash !== currentHash) {
114
+ await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
115
+ await writeFile(CACHE_META_PATH, JSON.stringify({ apiKeyHash: currentHash }, null, 2) + "\n", {
116
+ encoding: "utf8",
117
+ mode: 384
118
+ });
119
+ }
120
+ }
121
+ async function loadCachedScopes(agentName, apiKey) {
122
+ if (apiKey.length === 0) return null;
123
+ await ensureCacheIdentity(apiKey);
124
+ const key = cacheKey(agentName, apiKey);
92
125
  try {
93
126
  const raw = await readFile(SCOPES_PATH, "utf8");
94
127
  const parsed = JSON.parse(raw);
95
128
  if (!isScopesCacheFile(parsed)) return null;
96
- const entry = parsed[agentName];
129
+ const entry = parsed[key];
97
130
  return entry?.scopes ?? null;
98
131
  } catch {
99
132
  return null;
100
133
  }
101
134
  }
102
- async function saveCachedScopes(agentName, agentId, scopes) {
135
+ async function saveCachedScopes(agentName, agentId, scopes, apiKey) {
136
+ if (apiKey.length === 0) return;
137
+ await ensureCacheIdentity(apiKey);
138
+ const key = cacheKey(agentName, apiKey);
103
139
  await mkdir(MULTICORN_DIR, { recursive: true, mode: 448 });
104
140
  let existing = {};
105
141
  try {
@@ -110,7 +146,7 @@ async function saveCachedScopes(agentName, agentId, scopes) {
110
146
  }
111
147
  const updated = {
112
148
  ...existing,
113
- [agentName]: {
149
+ [key]: {
114
150
  agentId,
115
151
  scopes,
116
152
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -209,6 +245,13 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
209
245
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
210
246
  });
211
247
  if (!response.ok) {
248
+ const body2 = await response.json().catch(() => null);
249
+ if (response.status === 403) {
250
+ const msg = (body2?.error?.message ?? "").toLowerCase();
251
+ if (msg.includes("agent limit") || msg.includes("maximum")) {
252
+ throw new Error("Agent limit reached. Upgrade your plan at app.multicorn.ai/settings.");
253
+ }
254
+ }
212
255
  handleHttpError(response.status, logger);
213
256
  throw new Error(
214
257
  `Failed to register agent "${agentName}": service returned ${String(response.status)}.`
@@ -220,15 +263,28 @@ async function registerAgent(agentName, apiKey, baseUrl, logger) {
220
263
  }
221
264
  return body.data.id;
222
265
  }
266
+ var findOrRegisterInflight = /* @__PURE__ */ new Map();
223
267
  async function findOrRegisterAgent(agentName, apiKey, baseUrl, logger) {
224
- const existing = await findAgentByName(agentName, apiKey, baseUrl, logger);
225
- if (existing !== null) return existing;
226
- try {
227
- const id = await registerAgent(agentName, apiKey, baseUrl, logger);
228
- return { id, name: agentName };
229
- } catch {
230
- return null;
231
- }
268
+ const key = `${agentName}:${apiKey}:${baseUrl}`;
269
+ const existing = findOrRegisterInflight.get(key);
270
+ if (existing !== void 0) return existing;
271
+ const promise = (async () => {
272
+ const found = await findAgentByName(agentName, apiKey, baseUrl, logger);
273
+ if (found !== null) return found;
274
+ try {
275
+ const id = await registerAgent(agentName, apiKey, baseUrl, logger);
276
+ return { id, name: agentName };
277
+ } catch (err) {
278
+ if (err instanceof Error && err.message.includes("Agent limit reached")) {
279
+ throw err;
280
+ }
281
+ return null;
282
+ }
283
+ })().finally(() => {
284
+ findOrRegisterInflight.delete(key);
285
+ });
286
+ findOrRegisterInflight.set(key, promise);
287
+ return promise;
232
288
  }
233
289
  async function fetchGrantedScopes(agentId, apiKey, baseUrl, logger) {
234
290
  try {
@@ -431,6 +487,7 @@ var lastScopeRefresh = 0;
431
487
  var pluginLogger = null;
432
488
  var pluginConfig;
433
489
  var connectionLogged = false;
490
+ var pinnedAgentName = null;
434
491
  var SCOPE_REFRESH_INTERVAL_MS = 6e4;
435
492
  var cachedMulticornConfig = null;
436
493
  function loadMulticornConfig() {
@@ -453,9 +510,12 @@ function readConfig() {
453
510
  function asString(value) {
454
511
  return typeof value === "string" && value.length > 0 ? value : void 0;
455
512
  }
456
- function resolveAgentName(sessionKey, envOverride) {
457
- if (envOverride !== null && envOverride.trim().length > 0) {
458
- return envOverride.trim();
513
+ function resolveAgentName(sessionKey, configOverride, ctxAgentId) {
514
+ if (configOverride !== null && configOverride.trim().length > 0) {
515
+ return configOverride.trim();
516
+ }
517
+ if (ctxAgentId !== void 0 && ctxAgentId.trim().length > 0) {
518
+ return ctxAgentId.trim();
459
519
  }
460
520
  const parts = sessionKey.split(":");
461
521
  const name = parts[1];
@@ -464,12 +524,23 @@ function resolveAgentName(sessionKey, envOverride) {
464
524
  }
465
525
  return "openclaw";
466
526
  }
527
+ function getAgentName(sessionKey, configOverride, ctxAgentId) {
528
+ if (pinnedAgentName !== null) return pinnedAgentName;
529
+ const resolved = resolveAgentName(sessionKey, configOverride, ctxAgentId);
530
+ if (resolved !== "openclaw") {
531
+ pinnedAgentName = resolved;
532
+ }
533
+ return resolved;
534
+ }
467
535
  async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
468
- if (agentRecord !== null && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
536
+ if (agentRecord !== null && agentRecord.name === agentName && Date.now() - lastScopeRefresh < SCOPE_REFRESH_INTERVAL_MS) {
469
537
  return "ready";
470
538
  }
539
+ if (agentRecord !== null && agentRecord.name !== agentName) {
540
+ agentRecord = null;
541
+ }
471
542
  if (agentRecord === null) {
472
- const cached = await loadCachedScopes(agentName);
543
+ const cached = await loadCachedScopes(agentName, apiKey);
473
544
  if (cached !== null && cached.length > 0) {
474
545
  grantedScopes = cached;
475
546
  void findOrRegisterAgent(agentName, apiKey, baseUrl, pluginLogger ?? void 0).then(
@@ -501,7 +572,7 @@ async function ensureAgent(agentName, apiKey, baseUrl, failMode) {
501
572
  grantedScopes = scopes;
502
573
  lastScopeRefresh = Date.now();
503
574
  if (scopes.length > 0) {
504
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
575
+ await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
505
576
  });
506
577
  }
507
578
  if (!connectionLogged) {
@@ -543,7 +614,7 @@ async function ensureConsent(agentName, apiKey, baseUrl, scope) {
543
614
  pluginLogger ?? void 0
544
615
  );
545
616
  grantedScopes = scopes;
546
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
617
+ await saveCachedScopes(agentName, agentRecord.id, scopes, apiKey).catch(() => {
547
618
  });
548
619
  } finally {
549
620
  consentInProgress = false;
@@ -632,7 +703,7 @@ async function beforeToolCall(event, ctx) {
632
703
  console.error("[SHIELD] DECISION: allow (no API key)");
633
704
  return void 0;
634
705
  }
635
- const agentName = resolveAgentName(ctx.sessionKey ?? "", config.agentName);
706
+ const agentName = getAgentName(ctx.sessionKey ?? "", config.agentName, ctx.agentId);
636
707
  const readiness = await ensureAgent(agentName, config.apiKey, config.baseUrl, config.failMode);
637
708
  console.error("[SHIELD] ensureAgent result: " + JSON.stringify(readiness));
638
709
  if (readiness === "block") {
@@ -694,7 +765,7 @@ async function beforeToolCall(event, ctx) {
694
765
  grantedScopes = scopes;
695
766
  lastScopeRefresh = Date.now();
696
767
  if (Array.isArray(scopes) && scopes.length > 0) {
697
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
768
+ await saveCachedScopes(agentName, agentRecord.id, scopes, config.apiKey).catch(() => {
698
769
  });
699
770
  }
700
771
  }
@@ -702,10 +773,11 @@ async function beforeToolCall(event, ctx) {
702
773
  return void 0;
703
774
  }
704
775
  if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
705
- const dashboardUrl2 = deriveDashboardUrl(config.baseUrl);
776
+ const base2 = deriveDashboardUrl(config.baseUrl).replace(/\/+$/, "");
706
777
  const returnValue2 = {
707
778
  block: true,
708
- blockReason: `Action pending approval. Visit ${dashboardUrl2}approvals to approve or reject, then try again.`
779
+ blockReason: `Action pending approval.
780
+ Visit ${base2}/approvals to approve or reject, then try again.`
709
781
  };
710
782
  console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue2));
711
783
  return returnValue2;
@@ -726,7 +798,7 @@ async function beforeToolCall(event, ctx) {
726
798
  grantedScopes = scopes;
727
799
  lastScopeRefresh = Date.now();
728
800
  if (Array.isArray(scopes) && scopes.length > 0) {
729
- await saveCachedScopes(agentName, agentRecord.id, scopes).catch(() => {
801
+ await saveCachedScopes(agentName, agentRecord.id, scopes, config.apiKey).catch(() => {
730
802
  });
731
803
  }
732
804
  const recheckResult = await checkActionPermission(
@@ -751,16 +823,19 @@ async function beforeToolCall(event, ctx) {
751
823
  grantedScopes = refreshedScopes;
752
824
  lastScopeRefresh = Date.now();
753
825
  if (Array.isArray(refreshedScopes) && refreshedScopes.length > 0) {
754
- await saveCachedScopes(agentName, agentRecord.id, refreshedScopes).catch(() => {
755
- });
826
+ await saveCachedScopes(agentName, agentRecord.id, refreshedScopes, config.apiKey).catch(
827
+ () => {
828
+ }
829
+ );
756
830
  }
757
831
  console.error("[SHIELD] DECISION: allow (re-check after consent)");
758
832
  return void 0;
759
833
  }
760
834
  }
761
835
  const capitalizedService = mapping.service.charAt(0).toUpperCase() + mapping.service.slice(1);
762
- const dashboardUrl = deriveDashboardUrl(config.baseUrl);
763
- const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at ${dashboardUrl}/approvals `;
836
+ const base = deriveDashboardUrl(config.baseUrl).replace(/\/+$/, "");
837
+ const reason = `${capitalizedService} ${mapping.permissionLevel} access is not allowed. Check pending approvals at:
838
+ ${base}/approvals`;
764
839
  const returnValue = { block: true, blockReason: reason };
765
840
  console.error("[SHIELD] DECISION: " + JSON.stringify(returnValue));
766
841
  return returnValue;
@@ -773,7 +848,7 @@ async function beforeToolCall(event, ctx) {
773
848
  function afterToolCall(event, ctx) {
774
849
  const config = readConfig();
775
850
  if (config.apiKey.length === 0) return Promise.resolve();
776
- const agentName = resolveAgentName(ctx.sessionKey ?? "", config.agentName);
851
+ const agentName = getAgentName(ctx.sessionKey ?? "", config.agentName, ctx.agentId);
777
852
  const mapping = mapToolToScope(event.toolName);
778
853
  void logAction(
779
854
  {
@@ -801,11 +876,14 @@ var plugin = {
801
876
  pluginLogger = api.logger;
802
877
  pluginConfig = api.pluginConfig;
803
878
  cachedMulticornConfig = loadMulticornConfig();
879
+ const config = readConfig();
880
+ if (config.agentName !== null) {
881
+ pinnedAgentName = config.agentName;
882
+ }
804
883
  console.error("[SHIELD-DIAG] cachedMulticornConfig: " + JSON.stringify(cachedMulticornConfig));
805
884
  api.on("before_tool_call", beforeToolCall, { priority: 10 });
806
885
  api.on("after_tool_call", afterToolCall);
807
886
  api.logger.info("Multicorn Shield plugin registered.");
808
- const config = readConfig();
809
887
  if (config.apiKey.length === 0) {
810
888
  api.logger.error(
811
889
  "Multicorn Shield: No API key found. Run 'npx multicorn-proxy init' or set MULTICORN_API_KEY."
@@ -829,6 +907,7 @@ function resetState() {
829
907
  pluginConfig = void 0;
830
908
  cachedMulticornConfig = null;
831
909
  connectionLogged = false;
910
+ pinnedAgentName = null;
832
911
  }
833
912
 
834
913
  export { afterToolCall, beforeToolCall, plugin, readConfig, register, resetState, resolveAgentName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multicorn-shield",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
5
5
  "license": "MIT",
6
6
  "type": "module",