// Shared helper utilities — extracted from registry.js (GH#112) const User = require('../../models/User'); const Gateway = require('../../models/Gateway'); const { AgentInstallation } = require('../../models/AgentRegistry'); const { RUNTIME_IDENTITY_KEYS, declaredRuntimeIdentity } = require('../../utils/runtimeIdentity'); const { isK8sMode } = require('../../services/agentProvisionerService'); const AgentIdentityService = require('../../services/agentIdentityService').default; const { resolveAgentDisplayLabel } = require('../../services/agentIdentityService'); const { PRESET_DEFINITIONS } = require('./presets'); const { resolveDiscordBotToken } = require('../../utils/discordBotToken'); // Build an in-memory map of presetId → category once at module load. // Powers the `category` field on the agent payload — the V2 inspector // renders this as a small role chip in the member list, and the Your Team // page uses it to filter agents by function (Development / Design / // Strategy / etc.). PRESET_DEFINITIONS is the only authoritative source; // individual installations carry just `presetId`, not the category. const PRESET_CATEGORY_BY_ID: Record = (PRESET_DEFINITIONS as any[]) .reduce((acc: Record, p: any) => { if (p?.id && p?.category) acc[String(p.id)] = String(p.category); return acc; }, {}); const buildIdentityContent = (name: any, persona: any) => { const toneMap = { friendly: 'Warm, approachable, supportive.', professional: 'Precise, measured, focused.', sarcastic: 'Dry, sardonic, irreverent.', educational: 'Patient, explanatory, thorough.', humorous: 'Playful, witty, light.', }; const vibe = (toneMap as any)[persona?.tone] || persona?.tone || ''; const specialties = Array.isArray(persona?.specialties) ? persona.specialties.join(', ') : String(persona?.specialties || ''); const lines = [ '# IDENTITY.md', '', `- **Name:** ${name || ''}`, `- **Vibe:** ${vibe}`, ]; if (specialties) lines.push(`- **Domain:** ${specialties}`); if (persona?.customInstructions) { lines.push('', '## Notes', '', persona.customInstructions); } lines.push(''); return lines.join('\n'); }; const parseVerifiedFilter = (value: any) => { if (value === 'true') return true; if (value === 'false') return false; return null; }; const escapeRegExp = (value: any) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const getUserId = (req: any) => req.userId || req.user?.id || req.user?._id; // Both browser JWT and API-token auth supply req.user.username. Preserve the // agentUser shape for registry callers that pass one explicitly. const resolveUsername = (req: any): string | null => ( req.user?.username || req.agentUser?.username || null ); const normalizeConfigMap = (config: any) => { if (!config) return null; if (config instanceof Map) { return Object.fromEntries(config.entries()); } if (typeof config === 'object') { return config; } return null; }; const normalizeRuntimeAuthProfiles = (profiles: any) => { if (!profiles || typeof profiles !== 'object') return null; const normalized = {}; Object.entries(profiles).forEach(([key, value]) => { if (!value || typeof value !== 'object') return; const v = value as any; const provider = String(v.provider || '').trim().toLowerCase(); const rawKey = String(v.key || '').trim(); if (!provider || !rawKey) return; const type = String(v.type || 'api_key').trim().toLowerCase(); if (type !== 'api_key') return; const profileId = String(key || `${provider}:default`).trim(); (normalized as any)[profileId || `${provider}:default`] = { type: 'api_key', provider, key: rawKey, }; }); return Object.keys(normalized).length ? normalized : null; }; const normalizeSkillEnvEntries = (entries: any) => { if (!entries || typeof entries !== 'object') return null; const normalized = {}; Object.entries(entries).forEach(([skillName, value]) => { const name = String(skillName || '').trim(); if (!name || !value || typeof value !== 'object') return; const va = value as any; const env = va.env && typeof va.env === 'object' ? va.env : {}; const envEntries = Object.entries(env) .map(([key, val]) => [String(key || '').trim(), String((val as any) ?? '').trim()]) .filter(([key, val]) => key && val); const apiKey = String(va.apiKey ?? '').trim(); if (!envEntries.length && !apiKey) return; (normalized as any)[name] = { ...(envEntries.length ? { env: Object.fromEntries(envEntries) } : {}), ...(apiKey ? { apiKey } : {}), }; }); return Object.keys(normalized).length ? normalized : null; }; // Resolve `runtimeType` + `host` for the API/UI from whatever the install // row carries. Three input shapes are normalized: // 1. New shape (post-2026-05-04): `{ runtimeType: '', host: 'cloud' | 'byo' }` // — passed through. CLI attach writes this. // 2. Legacy CLI shape: `{ runtimeType: 'local-cli', wrappedCli: '' }` // — rewritten to `{ runtimeType: , host: 'byo' }`. No data // migration; this resolver fixes it on read. // 3. Empty/null `config.runtime` (most pre-CLI installs of built-in // agents) — `runtimeType` is filled from `AGENT_TYPES[agentName]`, // `host` defaults to `'cloud'` (the only host that exists for // first-party / cloud-deployed built-ins today). // // Identity-rename map — collapses CLI adapter names + provider-leaning // legacy values onto the canonical runtimeType set used by AGENT_TYPES // and the V2 inspector badge resolver. Examples: // - `claude` (CLI adapter `name`, written by pre-2026-05-04 attach) // → `claude-code` (AGENT_TYPES + frontend badge match) // - `openai` (older AGENT_TYPES `runtime`) // → `codex` (matches the CLI adapter and the frontend badge) // Adding a new adapter that uses a different CLI name than its canonical // runtimeType? Add a row here to keep the read path consistent for // installs that landed before the adapter exposed `runtimeType`. const LEGACY_RUNTIME_RENAME: Record = { openai: 'codex', claude: 'claude-code', }; // Runtime secrets follow the same explicit key-list rule as Integration // config. Keep the response projection here so a member of a pod can inspect // runtime metadata without receiving the webhook signing secret. const AGENT_RUNTIME_SECRET_CONFIG_KEYS = ['webhookSecret']; const normalizeRuntimeIdentity = (rest: any, agentName?: string) => { let runtimeType: string | undefined = rest.runtimeType ? String(rest.runtimeType) : undefined; let host: 'cloud' | 'byo' | undefined = rest.host === 'byo' || rest.host === 'cloud' ? rest.host : undefined; // Legacy CLI shape — `local-cli` + `wrappedCli` predates the two-field // model. Treat `wrappedCli` as the identity and stamp host=byo. // The wrappedCli value is the CLI adapter `name` (`claude`, `codex`), // which the rename map below collapses onto the canonical runtimeType // (`claude-code`, `codex`) so the frontend badge resolver matches. if (runtimeType === 'local-cli') { const cli = String(rest.wrappedCli || '').trim(); if (cli) runtimeType = cli; if (!host) host = 'byo'; } // No runtime info at all → fall back to AGENT_TYPES. Built-ins are // assumed cloud/first-party. CLI agents always set both fields, so // they never hit this branch. if (!runtimeType && agentName) { const typeConfig = AgentIdentityService.getAgentTypeConfig(agentName); if (typeConfig?.runtime) runtimeType = typeConfig.runtime; if (!host) host = 'cloud'; } // Apply the identity rename (claude → claude-code, openai → codex). if (runtimeType && LEGACY_RUNTIME_RENAME[runtimeType]) { runtimeType = LEGACY_RUNTIME_RENAME[runtimeType]; } return { runtimeType, host }; }; const sanitizeRuntimeConfig = (runtimeConfig: any, agentName?: string) => { const cfg = runtimeConfig && typeof runtimeConfig === 'object' ? runtimeConfig : {}; const { authProfiles, skillEnv, ...rest } = cfg; AGENT_RUNTIME_SECRET_CONFIG_KEYS.forEach((key) => { delete rest[key]; }); const providers = authProfiles && typeof authProfiles === 'object' ? Array.from(new Set( Object.values(authProfiles) .map((profile: any) => String(profile?.provider || '').trim().toLowerCase()) .filter(Boolean), )) : []; const skillKeys = skillEnv && typeof skillEnv === 'object' ? Object.keys(skillEnv).map((key) => String(key || '').trim()).filter(Boolean) : []; // `rest` must still carry `wrappedCli` so normalizeRuntimeIdentity can // read it for the legacy `local-cli` rewrite. The pre-fix version // destructured wrappedCli out before this call → legacy installs (local // wrapper seats) stayed as runtimeType:'local-cli' on the wire and // the inspector showed "Local CLI · BYO" instead of "Codex · BYO". const { runtimeType, host } = normalizeRuntimeIdentity(rest, agentName); // Strip `wrappedCli` from the response only AFTER normalization so // downstream consumers see the canonical two-field shape. The frontend // resolver retains a defensive `wrappedCli` branch as belt-and- // suspenders against future regressions, but the field shouldn't be // present on the wire by design. const { wrappedCli: _legacyWrappedCli, ...passthrough } = rest; return { ...passthrough, ...(runtimeType ? { runtimeType } : {}), ...(host ? { host } : {}), authProviders: providers, hasCustomAuthProfiles: providers.length > 0, hasCustomSkillEnv: skillKeys.length > 0, skillEnvKeys: skillKeys, }; }; const buildOpenClawIntegrationChannels = (integrations: any[] = []) => { const channels: any = { discord: [], slack: [], telegram: [], }; integrations.forEach((integration: any) => { if (!integration || typeof integration !== 'object') return; const id = String(integration._id || '').trim(); const type = String(integration.type || '').trim().toLowerCase(); const config = integration.config && typeof integration.config === 'object' ? integration.config : {}; const name = String( config.channelName || config.groupName || config.chatTitle || integration.name || `${type}-${id}`, ).trim(); if (type === 'discord') { const token = String(resolveDiscordBotToken(config.botToken) || '').trim(); if (!id || !token) return; channels.discord.push({ accountId: id, name, token, }); return; } // Slack is deliberately absent (TASK-151): the instance-wide // `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` fallback is retired, and the only // Slack token a runtime is handed now comes from the per-workspace OAuth // bind (`botTokenRef` -> `connectorSecrets`). `channels.slack` stays an // empty array so a consumer that iterates it keeps working. if (type === 'telegram') { // Same rule as Slack above, and measured: every live Telegram path reads // `process.env.TELEGRAM_BOT_TOKEN` (`routes/webhooks/telegram.ts`, // `services/telegramBridgeService.ts`, `decisionCardReconcileService.ts`, // both provisioners). This was the only reader that preferred a per-row // copy (TASK-140). const botToken = String(process.env.TELEGRAM_BOT_TOKEN || '').trim(); // The instance's, not the row's (TASK-141): `secretToken` is stripped from // a body like the two Slack keys above. const webhookSecret = String(process.env.TELEGRAM_SECRET_TOKEN || '').trim(); if (!id || !botToken) return; channels.telegram.push({ accountId: id, name, botToken, ...(webhookSecret ? { webhookSecret } : {}), ...(config.chatId ? { chatId: String(config.chatId) } : {}), }); } }); return channels; }; /** * The install intro, composed so its promise matches reality. * * The old copy said "Mention me with @handle when you need me" every time, * posted server-side the moment a seat is created — which for a BYO seat is * before any wrapper exists to answer. Production message history shows four * users taking that invitation and getting total silence: m0re (08-04), * l3r0ys4n3 (08-07), user-8863 (08-09, who asked three times in two phrasings * whether the connection was working), ngoc-tran (08-10). None came back. * * Pure on purpose: the caller derives liveness via `deriveAgentState` (the * same derivation the #891 honesty surface and the pod roster use — one * source of truth, not a second guess) and passes the verdict in. That keeps * the copy rules testable without a DB, which is the whole reason * agentStateService is pure too. */ /** * The intro is posted ONCE, at install, and a chat message cannot be * superseded — nothing later edits it or retracts it. So a state-dependent arm * has to do what a present-tense sentence cannot: scope its claim to the * moment it was posted, and name the surface that DOES change. Without the * first the line is accurate for minutes and false forever after; without the * second the reader has no way to find out (TASK-178). * * The reader is usually another agent, which cannot see the process — a * sentence in the seat's own voice is the most authoritative thing in the pod, * so it is believed over a signal that is merely absent. Measured 2026-09-29: * an attached seat's intro said "Nothing is running me yet"; the runner was up * within the minute and the seat posted its first turn before the intro had * scrolled, and 40 minutes later a peer routed a PR away from that seat * because of the sentence. The roster's derived state said otherwise the whole * time; nobody asked it, because the seat had already answered. */ const snapshotPointer = (handle: string): string => `That was true as I posted it and it never updates — type @${handle} and ` + 'the pod shows my current state.'; const composeInstallIntro = ({ displayName, blurb, handle, state, fixCommand, }: { displayName: string; blurb?: string; handle: string; // The `deriveAgentState` enum, passed WHOLE rather than flattened to a // boolean. An earlier draft took `listening: boolean`, which collapsed two // different certainty classes: `never-connected` is structurally certain (no // token has ever been used), while `gone-dark` is INFERRED from staleness. // Under the boolean, a gone-dark agent was told "Nothing is running me yet" // — flat-certain AND factually wrong, since it demonstrably ran. Asserting a // guess in the agent's own voice is the very defect this function exists to // remove, so the hedge is not decoration (fleet review 2026-08-14). state: string; fixCommand: string; }): string => { // Older CLI versions seeded description from displayName, producing intros // like "Hi all — I'm bot. bot Ping me ...". Drop a blurb that just repeats // the name. const trimmed = String(blurb || '').trim().replace(/\s+/g, ' '); const meaningless = !trimmed || trimmed.toLowerCase() === String(displayName).toLowerCase(); const lead = meaningless ? `Hi all — I'm ${displayName}, just joined the pod.` : `Hi all — I'm ${displayName}. ${trimmed}`; // Certain, and never ran: flat declarative, and SCOPED to the posting. The // certainty is about THIS MOMENT (no token has ever been used yet), which is // precisely what a reader an hour later mistakes for a claim about then. if (state === 'never-connected') { return `${lead} As I post this, nothing is running me yet, so mentioning me won't reach anyone. ` + `Whoever installed me can start me with \`${fixCommand}\` on the machine ` + `where I should live — then @${handle} will get through. ` + snapshotPointer(handle); } // Inferred, and it DID run: hedge the claim, keep the instruction, and scope // it the same way — a gone-dark seat that comes back leaves the same stale // sentence behind it. if (state === 'gone-dark') { return `${lead} As I post this I don't look connected — I was running earlier and ` + `have gone quiet, so a mention may not reach me. Whoever installed me can ` + `start me again with \`${fixCommand}\` — then @${handle} will get through. ` + snapshotPointer(handle); } // reachable / listening / unknown — anything we cannot show to be down keeps // the invitation. Wrongly telling a live agent's room that nothing listens is // the opposite lie. return `${lead} Mention me with @${handle} when you need me.`; }; // Internal ops/smoke/demo seats (spec §1.1, BEND-3 follow-up #1377): // the roster hides these behind a disclosure. The classification lives HERE, // at the boundary, so every consumer reads one `internal: true` flag instead // of curating its own name list — the client-side constant this replaces was // accepted for exactly one release. An explicit `config.internal: true` on // the installation wins; the name patterns cover the seats scripts already // create without the flag. // A seat is one runtime, but attach writes its runtime identity only onto the // install where it ran, so the seat's installs in other pods keep an older // identity or none (TASK-569: Sprint Review read claude-code in one pod and // nothing in Core runtime). Every pod therefore reads the seat's identity from // its most recently written install that declares one. Most recent, never the // first match, so a harness swap made in one pod shows in all of them. // The four keys move as a group: an older install's adapter must not outlive // a newer install's runtimeType, which would bring back the stale chip. const runtimeIdentityKey = (agentName: any, instanceId: any) => `${agentName}:${instanceId || 'default'}`; const resolveSeatRuntimeIdentities = async (installations: any[]): Promise>> => { const resolved = new Map>(); const seats = new Map(); for (const i of installations || []) { if (!i?.agentName) continue; seats.set(runtimeIdentityKey(i.agentName, i.instanceId), { agentName: i.agentName, instanceId: i.instanceId || 'default', }); } if (seats.size === 0) return resolved; const rows = await AgentInstallation.find({ status: 'active', $or: Array.from(seats.values()), }) // Newest attach wins (TASK-569). Not updatedAt: the skill-load report and // every settings save move it, which revived a stale harness in other pods. // Rows from before runtimeIdentityAt sort after it, by their creation. .select('agentName instanceId config.runtime runtimeIdentityAt createdAt') .sort({ runtimeIdentityAt: -1, createdAt: -1, _id: -1 }) .lean(); for (const row of rows as any[]) { const key = runtimeIdentityKey(row.agentName, row.instanceId); if (resolved.has(key)) continue; const identity = declaredRuntimeIdentity(row.config); if (identity) resolved.set(key, identity); } return resolved; }; const withSeatRuntimeIdentity = (runtime: any, identity: Record | null | undefined) => { if (!identity) return runtime; const base = runtime && typeof runtime === 'object' ? { ...runtime } : {}; RUNTIME_IDENTITY_KEYS.forEach((key: string) => { delete base[key]; }); return { ...base, ...identity }; }; const INTERNAL_SEAT_EXACT = new Set(['hosted-smoke', 'run-here-smoke', 'commonly-summarizer']); const INTERNAL_SEAT_PREFIXES = ['smoke-', 'recorder-', 'adr-', 'demo-']; const isInternalSeat = (agentName: any, normalizedConfig: any): boolean => { if (normalizedConfig?.internal === true) return true; const n = String(agentName || '').toLowerCase(); return INTERNAL_SEAT_EXACT.has(n) || INTERNAL_SEAT_PREFIXES.some((p) => n.startsWith(p)); }; // The card shows one line of it; shipping the median 2,700-char agent message // per roster row is payload waste, so trim at the boundary. const SNIPPET_MAX = 200; const toSnippet = (content: any): string => { const collapsed = String(content || '').replace(/\s+/g, ' ').trim(); if (!collapsed) return ''; return collapsed.length > SNIPPET_MAX ? `${collapsed.slice(0, SNIPPET_MAX - 1)}…` : collapsed; }; const buildAgentInstallationPayload = (installation: any, { profile = null, iconUrl = '', lastHeartbeatAt = null, lastActiveAt = null, user = null, lastMessage = null, revealWebhookUrl = false, runtimeIdentity = null, }: { profile?: any; iconUrl?: string; lastHeartbeatAt?: any; lastActiveAt?: any; user?: any; lastMessage?: { content?: any; createdAt?: any } | null; /** * Only the installer and instance admins see a push webhook's URL. Every * pod member reads this payload, and a URL can carry a credential in its * query string (`?secret=`), so it is withheld unless the caller opts in. */ revealWebhookUrl?: boolean; /** The seat's identity from resolveSeatRuntimeIdentities, over this install's own. */ runtimeIdentity?: Record | null; } = {}) => { if (!installation) return null; const normalizedConfig = normalizeConfigMap(installation.config); const sanitizedRuntime = sanitizeRuntimeConfig( withSeatRuntimeIdentity(normalizedConfig?.runtime || installation.config?.runtime || null, runtimeIdentity), installation.agentName, ); const runtimeConfig = (() => { if (revealWebhookUrl || !sanitizedRuntime || !('webhookUrl' in sanitizedRuntime)) return sanitizedRuntime; const { webhookUrl: _withheld, ...rest } = sanitizedRuntime as Record; return rest; })(); // Settings may edit these model controls, and the daemon prefers an explicit // ADR-008 environment over the runtime overlay. Expose only these benign // scalar fields; never project environment secrets or tool declarations. const environmentSettings = normalizedConfig?.environment; const editableEnvironment = environmentSettings && typeof environmentSettings === 'object' ? Object.fromEntries(['model', 'effort', 'adapter'] .filter((key) => typeof environmentSettings[key] === 'string') .map((key) => [key, environmentSettings[key]])) : {}; if (typeof environmentSettings?.sandbox?.mode === 'string') { editableEnvironment.sandbox = { mode: environmentSettings.sandbox.mode }; } // Chat attributes this seat through the User principal. Use that same // resolver for the listing; scoped names are only a fallback for installs // that have no User row yet. A scoped name equal to the agentName is the // runtime or type label, not a name, so the listing never carries it (TASK-551). const profileDisplayName = typeof profile?.name === 'string' ? profile.name.trim() : ''; const installationDisplayName = typeof installation.displayName === 'string' ? installation.displayName.trim() : ''; const identityFallback = installation.agentName || ''; const scopedDisplayName = [profileDisplayName, installationDisplayName] .find((name) => name && name !== identityFallback) || ''; const displayName = user ? resolveAgentDisplayLabel(user, identityFallback) : scopedDisplayName; return { name: installation.agentName, instanceId: installation.instanceId || 'default', displayName, iconUrl, version: installation.version, status: installation.status, scopes: installation.scopes, installedAt: installation.createdAt, lastHeartbeatAt, // Latest proof of life across every runtime class: delivered heartbeat // events (gateway moltbots), runtime-token use (BYO wrappers/MCP), and // AgentRun starts (native). `lastHeartbeatAt` alone reads as "Never // connected" for native agents that talked minutes ago (#915). lastActiveAt: lastActiveAt || lastHeartbeatAt, usage: installation.usage, internal: isInternalSeat(installation.agentName, normalizedConfig), // Spec §1.1 line 2: what the agent last said, pre-trimmed. Null when // it has never spoken in this pod (or the PG lookup was skipped/failed — // the roster never fails over a snippet). lastMessage: lastMessage && toSnippet(lastMessage.content) ? { snippet: toSnippet(lastMessage.content), at: lastMessage.createdAt || null } : null, installedBy: installation.installedBy?.toString?.() || installation.installedBy, // The portable principal's id, so a client can key per-agent attention // (AttentionItem.actorUserId) on it instead of matching names — three // writers store three different name shapes. userId: user?._id ? String(user._id) : null, // Chat reads this image from the same User principal. Installation and // registry artwork must not stand in for an agent's account avatar. profilePicture: user?.profilePicture || null, // Direction C Your Team card, line 3: one sentence from the curated // `botMetadata.description`. Null when unset — the card renders no line, // never a quote. description: typeof user?.botMetadata?.description === 'string' && user.botMetadata.description.trim() ? user.botMetadata.description.trim() : null, runtime: runtimeConfig, // Resolved at the boundary so the frontend doesn't need to know // about presets — `category` is the human-readable role family // (Development, Design, Strategy, …) used by the V2 inspector role // chip and the Your Team filter tabs. Falls back to an explicit // `config.role` override (rare; user-configured) before defaulting // to `null` for installs without a known preset. category: (normalizedConfig?.role && String(normalizedConfig.role)) || PRESET_CATEGORY_BY_ID[String(normalizedConfig?.presetId || '')] || null, config: normalizedConfig ? { presetId: normalizedConfig.presetId || null, customizations: normalizedConfig.customizations || null, heartbeat: normalizedConfig.heartbeat || null, wakeOnMessage: normalizedConfig.wakeOnMessage || null, boardWake: normalizedConfig.boardWake || null, autonomy: normalizedConfig.autonomy || null, errorRouting: normalizedConfig.errorRouting || null, heartbeatChecklist: normalizedConfig.heartbeatChecklist || '', skillSync: normalizedConfig.skillSync || null, environment: editableEnvironment, } : null, profile: profile ? { displayName: profile.name, purpose: profile.purpose, isDefault: profile.isDefault, modelPreferences: profile.modelPreferences, instructions: profile.instructions, persona: profile.persona, toolPolicy: profile.toolPolicy, contextPolicy: profile.contextPolicy, } : null, }; }; const normalizePluginIdentifier = (value: any) => String(value || '').trim().toLowerCase(); const getPluginSpecBase = (spec: any) => { const normalized = normalizePluginIdentifier(spec); if (!normalized) return ''; if (normalized.startsWith('@')) { const parts = normalized.split('@'); if (parts.length >= 2 && parts[1]) { return `@${parts[1]}`; } return normalized; } return normalized.split('@')[0]; }; const normalizeInstanceId = (raw: any) => { let normalized = String(raw || '') .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-'); if (normalized.startsWith('-')) normalized = normalized.slice(1); if (normalized.endsWith('-')) normalized = normalized.slice(0, -1); return normalized || 'default'; }; const normalizeDisplayName = (value: any) => String(value || '').trim().toLowerCase(); const buildRuntimeLogFilters = ({ runtimeType, agentName, instanceId }: { runtimeType: any; agentName: any; instanceId: any }) => { if (runtimeType !== 'moltbot') return []; const normalizedInstance = normalizeInstanceId(instanceId); const normalizedAgent = String(agentName || '').trim().toLowerCase(); const accountId = normalizedAgent === 'openclaw' ? normalizedInstance : `${normalizedAgent}-${normalizedInstance}`; return [accountId]; }; const resolveGatewayForRequest = async ({ gatewayId, userId }: { gatewayId: any; userId: any }) => { if (!gatewayId) return null; const user = await User.findById(userId).select('role').lean(); if (!user || user.role !== 'admin') { const error: any = new Error('Global admin required to select a gateway'); error.status = 403; throw error; } const gateway = await Gateway.findById(gatewayId).lean(); if (!gateway) { const error: any = new Error('Gateway not found'); error.status = 404; throw error; } const gw = gateway as any; if (gw.status && gw.status !== 'active') { const error: any = new Error('Gateway is not active'); error.status = 400; throw error; } if (isK8sMode() && gw.mode !== 'k8s') { const error: any = new Error('Gateway must be K8s mode in this environment'); error.status = 400; throw error; } return gateway; }; const isGlobalAdminUser = async (userId: any) => { const user = await User.findById(userId).select('role').lean(); return Boolean(user && user.role === 'admin'); }; const resolveGatewayForInstallation = async ({ gatewayId }: { gatewayId: any }) => { if (!gatewayId) return null; const gateway = await Gateway.findById(gatewayId).lean(); if (!gateway) { const error: any = new Error('Gateway not found'); error.status = 404; throw error; } const gw2 = gateway as any; if (gw2.status && gw2.status !== 'active') { const error: any = new Error('Gateway is not active'); error.status = 400; throw error; } if (isK8sMode() && gw2.mode !== 'k8s') { const error: any = new Error('Gateway must be K8s mode in this environment'); error.status = 400; throw error; } return gateway; }; const userHasPodAccess = (pod: any, userId: any) => { if (!pod || !userId) return false; const userIdStr = userId.toString(); if (pod.createdBy?.toString() === userIdStr) return true; return Boolean(pod.members?.some((m: any) => (m.userId?.toString?.() || m.toString()) === userIdStr)); }; const parseJsonFromText = (text: any) => { if (!text) return null; try { return JSON.parse(text); } catch (error) { const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start >= 0 && end > start) { try { return JSON.parse(text.slice(start, end + 1)); } catch (innerError) { return null; } } return null; } }; const serializeRuntimeTokens = (tokens: any[] = []) => tokens.map((token: any) => ({ id: token._id?.toString(), label: token.label, createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, })); const parseEnvFlag = (value: any) => { if (value === undefined || value === null) return false; const normalized = String(value).trim().toLowerCase(); if (!normalized) return false; return !['0', 'false', 'no', 'off', 'disabled', 'none'].includes(normalized); }; const hasAnyEnv = (keys = []) => keys.some((key) => parseEnvFlag(process.env[key])); const buildAgentProfileId = (agentName: any, instanceId: any) => ( `${agentName.toLowerCase()}:${normalizeInstanceId(instanceId)}` ); const resolveRuntimeInstanceId = ({ agentName, requestedInstanceId, installation }: { agentName: any; requestedInstanceId: any; installation: any }) => { // Runtime identity must follow the installed instance exactly. // Do not derive a different runtime instance from displayName, otherwise // shared tokens can drift and runtime pod authorization fails. const installedInstanceId = normalizeInstanceId(installation?.instanceId); if (installedInstanceId) return installedInstanceId; return normalizeInstanceId(requestedInstanceId); }; const resolveInstallation = async ({ agentName, podId, instanceId }: { agentName: any; podId: any; instanceId: any }) => { const normalizedInstanceId = normalizeInstanceId(instanceId); let installation = await AgentInstallation.findOne({ agentName: agentName.toLowerCase(), podId, instanceId: normalizedInstanceId, }); if (installation) { return { installation, instanceId: normalizedInstanceId }; } // Fallback: if instanceId is default and there is exactly one install, use it. if (normalizedInstanceId === 'default') { const installs = await AgentInstallation.find({ agentName: agentName.toLowerCase(), podId, status: { $ne: 'uninstalled' }, }).limit(2); if (installs.length === 1) { return { installation: installs[0], instanceId: installs[0].instanceId || 'default' }; } } // Fallback: if a specific instanceId was provided but there is exactly one active install, // use it to avoid hard failures when the UI doesn't know the instanceId. const activeInstalls = await AgentInstallation.find({ agentName: agentName.toLowerCase(), podId, status: { $ne: 'uninstalled' }, }).limit(2); if (activeInstalls.length === 1) { return { installation: activeInstalls[0], instanceId: activeInstalls[0].instanceId || 'default' }; } return { installation: null, instanceId: normalizedInstanceId }; }; module.exports = { buildIdentityContent, parseVerifiedFilter, escapeRegExp, getUserId, resolveUsername, normalizeConfigMap, normalizeRuntimeAuthProfiles, normalizeSkillEnvEntries, sanitizeRuntimeConfig, buildOpenClawIntegrationChannels, buildAgentInstallationPayload, resolveSeatRuntimeIdentities, isInternalSeat, toSnippet, normalizePluginIdentifier, getPluginSpecBase, normalizeInstanceId, normalizeDisplayName, buildRuntimeLogFilters, resolveGatewayForRequest, isGlobalAdminUser, resolveGatewayForInstallation, userHasPodAccess, parseJsonFromText, serializeRuntimeTokens, parseEnvFlag, hasAnyEnv, resolveInstallation, buildAgentProfileId, resolveRuntimeInstanceId, composeInstallIntro, }; export {};