* chore: remove legacy orchestrator, trading and one-off script leftovers The repository still carried the pre-evOo prototype: a Python orchestrator, the old HTML "evoo-platform" cockpit (whose test ran in the root unit suite), an apex-trading package, the hot-reload deploy.sh for it, a permissions manifest, an architecture note with local file links, agent handoff notes, a Steve Jobs persona template and about ninety one-off scripts that probe a developer database with hard-coded local URLs. None of it is used by the product, CI or the real deploy in ops/evo-deploy. Delete those files, drop the evoo-platform test glob from the root vitest config, the Cockpit section from .env.example and the dead biome and gitignore entries, and update the docs that listed the removed tools. scripts/start-tmux.sh no longer starts the removed sentinel and resolves the checkout from its own location instead of a machine-specific path; scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a local URL with a username. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): drop trading and persona prompts from the conversation shell The web shell still showed leftovers from the trading prototype: composer chips with German trading and "Steve Jobs" prompts, trading suggestion chips under the empty conversation line, a German trading hero for the empty transcript, and a German "data protection" status panel in the sidebar. They suggested actions the product does not have and added persistent chrome. Remove the composer chips and their helper, the empty-state chips and the hero (the empty conversation keeps its existing one-line start hint), the sidebar trading indicator and the data trust panel. The new-bot E2E now asserts the plain empty state; the sidebar spec no longer expects the trading status, and the placeholder Steve Jobs spec with no assertions is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * refactor: remove the trading bot status integration With the sidebar trading indicator gone, the only remaining consumer of the trading bot status was a banner in General settings that reused the same component. evOo is a personal assistant; a status proxy and poller for one external trading service, configured through service-specific TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in the core product. Remove the settings banner and its prop plumbing, the web poller, the indicator component, the /api/trading-bot/status proxy, the shared core contract, the env loading and both variables from .env.example, together with their tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: remove one-off database scripts and a stale review note packages/db carried four hand-run scripts outside the package build that query or seed a developer database with a hard-coded connection string, one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a one-time verification note for a workflow that no longer exists and named private machines and local paths. Nothing references them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: replace machine-specific paths and addresses with placeholders ORIGIN.md pinned the intended checkout to an absolute path under a personal home directory, docs/LOCAL-SETUP.md listed folders on one developer's machine, and two tests used what looks like a real tailnet address. Use a home-relative path, drop the machine-specific folder list, and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10, so both tests check the same thing as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): drop the trading status from the shell contrast test main's contrast test now waits for the sidebar trading status before measuring; this branch removes that status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
1008 lines
35 KiB
TypeScript
1008 lines
35 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { rm } from "node:fs/promises";
|
|
import { ORPCError, onError } from "@orpc/server";
|
|
import { RPCHandler } from "@orpc/server/fetch";
|
|
import type {
|
|
AgentRuntime,
|
|
JobPublisher,
|
|
ManagedConnectorProvider,
|
|
MessagingSurface,
|
|
RealtimeFanout,
|
|
SandboxProvider,
|
|
TransactionalEmailProvider,
|
|
} from "@rakazo/adapter-kit";
|
|
import type {
|
|
ComposioProvider,
|
|
ConnectorRegistry,
|
|
DestinationEmulator,
|
|
RemoteConnectorDependencies,
|
|
} from "@rakazo/adapters";
|
|
import {
|
|
applyMessagingOutboundStatus,
|
|
ChatSdkMessagingSurface,
|
|
ComposioConnector,
|
|
createBackgroundJobHandlers,
|
|
createCloudAgentConnection,
|
|
createConnectorStack,
|
|
createJobReconciler,
|
|
createMessagingContextLoader,
|
|
createMessagingTeamChatSender,
|
|
createProfileConsolidationScheduler,
|
|
createRunExecutor,
|
|
createRunSandbox,
|
|
createRunSecretWriter,
|
|
createWebProvider,
|
|
destroyBot,
|
|
EmailEmulator,
|
|
EncryptedSecretStore,
|
|
ExpoPushProvider,
|
|
GraphileJobPublisher,
|
|
GraphileJobWorkerHost,
|
|
InMemoryJobQueue,
|
|
InMemoryRealtimeFanout,
|
|
InstalledConnectorProvider,
|
|
IntegrationProviderSettings,
|
|
isComposioEnabled,
|
|
isMessagingSurfaceEnabled,
|
|
isPipedreamEnabled,
|
|
LocalAgentHomeStore,
|
|
LocalArtifactStore,
|
|
McpConnector,
|
|
McpOAuthBroker,
|
|
messagingPlatformsFromEnv,
|
|
PiAgentRuntime,
|
|
PiOAuthLogins,
|
|
PipedreamConnector,
|
|
PostgresRealtimeFanout,
|
|
pipedreamConfigFromEnv,
|
|
piSessionsRoot,
|
|
pushTokenPath,
|
|
reconcileCloudAgents,
|
|
reconcileComputerUpdates,
|
|
reconcileDelegatedTasks,
|
|
removePiUserSessions,
|
|
ScriptedAgentRuntime,
|
|
SmtpEmailProvider,
|
|
SpaceMemoryProviderResolver,
|
|
toTeamChatInbound,
|
|
} from "@rakazo/adapters";
|
|
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
|
|
import { signupPolicyFromEnv } from "@rakazo/core";
|
|
import type { Pool, PrismaClient } from "@rakazo/db";
|
|
import {
|
|
createDb,
|
|
createPool,
|
|
createThreadEvents,
|
|
parsePositiveInteger,
|
|
provisionMessagingIdentity,
|
|
requireMembership,
|
|
} from "@rakazo/db";
|
|
import type { Logger } from "@rakazo/logging";
|
|
import {
|
|
createServiceLogger,
|
|
enrichLogContext,
|
|
getLogger,
|
|
installLogger,
|
|
SERVICE_NAMES,
|
|
} from "@rakazo/logging";
|
|
import { requestLogging } from "@rakazo/logging/hono";
|
|
import { MarkdownMemoryStore } from "@rakazo/memory";
|
|
import { Hono } from "hono";
|
|
import { cors } from "hono/cors";
|
|
import type { AppEnv } from "./env.js";
|
|
import { loadEnv } from "./env.js";
|
|
import { mountLocalSettings } from "./local-settings.js";
|
|
import {
|
|
createMessagingInboundHandler,
|
|
teamChatSenderCanWakeMessageRoutines,
|
|
wakeMessageRoutines,
|
|
} from "./messaging-inbound.js";
|
|
import { mountMessagingWebhookRoutes } from "./messaging-webhook.js";
|
|
import { mountApiRequestBodyLimits } from "./request-body-limit.js";
|
|
import { createRouter } from "./router.js";
|
|
import { mountScreenTarget } from "./screen-proxy.js";
|
|
import { isDeferredReservationLost, TeamChatBridge } from "./team-chat-bridge.js";
|
|
import { ModelTeamChatEngagementJudge } from "./team-chat-judge.js";
|
|
import {
|
|
PendingTeamChatInbound,
|
|
prefersTeamChatSurface,
|
|
settleWithTimeout,
|
|
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
|
} from "./team-chat-startup.js";
|
|
import { mountVoiceHttpRoutes } from "./voice.js";
|
|
import { mountWebhookHttpRoutes } from "./webhook.js";
|
|
|
|
export interface AppHandles {
|
|
app: Hono;
|
|
prisma: PrismaClient;
|
|
jobs: JobPublisher;
|
|
sandbox: SandboxProvider;
|
|
connector: DestinationEmulator;
|
|
composio?: ComposioProvider;
|
|
connectors: ConnectorRegistry;
|
|
messaging?: MessagingSurface;
|
|
email?: TransactionalEmailProvider;
|
|
executor: ReturnType<typeof createRunExecutor>;
|
|
runtime: AgentRuntime;
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
export async function createApp(
|
|
overrides: Partial<AppEnv> & {
|
|
prisma?: PrismaClient;
|
|
realtime?: RealtimeFanout;
|
|
sandbox?: SandboxProvider;
|
|
composio?: ComposioProvider;
|
|
pipedream?: ManagedConnectorProvider;
|
|
messaging?: MessagingSurface;
|
|
email?: TransactionalEmailProvider;
|
|
remoteConnectors?: RemoteConnectorDependencies;
|
|
logger?: Logger;
|
|
} = {},
|
|
): Promise<AppHandles> {
|
|
const {
|
|
prisma: prismaOverride,
|
|
realtime: realtimeOverride,
|
|
sandbox: sandboxOverride,
|
|
composio: composioOverride,
|
|
pipedream: pipedreamOverride,
|
|
messaging: messagingOverride,
|
|
email: emailOverride,
|
|
remoteConnectors,
|
|
logger: loggerOverride,
|
|
...envOverrides
|
|
} = overrides;
|
|
const env = { ...loadEnv(process.env), ...envOverrides };
|
|
const logger = loggerOverride ?? createServiceLogger({ service: SERVICE_NAMES.api });
|
|
installLogger(logger);
|
|
const created = prismaOverride
|
|
? { prisma: prismaOverride, pool: undefined }
|
|
: createDb(env.databaseUrl, {
|
|
poolMax: parsePositiveInteger(process.env.DB_POOL_MAX, 4),
|
|
applicationName: "rakazo-api",
|
|
});
|
|
const { prisma } = created;
|
|
const realtime =
|
|
realtimeOverride ??
|
|
(created.pool
|
|
? new PostgresRealtimeFanout({
|
|
connectionString: env.realtimeDatabaseUrl,
|
|
publisher: created.pool,
|
|
})
|
|
: new InMemoryRealtimeFanout());
|
|
const secrets = new EncryptedSecretStore(env.encryptionKey);
|
|
const events = createThreadEvents(prisma, realtime, {
|
|
runSecretWriter: createRunSecretWriter(secrets),
|
|
});
|
|
const environmentSignupPolicy = signupPolicyFromEnv(env);
|
|
const deploymentSettings = await prisma.deploymentSettings.upsert({
|
|
where: { id: "default" },
|
|
create: {
|
|
id: "default",
|
|
signupsEnabled: environmentSignupPolicy.enabled,
|
|
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
|
|
signupPolicyInitialized: true,
|
|
},
|
|
update: {},
|
|
});
|
|
if (!deploymentSettings.signupPolicyInitialized) {
|
|
// Older versions created this row with schema defaults even though auth
|
|
// still enforced the environment policy. Copy that effective policy once
|
|
// so upgrades preserve behavior before Settings becomes authoritative.
|
|
await prisma.deploymentSettings.updateMany({
|
|
where: { id: "default", signupPolicyInitialized: false },
|
|
data: {
|
|
signupsEnabled: environmentSignupPolicy.enabled,
|
|
signupAllowlist: environmentSignupPolicy.allowlist.join(","),
|
|
signupPolicyInitialized: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
const jobKind = env.wakeupDriver;
|
|
const inMemoryJobs = jobKind === "memory" ? new InMemoryJobQueue() : undefined;
|
|
// prismaOverride skips createDb, so there is no shared pool. The previous
|
|
// GraphileJobPublisher(databaseUrl) path opened its own connections; keep a
|
|
// bounded pool for that override path instead of passing undefined.
|
|
let ownedJobPool: Pool | undefined;
|
|
if (!inMemoryJobs && !created.pool) {
|
|
ownedJobPool = createPool(env.databaseUrl, {
|
|
poolMax: parsePositiveInteger(process.env.DB_POOL_MAX, 4),
|
|
applicationName: "rakazo-api-jobs",
|
|
});
|
|
}
|
|
const jobPool = created.pool ?? ownedJobPool;
|
|
const jobs = inMemoryJobs
|
|
? inMemoryJobs
|
|
: new GraphileJobPublisher(
|
|
jobPool ??
|
|
(() => {
|
|
throw new Error("Graphile job publisher requires a PostgreSQL pool");
|
|
})(),
|
|
);
|
|
const sandbox: SandboxProvider =
|
|
sandboxOverride ??
|
|
createRunSandbox(env.sandboxProvider, {
|
|
supervisorUrl: env.sandboxSupervisorUrl,
|
|
supervisorToken: env.sandboxSupervisorToken,
|
|
e2bApiKey: env.e2bApiKey,
|
|
daytonaApiKey: env.daytonaApiKey,
|
|
daytonaApiUrl: env.daytonaApiUrl,
|
|
daytonaTarget: env.daytonaTarget,
|
|
boxApiKey: env.boxApiKey,
|
|
boxApiUrl: env.boxApiUrl,
|
|
dataDir: env.dataDir,
|
|
prisma,
|
|
});
|
|
const mcpOAuth = new McpOAuthBroker(prisma, secrets, remoteConnectors);
|
|
const memoryProviders = new SpaceMemoryProviderResolver(prisma, secrets);
|
|
const oauthLogins = new PiOAuthLogins();
|
|
const home = new LocalAgentHomeStore(env.dataDir);
|
|
const artifacts = new LocalArtifactStore(env.dataDir);
|
|
const memory = new MarkdownMemoryStore(prisma);
|
|
const mcp = new McpConnector(
|
|
prisma,
|
|
secrets,
|
|
{
|
|
stdioEnabled: env.mcpStdioEnabled,
|
|
allowedCommands: env.mcpStdioAllowedCommands,
|
|
network: remoteConnectors,
|
|
},
|
|
mcpOAuth,
|
|
);
|
|
const pipedreamConfig = pipedreamConfigFromEnv(env);
|
|
const pipedream =
|
|
pipedreamOverride ??
|
|
(isPipedreamEnabled(pipedreamConfig) ? new PipedreamConnector(pipedreamConfig) : undefined);
|
|
// This process registers the inbound sink (messaging.onInbound below),
|
|
// so it's the one that must hold Telegram's live getUpdates connection —
|
|
// see messagingPlatformsFromEnv's docstring for why a second poller
|
|
// elsewhere (e.g. the worker) would actively break this.
|
|
const messagingPlatforms = messagingPlatformsFromEnv(env, { pollInboundMessages: true });
|
|
const messaging =
|
|
messagingOverride ??
|
|
(isMessagingSurfaceEnabled(messagingPlatforms, {
|
|
deploymentModelKey: env.deploymentModelKey,
|
|
openSignup: env.messagingOpenSignup,
|
|
})
|
|
? new ChatSdkMessagingSurface(messagingPlatforms)
|
|
: undefined);
|
|
const localEmailEmulator =
|
|
!emailOverride && !env.smtpUrl && env.emailEmulator
|
|
? new EmailEmulator((message) => {
|
|
getLogger().info("email emulator captured message", {
|
|
"email.subject": message.subject,
|
|
});
|
|
})
|
|
: undefined;
|
|
if (localEmailEmulator && !isLoopbackHost(env.apiHost)) {
|
|
throw new Error("EMAIL_EMULATOR requires API_HOST to be a loopback host");
|
|
}
|
|
const email: TransactionalEmailProvider | undefined =
|
|
emailOverride ??
|
|
(env.smtpUrl
|
|
? new SmtpEmailProvider({ url: env.smtpUrl, from: env.emailFrom ?? "" })
|
|
: localEmailEmulator);
|
|
const installed = new InstalledConnectorProvider(prisma, secrets, remoteConnectors);
|
|
const integrationSettings = new IntegrationProviderSettings(prisma, secrets, env.encryptionKey, {
|
|
composio:
|
|
composioOverride ??
|
|
(isComposioEnabled(env.composioApiKey)
|
|
? new ComposioConnector(env.composioApiKey)
|
|
: undefined),
|
|
pipedream,
|
|
});
|
|
const stack = createConnectorStack(false, composioOverride, [
|
|
installed,
|
|
...integrationSettings
|
|
.providers()
|
|
.filter((provider) => !composioOverride || provider.describe().id !== "composio"),
|
|
mcp,
|
|
]);
|
|
const connector = stack.destination;
|
|
await connector.start();
|
|
integrationSettings.warmDirectories();
|
|
const runtime =
|
|
env.agentRuntime === "scripted"
|
|
? new ScriptedAgentRuntime()
|
|
: new PiAgentRuntime({
|
|
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
|
|
});
|
|
const notifications = new ExpoPushProvider(env.dataDir);
|
|
const auth = createAuth(prisma, {
|
|
secret: env.authSecret,
|
|
baseURL: env.authUrl,
|
|
webOrigin: env.webOrigin,
|
|
signupsEnabled: env.signupsEnabled,
|
|
signupAllowlist: env.signupAllowlist,
|
|
email,
|
|
onEmailError: (error) => getLogger().error("transactional email delivery failed", error),
|
|
extraOrigins: [
|
|
"rakazo://",
|
|
"exp://",
|
|
"exp://*",
|
|
"http://localhost:8081",
|
|
"http://127.0.0.1:8081",
|
|
"http://localhost:19006",
|
|
"http://127.0.0.1:19006",
|
|
"http://*:5173",
|
|
"http://*:3100",
|
|
"https://*:5173",
|
|
"https://*:3100",
|
|
"http://192.168.*:*",
|
|
"https://192.168.*:*",
|
|
"http://100.*:*",
|
|
"https://100.*:*",
|
|
"http://10.*:*",
|
|
"https://10.*:*",
|
|
"http://172.*:*",
|
|
"https://172.*:*",
|
|
"http://*.local:*",
|
|
"https://*.local:*",
|
|
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
|
|
],
|
|
beforeDeleteUser: async (userId) => {
|
|
const bots = await prisma.bot.findMany({
|
|
where: { userId },
|
|
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
|
|
});
|
|
await Promise.all(
|
|
bots.map((bot) =>
|
|
destroyBot(
|
|
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
|
|
bot,
|
|
{
|
|
operationId: `account-delete:${userId}`,
|
|
traceId: `account-delete:${userId}`,
|
|
spaceId: bot.spaceId,
|
|
userId,
|
|
botId: bot.id,
|
|
signal: new AbortController().signal,
|
|
},
|
|
{ deleteMemories: true },
|
|
),
|
|
),
|
|
);
|
|
await removePiUserSessions(env.dataDir, userId);
|
|
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
|
|
},
|
|
});
|
|
// One provider instance so emulator launches and polls share the same Map.
|
|
const cloudAgent = createCloudAgentConnection({
|
|
CLOUD_AGENT_PROVIDER: env.cloudAgentProvider,
|
|
CURSOR_API_KEY: env.cursorApiKey,
|
|
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
|
|
});
|
|
const shutdown = new AbortController();
|
|
const executor = createRunExecutor({
|
|
prisma,
|
|
runtime,
|
|
sandbox,
|
|
memory,
|
|
memoryProviders,
|
|
home,
|
|
artifacts,
|
|
connector: stack.connector,
|
|
connectors: stack.connector,
|
|
listConnectedPluginSlugs: async (userId) => {
|
|
const provider = await integrationSettings.resolve("composio");
|
|
if (!provider) return [];
|
|
return provider.listConnectedExternalIds({
|
|
userId,
|
|
spaceId: "",
|
|
operationId: "connections.sync",
|
|
traceId: "connections.sync",
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
},
|
|
secrets: [
|
|
env.deploymentModelKey ?? "",
|
|
env.composioApiKey ?? "",
|
|
env.cursorApiKey ?? "",
|
|
].filter(Boolean),
|
|
secretStore: secrets,
|
|
secretHttp: remoteConnectors,
|
|
deploymentModelKey: env.deploymentModelKey,
|
|
dataDir: env.dataDir,
|
|
notifications,
|
|
jobs,
|
|
events,
|
|
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
|
web: createWebProvider(),
|
|
cloudAgent,
|
|
shutdownSignal: shutdown.signal,
|
|
});
|
|
|
|
const jobHandlers = createBackgroundJobHandlers({
|
|
executor,
|
|
prisma,
|
|
sandbox,
|
|
home,
|
|
jobs,
|
|
events,
|
|
workerId: "api",
|
|
runtime,
|
|
secretStore: secrets,
|
|
memoryProviders,
|
|
deploymentModelKey: env.deploymentModelKey,
|
|
messaging,
|
|
cloudAgent,
|
|
});
|
|
if (inMemoryJobs) {
|
|
await inMemoryJobs.start(jobHandlers);
|
|
} else if (jobPool) {
|
|
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
|
|
concurrency: 4,
|
|
pollInterval: 500,
|
|
});
|
|
await graphileWorkerHost.start(jobHandlers);
|
|
}
|
|
const reconciler = inMemoryJobs
|
|
? createJobReconciler({
|
|
prisma,
|
|
jobs,
|
|
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
|
|
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
|
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
|
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
|
})
|
|
: undefined;
|
|
reconciler?.start();
|
|
|
|
const router = createRouter({
|
|
cloudAgent,
|
|
prisma,
|
|
events,
|
|
auth,
|
|
jobs,
|
|
sandbox,
|
|
memory,
|
|
memoryProviders,
|
|
home,
|
|
secrets,
|
|
oauthLogins,
|
|
integrationSettings,
|
|
mcpOAuth,
|
|
composio: stack.composio,
|
|
connectors: stack.connector,
|
|
remoteConnectors,
|
|
artifacts,
|
|
dataDir: env.dataDir,
|
|
messaging: {
|
|
enabled: Boolean(messaging),
|
|
providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
|
|
openSignup: env.messagingOpenSignup,
|
|
},
|
|
env: {
|
|
agentRuntime: env.agentRuntime,
|
|
defaultProvider: env.defaultProvider,
|
|
defaultModel: env.defaultModel,
|
|
teamChatJudgeProvider: env.teamChatJudgeProvider,
|
|
teamChatJudgeModel: env.teamChatJudgeModel,
|
|
onboardingScanHome: env.onboardingScanHome,
|
|
deploymentModelKey: env.deploymentModelKey,
|
|
webOrigin: env.webOrigin,
|
|
privacyPolicyUrl: env.privacyPolicyUrl,
|
|
screenProxySecret: env.screenProxySecret,
|
|
sandboxProvider: env.sandboxProvider,
|
|
gitSha: env.gitSha,
|
|
updaterUrl: env.updaterUrl,
|
|
updaterToken: env.updaterToken,
|
|
imageTag: env.imageTag,
|
|
integrationsCatalogUrl: env.integrationsCatalogUrl,
|
|
},
|
|
});
|
|
const rpc = new RPCHandler(router, {
|
|
clientInterceptors: [onError((error, { path }) => logUnexpectedRpcError(error, path))],
|
|
});
|
|
const app = new Hono();
|
|
app.use("*", requestLogging(logger));
|
|
app.use(
|
|
"*",
|
|
cors({
|
|
origin: (origin) => {
|
|
if (!origin) return env.webOrigin;
|
|
return isTrustedOrigin(origin, env) ? origin : "";
|
|
},
|
|
credentials: true,
|
|
}),
|
|
);
|
|
// Security headers
|
|
app.use("*", async (c, next) => {
|
|
await next();
|
|
c.res.headers.set(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
|
);
|
|
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
c.res.headers.set("X-Frame-Options", "DENY");
|
|
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
|
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
c.res.headers.set(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()",
|
|
);
|
|
});
|
|
app.get("/api/auth/capabilities", (c) =>
|
|
c.json({
|
|
passwordReset: Boolean(email),
|
|
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
|
}),
|
|
);
|
|
if (localEmailEmulator && env.nodeEnv === "development") {
|
|
app.get(
|
|
"/api/dev/emails",
|
|
() =>
|
|
new Response(JSON.stringify(localEmailEmulator.sent), {
|
|
headers: { "cache-control": "no-store", "content-type": "application/json" },
|
|
}),
|
|
);
|
|
}
|
|
mountApiRequestBodyLimits(app);
|
|
mountScreenTarget(app, prisma, env.screenProxySecret);
|
|
app.on(["GET", "POST"], "/api/auth/*", async (c) => {
|
|
const path = new URL(c.req.url).pathname.replace("/api/auth", "");
|
|
if (blockedAuthPaths.some((blocked) => path.startsWith(blocked))) {
|
|
return c.json({ error: "Not available in version 1" }, 404);
|
|
}
|
|
return auth.handler(c.req.raw);
|
|
});
|
|
mountLocalSettings(app, { token: env.desktopStackToken, prisma, rpc });
|
|
app.use("/rpc/*", async (c, next) => {
|
|
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
|
const requestedSpaceId = c.req.header("x-rakazo-space-id");
|
|
const actor = session?.user
|
|
? await requireMembership(prisma, session.user.id, requestedSpaceId).catch(() => null)
|
|
: null;
|
|
if (actor) {
|
|
enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
|
}
|
|
const { matched, response } = await rpc.handle(c.req.raw, {
|
|
prefix: "/rpc",
|
|
context: { actor, signal: c.req.raw.signal },
|
|
});
|
|
if (matched) return c.newResponse(response.body, response);
|
|
await next();
|
|
});
|
|
mountVoiceHttpRoutes(app, { prisma, secrets }, async (c) => {
|
|
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
|
if (!session?.user) return null;
|
|
const actor = await requireMembership(
|
|
prisma,
|
|
session.user.id,
|
|
c.req.header("x-rakazo-space-id"),
|
|
).catch(() => null);
|
|
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
|
return actor;
|
|
});
|
|
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
|
// Shared with stop so a shutdown during retry delays does not restart polling.
|
|
let messagingStopped = false;
|
|
let clearMessagingRetryDelay: (() => void) | undefined;
|
|
let messagingInitTask: Promise<void> | undefined;
|
|
let clearTeamChatRetryDelay: (() => void) | undefined;
|
|
let teamChatInitTask: Promise<void> | undefined;
|
|
// Messaging webhooks only exist when the surface is enabled.
|
|
let teamChatBridge: TeamChatBridge | undefined;
|
|
/** Constructed even before start() succeeds so stop() can cancel in-flight startup. */
|
|
let teamChatBridgeInstance: TeamChatBridge | undefined;
|
|
const pendingTeamChatInbound = new PendingTeamChatInbound();
|
|
if (messaging) {
|
|
const inboundDeps = {
|
|
prisma,
|
|
events,
|
|
jobs,
|
|
provision: (request, policyEnv) => provisionMessagingIdentity(prisma, request, policyEnv),
|
|
openSignup: env.messagingOpenSignup,
|
|
signupPolicy: {
|
|
signupsEnabled: env.signupsEnabled,
|
|
signupAllowlist: env.signupAllowlist,
|
|
},
|
|
typing: (threadId) => {
|
|
// Keep conversation addresses out of trace ids — those reach logs
|
|
// and telemetry, a different trust boundary than the database.
|
|
const operationId = `messaging.typing:${randomUUID()}`;
|
|
return messaging.sendTyping(threadId, {
|
|
operationId,
|
|
traceId: operationId,
|
|
spaceId: "",
|
|
userId: "",
|
|
// Cosmetic side call: the wait is bounded so a stalled vendor
|
|
// response never holds our callback chain (the Chat SDK adapter
|
|
// API cannot cancel the underlying request itself).
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
},
|
|
} satisfies Parameters<typeof createMessagingInboundHandler>[0];
|
|
const inbound = createMessagingInboundHandler(inboundDeps);
|
|
const handleTeamChatInbound = async (
|
|
bridge: TeamChatBridge,
|
|
event: Parameters<typeof wakeMessageRoutines>[2],
|
|
) => {
|
|
const mapped = toTeamChatInbound(event);
|
|
if (!mapped) {
|
|
await inbound(event);
|
|
return;
|
|
}
|
|
const canWake = await teamChatSenderCanWakeMessageRoutines(inboundDeps, event);
|
|
if (!canWake) {
|
|
await bridge.receive(mapped);
|
|
return;
|
|
}
|
|
|
|
// Persist a non-reconcilable row until routine routing owns or releases
|
|
// the message, so the timer cannot start a second TeamChat run.
|
|
const target = await bridge.receive(mapped, { queueAgent: false });
|
|
if (!target.deferred) return;
|
|
const leaseHeartbeat = await bridge.startDeferredReservationHeartbeat(
|
|
target.externalMessageId,
|
|
);
|
|
let woken = false;
|
|
bridge.markRoutineWakeInFlight(target.externalMessageId);
|
|
try {
|
|
const wakePromise = wakeMessageRoutines(inboundDeps, target, event, {
|
|
// Must match TeamChatBridge ExternalConversation / recovery provider.
|
|
deliveryProvider: bridge.providerId,
|
|
externalMessageId: target.externalMessageId,
|
|
});
|
|
try {
|
|
woken = await Promise.race([wakePromise, leaseHeartbeat.lost]);
|
|
} catch (error) {
|
|
if (isDeferredReservationLost(error)) {
|
|
// Lease loss must not start a fallback agent beside an in-flight
|
|
// wake: re-hold exclusive ownership while awaiting that wake, then
|
|
// resolve from its settled result.
|
|
let hold: { stop: () => void } | undefined;
|
|
try {
|
|
hold = await bridge.startDeferredReservationHeartbeat(target.externalMessageId);
|
|
} catch {
|
|
// Row already left deferred; wake CAS / resolve decide the winner.
|
|
}
|
|
try {
|
|
try {
|
|
woken = await wakePromise;
|
|
} catch (wakeError) {
|
|
const released = await bridge.resolveDeferredMessage(
|
|
target.externalMessageId,
|
|
"agent",
|
|
mapped.kind,
|
|
);
|
|
if (!released) {
|
|
throw new Error("Team chat deferred message ownership conflict", {
|
|
cause: wakeError,
|
|
});
|
|
}
|
|
await bridge.reconcileOnce();
|
|
getLogger().error(
|
|
"team chat routine wake failed after deferred lease loss",
|
|
wakeError,
|
|
);
|
|
return;
|
|
}
|
|
} finally {
|
|
hold?.stop();
|
|
}
|
|
} else {
|
|
await bridge.resolveDeferredMessage(target.externalMessageId, "agent", mapped.kind);
|
|
await bridge.reconcileOnce();
|
|
throw error;
|
|
}
|
|
}
|
|
const resolved = await bridge.resolveDeferredMessage(
|
|
target.externalMessageId,
|
|
woken ? "routine" : "agent",
|
|
mapped.kind,
|
|
);
|
|
if (!resolved) {
|
|
throw new Error("Team chat deferred message ownership conflict");
|
|
}
|
|
if (!woken) await bridge.reconcileOnce();
|
|
} finally {
|
|
bridge.clearRoutineWakeInFlight(target.externalMessageId);
|
|
leaseHeartbeat.stop();
|
|
}
|
|
};
|
|
const flushPendingTeamChatInbound = (bridge: TeamChatBridge) => {
|
|
pendingTeamChatInbound.flush((event) => handleTeamChatInbound(bridge, event));
|
|
};
|
|
if (env.teamChatBotId) {
|
|
const judge =
|
|
env.teamChatJudgeProvider && env.teamChatJudgeModel
|
|
? new ModelTeamChatEngagementJudge({
|
|
prisma,
|
|
runtime,
|
|
secrets,
|
|
deploymentProvider: env.defaultProvider,
|
|
deploymentModel: env.defaultModel,
|
|
deploymentModelKey: env.deploymentModelKey,
|
|
providerOverride: env.teamChatJudgeProvider,
|
|
modelOverride: env.teamChatJudgeModel,
|
|
})
|
|
: new ModelTeamChatEngagementJudge({
|
|
prisma,
|
|
runtime,
|
|
secrets,
|
|
deploymentProvider: env.defaultProvider,
|
|
deploymentModel: env.defaultModel,
|
|
deploymentModelKey: env.deploymentModelKey,
|
|
});
|
|
const bridge = new TeamChatBridge({
|
|
prisma,
|
|
events,
|
|
jobs,
|
|
send: createMessagingTeamChatSender(messaging),
|
|
providerId: "slack",
|
|
botId: env.teamChatBotId,
|
|
judge,
|
|
});
|
|
teamChatBridgeInstance = bridge;
|
|
try {
|
|
await bridge.start();
|
|
teamChatBridge = bridge;
|
|
} catch (error) {
|
|
getLogger().error("team chat bridge failed to start; retrying", error);
|
|
teamChatInitTask = (async () => {
|
|
let delayMs = 2_000;
|
|
while (!messagingStopped) {
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, delayMs);
|
|
clearTeamChatRetryDelay = () => {
|
|
clearTimeout(timer);
|
|
clearTeamChatRetryDelay = undefined;
|
|
resolve();
|
|
};
|
|
});
|
|
clearTeamChatRetryDelay = undefined;
|
|
if (messagingStopped) return;
|
|
try {
|
|
await bridge.start();
|
|
if (messagingStopped) {
|
|
await bridge.stop();
|
|
return;
|
|
}
|
|
teamChatBridge = bridge;
|
|
flushPendingTeamChatInbound(bridge);
|
|
return;
|
|
} catch (retryError) {
|
|
if (
|
|
retryError instanceof Error &&
|
|
retryError.message === "Team chat bridge start cancelled"
|
|
) {
|
|
return;
|
|
}
|
|
getLogger().error("team chat bridge failed to start; retrying", retryError);
|
|
delayMs = Math.min(delayMs * 5, 30_000);
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
messaging.onInbound(async (event) => {
|
|
if (event.type !== "message") {
|
|
await applyMessagingOutboundStatus(prisma, event);
|
|
return;
|
|
}
|
|
if (prefersTeamChatSurface(event, env.teamChatBotId)) {
|
|
const bridge = teamChatBridge;
|
|
if (bridge) {
|
|
await handleTeamChatInbound(bridge, event);
|
|
return;
|
|
}
|
|
// Bridge is still starting (or retrying). Do not fall through to the
|
|
// personal-line inbound path — that bypasses externalMessage ownership
|
|
// and can wake routines for unlinked TeamChat senders.
|
|
if (teamChatInitTask) {
|
|
const pending = pendingTeamChatInbound.enqueue(event);
|
|
if (!pending) {
|
|
throw new Error("Team chat inbound buffer is full");
|
|
}
|
|
await pending;
|
|
return;
|
|
}
|
|
throw new Error("Team chat bridge is unavailable");
|
|
}
|
|
await inbound(event);
|
|
});
|
|
mountMessagingWebhookRoutes(app, { messaging });
|
|
// Start polling-mode adapters (e.g. Telegram with no public webhook URL
|
|
// registered) immediately rather than waiting for the first webhook
|
|
// POST or outbound send to lazily trigger it. This is the process that
|
|
// owns the inbound sink registered just above, so it must be the one
|
|
// holding the live connection — a second poller elsewhere (e.g. the
|
|
// worker) would only fight this one for Telegram's single getUpdates
|
|
// slot without ever seeing the messages itself.
|
|
// Bounded retries cover transient Telegram startup failures; polling-only
|
|
// bots otherwise stay dark until an unrelated outbound send re-inits.
|
|
messagingInitTask = (async () => {
|
|
const delayMs = [0, 2_000, 10_000];
|
|
for (let attempt = 0; attempt < delayMs.length; attempt += 1) {
|
|
if (messagingStopped) return;
|
|
if (delayMs[attempt]! > 0) {
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, delayMs[attempt]);
|
|
clearMessagingRetryDelay = () => {
|
|
clearTimeout(timer);
|
|
clearMessagingRetryDelay = undefined;
|
|
resolve();
|
|
};
|
|
});
|
|
clearMessagingRetryDelay = undefined;
|
|
}
|
|
if (messagingStopped) return;
|
|
try {
|
|
await messaging.initialize?.();
|
|
return;
|
|
} catch (error) {
|
|
getLogger().error(
|
|
attempt === delayMs.length - 1
|
|
? "messaging surface initialize failed"
|
|
: "messaging surface initialize failed; retrying",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
// Data export endpoint — user downloads all their data as JSON
|
|
app.get("/api/data/export", async (c) => {
|
|
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
|
|
if (!session?.user) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const userId = session.user.id;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
emailVerified: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
|
|
const bots = await prisma.bot.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true, createdAt: true, archivedAt: true },
|
|
});
|
|
|
|
const botIds = bots.map((b) => b.id);
|
|
const runs = await prisma.run.findMany({
|
|
where: { botId: { in: botIds } },
|
|
select: { id: true, status: true, createdAt: true, updatedAt: true },
|
|
});
|
|
|
|
const consents = await prisma.aiDataConsent.findMany({
|
|
where: { userId },
|
|
select: { recipientKey: true, version: true, grantedAt: true },
|
|
});
|
|
|
|
const exportData = {
|
|
exportedAt: new Date().toISOString(),
|
|
user,
|
|
bots,
|
|
runs,
|
|
aiDataConsents: consents,
|
|
note: "Alle Daten werden lokal auf Ihrem Server gespeichert. Keine externen Dienste.",
|
|
};
|
|
|
|
const json = JSON.stringify(exportData, null, 2);
|
|
const filename = `evo-data-export-${new Date().toISOString().split("T")[0]}.json`;
|
|
|
|
return c.newResponse(json, 200, {
|
|
"Content-Type": "application/json",
|
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
});
|
|
});
|
|
|
|
app.get("/health", (c) =>
|
|
c.json({
|
|
ok: true,
|
|
runtime: env.agentRuntime,
|
|
sandbox: env.sandboxProvider,
|
|
composio: Boolean(stack.composio),
|
|
pipedream: Boolean(pipedream),
|
|
messaging: Boolean(messaging),
|
|
email: email?.describe().id ?? null,
|
|
jobs: jobKind,
|
|
realtime: realtime.describe().id,
|
|
revision: env.gitSha ?? null,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
app,
|
|
prisma,
|
|
jobs,
|
|
sandbox,
|
|
connector,
|
|
composio: stack.composio,
|
|
connectors: stack.connector,
|
|
messaging,
|
|
email,
|
|
executor,
|
|
runtime,
|
|
stop: async () => {
|
|
// Abort in-flight continueRun boot waits before draining jobs so stop() cannot sit
|
|
// on waitForComputerReady for the full boot-wait window during shared Postgres journeys.
|
|
shutdown.abort();
|
|
oauthLogins.abortAll();
|
|
messagingStopped = true;
|
|
clearMessagingRetryDelay?.();
|
|
clearTeamChatRetryDelay?.();
|
|
pendingTeamChatInbound.reject(new Error("Team chat bridge stopped before startup"));
|
|
// Cancel in-flight start() before awaiting the retry task so stop() cannot
|
|
// sit on DB/reconcile work that bridge.start() is still running.
|
|
await settleWithTimeout(
|
|
teamChatBridgeInstance ? teamChatBridgeInstance.stop().catch(() => undefined) : undefined,
|
|
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
|
);
|
|
await messagingInitTask?.catch(() => undefined);
|
|
await settleWithTimeout(teamChatInitTask, TEAM_CHAT_STARTUP_SHUTDOWN_MS);
|
|
await messaging?.shutdown?.();
|
|
await teamChatBridge?.stop();
|
|
await email?.drain?.();
|
|
await reconciler?.stop();
|
|
await jobs.close();
|
|
await realtime.close();
|
|
await connector.stop();
|
|
await mcp.close();
|
|
await prisma.$disconnect().catch(() => undefined);
|
|
await created.pool?.end().catch(() => undefined);
|
|
await ownedJobPool?.end().catch(() => undefined);
|
|
await logger.flush({ timeoutMs: 2_000 });
|
|
},
|
|
};
|
|
}
|
|
|
|
function isTrustedOrigin(origin: string, env: AppEnv) {
|
|
if (!origin) return true;
|
|
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
|
|
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
|
|
try {
|
|
const host = new URL(origin).hostname;
|
|
return isPrivateOrLocalHost(host);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isPrivateOrLocalHost(host: string): boolean {
|
|
if (isLoopbackHost(host)) return true;
|
|
if (host.endsWith(".local")) return true;
|
|
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
|
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
|
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
|
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
|
|
return false;
|
|
}
|
|
|
|
function isLoopbackHost(host: string): boolean {
|
|
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
}
|
|
|
|
function sessionHeaders(request: Request) {
|
|
const headers = new Headers(request.headers);
|
|
const authz = headers.get("authorization");
|
|
if (authz?.toLowerCase().startsWith("bearer ") && !headers.get("cookie")) {
|
|
headers.set("cookie", `better-auth.session_token=${authz.slice(7).trim()}`);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* An ORPCError is a decision the router made (BAD_REQUEST, UNAUTHORIZED, ...) and reaches the
|
|
* caller intact. Everything else is flattened into an opaque "Internal server error", so
|
|
* unless it is logged here the only record of what actually broke is gone.
|
|
*
|
|
* The cause chain matters as much as the message: undici and most SDKs report a bare
|
|
* "fetch failed" and keep the host and errno one level down.
|
|
*/
|
|
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
|
|
if (error instanceof ORPCError) return;
|
|
const where = `rpc ${path.join("/")} failed`;
|
|
getLogger().error(where, error);
|
|
}
|