From 99fac136704acbda4204f211d361afa8606b8c22 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 26 Aug 2026 20:41:59 -0500 Subject: [PATCH 1/3] fix(integrations): address low-severity bugs #12, #16, #17 - #12: replace btoa with secure random nonce in WhatsApp OAuth state - #16: stop swallowing network errors in resolveAccountToIntegrationId - #17: read COMPOSIO_API_KEY at call-time instead of module load --- packages/opencode/src/cli/cmd/platform-run.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 84e0d3159c58..dcd326891156 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -22,6 +22,7 @@ import { PLATFORM_URLS, getBridgeToken, writeJson } from "./iris-api" import { exec } from "child_process" +import { randomBytes } from "node:crypto" import { detectNewConnection, extractConnections, type ConnectionRow } from "./integration-connect-state" import { isLocalOAuthProvider, runLocalOAuthConnect } from "./integration-oauth-connect" import { PathwaysCommand } from "./platform-integrations-pathways" @@ -372,7 +373,16 @@ export async function resolveAccountToIntegrationId( }) if (candidates.length === 0) return null return Number(candidates[0].id) || null - } catch { + } catch (err) { + // Distinguish network/transport errors (which should surface) from a + // genuine "no match". Previously swallowed as null, masking outages + // (bug #16). + if (err instanceof TypeError || (err as any)?.name === "FetchError" || (err as any)?.code === "ECONNREFUSED") { + throw err + } + if (process.env.IRIS_DEBUG) { + console.error(dim(`[resolveAccountToIntegrationId] lookup failed for ${normalizedType}/${account}:`), err) + } return null } } @@ -949,7 +959,7 @@ const ConnectCommand = cmd({ auth_config: { id: authConfig.id }, connection: { user_id: `user-${userId}`, - callback_url: `${PLATFORM_URLS.irisApi}/api/v1/integrations-temp/oauth-callback/whatsapp?state=${encodeURIComponent(btoa(JSON.stringify({ type: "whatsapp", provider: "composio", user_id: userId, timestamp: Date.now() })))}`, + callback_url: `${PLATFORM_URLS.irisApi}/api/v1/integrations-temp/oauth-callback/whatsapp?state=${encodeURIComponent(Buffer.from(JSON.stringify({ type: "whatsapp", provider: "composio", user_id: userId, timestamp: Date.now(), nonce: randomBytes(16).toString("hex") }), "utf8").toString("base64"))}`, state: { authScheme: "OAUTH2", val: { generic_id: String(wabaId) } }, }, }), @@ -1450,19 +1460,26 @@ const ExecCommand = cmd({ // No hardcoded fallback: a stale key here silently 401s every integrations // call (see bug #164644). Require COMPOSIO_API_KEY and fail loud if missing. -const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" +// Read at call-time (not module load) so env vars set after import are honored +// (see bug #17 — COMPOSIO_API_KEY read at module load time missed late-set vars). const COMPOSIO_BASE = "https://backend.composio.dev/api" -async function composioFetch(path: string, init?: RequestInit) { - if (!COMPOSIO_KEY) { +function getComposioKey(): string { + const key = process.env.COMPOSIO_API_KEY ?? "" + if (!key) { throw new Error( "COMPOSIO_API_KEY is not set. Generate a key at https://dashboard.composio.dev → API Keys and export it (e.g. `export COMPOSIO_API_KEY=ak_…`) before running `iris integrations …`.", ) } + return key +} + +async function composioFetch(path: string, init?: RequestInit) { + const key = getComposioKey() return fetch(`${COMPOSIO_BASE}${path}`, { ...init, headers: { - "x-api-key": COMPOSIO_KEY, + "x-api-key": key, "Content-Type": "application/json", ...(init?.headers ?? {}), }, From 46f263f9ece60b74b41c45c442a12dc67fbf0477 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 26 Aug 2026 20:44:18 -0500 Subject: [PATCH 2/3] fix(desktop): resolve iris-cli sidecar via Tauri API (fix 'Failed to spawn IRIS Server') --- packages/desktop/src-tauri/src/lib.rs | 32 +++++++++------------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 6057fb5b89c3..1d2b7e3d124f 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ mod cli; mod window_customizer; -use cli::{get_sidecar_path, install_cli, sync_cli}; +use cli::{install_cli, sync_cli}; use std::{ collections::VecDeque, net::{SocketAddr, TcpListener}, @@ -93,10 +93,6 @@ fn get_sidecar_port() -> u32 { }) as u32 } -fn get_user_shell() -> String { - std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) -} - fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { let log_state = app.state::(); let log_state_clone = log_state.inner().clone(); @@ -119,22 +115,16 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { .expect("Failed to spawn opencode"); #[cfg(not(target_os = "windows"))] - let (mut rx, child) = { - let sidecar = get_sidecar_path(); - let shell = get_user_shell(); - app.shell() - .command(&shell) - .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("OPENCODE_CLIENT", "desktop") - .env("XDG_STATE_HOME", &state_dir) - .args([ - "-il", - "-c", - &format!("{} serve --port={}", sidecar.display(), port), - ]) - .spawn() - .expect("Failed to spawn opencode") - }; + let (mut rx, child) = app + .shell() + .sidecar("iris-cli") + .expect("Failed to resolve iris-cli sidecar") + .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("OPENCODE_CLIENT", "desktop") + .env("XDG_STATE_HOME", &state_dir) + .args(["serve", &format!("--port={port}")]) + .spawn() + .expect("Failed to spawn opencode"); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { From 7ad20cafb11d2d5883d4859690db853533203912 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 26 Aug 2026 20:44:54 -0500 Subject: [PATCH 3/3] fix(stats): add --json output for machine-readable usage stats (bug #5) stats emitted ASCII boxes with no --json option; scripts parsing `iris stats --json` got non-JSON. Build structured JSON via writeJson, reusing the existing aggregateSessionStats payload plus admin-derived metrics. --- packages/opencode/src/cli/cmd/stats.ts | 87 ++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index abf13dda651a..42ac7d20975b 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,5 +1,6 @@ import type { Argv } from "yargs" import { cmd } from "./cmd" +import { writeJson } from "./iris-api" import { Session } from "../../session" import { bootstrap } from "../bootstrap" import { Storage } from "../../storage/storage" @@ -71,6 +72,11 @@ export const StatsCommand = cmd({ type: "boolean", default: false }) + .option("json", { + describe: "emit machine-readable JSON instead of the formatted boxes", + type: "boolean", + default: false, + }) }, handler: async (args) => { try { @@ -84,6 +90,11 @@ export const StatsCommand = cmd({ modelLimit = args.models } + if (args.json) { + await writeJson(buildStatsJson(stats, args.admin, args.period, toolLimitFor(args.tools), modelLimit)) + return + } + if (args.admin) { displayAdminStats(stats, args.period, args.tools, modelLimit) } else { @@ -324,6 +335,82 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin return stats } +function toolLimitFor(tools?: number): number | undefined { + return tools === undefined ? undefined : tools +} + +function buildStatsJson( + stats: SessionStats, + admin: boolean, + period: string, + toolLimit: number | undefined, + modelLimit: number | undefined, +) { + const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => + admin ? b.cost - a.cost : b.messages - a.messages, + ) + const modelsToDisplay = + modelLimit === undefined ? [] : modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit) + const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a) + const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools + + const models = modelsToDisplay.map(([name, usage]) => ({ + name, + messages: usage.messages, + inputTokens: usage.tokens.input, + outputTokens: usage.tokens.output, + cost: usage.cost, + })) + + const tools = toolsToDisplay.map(([name, count]) => ({ name, count })) + + const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost + const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay + const tokensPerSession = isNaN(stats.tokensPerSession) ? 0 : stats.tokensPerSession + const medianTokensPerSession = isNaN(stats.medianTokensPerSession) ? 0 : stats.medianTokensPerSession + const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning + + const base = { + totalSessions: stats.totalSessions, + totalMessages: stats.totalMessages, + days: stats.days, + cost: { total: cost, perDay: costPerDay }, + tokens: { + input: stats.totalTokens.input, + output: stats.totalTokens.output, + reasoning: stats.totalTokens.reasoning, + cacheRead: stats.totalTokens.cache.read, + cacheWrite: stats.totalTokens.cache.write, + perSession: tokensPerSession, + medianPerSession: medianTokensPerSession, + }, + models, + tools, + dateRange: stats.dateRange, + } + + if (!admin) { + return base + } + + const cacheHitRate = totalTokens > 0 ? (stats.totalTokens.cache.read / totalTokens) * 100 : 0 + return { + ...base, + admin: { + period, + avgSessionsPerDay: Math.round(stats.totalSessions / Math.max(1, stats.days)), + avgMessagesPerSession: Math.round(stats.totalMessages / Math.max(1, stats.totalSessions)), + costPerSession: stats.totalSessions > 0 ? cost / stats.totalSessions : 0, + cacheHitRate, + efficiencyScore: calculateEfficiencyScore(stats), + utilizationRate: calculateUtilizationRate(stats), + costEfficiency: calculateCostEfficiency(stats), + healthStatus: getHealthStatus(stats), + insights: generateInsights(stats), + }, + } +} + export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56