Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/codex/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,10 +672,10 @@ export class CodexService {
);
}

public async account(): Promise<GetAccountResponse> {
public async account(refreshToken = false): Promise<GetAccountResponse> {
return await this.#rpc.request<GetAccountResponse>({
method: "account/read",
params: { refreshToken: false },
params: { refreshToken },
});
}

Expand Down
35 changes: 16 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,24 +156,21 @@ export async function runWirebot(): Promise<void> {
resources.push(runtime);
await runtime.start();

// The Mini App authenticates through Telegram initData, so it only runs
// when the Telegram connector is configured.
let miniApp: MiniAppServer | undefined;
if (config.telegram !== undefined) {
miniApp = new MiniAppServer({
host: config.host,
port: config.port,
botToken: config.telegram.botToken,
allowedUserIds: config.telegram.allowedUserIds,
configService,
runtime,
settings,
logger: logger.child({ component: "miniapp" }),
...(config.assetsDirectory === undefined ? {} : { assetDirectory: config.assetsDirectory }),
});
resources.push(miniApp);
await miniApp.start();
}
// The HTTP server always provides health; Mini App routes additionally
// require Telegram initData when that connector is configured.
const miniApp = new MiniAppServer({
host: config.host,
port: config.port,
codex,
...(config.telegram === undefined ? {} : { telegramAuth: config.telegram }),
configService,
runtime,
settings,
logger: logger.child({ component: "miniapp" }),
...(config.assetsDirectory === undefined ? {} : { assetDirectory: config.assetsDirectory }),
});
resources.push(miniApp);
await miniApp.start();

let publicUrl = config.publicUrl;
if (publicUrl === undefined && config.telegram !== undefined && config.tunnelMode === "auto") {
Expand Down Expand Up @@ -231,7 +228,7 @@ export async function runWirebot(): Promise<void> {
workspace: config.workspace,
logger: logger.child({ component: "scheduled-runs" }),
});
miniApp?.setScheduledRuns(scheduledRuns);
miniApp.setScheduledRuns(scheduledRuns);
const bridge = new CodexBridge(
codex,
publicUrl,
Expand Down
76 changes: 64 additions & 12 deletions src/miniapp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ import {
} from "../codex/config-service.js";
import { CodexRpcError } from "../codex/rpc.js";
import type { CodexRuntimeService } from "../codex/runtime-service.js";
import type { CodexService } from "../codex/service.js";
import { SkillBrowserError } from "../codex/skill-browser.js";
import type { ProviderReference } from "../core/channel.js";
import type { WirebotSettingsStore } from "../core/settings-store.js";
import type { GetAccountResponse } from "../generated/codex/v2/GetAccountResponse.js";
import { BridgeError, errorMessage } from "../shared/errors.js";
import type { Logger } from "../shared/logger.js";
import { type TelegramInitDataUser, validateTelegramInitData } from "./auth.js";
Expand Down Expand Up @@ -52,8 +54,11 @@ const applyBankedResetSchema = z.strictObject({
export interface MiniAppServerOptions {
readonly host: string;
readonly port: number;
readonly botToken: string;
readonly allowedUserIds: ReadonlySet<number>;
readonly telegramAuth?: {
readonly botToken: string;
readonly allowedUserIds: ReadonlySet<number>;
};
readonly codex: Pick<CodexService, "account">;
readonly configService: CodexConfigService;
readonly runtime: MiniAppRuntimeController;
readonly settings: WirebotSettingsStore;
Expand Down Expand Up @@ -86,6 +91,9 @@ export class MiniAppServer {
readonly #assetDirectory: string;
readonly #assetCache = new Map<string, Buffer>();
#scheduledRuns: MiniAppSchedulesController | undefined;
#codexHealth: CodexHealth = "starting";
#healthRefresh: Promise<void> | undefined;
#healthTimer: NodeJS.Timeout | undefined;
#started = false;

public constructor(options: MiniAppServerOptions) {
Expand Down Expand Up @@ -116,11 +124,13 @@ export class MiniAppServer {

public async start(): Promise<void> {
if (this.#started) return;
await Promise.all([
access(join(this.#assetDirectory, "index.html")),
access(join(this.#assetDirectory, "app.js")),
access(join(this.#assetDirectory, "app.css")),
]);
if (this.options.telegramAuth !== undefined) {
await Promise.all([
access(join(this.#assetDirectory, "index.html")),
access(join(this.#assetDirectory, "app.js")),
access(join(this.#assetDirectory, "app.css")),
]);
}
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => {
this.#server.off("listening", onListening);
Expand All @@ -135,14 +145,18 @@ export class MiniAppServer {
this.#server.listen(this.options.port, this.options.host);
});
this.#started = true;
this.options.logger.info("Mini App HTTP server listening", {
this.refreshCodexHealth();
this.#healthTimer = setInterval(() => this.refreshCodexHealth(), 30_000);
this.#healthTimer.unref();
this.options.logger.info("Wirebot HTTP server listening", {
host: this.options.host,
port: this.options.port,
});
}

public async stop(): Promise<void> {
if (!this.#started) return;
if (this.#healthTimer !== undefined) clearInterval(this.#healthTimer);
await new Promise<void>((resolve, reject) => {
this.#server.close((error) => {
if (error === undefined) resolve();
Expand All @@ -157,7 +171,7 @@ export class MiniAppServer {
const url = new URL(request.url ?? "/", "http://localhost");

if (request.method === "GET" && url.pathname === "/healthz") {
this.sendJson(response, 200, { ok: true });
this.sendJson(response, 200, { ok: true, codex: this.#codexHealth });
return;
}

Expand Down Expand Up @@ -326,7 +340,7 @@ export class MiniAppServer {
}

const asset = staticAssets.get(url.pathname);
if (asset !== undefined) {
if (asset !== undefined && this.options.telegramAuth !== undefined) {
await this.sendAsset(response, request.method, asset[0], asset[1]);
return;
}
Expand All @@ -335,17 +349,36 @@ export class MiniAppServer {
}

private authenticate(request: IncomingMessage): TelegramInitDataUser {
const telegram = this.options.telegramAuth;
if (telegram === undefined) throw new HttpError(404, "Not found");
const authorization = request.headers.authorization;
if (authorization === undefined || !authorization.toLowerCase().startsWith("tma ")) {
throw new BridgeError("Telegram authorization is required", "MINIAPP_UNAUTHORIZED");
}
return validateTelegramInitData(authorization.slice(4), {
botToken: this.options.botToken,
allowedUserIds: this.options.allowedUserIds,
botToken: telegram.botToken,
allowedUserIds: telegram.allowedUserIds,
maxAgeSeconds: MAX_AUTH_AGE_SECONDS,
});
}

private refreshCodexHealth(): void {
if (this.#healthRefresh !== undefined) return;
const request = this.options.codex.account(true);
this.#healthRefresh = (async () => {
try {
this.#codexHealth = classifyCodexHealth(await Promise.race([request, healthTimeout()]));
} catch {
this.#codexHealth = "degraded";
}
// Keep the single-flight guard if the RPC itself outlives our health
// timeout; otherwise a wedged app-server would accumulate requests.
await request.catch(() => undefined);
})().finally(() => {
this.#healthRefresh = undefined;
});
}

private methodNotAllowed(response: ServerResponse, allow: string): void {
response.setHeader("Allow", allow);
this.sendError(response, 405, "Method not allowed");
Expand Down Expand Up @@ -488,6 +521,25 @@ export class MiniAppServer {
}
}

export type CodexHealth =
| "starting"
| "authenticated"
| "not_required"
| "needs_login"
| "degraded";

export function classifyCodexHealth(status: GetAccountResponse): CodexHealth {
if (status.account !== null) return "authenticated";
return status.requiresOpenaiAuth ? "needs_login" : "not_required";
}

function healthTimeout(): Promise<never> {
return new Promise((_, reject) => {
const timer = setTimeout(() => reject(new Error("Codex health timed out")), 5_000);
timer.unref();
});
}

function telegramScheduleScope(userId: number): Readonly<{
owner: ProviderReference;
conversation: ProviderReference;
Expand Down
15 changes: 15 additions & 0 deletions test/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test";
import { classifyCodexHealth } from "../src/miniapp/server.js";

describe("Codex health", () => {
test("distinguishes login from configurations that need no account", () => {
expect(classifyCodexHealth({ account: null, requiresOpenaiAuth: true })).toBe("needs_login");
expect(classifyCodexHealth({ account: null, requiresOpenaiAuth: false })).toBe("not_required");
expect(
classifyCodexHealth({
account: { type: "apiKey" },
requiresOpenaiAuth: true,
}),
).toBe("authenticated");
});
});
Loading