/** * Remote Coding Agent - Main Entry Point * Multi-platform AI coding assistant (Telegram, Discord, Slack, GitHub, Gitea) */ // Strip CWD .env keys FIRST — before any application imports read process.env. // Bun auto-loads .env/.env.local/.env.development/.env.production from CWD; // when `bun run dev:server` is run from inside a target repo those keys leak // into the server process. stripCwdEnv() removes them before ~/.archon/.env loads. import '@archon/paths/strip-cwd-env-boot'; // ESM dependencies evaluate before this module's body reads configuration. import { envPath } from './boot/load-env'; import { existsSync } from 'fs'; import { BUNDLED_IS_BINARY, getArchonEnvPath } from '@archon/paths'; // Pure env→bool boot helper (zero deps) — safe to import before the heavier // application imports below. Decides the Claude global-auth sentinel posture. import { shouldDefaultClaudeGlobalAuth, hasClaudeBootAuthPosture, } from './boot/claude-auth-posture'; // Workflow scripts started by this server call back into the CLI through the // same host command the CLI publishes for its own runs. import { publishArchonCliCommand } from '@archon/paths/cli-command'; if (!BUNDLED_IS_BINARY) publishArchonCliCommand(); // Smart default: fall back to Claude Code's built-in OAuth (`claude /login`) // ONLY for solo installs with no explicit credentials. Per-user installs // (TOKEN_ENCRYPTION_KEY) deliver Claude auth per-request, so the global-auth // sentinel is skipped there — it's misleading, not load-bearing (#1983). if (shouldDefaultClaudeGlobalAuth(process.env)) { process.env.CLAUDE_USE_GLOBAL_AUTH = 'true'; } import { getPluginsPath } from '@archon/paths'; import { registerHostProviders } from '@archon/core/providers/register-host-providers'; import { claimPiExtensionProcessError } from '@archon/providers/pi/extension-error-broker'; import { formatCodexSetupDeprecation } from '@archon/providers/codex/setup-env'; import { CODEX_BOOT_CHECKED, readCodexBootAuth } from './boot/codex-auth-posture'; await registerHostProviders(); import { OpenAPIHono, z } from '@hono/zod-openapi'; import { serveWebUi } from './static-cache'; import { validationErrorHook } from './routes/openapi-defaults'; import { TelegramAdapter, GitHubAdapter, DiscordAdapter, SlackAdapter, SlackWorkflowBridge, } from '@archon/adapters'; import { defaultPlatformPolicies } from '@archon/adapters/platform-policies'; import { loadPlatformPolicies, loadChatPlugins } from '@archon/core/platforms/chat-plugins'; import { createChatHost } from './chat-host/runtime'; import { setPlatformPolicies } from '@archon/core/platforms/registry'; import { telegramPolicy } from '@archon/adapters/chat/telegram/policy'; import { slackPolicy } from '@archon/adapters/chat/slack/policy'; import { discordPolicy } from '@archon/adapters/community/chat/discord/policy'; import { GiteaAdapter } from '@archon/adapters/community/forge/gitea'; import { GitLabAdapter } from '@archon/adapters/community/forge/gitlab'; import { WebAdapter } from './adapters/web'; import { MessagePersistence } from './adapters/web/persistence'; import { SSETransport } from './adapters/web/transport'; import { WorkflowEventBridge } from './adapters/web/workflow-bridge'; import { DashboardEventPoller } from './adapters/web/dashboard-event-poller'; import { PgNotifyListener } from './adapters/web/pg-notify-listener'; import { registerApiRoutes } from './routes/api'; import { AttentionRouter } from './services/attention-router'; import { WebPushDestination } from './services/web-push'; import { registerGiteaWebhookRoute, registerGitlabWebhookRoute, registerGithubWebhookRoute, registerWebhookSourceRoutes, } from './routes/webhooks'; import { loadWebhookSourcePlugins } from './services/webhook-source-plugins'; import { createServerResourceStartHost } from './services/resource-start-hosting'; import { createSqlWorkflowHost } from '@archon/core/workflows/sql-host'; import { createWorkflowStore } from '@archon/core/workflows/store-adapter'; import { resumeWorkflowRunFromServer, startWorkflowContinuationScheduler, stopWorkflowContinuationScheduler, workflowResumeTargetForRun, } from './services/workflow-resume-service'; import { handleMessage, pool, ConversationLockManager, startCleanupScheduler, stopCleanupScheduler, getDbNotificationListener, loadConfig, logConfig, getPort, initializeWorkflowGitHubAppAuth, isPerUserGitHubEnabled, isPerUserProviderKeysEnabled, getDatabaseType, assertProviderKeysKeyAtBoot, getDecryptedAccessToken, type GitHubAuth, type IGitHubAppAuthProvider, } from '@archon/core'; import * as workflowDb from '@archon/core/db/workflows'; import type { IWorkflowPlatform } from '@archon/workflows/deps'; import { recordNodeProcessTrees } from '@archon/workflows/subprocess'; import { createLogger, logArchonPaths, validateAppDefaultsPaths, shutdownTelemetry, captureArchonStarted, captureArchonActive, getSourceWebDistDir, } from '@archon/paths'; import { selectGitHubAuthMode } from '@archon/core/utils/github-auth-mode'; import { resolveGitHubTokenFromEnv } from '@archon/core/github-auth/config'; import { parseGitCredentialPath } from './github-auth-bootstrap'; import { isDiscordMentionRequired } from './discord-mention'; import { getAuth, closeAuth, isWebAuthEnabled, assertWebAuthAtBoot, getSignupMode, isArchonOwnedAuthPath, } from './auth'; // From the pure config module: tests mock './auth', and the header name must stay real. import { webAuthHeaderName } from './auth/config'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; function getLog(): ReturnType { if (!cachedLog) cachedLog = createLogger('server'); return cachedLog; } import { resolveUserId, createMessageErrorHandler } from './chat-host/inbound'; export function handleUnhandledRejection(reason: unknown): void { if (BUNDLED_IS_BINARY && claimPiExtensionProcessError(reason)) return; getLog().fatal({ reason }, 'unhandled_rejection.fatal'); void exitAfterTelemetryFlush(1); } export function handleUncaughtException( error: Error, origin?: NodeJS.UncaughtExceptionOrigin ): void { if (BUNDLED_IS_BINARY && claimPiExtensionProcessError(error)) return; getLog().fatal({ err: error, origin }, 'uncaught_exception.fatal'); void exitAfterTelemetryFlush(1); } /** * Exit after flushing queued telemetry. Boot failures after `archon_started` * otherwise drop that event, since `process.exit` skips pending async work. */ export async function exitAfterTelemetryFlush(code: number): Promise { await shutdownTelemetry(); process.exit(code); } export interface ServerOptions { /** * Override the web dist path (for CLI binary with downloaded web-dist). * Only effective in production mode (NODE_ENV=production or WEB_UI_DEV unset). */ webDistPath?: string; /** Override the port. Range: 1–65535. */ port?: number; /** Run in standalone web-only mode (no Telegram/Slack/GitHub/Discord adapters). */ skipPlatformAdapters?: boolean; } export async function startServer(opts: ServerOptions = {}): Promise { const { loadStoreSelection } = await import('@archon/core/config/store-selection'); if ((await loadStoreSelection()) === 'files') { const { FileStoreUnsupportedError: fileStoreUnsupportedError } = await import( '@archon/workflows/file-store' ); throw new fileStoreUnsupportedError('the server (server routes read SQL)'); } // Every host bash and script node leads its own process group, so a run's cancel ends // that run's node trees, and each node's watcher ends its tree if the server dies. // The server never ends every tree at once, so the handle is not kept. recordNodeProcessTrees(); setPlatformPolicies(await loadPlatformPolicies(getPluginsPath(), defaultPlatformPolicies)); getLog().info('server_starting'); // Anonymous once-per-boot startup event (self-gates on opt-out). Flushed by // the shutdownTelemetry() call in the SIGINT/SIGTERM shutdown handler. // Deployment shape is categorical only — booleans/enums derived from which // integrations are configured, never the config values themselves. The // adapter gates mirror the env checks the adapter-init section below uses // (loadArchonEnv() ran at module load, so process.env is final here). const deploymentShape = { dbKind: getDatabaseType(), webAuthEnabled: isWebAuthEnabled(), multiUser: isPerUserProviderKeysEnabled(), githubAuthMode: selectGitHubAuthMode(process.env).kind, adapterSlack: Boolean(process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN), adapterTelegram: Boolean(process.env.TELEGRAM_BOT_TOKEN), adapterDiscord: Boolean(process.env.DISCORD_BOT_TOKEN), adapterGitea: Boolean( process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET ), adapterGitlab: Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET), }; captureArchonStarted({ surface: 'server', ...deploymentShape }); // Daily heartbeat so long-running servers stay visible in active-install // metrics (a boot-only event undercounts server installs after day one). // unref() so the timer never keeps the process alive on shutdown. // captureArchonActive is synchronous fire-and-forget (errors swallowed // internally) — if it ever becomes async, this callback must not return // its promise unhandled. const TELEMETRY_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; setInterval(() => { captureArchonActive({ surface: 'server', ...deploymentShape }); }, TELEMETRY_HEARTBEAT_INTERVAL_MS).unref(); // Phase 2: validate the encryption key the moment per-user provider keys are // enabled, regardless of GitHub App configuration. TOKEN_ENCRYPTION_KEY alone // is the gate — a malformed key must fail boot here rather than at the first // PUT /api/auth/providers/* (when an operator is already wired in). // No-op when the feature is disabled. assertProviderKeysKeyAtBoot(); // Database auto-detected: SQLite (default) or PostgreSQL (if DATABASE_URL set) // No required environment variables - SQLite works out of the box // Validate AI assistant credentials (warn if missing, don't fail). // A per-user install (TOKEN_ENCRYPTION_KEY) is a valid posture even with no // shared Claude key — auth is delivered per request from the encrypted store, // so it must NOT trip the no-credentials exit (#1983). const hasClaudeCredentials = hasClaudeBootAuthPosture(process.env); const codexAuth = readCodexBootAuth(process.env); if (codexAuth.deprecated.length > 0) { getLog().warn( { hint: formatCodexSetupDeprecation(codexAuth.deprecated) }, 'codex_setup_env_deprecated' ); } const hasCodexCredentials = codexAuth.hasCredentials; if (!hasClaudeCredentials && !hasCodexCredentials) { getLog().fatal( { checked: { claude: ['CLAUDE_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_USE_GLOBAL_AUTH'], codex: CODEX_BOOT_CHECKED, }, hints: [ 'Set CLAUDE_USE_GLOBAL_AUTH=true in .env (requires `claude /login` first)', 'Or set CLAUDE_API_KEY in .env', `Or set ${CODEX_BOOT_CHECKED.join(' + ')} in .env`, 'See .env.example for all options', ], envFile: BUNDLED_IS_BINARY ? getArchonEnvPath() : envPath, }, 'no_ai_credentials' ); await exitAfterTelemetryFlush(1); } if (!hasClaudeCredentials) { getLog().warn( { checked: ['CLAUDE_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_USE_GLOBAL_AUTH'] }, 'claude_credentials_missing' ); } if (!hasCodexCredentials) { getLog().warn({ checked: CODEX_BOOT_CHECKED }, 'codex_credentials_missing'); } // Test database connection try { await pool.query('SELECT 1'); getLog().info('database_connected'); } catch (error) { getLog().fatal({ err: error }, 'database_connection_failed'); await exitAfterTelemetryFlush(1); } const config = await loadConfig(); logConfig(config); // Start cleanup scheduler startCleanupScheduler(); // Note: orphaned-run cleanup intentionally NOT called at server startup. // Running it here killed parallel workflow runs from other processes // (CLI, adapters) by flipping their `running` rows to `failed` mid-flight. // Same lesson the CLI already learned — see packages/cli/src/cli.ts:256-258. // Per AGENTS.md "Do not guess lifecycle ownership": // surface ambiguous state to users and provide a one-click action instead. // Users transition a stuck `running` row via the per-row Cancel/Abandon // buttons in the Web UI dashboard, or `archon workflow abandon `. // (`archon workflow cleanup` is a separate command that deletes OLD terminal // rows for disk hygiene — it does not handle stuck `running` rows.) // See #1216. // Log Archon paths configuration logArchonPaths(); // Validate app defaults paths (non-blocking, just logs warnings) await validateAppDefaultsPaths(); // Initialize conversation lock manager const maxConcurrent = config.concurrency.maxConversations; const lockManager = new ConversationLockManager(maxConcurrent); getLog().info({ maxConcurrent }, 'lock_manager_initialized'); // Initialize web adapter (always enabled) // Note: Circular references between transport/persistence/workflowBridge are safe because: // - transport's cleanup callback references persistence/workflowBridge (declared after, but // only invoked from a grace period timer — well after all constructors complete) // - persistence's emitEvent closure references transport.emit (same lazy pattern) const transport = new SSETransport(conversationId => { // Flush (not clear!) — the orchestrator/workflow may still be writing messages // even though the SSE stream disconnected. Clearing the dbId mapping would cause // all subsequent messages to be lost (never persisted to DB). void persistence.flush(conversationId).catch((e: unknown) => { getLog().error({ conversationId, err: e }, 'transport_cleanup_flush_failed'); }); }); const persistence = new MessagePersistence((conversationId, event) => transport.emit(conversationId, event) ); const workflowBridge = new WorkflowEventBridge(transport); const webAdapter = new WebAdapter(transport, persistence, workflowBridge); await webAdapter.start(); const workflowPlatforms = new Map([ [webAdapter.getPlatformType(), webAdapter], ]); persistence.startPeriodicFlush(); // Stream workflow runs started in ANY process (incl. the `archon` CLI / `--detach`) // to the console dashboard. The in-process WorkflowEventBridge only sees runs // executed inside the server; this poller follows the store's event feed. On // Postgres, LISTEN/NOTIFY wakes it for near-instant push (poll becomes a slow // backstop); on SQLite it polls fast. const dashboardPoller = new DashboardEventPoller(createWorkflowStore()); const dbNotifier = getDbNotificationListener(); let pgNotifyListener: PgNotifyListener | undefined; if (dbNotifier) { await dashboardPoller.start(transport, 10_000); pgNotifyListener = new PgNotifyListener(dbNotifier, dashboardPoller); await pgNotifyListener.start(); } else { await dashboardPoller.start(transport, 1_500); } // Mutable — pushed to as each adapter starts, read by the /api/health endpoint. // Must be a live reference because Telegram starts after the HTTP listener begins // accepting requests, so a snapshot taken at registration time would miss it. const activePlatforms: string[] = ['Web']; const chatPlugins = opts.skipPlatformAdapters ? [] : await loadChatPlugins(getPluginsPath()); const installedChatIds = new Set(chatPlugins.map(plugin => plugin.descriptor.id)); const inlinePlatforms = new Set( chatPlugins .filter(plugin => plugin.descriptor.capabilities.runEvents) .map(plugin => plugin.descriptor.id) ); const chatHost = createChatHost( chatPlugins, config.streaming, lockManager, workflowPlatforms, activePlatforms, { routes: config.attentionNotices, inlinePlatforms } ); // Each route's activation baseline commits before the router's first scan. await Promise.all(chatHost.attentionDestinations.map(destination => destination.activate())); const webPush = new WebPushDestination(inlinePlatforms); const attentionRouter = new AttentionRouter( [webPush, ...chatHost.attentionDestinations], inlinePlatforms ); for (const plugin of chatPlugins) { getLog().info( { plugin: plugin.descriptor.id }, defaultPlatformPolicies.some(policy => policy.id === plugin.descriptor.id) ? 'chat.plugin.registered_superseding_bundled_adapter' : 'chat.plugin.registered' ); } // Platform adapters (skipped in CLI serve mode or when not configured) let github: GitHubAdapter | null = null; let githubAppAuthProvider: IGitHubAppAuthProvider | null = null; let gitea: GiteaAdapter | null = null; let gitlab: GitLabAdapter | null = null; let discord: DiscordAdapter | null = null; let slack: SlackAdapter | null = null; let slackBridge: SlackWorkflowBridge | null = null; if (!opts.skipPlatformAdapters) { // Check that at least one platform is configured const hasTelegram = Boolean(process.env.TELEGRAM_BOT_TOKEN); const hasDiscord = Boolean(process.env.DISCORD_BOT_TOKEN); // GitHub adapter: dual-mode (App vs PAT). Fail fast if both are configured — // silently preferring one would create 3am debugging sessions for an operator // who copy-pasted half a config and didn't realise the other half was already // set in /etc/archon/.env. (PRD: "fail-fast on misconfig".) const ghAuthMode = selectGitHubAuthMode(process.env); if (ghAuthMode.kind === 'conflict') { throw new Error(ghAuthMode.message); } const hasGitHub = ghAuthMode.kind !== 'none'; const hasGitea = Boolean( process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET ); const hasGitLab = Boolean(process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET); if (!hasTelegram && !hasDiscord && !hasGitHub && !hasGitea && !hasGitLab) { getLog().warn('no_platform_adapters_configured'); } if (ghAuthMode.kind === 'app') { const webhookSecret = process.env.WEBHOOK_SECRET; githubAppAuthProvider = initializeWorkflowGitHubAppAuth(); if (!githubAppAuthProvider || !webhookSecret) { throw new Error( 'GitHub App mode misconfigured: App credentials and WEBHOOK_SECRET required' ); } if (!isPerUserGitHubEnabled()) { getLog().warn( 'github_app.per_user_disabled — set TOKEN_ENCRYPTION_KEY (and GITHUB_APP_CLIENT_ID) to enable per-user GitHub identity' ); } const botMention = process.env.GITHUB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; const auth: GitHubAuth = { kind: 'app', provider: githubAppAuthProvider }; // Per-user comment attribution: when enabled, let the adapter author PR/ // issue comments under the originating user's GitHub identity. Resolver // returns undefined for unconnected users → bot identity fallback. const getUserToken = isPerUserGitHubEnabled() ? async (userId: string): Promise => (await getDecryptedAccessToken(userId)) ?? undefined : undefined; github = new GitHubAdapter(auth, webhookSecret, lockManager, botMention, { getUserToken, }); await github.start(); workflowPlatforms.set(github.getPlatformType(), github); activePlatforms.push('GitHub (App)'); getLog().info({ slug: githubAppAuthProvider.slug }, 'github.adapter_mode_app'); } else if (ghAuthMode.kind === 'pat') { const patToken = resolveGitHubTokenFromEnv(); const webhookSecret = process.env.WEBHOOK_SECRET; if (!patToken || !webhookSecret) { throw new Error('GitHub PAT mode misconfigured: GITHUB_TOKEN and WEBHOOK_SECRET required'); } const botMention = process.env.GITHUB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; const auth: GitHubAuth = { kind: 'pat', token: patToken }; github = new GitHubAdapter(auth, webhookSecret, lockManager, botMention); await github.start(); workflowPlatforms.set(github.getPlatformType(), github); activePlatforms.push('GitHub'); getLog().info('github.adapter_mode_pat'); } else { getLog().info('github_adapter_skipped'); } // Initialize Gitea adapter (conditional) if (process.env.GITEA_URL && process.env.GITEA_TOKEN && process.env.GITEA_WEBHOOK_SECRET) { const giteaBotMention = process.env.GITEA_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; gitea = new GiteaAdapter( process.env.GITEA_URL, process.env.GITEA_TOKEN, process.env.GITEA_WEBHOOK_SECRET, lockManager, giteaBotMention ); await gitea.start(); workflowPlatforms.set(gitea.getPlatformType(), gitea); activePlatforms.push('Gitea'); } else { getLog().info('gitea_adapter_skipped'); } // Initialize GitLab adapter (conditional) if (process.env.GITLAB_TOKEN && process.env.GITLAB_WEBHOOK_SECRET) { const gitlabBotMention = process.env.GITLAB_BOT_MENTION || process.env.BOT_DISPLAY_NAME || config.botName; gitlab = new GitLabAdapter( process.env.GITLAB_TOKEN, process.env.GITLAB_WEBHOOK_SECRET, lockManager, process.env.GITLAB_URL || undefined, gitlabBotMention ); await gitlab.start(); workflowPlatforms.set(gitlab.getPlatformType(), gitlab); activePlatforms.push('GitLab'); } else { getLog().info('gitlab_adapter_skipped'); } // Initialize Discord adapter (conditional) if (!installedChatIds.has(discordPolicy.id) && process.env.DISCORD_BOT_TOKEN) { const discordStreamingMode = (process.env[discordPolicy.streaming.envVar] ?? discordPolicy.streaming.defaultMode) as 'stream' | 'batch'; discord = new DiscordAdapter(process.env.DISCORD_BOT_TOKEN, discordStreamingMode); const discordAdapter = discord; // Capture for use in callback const discordRequireMention = isDiscordMentionRequired(); // Register message handler discordAdapter.onMessage(async ({ message, platformUserId, displayName }) => { // Get initial conversation ID let conversationId = discordAdapter.getConversationId(message); // Skip if no content if (!message.content) return; // Check if bot was mentioned (required for activation unless // DISCORD_REQUIRE_MENTION=false opts out of the gate) // Exception: DMs never require mention const isDM = !message.guild; if (!isDM && discordRequireMention && !discordAdapter.isBotMentioned(message)) { return; // Ignore messages that don't mention the bot } // Strip the bot mention from the message const content = discordAdapter.stripBotMention(message); if (!content) return; // Message was only a mention with no content // Ensure we're responding in a thread - creates one if needed conversationId = await discordAdapter.ensureThread(conversationId, message); // Check for thread context (now we're guaranteed to be in a thread if applicable) let threadContext: string | undefined; let parentConversationId: string | undefined; if (discordAdapter.isThread(message)) { // Fetch thread history for context (exclude current message) const history = await discordAdapter.fetchThreadHistory(message); if (history.length > 1) { threadContext = history.slice(0, -1).join('\n'); } // Get parent channel ID for context inheritance parentConversationId = discordAdapter.getParentChannelId(message) ?? undefined; } // Resolve Discord author → Archon user UUID. // displayName is already display-quality on Discord (no extra API call needed). const userId = await resolveUserId('discord', platformUserId, displayName); // Fire-and-forget: handler returns immediately, processing happens async lockManager .acquireLock(conversationId, async () => { await handleMessage(discordAdapter, conversationId, content, { threadContext, parentConversationId, isolationHints: { workflowType: 'thread', workflowId: conversationId }, actor: userId ? { kind: 'user', userId } : { kind: 'unidentified' }, }); }) .catch(createMessageErrorHandler('Discord', discordAdapter, conversationId)); }); // Don't let a Discord login failure (bad token, missing privileged // intents, etc.) bring down the whole server — users running // `archon serve` for the web UI shouldn't lose it because of an // unrelated bot misconfiguration. See #1365. try { await discord.start(); workflowPlatforms.set(discord.getPlatformType(), discord); activePlatforms.push('Discord'); } catch (error) { const err = error as Error; const isPrivilegedIntentError = err.message?.includes('disallowed intents'); const hint = isPrivilegedIntentError ? 'Enable "Message Content Intent" in the Discord Developer Portal ' + '(your application > Bot > Privileged Gateway Intents) and restart, ' + 'or unset DISCORD_BOT_TOKEN if you do not want the Discord adapter.' : 'Verify DISCORD_BOT_TOKEN is valid, or unset it to disable the Discord adapter.'; getLog().error({ err, hint }, 'discord.start_failed_continuing_without_adapter'); discord = null; } } else { getLog().info('discord_adapter_skipped'); } // Initialize Slack adapter (conditional) if ( !installedChatIds.has(slackPolicy.id) && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN ) { const slackStreamingMode = (process.env[slackPolicy.streaming.envVar] ?? slackPolicy.streaming.defaultMode) as 'stream' | 'batch'; slack = new SlackAdapter( process.env.SLACK_BOT_TOKEN, process.env.SLACK_APP_TOKEN, slackStreamingMode ); const slackAdapter = slack; // Capture for use in callback // Register message handler slackAdapter.onMessage(async event => { const conversationId = slackAdapter.getConversationId(event); // Skip if no text if (!event.text) return; // Strip the bot mention from the message const content = slackAdapter.stripBotMention(event.text); if (!content) return; // Message was only a mention with no content // Check for thread context let threadContext: string | undefined; let parentConversationId: string | undefined; if (slackAdapter.isThread(event)) { // Fetch thread history for context (exclude current message) const history = await slackAdapter.fetchThreadHistory(event); if (history.length > 1) { threadContext = history.slice(0, -1).join('\n'); } // Get parent conversation ID for context inheritance parentConversationId = slackAdapter.getParentConversationId(event) ?? undefined; } // Resolve Slack user → Archon user UUID. displayName comes from // the adapter's users.info enrichment (cached per slackUserId). const userId = await resolveUserId('slack', event.user, event.displayName); // Fire-and-forget: handler returns immediately, processing happens async lockManager .acquireLock(conversationId, async () => { await handleMessage(slackAdapter, conversationId, content, { threadContext, parentConversationId, isolationHints: { workflowType: 'thread', workflowId: conversationId }, actor: userId ? { kind: 'user', userId } : { kind: 'unidentified' }, }); }) .catch(createMessageErrorHandler('Slack', slackAdapter, conversationId)); }); // Attach the workflow bridge BEFORE app.start(): Bolt's Socket Mode // refuses new event-handler registrations once the connection is open, // so `app.action(...)` calls inside the bridge must run first. workflowPlatforms.set(slack.getPlatformType(), slack); slackBridge = new SlackWorkflowBridge(slack, async (runId, slackUserId) => { const run = await workflowDb.getWorkflowRun(runId); if (!run) return 'not_resumed'; const actorUserId = await resolveUserId('slack', slackUserId, undefined); const target = await workflowResumeTargetForRun(run, workflowPlatforms); const outcome = await resumeWorkflowRunFromServer( createSqlWorkflowHost(), run, actorUserId, target ); return outcome.kind === 'resource_busy' ? 'not_resumed' : outcome.kind; }); slackBridge.attach(); await slack.start(); activePlatforms.push('Slack'); } else { getLog().info('slack_adapter_skipped'); } } else { getLog().info('platform_adapters_skipped'); } // Fail fast on a misconfigured web-auth secret before binding a socket: when // web auth is enabled, BETTER_AUTH_SECRET must be long enough to be a real // signing key. No-op when web auth is disabled. assertWebAuthAtBoot(); // Setup Hono server const app = new OpenAPIHono({ defaultHook: validationErrorHook }); const port = opts.port ?? (await getPort()); const resourceStartWorkflowHost = createSqlWorkflowHost(); const webhookSourcesConfigPath = process.env.ARCHON_WEBHOOK_SOURCES; const webhookSources = webhookSourcesConfigPath ? await loadWebhookSourcePlugins(webhookSourcesConfigPath, { acceptReceipt: input => resourceStartWorkflowHost.deps.store.acceptStartReceipt(input), isKnownUser: async id => (await resourceStartWorkflowHost.records.users.getUserById(id)) !== null, hasDeliveryReceipt: (sourceInstanceId, deliveryId) => resourceStartWorkflowHost.deps.store.hasStartReceiptForDelivery( sourceInstanceId, deliveryId ), }) : undefined; // Explicit only: bindings choose their execution host, so the server never guesses one. const resourceStartHostId = process.env.ARCHON_TRIGGER_HOST?.trim(); const resourceStartHost = resourceStartHostId ? createServerResourceStartHost(resourceStartHostId, resourceStartWorkflowHost) : undefined; const requestResourceStartDrain = resourceStartHost ? (): void => void resourceStartHost.requestDrain() : undefined; // Global error handler for unhandled exceptions app.onError((err, c) => { getLog().error({ err, path: c.req.path, method: c.req.method }, 'unhandled_request_error'); return c.json({ error: 'Internal server error' }, 500); }); // Opt-in web auth (Better Auth). Mount the handler at /api/auth/* AFTER // app.onError and BEFORE registerApiRoutes (so it wins over the '*' SPA // fallback). getAuth() returns null when web auth is disabled — solo/SQLite // installs mount nothing and behave exactly as before. // // Better Auth's basePath is /api/auth, so its handler is a catch-all under // that prefix. Archon ALSO owns a few /api/auth/* routes (status + the GitHub // device flow) registered later in registerApiRoutes. To avoid shadowing // them, explicitly fall through (next()) for those Archon-owned paths so the // later route handlers run; everything else under /api/auth/* is Better Auth. // DELETE isn't registered here, so DELETE /api/auth/github is never // intercepted either. (Raw app.on, not registerOpenApiRoute: Better Auth is // an external handler serving its own non-OpenAPI surface, like the webhooks.) const webAuth = getAuth(); if (webAuth) { // isArchonOwnedAuthPath (in ./auth/config) is the single source of truth for // which /api/auth/* paths fall through to Archon's own handlers vs. Better // Auth. A guard test asserts every Archon-registered /api/auth/* route is in // it, so adding a route without exempting it fails CI rather than 404ing live. app.on(['POST', 'GET'], '/api/auth/*', (c, next) => { if (isArchonOwnedAuthPath(c.req.path)) return next(); return webAuth.handler(c.req.raw); }); getLog().info('web_auth.handler_registered'); // Safe-default signal: web auth is on but no allowlist + no open-signup flag // → self-serve registration is OFF. Surface it so an operator who meant to // invite teammates isn't silently locked out of signups. if (getSignupMode() === 'disabled') { getLog().warn( { hint: 'Set ARCHON_AUTH_ALLOWED_EMAILS to invite users, or ARCHON_AUTH_OPEN_SIGNUP=true for open signup.', }, 'web_auth.signup_disabled_no_allowlist' ); } } // Register Web UI API routes registerApiRoutes( app, webAdapter, lockManager, activePlatforms, { webPush, router: attentionRouter, }, { githubAppAuth: githubAppAuthProvider ?? undefined } ); // GitHub webhook endpoint if (github) { registerGithubWebhookRoute(app, github); getLog().info('github_webhook_registered'); } if (webhookSources) { registerWebhookSourceRoutes(app, webhookSources, requestResourceStartDrain); getLog().info('webhook_sources_registered'); } // Internal endpoint: git credential helper. // // SECURITY: hands out live installation access tokens to anyone who can hit // this URL. MUST be exposed on 127.0.0.1 only — the reverse proxy in front // of Archon must NOT forward `/internal/*`. The startup guard below refuses // to start the server (fatal error) when the operator binds to a non-loopback // host with App mode active, unless ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1. if (github?.getAuthMode() === 'app') { // Request schema for /internal/git-credential. Validates the small // host/path payload the credential helper sends. Inline declaration // because the endpoint is a one-off internal surface (not part of the // OpenAPI-published API), so it doesn't belong in routes/schemas/. const gitCredentialRequestSchema = z.object({ host: z.string().optional(), path: z.string().optional(), }); app.post('/internal/git-credential', async c => { try { const raw = await c.req.json().catch(() => null); const parseResult = gitCredentialRequestSchema.safeParse(raw); if (!parseResult.success || parseResult.data.host !== 'github.com') { return c.json({ error: 'unsupported host' }, 400); } const parsed = parseGitCredentialPath(parseResult.data.path ?? ''); if (!parsed) { return c.json({ error: 'unparseable path' }, 400); } const token = await github.getInstallationToken(parsed.owner, parsed.repo); return c.json({ token }); } catch (err) { // ERROR (not WARN): this is a live credential-vending failure. If we // can't issue a token, every workflow `git push` and `gh` call against // that repo will start failing — operators need this surfaced loudly. getLog().error({ err }, 'internal.git_credential_resolve_failed'); return c.json({ error: 'resolution failed' }, 500); } }); getLog().info('internal_git_credential_endpoint_registered'); } // Gitea webhook endpoint if (gitea) { registerGiteaWebhookRoute(app, gitea); getLog().info('gitea_webhook_registered'); } // GitLab webhook endpoint if (gitlab) { registerGitlabWebhookRoute(app, gitlab); getLog().info('gitlab_webhook_registered'); } // Health check endpoints app.get('/health', c => { return c.json({ status: 'ok' }); }); app.get('/health/db', async c => { try { await pool.query('SELECT 1'); return c.json({ status: 'ok', database: 'connected' }); } catch (error) { getLog().error({ err: error }, 'health_check_db_failed'); return c.json({ status: 'error', database: 'disconnected' }, 500); } }); app.get('/health/concurrency', c => { const { active, queuedTotal, maxConcurrent } = lockManager.getStats(); return c.json({ status: 'ok', active, queuedTotal, maxConcurrent }); }); // Serve web UI static files in production if (process.env.NODE_ENV === 'production' || !process.env.WEB_UI_DEV) { // Without an explicit path this is a source checkout or the Docker image, // where the web UI is whatever `bun run build:web` produced. The resolved // path is absolute because CWD varies with `bun --filter`. const webDistPath = opts.webDistPath ?? getSourceWebDistDir(); if (!existsSync(webDistPath)) { getLog().warn({ webDistPath }, 'web_dist_not_found'); } serveWebUi(app, webDistPath); } const hostname = process.env.HOST || '0.0.0.0'; // Security guardrail: /internal/git-credential hands out live installation // access tokens. Fail fast (not just WARN) when App mode is active and the // server is bound to a non-loopback interface — a WARN line in startup // logs is too easy to scroll past, and the failure mode is "anyone on the // network who can hit the port pulls a live token". Operators who deliberately // firewall externally (so loopback bind would block their reverse proxy's // upstream) can opt out via ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1. // // Runs BEFORE Bun.serve so a rejected config never opens the listening // socket — even briefly — and `server_listening` is never logged. if (githubAppAuthProvider && hostname !== '127.0.0.1' && hostname !== 'localhost') { if (process.env.ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND === '1') { getLog().warn({ hostname }, 'github_app.internal_endpoint_exposed_acknowledged'); } else { getLog().fatal({ hostname }, 'github_app.internal_endpoint_public_bind_rejected'); throw new Error( 'GitHub App mode is active but the server is bound to a non-loopback ' + `interface (${hostname}). The /internal/git-credential endpoint hands out ` + 'live installation tokens — exposing it would leak credentials to the network. ' + 'Either bind to 127.0.0.1 (HOST=127.0.0.1), or, if your reverse proxy already ' + 'drops /internal/* and the upstream needs a non-loopback bind, opt out by ' + 'setting ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1.' ); } } // Security guardrail (advisory): the web identity header (ARCHON_WEB_AUTH_HEADER, // see webAuthHeaderName) is trusted as-is — Archon attributes web requests to // whoever the header names. That is only sound when Archon is reachable SOLELY // through a reverse proxy that authenticates and sets the header (loopback bind). // On a non-loopback bind any client that can reach the port can forge it: // cosmetic misattribution without per-user GitHub, but in per-user mode a forged // header can read/disconnect another user's GitHub connection or bind a // device-flow token under their identity. WARN (not fatal) so existing exposed // installs without per-user identity keep starting — but the misconfiguration is // surfaced. The default header name means the trust is live even when // ARCHON_WEB_AUTH_HEADER is unset, so per-user mode alone arms this check. // Web auth (Better Auth) also keeps the header active as a fallback (proxy // deploys / auth-service sidecar), so an enabled install on a public bind // gets the same advisory. const webAuthHeaderTrustActive = Boolean(process.env.ARCHON_WEB_AUTH_HEADER) || isPerUserGitHubEnabled() || isWebAuthEnabled(); if (webAuthHeaderTrustActive && hostname !== '127.0.0.1' && hostname !== 'localhost') { getLog().warn( { hostname, headerName: webAuthHeaderName() }, 'web_auth.header_trust_on_public_bind' ); } const server = Bun.serve({ fetch: app.fetch, hostname, port, idleTimeout: 255, // Max value (seconds) - prevents SSE connections from being killed }); // Children must use the bound socket, including explicit overrides and worktree ports. process.env.ARCHON_PORT = String(server.port); getLog().info({ port: server.port, hostname }, 'server_listening'); // Initialize Telegram adapter (conditional, skipped in CLI serve mode) let telegram: TelegramAdapter | null = null; if ( !opts.skipPlatformAdapters && !installedChatIds.has(telegramPolicy.id) && process.env.TELEGRAM_BOT_TOKEN ) { const streamingMode = (process.env[telegramPolicy.streaming.envVar] ?? telegramPolicy.streaming.defaultMode) as 'stream' | 'batch'; telegram = new TelegramAdapter(process.env.TELEGRAM_BOT_TOKEN, streamingMode); const telegramAdapter = telegram; // Capture for use in callback // Register message handler (auth is handled internally by adapter) telegramAdapter.onMessage( async ({ conversationId, message, userId: telegramUserId, displayName }) => { // Resolve Telegram user id (numeric) → Archon user UUID. const userId = await resolveUserId('telegram', telegramUserId, displayName); // Fire-and-forget: handler returns immediately, processing happens async lockManager .acquireLock(conversationId, async () => { await handleMessage(telegramAdapter, conversationId, message, { isolationHints: { workflowType: 'thread', workflowId: conversationId }, actor: userId ? { kind: 'user', userId } : { kind: 'unidentified' }, }); }) .catch(createMessageErrorHandler('Telegram', telegramAdapter, conversationId)); } ); try { await telegramAdapter.start(); workflowPlatforms.set(telegramAdapter.getPlatformType(), telegramAdapter); activePlatforms.push('Telegram'); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); getLog().error({ err: error, errorType: error.constructor.name }, 'telegram.start_failed'); telegram = null; // Don't include in active platforms or shutdown } } else if (!opts.skipPlatformAdapters) { getLog().info('telegram_adapter_skipped'); } // Web background runs execute against a hidden worker conversation but deliver // to their visible parent; other runs use their owning conversation directly. startWorkflowContinuationScheduler( createSqlWorkflowHost(), run => workflowResumeTargetForRun(run, workflowPlatforms), requestResourceStartDrain ); if (resourceStartHostId) getLog().info({ hostId: resourceStartHostId }, 'resource_start_host_enabled'); // Graceful shutdown const shutdown = (): void => { getLog().info('server_shutting_down'); stopCleanupScheduler(); stopWorkflowContinuationScheduler(); persistence.stopPeriodicFlush(); // Flush all buffered messages before stopping adapters persistence .flushAll() .catch((e: unknown) => { getLog().error({ err: e }, 'shutdown_flush_failed'); }) .then(async () => { try { await attentionRouter.stop(); await chatHost.stop(); } catch (error) { getLog().error({ err: error }, 'chat_host_stop_error'); } // Stop adapters (these should not throw, but be defensive) try { telegram?.stop(); discord?.stop(); // Detach Slack workflow bridge BEFORE stopping the adapter so a // pending debounced chat.update can't fire against a closed socket. slackBridge?.detach(); slack?.stop(); gitea?.stop(); gitlab?.stop(); pgNotifyListener?.stop(); dashboardPoller.stop(); await webAdapter.stop(); } catch (error) { getLog().error({ err: error }, 'adapter_stop_error'); } // Flush queued telemetry events before pool closes the process. await shutdownTelemetry(); // Release the dedicated Better Auth pool (no-op when web auth is off). await closeAuth(); return pool.end(); }) .then(() => { getLog().info('database_pool_closed'); process.exit(0); }) .catch((error: unknown) => { getLog().error({ err: error }, 'database_pool_close_failed'); process.exit(1); }); }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); chatHost.start(); attentionRouter.start(); // Every unhandled rejection or exception exits: vendor error text (such as the Claude // SDK's "Operation aborted") must not decide what gets suppressed. The one exception is // a stack-attested Pi extension failure, which goes back to the Pi turn that loaded it. process.on('unhandledRejection', handleUnhandledRejection); process.on('uncaughtException', handleUncaughtException); getLog().info({ activePlatforms, port }, 'server_ready'); // Non-blocking: warn at startup if gh CLI auth is unavailable checkGhAuth().catch((err: unknown) => { getLog().debug({ err }, 'gh_auth.check_unexpected_error'); }); } /** * Run `gh auth status` and warn if it fails. * Helps diagnose expired tokens or missing auth before workflows fail. */ async function checkGhAuth(): Promise { const { execFileAsync } = await import('@archon/git'); try { await execFileAsync('gh', ['auth', 'status'], { timeout: 10_000 }); getLog().info('gh_auth.status_ok'); } catch { getLog().warn( 'gh_auth.status_failed — gh CLI is not authenticated. Workflows using gh commands may fail. ' + 'Run `gh auth login` or set GH_TOKEN in .env to fix this.' ); } } // Run the application when executed directly (not imported as a library) if (import.meta.main) { startServer().catch(async (error: unknown) => { getLog().fatal({ err: error }, 'startup_failed'); await exitAfterTelemetryFlush(1); }); }