airlock-bot 0.2.36 → 0.2.37

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,3 +1,4 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
2
3
  import { parseArgs } from 'util';
3
4
  import Fastify from 'fastify';
@@ -7,8 +8,10 @@ import { buildAdapters } from '../backend/factory.js';
7
8
  import { AuditConfig, GatewayConfig, McpServerConfig, ProviderConfig, getMcpConfigs, } from '../config/schema.js';
8
9
  import { rememberAllow } from '../config/mutator.js';
9
10
  import { validateConfig } from '../config/loader.js';
11
+ import { applyProfiles } from '../config/profiles.js';
10
12
  import { ClientPool } from '../pool/pool.js';
11
13
  import { checkSuspiciousPatterns } from '../registry/sanitizer.js';
14
+ import { bestSpecificity } from '../allowlist/pattern.js';
12
15
  import { VERSION } from '../version.js';
13
16
  const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
14
17
  let latestVersionCache = null;
@@ -74,7 +77,9 @@ export async function runDashboard(argv) {
74
77
  const port = Number(values.port ?? '4177');
75
78
  const host = values.host ?? '127.0.0.1';
76
79
  const gatewayUrl = values['gateway-url'] ?? 'http://127.0.0.1:4111';
77
- const gatewaySecret = values['gateway-secret'] ?? process.env['AIRLOCK_GATEWAY_SECRET'] ?? process.env['AIRLOCK_API_SECRET'];
80
+ const gatewaySecret = values['gateway-secret'] ??
81
+ process.env['AIRLOCK_GATEWAY_SECRET'] ??
82
+ process.env['AIRLOCK_API_SECRET'];
78
83
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
79
84
  throw new Error(`Invalid --port: ${values.port}`);
80
85
  }
@@ -362,7 +367,7 @@ async function readRemoteCommandCenterStatus(configPath, remoteGateway) {
362
367
  id,
363
368
  type,
364
369
  enabled,
365
- status: enabled ? runtime ?? 'ok' : 'disabled',
370
+ status: enabled ? (runtime ?? 'ok') : 'disabled',
366
371
  toolCount: 0,
367
372
  toolFingerprint: '',
368
373
  ...(runtime === 'down' ? { error: 'Provider is down on the gateway' } : {}),
@@ -430,10 +435,11 @@ function registerRemoteGatewayRoutes(app, configPath, remoteGateway) {
430
435
  const reader = response.body.getReader();
431
436
  try {
432
437
  while (true) {
433
- const { done, value } = await reader.read();
434
- if (done)
438
+ const result = await reader.read();
439
+ if (result.done)
435
440
  break;
436
- reply.raw.write(Buffer.from(value));
441
+ if (result.value)
442
+ reply.raw.write(Buffer.from(result.value));
437
443
  }
438
444
  }
439
445
  catch {
@@ -504,7 +510,8 @@ async function rememberRemoteDecision(configPath, remoteGateway, code, remember,
504
510
  }
505
511
  }
506
512
  async function forwardRemoteApproval(remoteGateway, action, code) {
507
- const response = await remoteGatewayFetch(remoteGateway, `/${action}?${new URLSearchParams({ code })}`, {
513
+ const params = new URLSearchParams({ code });
514
+ const response = await remoteGatewayFetch(remoteGateway, `/${action}?${params.toString()}`, {
508
515
  method: 'POST',
509
516
  });
510
517
  if (!response.ok)
@@ -564,6 +571,7 @@ export function saveRules(configPath, body) {
564
571
  existing.deny = parseStringArray(body.deny, 'deny');
565
572
  }
566
573
  else {
574
+ existing.extends = parseStringArray(body.extends, 'extends');
567
575
  existing.allow = parseStringArray(body.allow, 'allow');
568
576
  existing.ask = parseStringArray(body.ask, 'ask');
569
577
  existing.deny = parseStringArray(body.deny, 'deny');
@@ -584,15 +592,32 @@ export function createEntity(configPath, body) {
584
592
  const section = asMutableRecord(doc[sectionName]);
585
593
  if (section[id])
586
594
  throw new Error(`${kind} "${id}" already exists`);
587
- if (body.baseId && section[body.baseId]) {
588
- section[id] = structuredClone(section[body.baseId]);
595
+ const baseId = parseOptionalId(body.baseId);
596
+ const base = baseId && section[baseId] ? structuredClone(section[baseId]) : undefined;
597
+ const createdToken = kind === 'agent' ? createAgentToken() : undefined;
598
+ if (kind === 'agent') {
599
+ const agent = asMutableRecord(base);
600
+ const profileIds = parseOptionalStringArray(body.profileIds, 'profileIds');
601
+ delete agent.token;
602
+ agent.extends = profileIds ?? stringArray(agent.extends);
603
+ agent.allow = stringArray(agent.allow);
604
+ agent.ask = stringArray(agent.ask);
605
+ agent.deny = stringArray(agent.deny);
606
+ agent.token = createdToken;
607
+ section[id] = agent;
608
+ }
609
+ else if (base) {
610
+ section[id] = structuredClone(base);
589
611
  }
590
612
  else {
591
- section[id] = { allow: [], ask: [], deny: [] };
613
+ section[id] = { extends: [], allow: [], ask: [], deny: [] };
592
614
  }
593
615
  doc[sectionName] = section;
594
616
  writeValidatedConfig(configPath, doc);
595
- return readState(configPath);
617
+ return {
618
+ ...readState(configPath),
619
+ ...(createdToken ? { createdToken: { agentId: id, token: createdToken } } : {}),
620
+ };
596
621
  }
597
622
  export function deleteEntity(configPath, kind, id) {
598
623
  const parsedKind = parseKind(kind);
@@ -685,6 +710,44 @@ export function annotationTags(annotations, suspiciousPatterns = []) {
685
710
  tags.push('injection');
686
711
  return tags;
687
712
  }
713
+ export function resolveEditableRuleDecision(rules, toolName) {
714
+ return resolveEditableRuleMatch(rules, toolName).decision;
715
+ }
716
+ export function resolveEditableRuleMatch(rules, toolName) {
717
+ const deny = bestRuleMatch(rules.deny, toolName);
718
+ const ask = bestRuleMatch(rules.ask, toolName);
719
+ const allow = bestRuleMatch(rules.allow, toolName);
720
+ const denySpec = deny?.specificity ?? -1;
721
+ const askSpec = ask?.specificity ?? -1;
722
+ const allowSpec = allow?.specificity ?? -1;
723
+ const best = Math.max(denySpec, askSpec, allowSpec);
724
+ if (best < 0)
725
+ return { decision: 'unset', source: 'none' };
726
+ if (denySpec === best)
727
+ return editableRuleMatch('deny', deny, toolName);
728
+ if (askSpec === best)
729
+ return editableRuleMatch('ask', ask, toolName);
730
+ return editableRuleMatch('allow', allow, toolName);
731
+ }
732
+ function editableRuleMatch(decision, match, toolName) {
733
+ if (!match)
734
+ return { decision, source: 'none' };
735
+ return {
736
+ decision,
737
+ source: match.pattern === toolName ? 'exact' : 'pattern',
738
+ pattern: match.pattern,
739
+ };
740
+ }
741
+ function bestRuleMatch(patterns, toolName) {
742
+ let best;
743
+ for (const pattern of patterns) {
744
+ const specificity = bestSpecificity([pattern], toolName);
745
+ if (specificity >= 0 && (!best || specificity > best.specificity)) {
746
+ best = { pattern, specificity };
747
+ }
748
+ }
749
+ return best;
750
+ }
688
751
  export async function discoverTools(configPath) {
689
752
  const raw = readConfigObject(configPath);
690
753
  const parsed = GatewayConfig.parse(withoutDisabledProviders(raw));
@@ -820,6 +883,7 @@ function parseGatewayConfig(doc) {
820
883
  ],
821
884
  };
822
885
  }
886
+ applyProfiles(result.data);
823
887
  return { config: result.data, diagnostics: validateConfig(result.data) };
824
888
  }
825
889
  catch (err) {
@@ -838,6 +902,7 @@ function toEditableAgents(input) {
838
902
  allow: stringArray(agent.allow),
839
903
  ask: stringArray(agent.ask),
840
904
  deny: stringArray(agent.deny),
905
+ hasToken: typeof agent.token === 'string' && agent.token.trim().length > 0,
841
906
  },
842
907
  ];
843
908
  }));
@@ -868,6 +933,7 @@ function toEditableProfiles(input) {
868
933
  return [
869
934
  id,
870
935
  {
936
+ extends: stringArray(profile.extends),
871
937
  allow: stringArray(profile.allow),
872
938
  ask: stringArray(profile.ask),
873
939
  deny: stringArray(profile.deny),
@@ -895,6 +961,14 @@ function parseId(value) {
895
961
  }
896
962
  return id;
897
963
  }
964
+ function parseOptionalId(value) {
965
+ if (value === undefined || value === null || value === '')
966
+ return undefined;
967
+ return parseId(value);
968
+ }
969
+ function createAgentToken() {
970
+ return `airlock_agent_${randomBytes(32).toString('base64url')}`;
971
+ }
898
972
  function parseBoolean(value) {
899
973
  if (typeof value === 'boolean')
900
974
  return value;
@@ -1479,6 +1553,25 @@ const INDEX_HTML = `<!doctype html>
1479
1553
  color: var(--red);
1480
1554
  font-weight: 700;
1481
1555
  }
1556
+ .rule-actions button.active.pattern-match {
1557
+ border-style: dashed;
1558
+ font-weight: 650;
1559
+ }
1560
+ .rule-actions button.active.pattern-match[data-decision="allow"] {
1561
+ border-color: #b8dfca;
1562
+ background: #f6fbf8;
1563
+ color: #4d7f63;
1564
+ }
1565
+ .rule-actions button.active.pattern-match[data-decision="ask"] {
1566
+ border-color: #f1d99a;
1567
+ background: #fffaf0;
1568
+ color: #8a6a19;
1569
+ }
1570
+ .rule-actions button.active.pattern-match[data-decision="deny"] {
1571
+ border-color: #f2c0bb;
1572
+ background: #fff8f7;
1573
+ color: #9b4a43;
1574
+ }
1482
1575
  .pill {
1483
1576
  display: inline-flex;
1484
1577
  align-items: center;
@@ -1571,6 +1664,43 @@ const INDEX_HTML = `<!doctype html>
1571
1664
  overflow: auto;
1572
1665
  padding: 14px;
1573
1666
  }
1667
+ .form-grid {
1668
+ display: grid;
1669
+ gap: 12px;
1670
+ }
1671
+ .field {
1672
+ display: grid;
1673
+ gap: 6px;
1674
+ }
1675
+ .field label {
1676
+ color: var(--muted);
1677
+ font-size: 12px;
1678
+ font-weight: 700;
1679
+ text-transform: uppercase;
1680
+ letter-spacing: 0.08em;
1681
+ }
1682
+ .field input, .field select {
1683
+ min-height: 36px;
1684
+ min-width: 0;
1685
+ border: 1px solid var(--line);
1686
+ border-radius: 7px;
1687
+ padding: 0 10px;
1688
+ background: #fff;
1689
+ color: var(--ink);
1690
+ }
1691
+ .profile-select-list {
1692
+ display: grid;
1693
+ gap: 6px;
1694
+ padding: 8px;
1695
+ border: 1px solid var(--line);
1696
+ border-radius: 7px;
1697
+ background: #fbfcfe;
1698
+ }
1699
+ .token-value {
1700
+ width: 100%;
1701
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1702
+ font-size: 12px;
1703
+ }
1574
1704
  .modal-title {
1575
1705
  min-width: 0;
1576
1706
  overflow-wrap: anywhere;
@@ -1725,6 +1855,19 @@ const INDEX_HTML = `<!doctype html>
1725
1855
  <div id="approvalModalFooter" class="modal-footer"></div>
1726
1856
  </div>
1727
1857
  </div>
1858
+ <div id="entityModal" class="modal-backdrop">
1859
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="entityModalTitle">
1860
+ <div class="modal-header">
1861
+ <div>
1862
+ <div id="entityModalTitle" class="modal-title"></div>
1863
+ <div id="entityModalMeta" class="subtle"></div>
1864
+ </div>
1865
+ <button id="entityModalClose" title="Close">Close</button>
1866
+ </div>
1867
+ <div id="entityModalBody" class="modal-body"></div>
1868
+ <div id="entityModalFooter" class="modal-footer"></div>
1869
+ </div>
1870
+ </div>
1728
1871
  <script>
1729
1872
  const state = {
1730
1873
  config: null,
@@ -1739,6 +1882,7 @@ const INDEX_HTML = `<!doctype html>
1739
1882
  errors: [],
1740
1883
  activeKind: 'agent',
1741
1884
  activeId: '',
1885
+ createKind: '',
1742
1886
  drafts: {},
1743
1887
  descriptionExpanded: {},
1744
1888
  search: '',
@@ -2208,7 +2352,8 @@ const INDEX_HTML = `<!doctype html>
2208
2352
  el('addProvider').style.display = state.activeKind === 'providers' ? 'inline-flex' : 'none';
2209
2353
  el('deleteEntity').style.display = state.activeKind === 'activity' ? 'none' : el('deleteEntity').style.display;
2210
2354
  el('deleteEntity').disabled = !state.activeId || state.activeKind === 'activity';
2211
- el('profilePanel').style.display = state.activeKind === 'agent' ? 'block' : 'none';
2355
+ el('profilePanel').style.display =
2356
+ state.activeKind === 'agent' || state.activeKind === 'profile' ? 'block' : 'none';
2212
2357
 
2213
2358
  if (!draft) {
2214
2359
  if (state.activeKind === 'providers') {
@@ -2236,12 +2381,18 @@ const INDEX_HTML = `<!doctype html>
2236
2381
  }
2237
2382
 
2238
2383
  const denyCount = draft.deny.length;
2384
+ const tokenPill = state.activeKind === 'agent'
2385
+ ? '<span class="pill ' + (state.config.agents[state.activeId]?.hasToken ? 'tag-good' : 'tag-danger') + '">' + (state.config.agents[state.activeId]?.hasToken ? 'token set' : 'token missing') + '</span>'
2386
+ : '';
2239
2387
  el('summary').innerHTML =
2240
2388
  '<span class="pill">allow ' + draft.allow.length + '</span>' +
2241
2389
  '<span class="pill">ask ' + draft.ask.length + '</span>' +
2242
- '<span class="pill">deny ' + denyCount + '</span>';
2390
+ '<span class="pill">deny ' + denyCount + '</span>' +
2391
+ tokenPill;
2243
2392
 
2244
- const profiles = Object.keys(state.config.profiles);
2393
+ const profiles = Object.keys(state.config.profiles).filter(
2394
+ (id) => state.activeKind !== 'profile' || id !== state.activeId
2395
+ );
2245
2396
  el('profileChecks').innerHTML = profiles.length
2246
2397
  ? profiles.map((id) => {
2247
2398
  const checked = (draft.extends || []).includes(id) ? ' checked' : '';
@@ -2292,7 +2443,7 @@ const INDEX_HTML = `<!doctype html>
2292
2443
 
2293
2444
  const visible = filteredTools();
2294
2445
  el('tools').innerHTML = visible.length
2295
- ? visible.map((tool) => toolRow(tool, getDecision(draft, tool.name))).join('')
2446
+ ? visible.map((tool) => toolRow(tool, getDecisionMatch(draft, tool.name))).join('')
2296
2447
  : '<div class="empty">No tools match the current filters.</div>';
2297
2448
 
2298
2449
  document.querySelectorAll('[data-rule]').forEach((button) => {
@@ -2321,7 +2472,8 @@ const INDEX_HTML = `<!doctype html>
2321
2472
  });
2322
2473
  }
2323
2474
 
2324
- function toolRow(tool, decision) {
2475
+ function toolRow(tool, match) {
2476
+ const decision = match.decision;
2325
2477
  const tags = tool.tags && tool.tags.length ? tool.tags : ['untagged'];
2326
2478
  const tagHtml = tags.map((tag) => '<span class="tag ' + tagClass(tag) + '">' + escapeHtml(tag) + '</span>').join('');
2327
2479
  const recommended = '<span class="tag tag-recommended ' + recommendedClass(tool.recommended) + '">recommended ' + escapeHtml(tool.recommended) + '</span>';
@@ -2336,10 +2488,10 @@ const INDEX_HTML = `<!doctype html>
2336
2488
  '<div class="tool-name"><span class="provider">' + escapeHtml(tool.provider) + '</span><br>' + escapeHtml(tool.shortName || tool.name) + '</div>' +
2337
2489
  '<div class="description"><div class="description-content' + descriptionClass + '">' + formatDescription(description) + '</div>' + toggle + '<div class="tag-list">' + tagHtml + recommended + '</div></div>' +
2338
2490
  '<div class="rule-actions">' +
2339
- ruleButton(tool.name, 'allow', decision) +
2340
- ruleButton(tool.name, 'ask', decision) +
2341
- ruleButton(tool.name, 'deny', decision) +
2342
- ruleButton(tool.name, 'unset', decision) +
2491
+ ruleButton(tool.name, 'allow', match) +
2492
+ ruleButton(tool.name, 'ask', match) +
2493
+ ruleButton(tool.name, 'deny', match) +
2494
+ ruleButton(tool.name, 'unset', match) +
2343
2495
  '</div>' +
2344
2496
  '</div>';
2345
2497
  }
@@ -2394,17 +2546,67 @@ const INDEX_HTML = `<!doctype html>
2394
2546
  return 'tag-good';
2395
2547
  }
2396
2548
 
2397
- function ruleButton(tool, decision, activeDecision) {
2398
- const active = decision === activeDecision ? ' active' : '';
2549
+ function ruleButton(tool, decision, activeMatch) {
2550
+ const active = decision === activeMatch.decision ? ' active' : '';
2551
+ const patternMatch = active && activeMatch.source === 'pattern' ? ' pattern-match' : '';
2399
2552
  const label = decision === 'unset' ? 'Clear' : decision;
2400
- return '<button class="' + active + '" data-rule data-tool="' + escapeHtml(tool) + '" data-decision="' + decision + '">' + label + '</button>';
2553
+ const title = activeMatch.source === 'pattern' && decision === activeMatch.decision
2554
+ ? decision + ' by ' + activeMatch.pattern + '. Click to add an exact rule.'
2555
+ : decision === activeMatch.decision && activeMatch.source === 'exact'
2556
+ ? decision + ' by exact rule.'
2557
+ : decision === 'unset'
2558
+ ? 'Clear exact rule for this tool.'
2559
+ : 'Set exact ' + decision + ' rule for this tool.';
2560
+ return '<button class="' + active + patternMatch + '" title="' + escapeHtml(title) + '" data-rule data-tool="' + escapeHtml(tool) + '" data-decision="' + decision + '">' + label + '</button>';
2401
2561
  }
2402
2562
 
2403
2563
  function getDecision(draft, tool) {
2404
- if (draft.deny.includes(tool)) return 'deny';
2405
- if (draft.ask.includes(tool)) return 'ask';
2406
- if (draft.allow.includes(tool)) return 'allow';
2407
- return 'unset';
2564
+ return getDecisionMatch(draft, tool).decision;
2565
+ }
2566
+
2567
+ function getDecisionMatch(draft, tool) {
2568
+ const deny = bestRuleMatch(draft.deny, tool);
2569
+ const ask = bestRuleMatch(draft.ask, tool);
2570
+ const allow = bestRuleMatch(draft.allow, tool);
2571
+ const denySpec = deny ? deny.specificity : -1;
2572
+ const askSpec = ask ? ask.specificity : -1;
2573
+ const allowSpec = allow ? allow.specificity : -1;
2574
+ const best = Math.max(denySpec, askSpec, allowSpec);
2575
+ if (best < 0) return { decision: 'unset', source: 'none' };
2576
+ if (denySpec === best) return decisionMatch('deny', deny, tool);
2577
+ if (askSpec === best) return decisionMatch('ask', ask, tool);
2578
+ return decisionMatch('allow', allow, tool);
2579
+ }
2580
+
2581
+ function decisionMatch(decision, match, tool) {
2582
+ if (!match) return { decision, source: 'none' };
2583
+ return {
2584
+ decision,
2585
+ source: match.pattern === tool ? 'exact' : 'pattern',
2586
+ pattern: match.pattern
2587
+ };
2588
+ }
2589
+
2590
+ function bestRuleMatch(patterns, tool) {
2591
+ let best = null;
2592
+ for (const pattern of patterns || []) {
2593
+ const score = ruleSpecificity(pattern, tool);
2594
+ if (score >= 0 && (!best || score > best.specificity)) best = { pattern, specificity: score };
2595
+ }
2596
+ return best;
2597
+ }
2598
+
2599
+ function ruleSpecificity(pattern, tool) {
2600
+ if (pattern === tool) return pattern.length + 1;
2601
+ if (pattern.endsWith('/*')) {
2602
+ const prefix = pattern.slice(0, -1);
2603
+ return tool.startsWith(prefix) && !tool.slice(prefix.length).includes('/') ? prefix.length : -1;
2604
+ }
2605
+ if (pattern.endsWith('*')) {
2606
+ const prefix = pattern.slice(0, -1);
2607
+ return tool.startsWith(prefix) ? prefix.length : -1;
2608
+ }
2609
+ return -1;
2408
2610
  }
2409
2611
 
2410
2612
  function setDecision(draft, tool, decision) {
@@ -2473,12 +2675,104 @@ const INDEX_HTML = `<!doctype html>
2473
2675
  render();
2474
2676
  }
2475
2677
 
2476
- async function addEntity(kind) {
2477
- const id = prompt('New ' + kind + ' id');
2478
- if (!id) return;
2479
- state.config = await api('/api/entities', { method: 'POST', body: JSON.stringify({ kind, id }) });
2678
+ function openCreateModal(kind) {
2679
+ state.createKind = kind;
2680
+ renderCreateModal();
2681
+ el('entityModal').classList.add('open');
2682
+ setTimeout(() => el('entityId')?.focus(), 0);
2683
+ }
2684
+
2685
+ function closeEntityModal() {
2686
+ el('entityModal').classList.remove('open');
2687
+ state.createKind = '';
2688
+ }
2689
+
2690
+ function renderCreateModal() {
2691
+ const kind = state.createKind;
2692
+ const isAgent = kind === 'agent';
2693
+ const section = isAgent ? state.config.agents : state.config.profiles;
2694
+ const baseOptions = ['<option value="">Blank</option>'].concat(
2695
+ Object.keys(section).map((id) => '<option value="' + escapeHtml(id) + '">' + escapeHtml(id) + '</option>')
2696
+ ).join('');
2697
+ el('entityModalTitle').textContent = isAgent ? 'New agent' : 'New profile';
2698
+ el('entityModalMeta').textContent = isAgent ? 'Creates a bearer token and permission policy' : 'Creates a reusable permission policy';
2699
+ el('entityModalBody').innerHTML =
2700
+ '<div class="form-grid">' +
2701
+ '<div class="field"><label for="entityId">Id</label><input id="entityId" autocomplete="off" placeholder="' + (isAgent ? 'claude-code' : 'readonly') + '"></div>' +
2702
+ '<div class="field"><label for="entityBase">Start from</label><select id="entityBase">' + baseOptions + '</select></div>' +
2703
+ (isAgent ? profilePicker() : '') +
2704
+ '</div>';
2705
+ el('entityModalFooter').innerHTML =
2706
+ '<button id="entityCreate" class="primary">Create</button>' +
2707
+ '<button id="entityCancel">Cancel</button>';
2708
+ el('entityCreate').addEventListener('click', () => submitCreateEntity().catch((error) => alert(error.message)));
2709
+ el('entityCancel').addEventListener('click', closeEntityModal);
2710
+ el('entityId').addEventListener('keydown', (event) => {
2711
+ if (event.key === 'Enter') submitCreateEntity().catch((error) => alert(error.message));
2712
+ });
2713
+ }
2714
+
2715
+ function profilePicker() {
2716
+ const profiles = Object.keys(state.config.profiles);
2717
+ if (profiles.length === 0) {
2718
+ return '<div class="field"><label>Profiles</label><div class="subtle">No profiles yet.</div></div>';
2719
+ }
2720
+ return '<div class="field"><label>Profiles</label><div class="profile-select-list">' +
2721
+ profiles.map((id) => '<label class="check"><input type="checkbox" data-create-profile="' + escapeHtml(id) + '"> ' + escapeHtml(id) + '</label>').join('') +
2722
+ '</div></div>';
2723
+ }
2724
+
2725
+ async function submitCreateEntity() {
2726
+ const kind = state.createKind;
2727
+ const id = el('entityId').value.trim();
2728
+ if (!id) throw new Error('Id is required');
2729
+ const profileIds = Array.from(document.querySelectorAll('[data-create-profile]:checked')).map((box) => box.dataset.createProfile);
2730
+ const body = {
2731
+ kind,
2732
+ id,
2733
+ baseId: el('entityBase').value,
2734
+ profileIds
2735
+ };
2736
+ const result = await api('/api/entities', { method: 'POST', body: JSON.stringify(body) });
2737
+ state.config = result;
2480
2738
  state.drafts = {};
2481
- setActive(kind, id.trim());
2739
+ setActive(kind, id);
2740
+ if (result.createdToken?.token) {
2741
+ showCreatedToken(result.createdToken.agentId, result.createdToken.token);
2742
+ } else {
2743
+ closeEntityModal();
2744
+ }
2745
+ }
2746
+
2747
+ function showCreatedToken(agentId, token) {
2748
+ el('entityModalTitle').textContent = 'Agent token';
2749
+ el('entityModalMeta').textContent = agentId;
2750
+ el('entityModalBody').innerHTML =
2751
+ '<div class="form-grid">' +
2752
+ '<div class="field"><label for="createdToken">Bearer token</label><input id="createdToken" class="token-value" readonly value="' + escapeHtml(token) + '"></div>' +
2753
+ '<div class="field"><label>Header</label><input class="token-value" readonly value="Authorization: Bearer ' + escapeHtml(token) + '"></div>' +
2754
+ '</div>';
2755
+ el('entityModalFooter').innerHTML =
2756
+ '<button id="copyCreatedToken" class="primary">Copy Token</button>' +
2757
+ '<button id="copyCreatedHeader">Copy Header</button>' +
2758
+ '<button id="entityDone">Done</button>';
2759
+ el('copyCreatedToken').addEventListener('click', () => copyText(token));
2760
+ el('copyCreatedHeader').addEventListener('click', () => copyText('Authorization: Bearer ' + token));
2761
+ el('entityDone').addEventListener('click', closeEntityModal);
2762
+ el('createdToken').select();
2763
+ }
2764
+
2765
+ async function copyText(value) {
2766
+ if (navigator.clipboard?.writeText) {
2767
+ await navigator.clipboard.writeText(value);
2768
+ return;
2769
+ }
2770
+ const textarea = document.createElement('textarea');
2771
+ textarea.value = value;
2772
+ document.body.appendChild(textarea);
2773
+ textarea.select();
2774
+ document.execCommand('copy');
2775
+ textarea.remove();
2482
2776
  }
2483
2777
 
2484
2778
  async function addProvider() {
@@ -2642,8 +2936,8 @@ const INDEX_HTML = `<!doctype html>
2642
2936
  el('manageProviders').addEventListener('click', () => setActive('providers'));
2643
2937
  el('viewActivity').addEventListener('click', () => setActive('activity'));
2644
2938
  el('addProvider').addEventListener('click', () => addProvider().catch((error) => alert(error.message)));
2645
- el('addAgent').addEventListener('click', () => addEntity('agent').catch((error) => alert(error.message)));
2646
- el('addProfile').addEventListener('click', () => addEntity('profile').catch((error) => alert(error.message)));
2939
+ el('addAgent').addEventListener('click', () => openCreateModal('agent'));
2940
+ el('addProfile').addEventListener('click', () => openCreateModal('profile'));
2647
2941
  el('deleteEntity').addEventListener('click', () => deleteCurrent().catch((error) => alert(error.message)));
2648
2942
  el('search').addEventListener('input', (event) => { state.search = event.target.value; renderTools(); });
2649
2943
  el('providerFilter').addEventListener('change', (event) => { state.provider = event.target.value; renderTools(); });
@@ -2669,11 +2963,16 @@ const INDEX_HTML = `<!doctype html>
2669
2963
  el('resetAllRules').addEventListener('click', resetAllToCurrentConfig);
2670
2964
  el('recommendedRules').addEventListener('click', setRecommended);
2671
2965
  el('approvalModalClose').addEventListener('click', closeApprovalModal);
2966
+ el('entityModalClose').addEventListener('click', closeEntityModal);
2672
2967
  el('approvalModal').addEventListener('click', (event) => {
2673
2968
  if (event.target === el('approvalModal')) closeApprovalModal();
2674
2969
  });
2970
+ el('entityModal').addEventListener('click', (event) => {
2971
+ if (event.target === el('entityModal')) closeEntityModal();
2972
+ });
2675
2973
  document.addEventListener('keydown', (event) => {
2676
2974
  if (event.key === 'Escape') {
2975
+ closeEntityModal();
2677
2976
  closeApprovalModal();
2678
2977
  return;
2679
2978
  }