fullcourtdefense-cli 1.15.6 → 1.15.8

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.
@@ -47,6 +47,7 @@ const telemetry_1 = require("../telemetry");
47
47
  const notify_1 = require("../notify");
48
48
  const integrity_1 = require("../integrity");
49
49
  const machineIdentity_1 = require("../machineIdentity");
50
+ const discoveryMarker_1 = require("../discoveryMarker");
50
51
  const COLOR = {
51
52
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
52
53
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -69,6 +70,8 @@ const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
69
70
  const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
70
71
  /** Bundle (mode / suspension / policy version) poll cadence. */
71
72
  const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
73
+ /** Delay before the one-time initial discovery sweep on a fresh machine. */
74
+ const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
72
75
  /** Rotate the daemon log when it grows past this size. */
73
76
  const LOG_MAX_BYTES = 1_000_000;
74
77
  function daemonDir() {
@@ -129,6 +132,24 @@ function stopPid(pid) {
129
132
  function logFile() {
130
133
  return path.join(daemonDir(), 'daemon.log');
131
134
  }
135
+ /** Spawn `discover --upload --surface all --silent` and wait for it to finish. */
136
+ function runDiscoverSweep(timeoutMs = 300_000) {
137
+ return new Promise((resolve, reject) => {
138
+ const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'discover', '--upload', '--surface', 'all', '--silent'], {
139
+ windowsHide: true,
140
+ stdio: 'ignore',
141
+ // Daemon/scheduled-task cwd is C:\WINDOWS\system32 — scan from the user's
142
+ // home so the posture scope reports a meaningful folder, not an OS dir.
143
+ cwd: os.homedir(),
144
+ });
145
+ const timer = setTimeout(() => {
146
+ child.kill();
147
+ reject(new Error(`Discovery command timed out after ${Math.round(timeoutMs / 60_000)} minutes`));
148
+ }, timeoutMs);
149
+ child.on('error', error => { clearTimeout(timer); reject(error); });
150
+ child.on('close', code => { clearTimeout(timer); resolve(code ?? 1); });
151
+ });
152
+ }
132
153
  function cliEntry() {
133
154
  return path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
134
155
  }
@@ -193,8 +214,12 @@ function acquirePidLock() {
193
214
  // No meta = pre-1.15.4 build (never wrote one) → treated as older.
194
215
  if (compareVersions(cliVersion(), runningVersion) > 0) {
195
216
  log(`Superseding older daemon (pid ${existing}, version ${runningVersion || 'unknown'}) with ${cliVersion() || 'this build'}.`);
196
- if (!stopPid(existing))
197
- return false; // could not stop it yield rather than double-run
217
+ if (!stopPid(existing)) {
218
+ // Elevated daemons (e.g. spawned by an elevated MSI install) cannot be
219
+ // stopped from a normal-user CLI — surface the real reason.
220
+ log(`Could not stop daemon pid ${existing} — it may be running elevated. Run this command from an elevated terminal, or reboot so the logon autostart takes over.`);
221
+ return false; // yield rather than double-run
222
+ }
198
223
  }
199
224
  else {
200
225
  return false;
@@ -448,18 +473,7 @@ async function runDaemon(args, config) {
448
473
  await uploadLogTail();
449
474
  const logPump = setInterval(() => { void uploadLogTail(); }, 15_000);
450
475
  try {
451
- const exitCode = await new Promise((resolve, reject) => {
452
- const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'discover', '--upload', '--surface', 'all', '--silent'], {
453
- windowsHide: true,
454
- stdio: 'ignore',
455
- });
456
- const timer = setTimeout(() => {
457
- child.kill();
458
- reject(new Error('Discovery command timed out after 5 minutes'));
459
- }, 300_000);
460
- child.on('error', error => { clearTimeout(timer); reject(error); });
461
- child.on('close', code => { clearTimeout(timer); resolve(code ?? 1); });
462
- });
476
+ const exitCode = await runDiscoverSweep();
463
477
  if (exitCode !== 0)
464
478
  throw new Error(`Discovery command failed with exit code ${exitCode}`);
465
479
  }
@@ -549,6 +563,34 @@ async function runDaemon(args, config) {
549
563
  // One protective pass at startup so a machine that drifted while the daemon
550
564
  // was down converges immediately.
551
565
  await reprotect(['startup pass']);
566
+ // Fresh machines have never uploaded an inventory (MSI/onboard defers the
567
+ // initial discovery to keep setup fast), so the dashboard shows "Never" for
568
+ // discovery + posture until the daily scheduled job fires — up to 24h later.
569
+ // Run ONE full sweep shortly after the first daemon boot instead, then leave
570
+ // a marker so subsequent boots skip it (the daily job owns refreshes).
571
+ let initialDiscoverTimer;
572
+ if (creds.shieldId && !(0, discoveryMarker_1.hasDiscoveryUploadMarker)()) {
573
+ log(`Initial discovery: no prior inventory upload found — full sweep scheduled in ${Math.round(INITIAL_DISCOVER_DELAY_MS / 60_000)} min.`);
574
+ initialDiscoverTimer = setTimeout(async () => {
575
+ if (stopped)
576
+ return;
577
+ log('Initial discovery: starting full surface sweep (MCP + secrets + agent files + posture)…');
578
+ await uploadLogTail();
579
+ try {
580
+ const exitCode = await runDiscoverSweep();
581
+ if (exitCode !== 0)
582
+ throw new Error(`discover exited with code ${exitCode}`);
583
+ // The discover child writes the upload marker itself on success, so
584
+ // subsequent boots skip this. On failure the next daemon start
585
+ // retries, and the daily scheduled job remains the backstop.
586
+ log('Initial discovery: upload complete — dashboard discovery + posture timestamps are now fresh.');
587
+ }
588
+ catch (error) {
589
+ log(`Initial discovery failed (will retry on next daemon start): ${error.message}`);
590
+ }
591
+ await uploadLogTail();
592
+ }, INITIAL_DISCOVER_DELAY_MS);
593
+ }
552
594
  const rescanTimer = setInterval(() => {
553
595
  const count = refreshWatchTargets();
554
596
  log(`Rescan: watching ${count} config file(s).`);
@@ -563,6 +605,8 @@ async function runDaemon(args, config) {
563
605
  clearInterval(rescanTimer);
564
606
  clearInterval(bundleTimer);
565
607
  clearInterval(heartbeatTimer);
608
+ if (initialDiscoverTimer)
609
+ clearTimeout(initialDiscoverTimer);
566
610
  if (debounceTimer)
567
611
  clearTimeout(debounceTimer);
568
612
  for (const watcher of watchers.values())
@@ -603,7 +647,7 @@ function isWindowsRunKeyInstalled() {
603
647
  /** Launch the daemon right now, outside our own process tree. WMI process
604
648
  * creation escapes the Windows Installer job object, which would otherwise
605
649
  * kill the daemon the moment an MSI custom action finishes. */
606
- function startDaemonNowWindows(vbs) {
650
+ function startDaemonNowWindows(vbs, viaTask = false) {
607
651
  try {
608
652
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
609
653
  if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
@@ -617,6 +661,15 @@ function startDaemonNowWindows(vbs) {
617
661
  }
618
662
  }
619
663
  catch { /* not running */ }
664
+ if (viaTask) {
665
+ // Start through the scheduled task so the daemon runs with the task's
666
+ // LIMITED (non-elevated) token. Launching directly from an elevated MSI
667
+ // custom action would leave an elevated daemon that a normal-user CLI can
668
+ // never stop or supersede.
669
+ const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
670
+ if (run.status === 0)
671
+ return;
672
+ }
620
673
  const escaped = vbs.replace(/'/g, "''");
621
674
  (0, child_process_1.spawnSync)('powershell', [
622
675
  '-NoProfile', '-NonInteractive', '-Command',
@@ -631,7 +684,8 @@ function installWindows() {
631
684
  '/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
632
685
  '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
633
686
  ], { stdio: 'ignore', windowsHide: true });
634
- let ok = task.status === 0;
687
+ const taskOk = task.status === 0;
688
+ let ok = taskOk;
635
689
  if (!ok) {
636
690
  // Fallback: per-user Run key — no elevation needed, runs at every logon.
637
691
  const reg = (0, child_process_1.spawnSync)('reg', [
@@ -641,7 +695,7 @@ function installWindows() {
641
695
  ok = reg.status === 0;
642
696
  }
643
697
  if (ok)
644
- startDaemonNowWindows(vbs);
698
+ startDaemonNowWindows(vbs, taskOk);
645
699
  return ok;
646
700
  }
647
701
  function uninstallWindows() {
@@ -815,6 +869,19 @@ async function daemonCommand(args, config) {
815
869
  const ok = process.platform === 'win32' ? uninstallWindows()
816
870
  : process.platform === 'darwin' ? uninstallMacos()
817
871
  : uninstallLinux();
872
+ // Uninstall must also STOP the resident daemon — otherwise it keeps running
873
+ // (heartbeats, log writes) until the next reboot and holds ~/.fullcourtdefense.
874
+ const meta = readDaemonMeta();
875
+ if (meta?.pid && meta.pid !== process.pid && isPidAlive(meta.pid)) {
876
+ const stopped = stopPid(meta.pid);
877
+ console.log(stopped
878
+ ? `${COLOR.green}Stopped the running daemon (pid ${meta.pid}).${COLOR.reset}`
879
+ : `${COLOR.yellow}Could not stop the running daemon (pid ${meta.pid}) — it may be running elevated. Stop it from an elevated terminal: taskkill /PID ${meta.pid} /F${COLOR.reset}`);
880
+ }
881
+ try {
882
+ fs.unlinkSync(metaFile());
883
+ }
884
+ catch { /* may not exist */ }
818
885
  console.log(ok
819
886
  ? `${COLOR.green}Removed the FullCourtDefense daemon autostart.${COLOR.reset}`
820
887
  : `${COLOR.yellow}No daemon autostart found (or removal failed).${COLOR.reset}`);
@@ -50,6 +50,7 @@ const discoverAgentFiles_1 = require("./discoverAgentFiles");
50
50
  const discoverBlastRadius_1 = require("./discoverBlastRadius");
51
51
  const discoverSecrets_1 = require("./discoverSecrets");
52
52
  const windowsAudit_1 = require("./windowsAudit");
53
+ const discoveryMarker_1 = require("../discoveryMarker");
53
54
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
54
55
  function parseSurfaces(args) {
55
56
  const raw = (args.surface || args.type || 'mcp').toLowerCase().trim();
@@ -122,6 +123,15 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
122
123
  function candidateConfigPaths(cwd, extra) {
123
124
  return (0, discoverPaths_1.discoverScanTargets)(cwd, extra);
124
125
  }
126
+ /** True when a path is an operating-system folder (daemon/scheduled-task cwd), never a real project. */
127
+ function isOsSystemFolder(dir) {
128
+ const normalized = path.resolve(dir).toLowerCase();
129
+ if (process.platform === 'win32') {
130
+ const windir = (process.env.SystemRoot || 'C:\\Windows').toLowerCase();
131
+ return normalized === windir || normalized.startsWith(`${windir}${path.sep}`) || /^[a-z]:\\$/.test(normalized);
132
+ }
133
+ return normalized === '/' || ['/usr', '/bin', '/sbin', '/etc', '/var'].some(root => normalized === root || normalized.startsWith(`${root}/`));
134
+ }
125
135
  /** Dot-dirs that hold a client's project config; the real project root is their parent. */
126
136
  const CONFIG_DOT_DIRS = new Set(['.cursor', '.claude', '.codex', '.vscode', '.gemini', '.kiro']);
127
137
  /** Map discovered project config file paths back to their owning project folders. */
@@ -696,6 +706,9 @@ async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName
696
706
  throw new Error(data.error || `Upload failed (${resp.status})`);
697
707
  }
698
708
  const ingested = data.data?.ingested ?? servers.length;
709
+ // Any successful inventory upload satisfies the daemon's "initial discovery"
710
+ // requirement — onboard-time, manual, scheduled, or remote-action sweeps all count.
711
+ (0, discoveryMarker_1.writeDiscoveryUploadMarker)('discover_upload');
699
712
  const postureNote = extras?.posture ? ` · machine score ${extras.posture.score}/100 (${extras.posture.grade})` : '';
700
713
  console.log(`${COLOR.green}Uploaded ${ingested} MCP server(s) from ${host.hostname} to your AI Inventory${postureNote}.${COLOR.reset}`);
701
714
  }
@@ -793,7 +806,12 @@ async function discoverCommand(args, config) {
793
806
  }
794
807
  return;
795
808
  }
796
- const cwd = process.cwd();
809
+ // Scheduled tasks and the daemon run with cwd=C:\WINDOWS\system32 (or "/" on
810
+ // POSIX). Scanning "the current project folder" from an OS directory is
811
+ // meaningless and confusing in the dashboard scan-scope report — fall back to
812
+ // the user's home, which is where all the AI-dev roots we scan live anyway.
813
+ const rawCwd = process.cwd();
814
+ const cwd = isOsSystemFolder(rawCwd) ? os.homedir() : rawCwd;
797
815
  const runMcp = surfaces.has('mcp');
798
816
  const scanned = [];
799
817
  let found = [];
@@ -878,7 +896,9 @@ async function discoverCommand(args, config) {
878
896
  workingDirectory: cwd,
879
897
  mcpConfigPaths: scanned.map(s => ({ ...s, exists: fs.existsSync(s.path) })),
880
898
  included: [
881
- `Current command folder: ${cwd}`,
899
+ cwd === rawCwd
900
+ ? `Current command folder: ${cwd}`
901
+ : `User home folder: ${cwd} (scan started by the background daemon/scheduler)`,
882
902
  'Known MCP/AI client config files for Cursor, Claude, Codex, Gemini, Windsurf, and VS Code',
883
903
  `User-level AI rules, skills, hooks, and instruction files under ${home}`,
884
904
  `Credential stores and shell history under ${home}`,
@@ -1336,6 +1336,22 @@ function extractWrappedDownstream(entry) {
1336
1336
  }
1337
1337
  return { kind: 'stdio', command, args: downstreamArgs };
1338
1338
  }
1339
+ /**
1340
+ * Like extractWrappedDownstream, but peels NESTED gateway wraps until the real
1341
+ * server surfaces. Old CLIs compared the wrapper against their own node path, so
1342
+ * an npm install and an MSI install could wrap each other's wraps repeatedly —
1343
+ * unwrapping must restore the original server no matter how deep that went.
1344
+ */
1345
+ function extractFullyUnwrappedDownstream(entry) {
1346
+ let downstream = extractWrappedDownstream(entry);
1347
+ for (let depth = 0; depth < 20 && downstream && downstream.kind === 'stdio' && downstream.args.includes('mcp-gateway'); depth++) {
1348
+ const inner = extractWrappedDownstream({ args: downstream.args });
1349
+ if (!inner)
1350
+ break;
1351
+ downstream = inner;
1352
+ }
1353
+ return downstream;
1354
+ }
1339
1355
  /** All server maps inside a parsed JSON config, across every known client shape. */
1340
1356
  function collectJsonServerMaps(json) {
1341
1357
  const maps = [];
@@ -1535,7 +1551,7 @@ function wrapJsonConfigFile(file, gatewayConfig, agentClient, dryRun) {
1535
1551
  // the original downstream server from the existing wrapper args and
1536
1552
  // rewrap with the fixed matched-pair credential handling.
1537
1553
  if (isGatewayWrappedEntry(entry) && wrappedEntryNeedsHeal(entry)) {
1538
- const downstream = extractWrappedDownstream(entry);
1554
+ const downstream = extractFullyUnwrappedDownstream(entry);
1539
1555
  if (downstream) {
1540
1556
  const perServer = { ...gatewayConfig, agentClient, agentName: perServerAgentName(gatewayConfig.developerName, agentClient, name) };
1541
1557
  entry.command = nodeExe;
@@ -1612,7 +1628,7 @@ function unwrapJsonConfigFile(file, dryRun) {
1612
1628
  }
1613
1629
  if (!isGatewayWrappedEntry(entry))
1614
1630
  continue;
1615
- const downstream = extractWrappedDownstream(entry);
1631
+ const downstream = extractFullyUnwrappedDownstream(entry);
1616
1632
  if (!downstream)
1617
1633
  continue;
1618
1634
  if (downstream.kind === 'http') {
@@ -1731,7 +1747,11 @@ function transformCodexToml(file, mode, gatewayConfig, dryRun) {
1731
1747
  if (section.cmdLine === -1 || !section.command) {
1732
1748
  continue;
1733
1749
  }
1734
- const wrappedAlready = section.command === nodeExe && (section.args || []).includes('mcp-gateway');
1750
+ // Command-agnostic like the JSON path: a wrap made by ANY CLI install (npm node,
1751
+ // MSI runtime node, different nvm version) must be recognized — comparing against
1752
+ // THIS process's node path made one install re-wrap another install's wrap and
1753
+ // left unprotect-all unable to remove it.
1754
+ const wrappedAlready = (section.args || []).includes('mcp-gateway');
1735
1755
  if (mode === 'wrap') {
1736
1756
  if (section.name === MANAGED_SERVER_NAME || wrappedAlready) {
1737
1757
  stats.skippedManaged.push(section.name);
@@ -1752,7 +1772,7 @@ function transformCodexToml(file, mode, gatewayConfig, dryRun) {
1752
1772
  else {
1753
1773
  if (!wrappedAlready)
1754
1774
  continue;
1755
- const downstream = extractWrappedDownstream({ args: section.args });
1775
+ const downstream = extractFullyUnwrappedDownstream({ args: section.args });
1756
1776
  // Codex TOML wraps are always stdio (remote URLs never get wrapped into TOML).
1757
1777
  if (!downstream || downstream.kind !== 'stdio')
1758
1778
  continue;
@@ -0,0 +1,2 @@
1
+ export declare function hasDiscoveryUploadMarker(): boolean;
2
+ export declare function writeDiscoveryUploadMarker(trigger: string): void;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.hasDiscoveryUploadMarker = hasDiscoveryUploadMarker;
37
+ exports.writeDiscoveryUploadMarker = writeDiscoveryUploadMarker;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ /**
42
+ * Marker recording that this machine has uploaded at least one full discovery
43
+ * inventory. The daemon uses it to decide whether a fresh machine still needs
44
+ * its one-time initial sweep (MSI/onboard defer discovery to keep setup fast,
45
+ * which otherwise leaves the dashboard showing "Never" until the daily job).
46
+ */
47
+ function markerFile() {
48
+ return path.join(os.homedir(), '.fullcourtdefense', 'initial-discover.json');
49
+ }
50
+ function hasDiscoveryUploadMarker() {
51
+ try {
52
+ return fs.existsSync(markerFile());
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function writeDiscoveryUploadMarker(trigger) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(markerFile()), { recursive: true });
61
+ fs.writeFileSync(markerFile(), JSON.stringify({ completedAt: new Date().toISOString(), trigger }, null, 2), 'utf8');
62
+ }
63
+ catch { /* marker is best-effort */ }
64
+ }
@@ -77,11 +77,17 @@ function normalizeHostname(raw) {
77
77
  function rawStableId() {
78
78
  const platform = os.platform();
79
79
  if (platform === 'win32') {
80
- return safe(() => {
81
- const out = (0, child_process_1.execFileSync)('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
80
+ // MachineGuid lives only in the 64-bit registry view. When this process is
81
+ // 32-bit (e.g. spawned by an MSI custom action via SysWOW64 PowerShell),
82
+ // a plain query is redirected to WOW6432Node and finds nothing — which
83
+ // would silently change the machineId and enroll a DUPLICATE fleet record.
84
+ // Query the 64-bit view first, then fall back for true 32-bit Windows.
85
+ const queryMachineGuid = (extraArgs) => safe(() => {
86
+ const out = (0, child_process_1.execFileSync)('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid', ...extraArgs], { encoding: 'utf8', windowsHide: true, timeout: 4000 });
82
87
  const match = out.match(/MachineGuid\s+REG_SZ\s+([\w-]+)/i);
83
88
  return match ? match[1] : undefined;
84
89
  });
90
+ return queryMachineGuid(['/reg:64']) || queryMachineGuid([]);
85
91
  }
86
92
  if (platform === 'darwin') {
87
93
  return safe(() => {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.6"
2
+ "version": "1.15.8"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.15.6",
3
+ "version": "1.15.8",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {