From 3e8e974613209822cad2f661f112018d6bd16617 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:51:56 -0500 Subject: [PATCH 001/134] feat(go): add Go Teams plan and limits to /go (#60308) --- .../console/app/src/component/go-models.ts | 40 ++++- .../app/src/component/go-plan-chart.css | 5 +- .../app/src/component/go-plan-chart.tsx | 117 ++++++++----- packages/console/app/src/i18n/ar.ts | 11 ++ packages/console/app/src/i18n/br.ts | 11 ++ packages/console/app/src/i18n/da.ts | 11 ++ packages/console/app/src/i18n/de.ts | 11 ++ packages/console/app/src/i18n/en.ts | 11 ++ packages/console/app/src/i18n/es.ts | 11 ++ packages/console/app/src/i18n/fr.ts | 11 ++ packages/console/app/src/i18n/it.ts | 11 ++ packages/console/app/src/i18n/ja.ts | 11 ++ packages/console/app/src/i18n/ko.ts | 11 ++ packages/console/app/src/i18n/no.ts | 11 ++ packages/console/app/src/i18n/pl.ts | 11 ++ packages/console/app/src/i18n/ru.ts | 11 ++ packages/console/app/src/i18n/th.ts | 11 ++ packages/console/app/src/i18n/tr.ts | 11 ++ packages/console/app/src/i18n/uk.ts | 11 ++ packages/console/app/src/i18n/zh.ts | 11 ++ packages/console/app/src/i18n/zht.ts | 11 ++ packages/console/app/src/routes/go/index.css | 68 ++++++++ packages/console/app/src/routes/go/index.tsx | 165 +++++++++++------- packages/console/app/test/go-models.test.ts | 45 +++++ services/www/src/docs/content/console/go.mdx | 73 ++++---- 25 files changed, 567 insertions(+), 144 deletions(-) create mode 100644 packages/console/app/test/go-models.test.ts diff --git a/packages/console/app/src/component/go-models.ts b/packages/console/app/src/component/go-models.ts index 9e07c6aeff52..8c8cc6e85ccf 100644 --- a/packages/console/app/src/component/go-models.ts +++ b/packages/console/app/src/component/go-models.ts @@ -99,7 +99,7 @@ export const goModels: GoModel[] = [ }, ].sort((a, b) => a.requests - b.requests) -// Go Plus limits are published per model in the Console, not a fixed multiplier of Go. +// Go Plus and Go Teams limits are published per model in the Console (packages/shared/src/domain/go-published-limits.ts). const plusLimits: Record = { "kimi-k3": [440, 60], "kimi-k3.1-pro": [588, 60], @@ -133,14 +133,50 @@ const plusLimits: Record = { "iceman-contributor-free": [Infinity, Infinity], } +const teamsLimits: Record = { + "kimi-k3": [147, 20], + "kimi-k3.1-pro": [196, 20], + "qwen3.8-max": [213, 20], + "grok-4.7": [225, 20], + "glm-5.3": [293, 20], + "step-5-preview": [573, 20], + "gemini-4-argon": [513, 35], + "glm-5.2": [953, 65], + "deepseek-v4-pro": [1400, 20], + "kimi-k2.7-code": [1462, 65], + "hy4-preview": [1575, 35], + "gpt-5.6-luna": [2733, 20], + "minimax-m3": [3467, 65], + "mimo-v2.6-pro": [4333, 20], + "mimo-v2.5-pro": [4333, 20], + "minimax-m3.1-flash": [4850, 25], + "qwen3.7-plus": [4658, 65], + "gpt-6-luna": [5640, 20], + "claude-haiku-5-5": [5133, 20], + hy3: [4658, 65], + "qwen3.8-flash": [6300, 35], + "glm-5.3-flash": [6847, 65], + "deepseek-flash": [36837, 85], + "longcat-2.0": [12350, 65], + "mimo-v2.6-flash": [32608, 65], + "mimo-v2.5": [32608, 65], + "muse-spark-1.3-contributor": [49075, 65], + "muse-spark-1.2-contributor": [49075, 65], + "beam-free": [Infinity, Infinity], + "iceman-contributor-free": [Infinity, Infinity], +} + // A bonus multiplies a model's usual allowance, which is shown crossed out beside the current limits. export const goPlanModels = goModels.map((model) => { const [plusRequests, plusAllowance] = plusLimits[model.id]! + const [teamsRequests, teamsAllowance] = teamsLimits[model.id]! const bonus = model.bonus return { ...model, plusRequests, plusAllowance, + teamsRequests, + teamsAllowance, previous: bonus === undefined ? undefined @@ -149,6 +185,8 @@ export const goPlanModels = goModels.map((model) => { allowance: model.allowance / bonus, plusRequests: plusRequests / bonus, plusAllowance: plusAllowance / bonus, + teamsRequests: teamsRequests / bonus, + teamsAllowance: teamsAllowance / bonus, }, } }) diff --git a/packages/console/app/src/component/go-plan-chart.css b/packages/console/app/src/component/go-plan-chart.css index efd6b6d7b264..cb54a54aaabc 100644 --- a/packages/console/app/src/component/go-plan-chart.css +++ b/packages/console/app/src/component/go-plan-chart.css @@ -80,6 +80,9 @@ [data-tier="plus"] { background: linear-gradient(var(--v2-orange-500), var(--v2-orange-600)); } + [data-tier="teams"] { + background: linear-gradient(var(--v2-blue-500), var(--v2-blue-600)); + } button { display: flex; align-items: center; @@ -183,7 +186,7 @@ [data-slot="track"] { position: relative; display: grid; - grid-template-rows: 0.5rem 0.5rem; + grid-auto-rows: 0.5rem; row-gap: 0.5rem; margin: 0.5rem 0; } diff --git a/packages/console/app/src/component/go-plan-chart.tsx b/packages/console/app/src/component/go-plan-chart.tsx index a6f16f48bd57..d5522a6176e3 100644 --- a/packages/console/app/src/component/go-plan-chart.tsx +++ b/packages/console/app/src/component/go-plan-chart.tsx @@ -7,16 +7,57 @@ import { goPlanModels } from "./go-models" const ticks = [100, 1000, 10000] const max = Math.max(...goPlanModels.map((model) => model.plusRequests).filter(Number.isFinite)) -export function GoPlanChart(props: { href: string }) { +export function GoPlanChart(props: { href: string; audience?: "individuals" | "teams" }) { const i18n = useI18n() const language = useLanguage() const id = createUniqueId() const [expanded, setExpanded] = createSignal(false) + const tiers = createMemo(() => + props.audience === "teams" + ? ([{ tier: "teams", label: "Go Teams" }] as const) + : ([ + { tier: "go", label: "Go" }, + { tier: "plus", label: "Go Plus" }, + ] as const), + ) const models = createMemo(() => goPlanModels.filter((model) => expanded() || model.featured)) const position = (requests: number) => Number.isFinite(requests) ? 4 + Math.pow(Math.log10(Math.max(requests / 100, 1)) / Math.log10(max / 100), 2.2) * 78 : 100 + const lanes = (model: (typeof goPlanModels)[number]) => + props.audience === "teams" + ? [ + { + tier: "teams" as const, + width: position(model.teamsRequests), + requests: model.teamsRequests, + allowance: model.teamsAllowance, + previousRequests: model.previous?.teamsRequests, + previousAllowance: model.previous?.teamsAllowance, + }, + ] + : [ + { + tier: "go" as const, + // The Go bar is a linear fraction of the Plus bar so the multiplier reads true on the log scale. + width: Number.isFinite(model.plusRequests) + ? (position(model.plusRequests) * model.requests) / model.plusRequests + : position(model.requests), + requests: model.requests, + allowance: model.allowance, + previousRequests: model.previous?.requests, + previousAllowance: model.previous?.allowance, + }, + { + tier: "plus" as const, + width: position(model.plusRequests), + requests: model.plusRequests, + allowance: model.plusAllowance, + previousRequests: model.previous?.plusRequests, + previousAllowance: model.previous?.plusAllowance, + }, + ] const format = createMemo(() => new Intl.NumberFormat(language.tag(language.locale()))) const currency = createMemo( () => @@ -36,14 +77,14 @@ export function GoPlanChart(props: { href: string }) {

{i18n.t("go.plans.description")}

    -
  • - - Go -
  • -
  • - - Go Plus -
  • + + {(item) => ( +
  • + + {item.label} +
  • + )} +
@@ -93,45 +134,35 @@ export function GoPlanChart(props: { href: string }) { {(tick) => }
-
- {/* The Go bar is a linear fraction of the Plus bar so the multiplier reads true on the log scale. */} - - -
-
- - -
+ + {(lane) => ( +
+ + +
+ )} +
- - {(base) => {format().format(base())}} - {format().format(model.requests)} - - - {(base) => {format().format(base())}} - {format().format(model.plusRequests)} - + + {(lane) => ( + + {(base) => {format().format(base())}} + {format().format(lane.requests)} + + )} + - - {(base) => {currency().format(base())}} - {Number.isFinite(model.allowance) ? currency().format(model.allowance) : "∞"} - - - {(base) => {currency().format(base())}} - {Number.isFinite(model.plusAllowance) ? currency().format(model.plusAllowance) : "∞"} - + + {(lane) => ( + + {(base) => {currency().format(base())}} + {Number.isFinite(lane.allowance) ? currency().format(lane.allowance) : "∞"} + + )} + )} diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index dd24497490a6..4f9ef8b63db8 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -209,15 +209,26 @@ export const dict = { "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", "go.cta.text": "اشترك في Go", + "go.plans.audience": "خطط Go لـ", + "go.plans.individuals": "الأفراد", + "go.plans.teams": "الفرق", "go.plans.month": "شهريًا", + "go.plans.memberMonth": "لكل عضو شهريًا", "go.plans.plus.cta": "الاشتراك في Go Plus", "go.plans.plus.description": "تبلغ تكلفة Go Plus ‏$40/شهر مع حدود استخدام أعلى.", + "go.plans.teams.cta": "الاشتراك في Go Teams", + "go.plans.teams.description": "تبلغ تكلفة Go Teams ‏$20/عضو/شهر مع حدود استخدام لكل عضو.", "go.plans.go.feature1": "نماذج مختارة بأسعار معقولة", "go.plans.go.feature2": "مختبرة للبرمجة باستخدام الوكلاء", "go.plans.go.feature3": "حدود سخية ووصول موثوق", "go.plans.plus.feature1": "يشمل كل مزايا Go", "go.plans.plus.feature2": "حدود أعلى لجلسات برمجة أطول وأكثر تركيزًا", "go.plans.plus.feature3": "للمشاريع الأكبر والأكثر تطلبًا", + "go.plans.teams.feature1": "يحصل كل عضو على حدود Go الخاصة به", + "go.plans.teams.feature2": "حدود شهرية سخية لكل نموذج", + "go.plans.teams.feature3": "فاتورة واحدة لمساحة العمل بالكامل", + "go.plans.teams.feature4": "استخدام إضافي مع ميزانيات لكل عضو", + "go.plans.teams.feature5": "ضوابط الخصوصية والمناطق للنماذج", "go.plans.limits": "الحدود", "go.plans.description": "الطلبات المقدرة لكل 5 ساعات وحدود الاستخدام الشهرية لكل نموذج", "go.plans.legend": "الخطط", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 4f1598bcbada..3ace50cad68f 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -214,15 +214,26 @@ export const dict = { "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", "go.cta.text": "Assinar o Go", + "go.plans.audience": "Planos Go para", + "go.plans.individuals": "Indivíduos", + "go.plans.teams": "Equipes", "go.plans.month": "por mês", + "go.plans.memberMonth": "por membro por mês", "go.plans.plus.cta": "Assinar Go Plus", "go.plans.plus.description": "O Go Plus custa $40/mês e oferece limites maiores.", + "go.plans.teams.cta": "Assinar Go Teams", + "go.plans.teams.description": "O Go Teams custa $20/membro/mês com limites para cada membro.", "go.plans.go.feature1": "Modelos selecionados e acessíveis", "go.plans.go.feature2": "Testados para programação com agentes", "go.plans.go.feature3": "Limites generosos e acesso confiável", "go.plans.plus.feature1": "Tudo o que está incluído no Go", "go.plans.plus.feature2": "Limites maiores para sessões de programação mais longas e focadas", "go.plans.plus.feature3": "Para projetos maiores e exigentes", + "go.plans.teams.feature1": "Cada membro recebe seus próprios limites do Go", + "go.plans.teams.feature2": "Limites mensais generosos para cada modelo", + "go.plans.teams.feature3": "Uma única fatura para todo o workspace", + "go.plans.teams.feature4": "Uso extra com orçamentos por membro", + "go.plans.teams.feature5": "Controles de privacidade e região de modelos", "go.plans.limits": "Limites", "go.plans.description": "Solicitações estimadas a cada 5 horas e limites mensais por modelo", "go.plans.legend": "Planos", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 261b4ffc1940..2a78e8c66372 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -213,15 +213,26 @@ export const dict = { "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", "go.cta.text": "Abonner på Go", + "go.plans.audience": "Go-abonnementer til", + "go.plans.individuals": "Enkeltpersoner", + "go.plans.teams": "Teams", "go.plans.month": "om måneden", + "go.plans.memberMonth": "pr. medlem om måneden", "go.plans.plus.cta": "Abonnér på Go Plus", "go.plans.plus.description": "Go Plus koster $40/måned og giver højere brugsgrænser.", + "go.plans.teams.cta": "Abonnér på Go Teams", + "go.plans.teams.description": "Go Teams koster $20/medlem/måned med brugsgrænser for hvert medlem.", "go.plans.go.feature1": "Udvalgte, prisvenlige modeller", "go.plans.go.feature2": "Testet til agentbaseret kodning", "go.plans.go.feature3": "Generøse grænser og pålidelig adgang", "go.plans.plus.feature1": "Alt i Go er inkluderet", "go.plans.plus.feature2": "Højere grænser til længere, fokuserede kodningsforløb", "go.plans.plus.feature3": "Til større, krævende projekter", + "go.plans.teams.feature1": "Hvert medlem får sine egne Go-grænser", + "go.plans.teams.feature2": "Generøse månedlige grænser for hver model", + "go.plans.teams.feature3": "Én regning for hele arbejdsområdet", + "go.plans.teams.feature4": "Ekstra forbrug med budgetter pr. medlem", + "go.plans.teams.feature5": "Styring af privatliv og regioner for modeller", "go.plans.limits": "Grænser", "go.plans.description": "Anslåede anmodninger pr. 5 timer og månedlige grænser pr. model", "go.plans.legend": "Abonnementer", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 4d2c1ac8c945..2115d8d8810c 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -215,15 +215,26 @@ export const dict = { "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", "go.cta.text": "Go abonnieren", + "go.plans.audience": "Go-Tarife für", + "go.plans.individuals": "Einzelpersonen", + "go.plans.teams": "Teams", "go.plans.month": "pro Monat", + "go.plans.memberMonth": "pro Mitglied und Monat", "go.plans.plus.cta": "Go Plus abonnieren", "go.plans.plus.description": "Go Plus kostet $40/Monat und bietet höhere Nutzungslimits.", + "go.plans.teams.cta": "Go Teams abonnieren", + "go.plans.teams.description": "Go Teams kostet $20/Mitglied/Monat mit Nutzungslimits für jedes Mitglied.", "go.plans.go.feature1": "Ausgewählte, erschwingliche Modelle", "go.plans.go.feature2": "Für agentisches Programmieren getestet", "go.plans.go.feature3": "Großzügige Limits und zuverlässiger Zugriff", "go.plans.plus.feature1": "Alle Vorteile von Go", "go.plans.plus.feature2": "Höhere Limits für längere, konzentrierte Coding-Sessions", "go.plans.plus.feature3": "Für größere, anspruchsvolle Projekte", + "go.plans.teams.feature1": "Jedes Mitglied erhält eigene Go-Limits", + "go.plans.teams.feature2": "Großzügige monatliche Limits für jedes Modell", + "go.plans.teams.feature3": "Eine Rechnung für den gesamten Workspace", + "go.plans.teams.feature4": "Zusätzliche Nutzung mit Budgets pro Mitglied", + "go.plans.teams.feature5": "Datenschutz- und Regionskontrollen für Modelle", "go.plans.limits": "Limits", "go.plans.description": "Geschätzte Anfragen pro 5 Stunden und monatliche Nutzungslimits je Modell", "go.plans.legend": "Tarife", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index d38b824810b4..02ea4b99eed5 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -211,15 +211,26 @@ export const dict = { "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable coding models, so you can build with powerful agents without worrying about cost or availability.", "go.cta.text": "Subscribe to Go", + "go.plans.audience": "Go plans for", + "go.plans.individuals": "Individuals", + "go.plans.teams": "Teams", "go.plans.month": "per month", + "go.plans.memberMonth": "per member per month", "go.plans.plus.cta": "Subscribe to Go Plus", "go.plans.plus.description": "Go Plus costs $40/month with higher limits.", + "go.plans.teams.cta": "Subscribe to Go Teams", + "go.plans.teams.description": "Go Teams costs $20/member/month with limits for every member.", "go.plans.go.feature1": "Curated, affordable models", "go.plans.go.feature2": "Tested for agentic coding", "go.plans.go.feature3": "Generous limits and reliable access", "go.plans.plus.feature1": "Everything included in Go", "go.plans.plus.feature2": "Higher limits and fewer interruptions", "go.plans.plus.feature3": "Built for larger, demanding projects", + "go.plans.teams.feature1": "Every member gets their own Go limits", + "go.plans.teams.feature2": "Generous monthly limits for every model", + "go.plans.teams.feature3": "One bill for the whole workspace", + "go.plans.teams.feature4": "Extra usage with per-member budgets", + "go.plans.teams.feature5": "Model privacy and region controls", "go.plans.limits": "Limits", "go.plans.description": "Estimated requests per 5 hours and monthly usage limits by model", "go.plans.legend": "Plans", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 10c77a469e66..1db66af5c068 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -215,15 +215,26 @@ export const dict = { "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", "go.cta.text": "Suscribirse a Go", + "go.plans.audience": "Planes de Go para", + "go.plans.individuals": "Individual", + "go.plans.teams": "Equipos", "go.plans.month": "al mes", + "go.plans.memberMonth": "por miembro al mes", "go.plans.plus.cta": "Suscribirse a Go Plus", "go.plans.plus.description": "Go Plus cuesta 40 $/mes y ofrece límites más altos.", + "go.plans.teams.cta": "Suscribirse a Go Teams", + "go.plans.teams.description": "Go Teams cuesta 20 $/miembro/mes con límites para cada miembro.", "go.plans.go.feature1": "Modelos seleccionados y asequibles", "go.plans.go.feature2": "Probados para programación con agentes", "go.plans.go.feature3": "Límites generosos y acceso fiable", "go.plans.plus.feature1": "Todo lo incluido en Go", "go.plans.plus.feature2": "Límites más altos para programar durante más tiempo con concentración", "go.plans.plus.feature3": "Para proyectos más grandes y exigentes", + "go.plans.teams.feature1": "Cada miembro tiene sus propios límites de Go", + "go.plans.teams.feature2": "Límites mensuales generosos para cada modelo", + "go.plans.teams.feature3": "Una sola factura para todo el espacio de trabajo", + "go.plans.teams.feature4": "Uso adicional con presupuestos por miembro", + "go.plans.teams.feature5": "Controles de privacidad y región de modelos", "go.plans.limits": "Límites", "go.plans.description": "Solicitudes estimadas cada 5 horas y límites mensuales por modelo", "go.plans.legend": "Planes", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 10373d3f3133..c58bf73b1651 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -218,15 +218,26 @@ export const dict = { "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", "go.cta.text": "S'abonner à Go", + "go.plans.audience": "Offres Go pour", + "go.plans.individuals": "Individuels", + "go.plans.teams": "Équipes", "go.plans.month": "par mois", + "go.plans.memberMonth": "par membre et par mois", "go.plans.plus.cta": "S'abonner à Go Plus", "go.plans.plus.description": "Go Plus coûte 40 $/mois et offre des limites plus élevées.", + "go.plans.teams.cta": "S'abonner à Go Teams", + "go.plans.teams.description": "Go Teams coûte 20 $/membre/mois avec des limites pour chaque membre.", "go.plans.go.feature1": "Modèles sélectionnés et abordables", "go.plans.go.feature2": "Testés pour le codage agentique", "go.plans.go.feature3": "Limites généreuses et accès fiable", "go.plans.plus.feature1": "Tous les avantages de Go", "go.plans.plus.feature2": "Des limites plus élevées pour coder plus longtemps sans perdre le fil", "go.plans.plus.feature3": "Pour les projets plus vastes et exigeants", + "go.plans.teams.feature1": "Chaque membre dispose de ses propres limites Go", + "go.plans.teams.feature2": "Limites mensuelles généreuses pour chaque modèle", + "go.plans.teams.feature3": "Une seule facture pour tout l'espace de travail", + "go.plans.teams.feature4": "Utilisation supplémentaire avec budgets par membre", + "go.plans.teams.feature5": "Contrôles de confidentialité et de région des modèles", "go.plans.limits": "Limites", "go.plans.description": "Requêtes estimées par 5 heures et limites mensuelles par modèle", "go.plans.legend": "Offres", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index d63579eaa65c..2bb274da85f7 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -213,15 +213,26 @@ export const dict = { "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", "go.cta.text": "Abbonati a Go", + "go.plans.audience": "Piani Go per", + "go.plans.individuals": "Singoli", + "go.plans.teams": "Team", "go.plans.month": "al mese", + "go.plans.memberMonth": "per membro al mese", "go.plans.plus.cta": "Abbonati a Go Plus", "go.plans.plus.description": "Go Plus costa $40/mese e offre limiti più alti.", + "go.plans.teams.cta": "Abbonati a Go Teams", + "go.plans.teams.description": "Go Teams costa $20/membro/mese con limiti per ogni membro.", "go.plans.go.feature1": "Modelli selezionati e convenienti", "go.plans.go.feature2": "Testati per la programmazione agentica", "go.plans.go.feature3": "Limiti generosi e accesso affidabile", "go.plans.plus.feature1": "Tutto ciò che include Go", "go.plans.plus.feature2": "Limiti più alti per sessioni di coding più lunghe e concentrate", "go.plans.plus.feature3": "Per progetti più grandi e impegnativi", + "go.plans.teams.feature1": "Ogni membro ha i propri limiti Go", + "go.plans.teams.feature2": "Limiti mensili generosi per ogni modello", + "go.plans.teams.feature3": "Un'unica fattura per l'intero workspace", + "go.plans.teams.feature4": "Utilizzo extra con budget per singolo membro", + "go.plans.teams.feature5": "Controlli di privacy e regione per i modelli", "go.plans.limits": "Limiti", "go.plans.description": "Richieste stimate ogni 5 ore e limiti mensili per modello", "go.plans.legend": "Piani", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 2102661d1750..405781eb6af6 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -212,15 +212,26 @@ export const dict = { "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", "go.cta.text": "Goを購読する", + "go.plans.audience": "Go プランの対象", + "go.plans.individuals": "個人", + "go.plans.teams": "チーム", "go.plans.month": "/月", + "go.plans.memberMonth": "/メンバー/月", "go.plans.plus.cta": "Go Plus に登録", "go.plans.plus.description": "Go Plus は月額$40で、より高い利用枠を提供します。", + "go.plans.teams.cta": "Go Teams に登録", + "go.plans.teams.description": "Go Teams はメンバーあたり月額$20で、各メンバーに利用枠が付与されます。", "go.plans.go.feature1": "厳選された手頃なモデル", "go.plans.go.feature2": "エージェント型コーディングで検証済み", "go.plans.go.feature3": "十分な利用枠と安定したアクセス", "go.plans.plus.feature1": "Go のすべての特典を含む", "go.plans.plus.feature2": "より高い利用枠で、長時間のコーディングに集中", "go.plans.plus.feature3": "大規模で高度なプロジェクト向け", + "go.plans.teams.feature1": "各メンバーに専用の Go 利用枠を付与", + "go.plans.teams.feature2": "すべてのモデルでゆとりある月間利用枠", + "go.plans.teams.feature3": "ワークスペース全体で請求を一本化", + "go.plans.teams.feature4": "メンバーごとの予算上限付きで追加利用に対応", + "go.plans.teams.feature5": "モデルのプライバシーとリージョンの管理", "go.plans.limits": "利用枠", "go.plans.description": "モデル別の5時間あたりの推定リクエスト数と月間利用枠", "go.plans.legend": "プラン", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 59a985fac7f4..fdfda6349b98 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -208,15 +208,26 @@ export const dict = { "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", "go.cta.text": "Go 구독하기", + "go.plans.audience": "Go 요금제 대상", + "go.plans.individuals": "개인", + "go.plans.teams": "팀", "go.plans.month": "/월", + "go.plans.memberMonth": "/멤버/월", "go.plans.plus.cta": "Go Plus 구독하기", "go.plans.plus.description": "Go Plus는 월 $40로 더 높은 사용 한도를 제공합니다.", + "go.plans.teams.cta": "Go Teams 구독하기", + "go.plans.teams.description": "Go Teams는 멤버당 월 $20로 모든 멤버에게 개별 사용 한도를 제공합니다.", "go.plans.go.feature1": "엄선된 합리적인 가격의 모델", "go.plans.go.feature2": "에이전트 코딩에 최적화된 테스트", "go.plans.go.feature3": "넉넉한 한도와 안정적인 접근", "go.plans.plus.feature1": "Go의 모든 혜택 포함", "go.plans.plus.feature2": "더 높은 한도로 장시간 코딩에 집중", "go.plans.plus.feature3": "더 크고 복잡한 프로젝트에 적합", + "go.plans.teams.feature1": "모든 멤버에게 개별 Go 한도 제공", + "go.plans.teams.feature2": "모든 모델에 넉넉한 월간 한도 제공", + "go.plans.teams.feature3": "전체 워크스페이스 통합 청구", + "go.plans.teams.feature4": "멤버별 예산 설정이 가능한 추가 사용량", + "go.plans.teams.feature5": "모델 개인정보 보호 및 리전 제어", "go.plans.limits": "사용 한도", "go.plans.description": "모델별 5시간당 예상 요청 횟수 및 월간 사용 한도", "go.plans.legend": "요금제", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 2ebc2f3510a3..deb9e2d49ea1 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -213,15 +213,26 @@ export const dict = { "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", "go.cta.text": "Abonner på Go", + "go.plans.audience": "Go-abonnementer for", + "go.plans.individuals": "Enkeltpersoner", + "go.plans.teams": "Team", "go.plans.month": "per måned", + "go.plans.memberMonth": "per medlem per måned", "go.plans.plus.cta": "Abonner på Go Plus", "go.plans.plus.description": "Go Plus koster $40/måned og gir høyere bruksgrenser.", + "go.plans.teams.cta": "Abonner på Go Teams", + "go.plans.teams.description": "Go Teams koster $20/medlem/måned med bruksgrenser for hvert medlem.", "go.plans.go.feature1": "Utvalgte, rimelige modeller", "go.plans.go.feature2": "Testet for agentbasert koding", "go.plans.go.feature3": "Romslige grenser og pålitelig tilgang", "go.plans.plus.feature1": "Alt i Go er inkludert", "go.plans.plus.feature2": "Høyere grenser for lengre, fokuserte kodeøkter", "go.plans.plus.feature3": "For større, krevende prosjekter", + "go.plans.teams.feature1": "Hvert medlem får sine egne Go-grenser", + "go.plans.teams.feature2": "Romslige månedlige grenser for hver modell", + "go.plans.teams.feature3": "Én regning for hele arbeidsområdet", + "go.plans.teams.feature4": "Ekstra bruk med budsjetter per medlem", + "go.plans.teams.feature5": "Personvern- og regionkontroller for modeller", "go.plans.limits": "Grenser", "go.plans.description": "Anslåtte forespørsler per 5 timer og månedlige grenser per modell", "go.plans.legend": "Abonnementer", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 3e6e00270e6a..f541990615af 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -213,15 +213,26 @@ export const dict = { "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", "go.cta.text": "Zasubskrybuj Go", + "go.plans.audience": "Plany Go dla", + "go.plans.individuals": "Dla osób indywidualnych", + "go.plans.teams": "Dla zespołów", "go.plans.month": "miesięcznie", + "go.plans.memberMonth": "za członka miesięcznie", "go.plans.plus.cta": "Subskrybuj Go Plus", "go.plans.plus.description": "Go Plus kosztuje $40/miesiąc i oferuje wyższe limity.", + "go.plans.teams.cta": "Subskrybuj Go Teams", + "go.plans.teams.description": "Go Teams kosztuje $20/członka/miesiąc i daje limity każdemu członkowi.", "go.plans.go.feature1": "Wyselekcjonowane, przystępne modele", "go.plans.go.feature2": "Przetestowane do kodowania agentowego", "go.plans.go.feature3": "Hojne limity i niezawodny dostęp", "go.plans.plus.feature1": "Wszystko, co obejmuje Go", "go.plans.plus.feature2": "Wyższe limity na dłuższe sesje kodowania w skupieniu", "go.plans.plus.feature3": "Do większych, wymagających projektów", + "go.plans.teams.feature1": "Każdy członek otrzymuje własne limity Go", + "go.plans.teams.feature2": "Hojne miesięczne limity dla każdego modelu", + "go.plans.teams.feature3": "Jeden rachunek dla całego obszaru roboczego", + "go.plans.teams.feature4": "Dodatkowe użycie z budżetami na członka", + "go.plans.teams.feature5": "Kontrola prywatności i regionów modeli", "go.plans.limits": "Limity", "go.plans.description": "Szacowana liczba żądań na 5 godzin i miesięczne limity dla modeli", "go.plans.legend": "Plany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 0647f883582b..eb23bb40528d 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -216,15 +216,26 @@ export const dict = { "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", "go.cta.text": "Подписаться на Go", + "go.plans.audience": "Тарифы Go для", + "go.plans.individuals": "Для одного", + "go.plans.teams": "Для команд", "go.plans.month": "в месяц", + "go.plans.memberMonth": "за участника в месяц", "go.plans.plus.cta": "Подписаться на Go Plus", "go.plans.plus.description": "Go Plus стоит $40/месяц и предлагает более высокие лимиты.", + "go.plans.teams.cta": "Подписаться на Go Teams", + "go.plans.teams.description": "Go Teams стоит $20/участника/месяц с лимитами для каждого участника.", "go.plans.go.feature1": "Отобранные доступные модели", "go.plans.go.feature2": "Проверены для агентного программирования", "go.plans.go.feature3": "Щедрые лимиты и надёжный доступ", "go.plans.plus.feature1": "Все возможности Go", "go.plans.plus.feature2": "Повышенные лимиты для длительной сосредоточенной работы над кодом", "go.plans.plus.feature3": "Для крупных и сложных проектов", + "go.plans.teams.feature1": "У каждого участника свои лимиты Go", + "go.plans.teams.feature2": "Щедрые месячные лимиты для каждой модели", + "go.plans.teams.feature3": "Единый счёт на всё рабочее пространство", + "go.plans.teams.feature4": "Дополнительное использование с бюджетами на участника", + "go.plans.teams.feature5": "Управление приватностью и регионами моделей", "go.plans.limits": "Лимиты", "go.plans.description": "Примерное число запросов за 5 часов и месячные лимиты по моделям", "go.plans.legend": "Тарифы", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 5352cf4a67cf..0f462876fb7f 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -211,15 +211,26 @@ export const dict = { "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", "go.cta.text": "สมัครสมาชิก Go", + "go.plans.audience": "แพ็กเกจ Go สำหรับ", + "go.plans.individuals": "บุคคลทั่วไป", + "go.plans.teams": "ทีม", "go.plans.month": "ต่อเดือน", + "go.plans.memberMonth": "ต่อสมาชิกต่อเดือน", "go.plans.plus.cta": "สมัคร Go Plus", "go.plans.plus.description": "Go Plus ราคา $40/เดือน พร้อมขีดจำกัดการใช้งานที่สูงขึ้น", + "go.plans.teams.cta": "สมัคร Go Teams", + "go.plans.teams.description": "Go Teams ราคา $20/สมาชิก/เดือน พร้อมขีดจำกัดสำหรับสมาชิกทุกคน", "go.plans.go.feature1": "โมเดลคัดสรรในราคาคุ้มค่า", "go.plans.go.feature2": "ทดสอบสำหรับการเขียนโค้ดด้วยเอเจนต์", "go.plans.go.feature3": "ขีดจำกัดสูงและเข้าถึงได้อย่างเสถียร", "go.plans.plus.feature1": "รวมทุกสิ่งที่มีใน Go", "go.plans.plus.feature2": "ขีดจำกัดสูงขึ้น เพื่อเขียนโค้ดอย่างต่อเนื่องและมีสมาธิได้นานขึ้น", "go.plans.plus.feature3": "เหมาะสำหรับโปรเจกต์ขนาดใหญ่ที่ซับซ้อน", + "go.plans.teams.feature1": "สมาชิกแต่ละคนได้รับขีดจำกัด Go ของตัวเอง", + "go.plans.teams.feature2": "ขีดจำกัดรายเดือนที่คุ้มค่าสำหรับทุกโมเดล", + "go.plans.teams.feature3": "รวมบิลเดียวสำหรับทั้งเวิร์กสเปซ", + "go.plans.teams.feature4": "ใช้งานเพิ่มเติมพร้อมตั้งงบประมาณรายคน", + "go.plans.teams.feature5": "ควบคุมความเป็นส่วนตัวและภูมิภาคของโมเดล", "go.plans.limits": "ขีดจำกัด", "go.plans.description": "จำนวนคำขอโดยประมาณต่อ 5 ชั่วโมงและขีดจำกัดรายเดือนแยกตามโมเดล", "go.plans.legend": "แพ็กเกจ", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 1ed23b268719..b1dbacf52657 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -214,15 +214,26 @@ export const dict = { "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", "go.cta.text": "Go'ya abone ol", + "go.plans.audience": "Go planları", + "go.plans.individuals": "Bireysel", + "go.plans.teams": "Ekipler", "go.plans.month": "aylık", + "go.plans.memberMonth": "üye başına aylık", "go.plans.plus.cta": "Go Plus'a abone ol", "go.plans.plus.description": "Go Plus ayda 40$'dır ve daha yüksek limitler sunar.", + "go.plans.teams.cta": "Go Teams'e abone ol", + "go.plans.teams.description": "Go Teams üye başına ayda 20$'dır ve her üyeye kendi limitlerini sunar.", "go.plans.go.feature1": "Seçilmiş, uygun fiyatlı modeller", "go.plans.go.feature2": "Ajan destekli kodlama için test edildi", "go.plans.go.feature3": "Cömert limitler ve güvenilir erişim", "go.plans.plus.feature1": "Go'daki her şey dahil", "go.plans.plus.feature2": "Daha uzun, odaklı kodlama oturumları için daha yüksek limitler", "go.plans.plus.feature3": "Daha büyük ve zorlu projeler için", + "go.plans.teams.feature1": "Her üyeye kendine ait Go limitleri", + "go.plans.teams.feature2": "Her model için cömert aylık limitler", + "go.plans.teams.feature3": "Tüm çalışma alanı için tek fatura", + "go.plans.teams.feature4": "Üye başına bütçelerle ekstra kullanım", + "go.plans.teams.feature5": "Model gizliliği ve bölge kontrolleri", "go.plans.limits": "Limitler", "go.plans.description": "Model başına 5 saatlik tahmini istek ve aylık kullanım limitleri", "go.plans.legend": "Planlar", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 0a78a3d02d06..cebdd01e35a0 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -212,15 +212,26 @@ export const dict = { "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", "go.cta.text": "Підписатися на Go", + "go.plans.audience": "Плани Go для", + "go.plans.individuals": "Для одного", + "go.plans.teams": "Для команд", "go.plans.month": "на місяць", + "go.plans.memberMonth": "за учасника на місяць", "go.plans.plus.cta": "Підписатися на Go Plus", "go.plans.plus.description": "Go Plus коштує $40/місяць і пропонує вищі ліміти.", + "go.plans.teams.cta": "Підписатися на Go Teams", + "go.plans.teams.description": "Go Teams коштує $20/учасника/місяць із лімітами для кожного учасника.", "go.plans.go.feature1": "Відібрані доступні моделі", "go.plans.go.feature2": "Перевірені для агентного програмування", "go.plans.go.feature3": "Щедрі ліміти й надійний доступ", "go.plans.plus.feature1": "Усе, що входить до Go", "go.plans.plus.feature2": "Вищі ліміти для тривалішої зосередженої роботи над кодом", "go.plans.plus.feature3": "Для більших і складніших проєктів", + "go.plans.teams.feature1": "Кожен учасник отримує власні ліміти Go", + "go.plans.teams.feature2": "Щедрі місячні ліміти для кожної моделі", + "go.plans.teams.feature3": "Єдиний рахунок для всього робочого простору", + "go.plans.teams.feature4": "Додаткове використання з бюджетами на учасника", + "go.plans.teams.feature5": "Керування конфіденційністю та регіонами моделей", "go.plans.limits": "Ліміти", "go.plans.description": "Орієнтовні запити за 5 годин і місячні ліміти за моделями", "go.plans.legend": "Плани", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index c3bf3cd7830e..47489e21ea65 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -201,15 +201,26 @@ export const dict = { "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", "go.cta.text": "订阅 Go", + "go.plans.audience": "Go 套餐适用对象", + "go.plans.individuals": "个人", + "go.plans.teams": "团队", "go.plans.month": "每月", + "go.plans.memberMonth": "每位成员每月", "go.plans.plus.cta": "订阅 Go Plus", "go.plans.plus.description": "Go Plus 每月 $40,提供更高的使用额度。", + "go.plans.teams.cta": "订阅 Go Teams", + "go.plans.teams.description": "Go Teams 每位成员每月 $20,为每位成员提供各自的使用额度。", "go.plans.go.feature1": "精选实惠的模型", "go.plans.go.feature2": "专为智能体编程测试", "go.plans.go.feature3": "充足额度与稳定访问", "go.plans.plus.feature1": "包含 Go 的全部权益", "go.plans.plus.feature2": "更高额度,支持更长时间的专注编码", "go.plans.plus.feature3": "适合更大型、更复杂的项目", + "go.plans.teams.feature1": "每位成员独享各自的 Go 额度", + "go.plans.teams.feature2": "所有模型均享充裕的月度额度", + "go.plans.teams.feature3": "整个工作区统一账单结算", + "go.plans.teams.feature4": "支持额外用量与成员预算上限", + "go.plans.teams.feature5": "模型隐私与地区访问控制", "go.plans.limits": "使用额度", "go.plans.description": "各模型每 5 小时的预计请求次数和每月使用额度", "go.plans.legend": "套餐", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index f46c606e288e..f6faa77b1516 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -202,15 +202,26 @@ export const dict = { "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", "go.cta.text": "訂閱 Go", + "go.plans.audience": "Go 方案適用對象", + "go.plans.individuals": "個人", + "go.plans.teams": "團隊", "go.plans.month": "每月", + "go.plans.memberMonth": "每位成員每月", "go.plans.plus.cta": "訂閱 Go Plus", "go.plans.plus.description": "Go Plus 每月 $40,提供更高的使用額度。", + "go.plans.teams.cta": "訂閱 Go Teams", + "go.plans.teams.description": "Go Teams 每位成員每月 $20,為每位成員提供各自的使用額度。", "go.plans.go.feature1": "精選實惠的模型", "go.plans.go.feature2": "專為代理程式編碼測試", "go.plans.go.feature3": "充足額度與穩定存取", "go.plans.plus.feature1": "包含 Go 的所有權益", "go.plans.plus.feature2": "更高額度,支援更長時間的專注編碼", "go.plans.plus.feature3": "適合更大型、更複雜的專案", + "go.plans.teams.feature1": "每位成員獨享各自的 Go 額度", + "go.plans.teams.feature2": "所有模型均享充裕的月度額度", + "go.plans.teams.feature3": "整個工作區統一帳單結算", + "go.plans.teams.feature4": "支援額外用量與成員預算上限", + "go.plans.teams.feature5": "模型隱私與地區存取控制", "go.plans.limits": "使用額度", "go.plans.description": "各模型每 5 小時的預估請求次數及每月使用額度", "go.plans.legend": "方案", diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 171660564023..b8b4069fcec8 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -1059,6 +1059,60 @@ body { } } + [data-slot="plans-group"] { + display: flex; + flex-direction: column; + gap: 24px; + } + + [data-slot="audience"] { + display: flex; + align-self: center; + max-width: 100%; + gap: 4px; + padding: 4px; + border: 1px solid var(--color-border-weak); + background: var(--color-background); + + @media (max-width: 30rem) { + width: 100%; + } + + button { + min-width: 136px; + height: 32px; + padding: 0 16px; + border: 0; + background: transparent; + color: var(--color-text); + font: inherit; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + white-space: nowrap; + cursor: pointer; + + @media (max-width: 30rem) { + min-width: 0; + flex: 1; + } + } + + button:is(:hover, :focus-visible) { + color: var(--color-text-strong); + } + + button:focus-visible { + outline: 2px solid var(--color-text-strong); + outline-offset: 2px; + } + + button[aria-pressed="true"] { + background: color-mix(in srgb, var(--color-text-strong) 8%, var(--color-background-weak)); + color: var(--color-text-strong); + } + } + [data-slot="plans"] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1066,6 +1120,16 @@ body { font-size: 16px; line-height: 1.5; + &[data-audience="teams"] { + grid-template-columns: minmax(0, 1fr); + } + + [data-slot="plan"][data-plan="go-teams"] ul { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 24px; + } + [data-slot="plan"] { display: flex; flex-direction: column; @@ -1153,6 +1217,10 @@ body { @media (max-width: 42rem) { grid-template-columns: 1fr; + [data-slot="plan"][data-plan="go-teams"] ul { + grid-template-columns: 1fr; + } + [data-slot="price"] { font-size: 48px; } diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 1b7a05f30eef..79b8bf4a6e59 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -62,22 +62,64 @@ const models = [ { name: "Hy3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" }, ] as const +const audiences = [ + { id: "individuals", label: "go.plans.individuals" }, + { id: "teams", label: "go.plans.teams" }, +] as const + +const plans = [ + { + id: "go", + audience: "individuals", + name: "Go", + price: "$10", + period: "go.plans.month", + cta: "go.cta.text", + features: ["go.plans.go.feature1", "go.plans.go.feature2", "go.plans.go.feature3"], + marker: "square", + }, + { + id: "go-plus", + audience: "individuals", + name: "Go Plus", + price: "$40", + period: "go.plans.month", + cta: "go.plans.plus.cta", + features: ["go.plans.plus.feature1", "go.plans.plus.feature2", "go.plans.plus.feature3"], + marker: "plus", + }, + { + id: "go-teams", + audience: "teams", + name: "Go Teams", + price: "$20", + period: "go.plans.memberMonth", + cta: "go.plans.teams.cta", + features: [ + "go.plans.teams.feature1", + "go.plans.teams.feature2", + "go.plans.teams.feature3", + "go.plans.teams.feature4", + "go.plans.teams.feature5", + ], + marker: "plus", + }, +] as const + export default function Home() { const subscribeUrl = "https://opencode.ai/console/go" const i18n = useI18n() - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() + const audience = () => (searchParams.for === "teams" ? "teams" : "individuals") + const description = () => + `${i18n.t("go.meta.description")} ${i18n.t("go.plans.plus.description")} ${i18n.t("go.plans.teams.description")}` return (
{/**/} {i18n.t("go.title")} - + - +
@@ -119,60 +161,61 @@ export default function Home() {

{i18n.t("go.hero.tagline")}

-
- - {(plan) => ( -
-
-

{plan.name}

-

- {plan.price} {i18n.t("go.plans.month")} -

-
    - - {(feature) => ( -
  • - - {i18n.t(feature)} -
  • - )} -
    -
+
+
+ + {(item) => ( + + )} + +
+
+ plan.audience === audience())}> + {(plan) => ( +
+
+

{plan.name}

+

+ {plan.price} {i18n.t(plan.period)} +

+
    + + {(feature) => ( +
  • + + {i18n.t(feature)} +
  • + )} +
    +
+
+ {i18n.t(plan.cta)}
- {i18n.t(plan.cta)} -
- )} - + )} + +
- +
@@ -216,7 +259,7 @@ export default function Home() {
{i18n.t("go.how.step2.title")} -{" "} {i18n.t("go.how.step2.link")} {i18n.t("go.how.step2.afterLink")} ·{" "} - {i18n.t("go.plans.plus.description")} + {i18n.t("go.plans.plus.description")} · {i18n.t("go.plans.teams.description")}
  • @@ -249,7 +292,7 @@ export default function Home() {
  • - {i18n.t("go.faq.a3")} {i18n.t("go.plans.plus.description")} + {i18n.t("go.faq.a3")} {i18n.t("go.plans.plus.description")} {i18n.t("go.plans.teams.description")}
  • @@ -257,8 +300,8 @@ export default function Home() { {i18n.t("go.faq.a4.p1.beforePricing")}{" "} {i18n.t("go.faq.a4.p1.pricingLink")}{" "} {i18n.t("go.faq.a4.p1.afterPricing")} {i18n.t("go.plans.plus.description")}{" "} - {i18n.t("go.faq.a4.p2.beforeAccount")} {i18n.t("go.faq.a4.p2.accountLink")} - . {i18n.t("go.faq.a4.p3")} + {i18n.t("go.plans.teams.description")} {i18n.t("go.faq.a4.p2.beforeAccount")}{" "} + {i18n.t("go.faq.a4.p2.accountLink")}. {i18n.t("go.faq.a4.p3")}
  • diff --git a/packages/console/app/test/go-models.test.ts b/packages/console/app/test/go-models.test.ts new file mode 100644 index 000000000000..0a0eb0dea37e --- /dev/null +++ b/packages/console/app/test/go-models.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { goPlanModels } from "../src/component/go-models" +import { i18n } from "../src/i18n" +import { LOCALES } from "../src/lib/language" + +describe("goPlanModels", () => { + test("publishes Go, Go Plus, and Go Teams limits for every model", () => { + expect(goPlanModels.length).toBeGreaterThan(0) + for (const model of goPlanModels) { + if (!Number.isFinite(model.allowance)) { + expect(model.requests).toBe(Infinity) + expect(model.plusRequests).toBe(Infinity) + expect(model.plusAllowance).toBe(Infinity) + expect(model.teamsRequests).toBe(Infinity) + expect(model.teamsAllowance).toBe(Infinity) + continue + } + expect(model.teamsAllowance).toBe(model.allowance + 5) + expect(Number.isFinite(model.teamsRequests)).toBe(true) + expect(model.teamsRequests).toBeGreaterThan(model.requests) + expect(model.plusAllowance).toBeGreaterThan(model.teamsAllowance) + expect(model.plusRequests).toBeGreaterThan(model.teamsRequests) + } + }) + + test.each([...LOCALES])("translates Go Teams plan copy for %s", (locale) => { + const dict = i18n(locale) + for (const key of [ + "go.plans.audience", + "go.plans.individuals", + "go.plans.teams", + "go.plans.memberMonth", + "go.plans.teams.cta", + "go.plans.teams.description", + "go.plans.teams.feature1", + "go.plans.teams.feature2", + "go.plans.teams.feature3", + "go.plans.teams.feature4", + "go.plans.teams.feature5", + ] as const) { + expect(dict[key].length).toBeGreaterThan(0) + } + expect(dict["go.plans.teams.description"]).toContain("20") + }) +}) diff --git a/services/www/src/docs/content/console/go.mdx b/services/www/src/docs/content/console/go.mdx index 3247cdc7358c..df20431e9a7e 100644 --- a/services/www/src/docs/content/console/go.mdx +++ b/services/www/src/docs/content/console/go.mdx @@ -5,11 +5,11 @@ description: "Reliable access to open coding models with three plans, for one pe OpenCode Go gives you reliable access to popular open coding models, with three monthly plans: -| Plan | Price | Included usage | -| ------------ | -------------------- | ------------------------------------------------------------------- | -| **Go** | **$10/month** | Lower-cost access to the models below | -| **Go Plus** | **$40/month** | Higher usage limits across the models below | -| **Go Teams** | **$20/member/month** | Every member gets their own Go limits, plus $5/month on every model | +| Plan | Price | Included usage | +| ------------ | -------------------- | -------------------------------------------------------------------------- | +| **Go** | **$10/month** | Lower-cost access to the models below | +| **Go Plus** | **$40/month** | Higher usage limits across the models below | +| **Go Teams** | **$20/member/month** | Every member gets their own limits, $5/month higher than Go on every model | Go works like any other provider in OpenCode. You subscribe to OpenCode Go and get your API key. It's **completely optional** and you don't need it to use OpenCode. @@ -135,8 +135,7 @@ monthly limit for each plan and the token costs for each model. Token pricing is the same for Go, Go Plus, and Go Teams. Each model has the following usage limits: 5-hour — 20% of the monthly limit; -weekly — 50%; and monthly — 100%. On Go Teams, each member's 5-hour and weekly -limits are Go's, and their monthly limit is $5 higher. +weekly — 50%; and monthly — 100%. Each model's monthly limit below determines how its usage counts toward those allowances. Each response reports how much the request counted, as [`opencode.allowance`](/console/api/inference#cost). @@ -289,9 +288,7 @@ Token prices are per 1M tokens. ### Estimated requests The tables below estimate request counts based on typical Go usage patterns. Go -Plus estimates scale with each model's higher usage limit. Go Teams keeps Go's -5-hour and weekly estimates, and its monthly estimates scale with each model's -$5 higher monthly limit. +Plus and Go Teams estimates scale with each model's higher usage limit.
    @@ -370,36 +367,36 @@ $5 higher monthly limit. | Model | Requests per 5 hours | Requests per week | Requests per month | | -------------------------- | -------------------- | ----------------- | ------------------ | - | GLM-5.3-Flash | 6,320 | 15,790 | 34,210 | - | GLM-5.3 | 220 | 540 | 1,440 | - | GLM-5.2 | 880 | 2,150 | 4,660 | - | Kimi K3.1 Pro | 147 | 368 | 980 | - | Kimi K3 | 110 | 250 | 653 | - | Kimi K2.7 Code | 1,350 | 3,380 | 7,310 | - | LongCat-2.0 | 11,400 | 28,600 | 61,970 | - | Step 5 Preview | 430 | 1,070 | 2,840 | + | GLM-5.3-Flash | 6,847 | 17,110 | 34,210 | + | GLM-5.3 | 293 | 720 | 1,440 | + | GLM-5.2 | 953 | 2,330 | 4,660 | + | Kimi K3.1 Pro | 196 | 491 | 980 | + | Kimi K3 | 147 | 333 | 653 | + | Kimi K2.7 Code | 1,462 | 3,660 | 7,310 | + | LongCat-2.0 | 12,350 | 30,980 | 61,970 | + | Step 5 Preview | 573 | 1,430 | 2,840 | | Beam Free | Unlimited | Unlimited | Unlimited | | Iceman Contributor Free | Unlimited | Unlimited | Unlimited | - | MiMo-V2.6-Flash | 30,100 | 75,200 | 162,930 | - | MiMo-V2.6-Pro | 3,250 | 8,150 | 21,730 | - | MiMo-V2.5 | 30,100 | 75,200 | 162,930 | - | MiMo-V2.5-Pro | 3,250 | 8,150 | 21,730 | - | MiniMax M3.1 Flash | 3,880 | 9,690 | 24,230 | - | MiniMax M3 | 3,200 | 8,000 | 17,330 | - | Muse Spark 1.3 Contributor | 45,300 | 113,300 | 245,480 | - | Muse Spark 1.2 Contributor | 45,300 | 113,300 | 245,480 | - | Qwen3.8 Max | 160 | 400 | 1,080 | - | Qwen3.8 Flash | 5,400 | 13,500 | 31,500 | - | Qwen3.7 Plus | 4,300 | 10,800 | 23,400 | - | DeepSeek V4.1 Flash | 34,670 | 86,670 | 184,160 | - | DeepSeek V4 Pro | 1,050 | 2,600 | 6,930 | - | Hy4 preview | 1,350 | 3,380 | 7,900 | - | Hy3 | 4,300 | 10,750 | 23,290 | - | Grok 4.7 | 169 | 423 | 1,130 | - | GPT 6 Luna | 4,230 | 10,560 | 28,170 | - | GPT 5.6 Luna | 2,050 | 5,100 | 13,670 | - | Claude Haiku 5.5 | 3,850 | 9,620 | 25,640 | - | Gemini 4 Argon | 440 | 1,080 | 2,470 | + | MiMo-V2.6-Flash | 32,608 | 81,470 | 162,930 | + | MiMo-V2.6-Pro | 4,333 | 10,870 | 21,730 | + | MiMo-V2.5 | 32,608 | 81,470 | 162,930 | + | MiMo-V2.5-Pro | 4,333 | 10,870 | 21,730 | + | MiniMax M3.1 Flash | 4,850 | 12,110 | 24,230 | + | MiniMax M3 | 3,467 | 8,670 | 17,330 | + | Muse Spark 1.3 Contributor | 49,075 | 122,740 | 245,480 | + | Muse Spark 1.2 Contributor | 49,075 | 122,740 | 245,480 | + | Qwen3.8 Max | 213 | 530 | 1,080 | + | Qwen3.8 Flash | 6,300 | 15,750 | 31,500 | + | Qwen3.7 Plus | 4,658 | 11,700 | 23,400 | + | DeepSeek V4.1 Flash | 36,837 | 92,080 | 184,160 | + | DeepSeek V4 Pro | 1,400 | 3,470 | 6,930 | + | Hy4 preview | 1,575 | 3,940 | 7,900 | + | Hy3 | 4,658 | 11,650 | 23,290 | + | Grok 4.7 | 225 | 564 | 1,130 | + | GPT 6 Luna | 5,640 | 14,080 | 28,170 | + | GPT 5.6 Luna | 2,733 | 6,800 | 13,670 | + | Claude Haiku 5.5 | 5,133 | 12,830 | 25,640 | + | Gemini 4 Argon | 513 | 1,260 | 2,470 |
    From 149767cafe836251914d1cb248960ce97721c268 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 29 Oct 2026 14:05:37 -0400 Subject: [PATCH 002/134] fix(tui): match the empty dialog search cursor to the highlighted row (#60310) --- packages/tui/src/ui/dialog-select.tsx | 9 ++++- .../tui/test/cli/tui/dialog-select.test.tsx | 33 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 6b159d0c8dd9..63906b47f81a 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -653,6 +653,13 @@ export function DialogSelect(props: DialogSelectProps) { const searchInTitle = () => newDesigns() && props.searchInTitle === true && props.renderFilter !== false + // Terminals paint the glyph under a block cursor in their own color, so a near-white cursor hides the + // placeholder's first letter. Until the user types, match the highlighted row so that letter shows through. + const searchCursorColor = () => { + if (!newDesigns()) return theme.text.formfield.focused + return store.filter.length > 0 ? theme.text.base : theme.background.action.primary.focused + } + const searchInput = () => ( (props: DialogSelectProps) { }) }} focusedBackgroundColor={theme.background.formfield.focused} - cursorColor={newDesigns() ? theme.text.base : theme.text.formfield.focused} + cursorColor={searchCursorColor()} cursorStyle={config.cursor} focusedTextColor={newDesigns() ? theme.text.formfield.base : theme.text.formfield.focused} ref={(r) => { diff --git a/packages/tui/test/cli/tui/dialog-select.test.tsx b/packages/tui/test/cli/tui/dialog-select.test.tsx index a12b3500ddfb..bda8e67a7c21 100644 --- a/packages/tui/test/cli/tui/dialog-select.test.tsx +++ b/packages/tui/test/cli/tui/dialog-select.test.tsx @@ -12,6 +12,8 @@ import { type DialogSelectProps, } from "../../../src/ui/dialog-select" import { truncateFilePath } from "../../../src/ui/file-path" +import { getOpenCodeTheme } from "../../../src/theme" +import { resolveThemeDocument } from "@opencode/theme/tui" import { stringWidth } from "../../../src/util/string-width" import { emptyThemeSource, tmpdir } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -23,10 +25,11 @@ async function renderSelect( onGlobal: () => void, onRow: (option: DialogSelectOption) => void, current?: string, + settings: Parameters[0] = {}, ) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) - const config = createTuiResolvedConfig() + const config = createTuiResolvedConfig(settings) const [{ ConfigProvider }, { ThemeProvider }, { Keymap }, { DialogProvider }, { DialogSelect }, { ToastProvider }] = await Promise.all([ import("../../../src/config"), @@ -375,6 +378,30 @@ test("dialog actions run without options while row actions still require a selec } }) +test("matches the search cursor to the highlighted row until text is typed under new designs", async () => { + await using tmp = await tmpdir() + const theme = resolveThemeDocument(getOpenCodeTheme(), "dark") + const app = await renderSelect( + tmp.path, + [{ title: "First", value: "first" }], + () => {}, + () => {}, + undefined, + { experimental: { "new-designs": true } }, + ) + + try { + await app.waitFor(() => app.renderer.getCursorState().visible) + expect(app.renderer.getCursorState().color.toInts()).toEqual(theme.background.action.primary.focused.toInts()) + + await app.mockInput.typeText("f") + await app.waitFor(() => app.renderer.getCursorState().color.equals(theme.text.base)) + expect(app.renderer.getCursorState().color.toInts()).toEqual(theme.text.base.toInts()) + } finally { + app.renderer.destroy() + } +}) + test("renders one gap before an empty state", async () => { await using tmp = await tmpdir() const app = await renderSelect( @@ -638,7 +665,9 @@ test("dialog rows do not add renderer resize listeners", async () => { const before = count() // Mounting 30 rows must not add one resize listener per row (Node warns // past 10 listeners on one emitter). - select.replaceOptions(Array.from({ length: 30 }, (_, index) => ({ title: `Item ${index}`, value: `item-${index}` }))) + select.replaceOptions( + Array.from({ length: 30 }, (_, index) => ({ title: `Item ${index}`, value: `item-${index}` })), + ) await select.app.waitForVisualIdle() expect(count()).toBe(before) } finally { From b2bd02ee719c313dd0cda1c7a28a0335f6244374 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 29 Oct 2026 14:24:22 -0400 Subject: [PATCH 003/134] feat(core): integration.connect and integration.use policies (#60315) --- .../client/src/promise/generated/types.ts | 18 +- packages/core/src/config.ts | 71 ++++++- packages/core/src/config/integration.ts | 21 +- packages/core/src/config/managed.ts | 6 + packages/core/src/config/plugin/policy.ts | 71 +++++-- packages/core/src/config/plugin/provider.ts | 14 +- packages/core/src/managed-policy.ts | 41 +++- packages/core/src/plugin/module.ts | 8 +- packages/core/src/websearch.ts | 4 + packages/core/test/config/config.test.ts | 168 ++++++++++++---- .../core/test/config/normalization.test.ts | 16 ++ packages/core/test/config/policy.test.ts | 183 ++++++++++++++++++ packages/core/test/fixture/policy.ts | 27 +++ packages/core/test/mcp.test.ts | 6 +- packages/core/test/plugin/module.test.ts | 4 +- .../test/plugin/provider-opencode.test.ts | 39 ++-- .../test/plugin/supervisor-reload.test.ts | 4 +- packages/core/test/session-skill.test.ts | 2 +- packages/core/test/skill.test.ts | 14 +- packages/core/test/tool-skill.test.ts | 3 +- packages/protocol/openapi.json | 22 ++- packages/protocol/src/groups/credential.ts | 6 +- packages/schema/src/config/policy.ts | 48 ++++- packages/schema/test/config.test.ts | 36 ++++ packages/server/src/handlers/credential.ts | 110 ++++++----- packages/server/src/handlers/integration.ts | 45 ++++- .../server/test/integration-policy.test.ts | 91 +++++++++ services/www/src/docs/content/policies.mdx | 107 ++++++++-- specs/v2/provider-policy.md | 29 ++- 29 files changed, 1034 insertions(+), 180 deletions(-) create mode 100644 packages/core/src/config/managed.ts create mode 100644 packages/server/test/integration-policy.test.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 64ba3f90b124..0aa6fc166d78 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2294,7 +2294,14 @@ export type ConfigEntry = timezone?: string permissions?: PermissionRuleset policies?: Array<{ - action: "provider.use" | "tool.use" | "integration.use" + action: + | "provider.use" + | "tool.use" + | "integration.connect" + | "integration.use" + | "mcp.use" + | "skill.use" + | "plugin.use" resource: string effect: "allow" | "deny" }> @@ -2457,7 +2464,14 @@ export type ConfigEntry = subagent_depth?: number location_idle_timeout?: string | false policies?: Array<{ - action: "provider.use" | "tool.use" | "integration.use" + action: + | "provider.use" + | "tool.use" + | "integration.connect" + | "integration.use" + | "mcp.use" + | "skill.use" + | "plugin.use" resource: string effect: "allow" | "deny" }> diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 3219c2c7647c..42be62ed7b2c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,6 +18,9 @@ import { ConfigNormalize } from "./config/normalize.js" import { ConfigDiscovery } from "./config/discovery.js" import { ConfigWatch } from "./config/watch.js" import { ConfigIntegration } from "./config/integration.js" +import { ConfigManaged } from "./config/managed.js" +import { ManagedPolicy } from "./managed-policy.js" +import { Connection } from "@opencode/schema/connection" import { WellKnown } from "./wellknown.js" import { AppProcess } from "@opencode/util/process" import { ChildProcess } from "effect/process" @@ -195,7 +198,15 @@ export const layer = (options?: Options) => env: variables, }).pipe( Effect.flatMap((text) => parseInfo(text, entry.origin)), - Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), + Effect.map((info) => + info + ? new ConfigIntegration.WellKnownDocument({ + type: "document", + info, + integrationID: entry.integrationID, + }) + : undefined, + ), ), ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) }) @@ -208,7 +219,28 @@ export const layer = (options?: Options) => Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)), ), ) - return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat())) + return yield* Effect.forEach(entries, (entry) => + loadWellknownEntry(entry).pipe( + Effect.map((documents) => ({ integrationID: entry.integrationID, documents })), + ), + ) + }) + + // `integration.use` decides whether an integration may supply anything. Only managed policies can withhold the + // documents an integration delivers, so a project file can never strip the Console document carrying org policy. + // A withheld document keeps its policies, so denying an integration never lifts the restrictions it carries. + const supplies = Effect.fnUntraced(function* ( + lockdown: ReturnType, + integrationID: string, + connection?: Connection.Info, + ) { + const stored = + connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined + const resource = ManagedPolicy.integrationResource( + integrationID, + stored ? ManagedPolicy.account(stored.value) : undefined, + ) + return ManagedPolicy.decision(lockdown, "integration.use", resource) !== "deny" }) const loadPreferences = Effect.fn("Config.loadPreferences")(function* (plist: AbsolutePath) { @@ -278,7 +310,7 @@ export const layer = (options?: Options) => .flatMap((entry) => entry.type === "document" && entry.info.policies ? [ - new Document({ + new ConfigManaged.Document({ type: "document", path: entry.path, info: new Info({ policies: entry.info.policies }), @@ -288,8 +320,8 @@ export const layer = (options?: Options) => ) .toReversed() const managedSettings = managed.map((entry) => - entry.type === "document" && entry.info.policies - ? new Document({ ...entry, info: new Info({ ...entry.info, policies: undefined }) }) + entry.type === "document" + ? new ConfigManaged.Document({ ...entry, info: new Info({ ...entry.info, policies: undefined }) }) : entry, ) // Integration documents load right after managed policies: every remote, user, and project layer overrides @@ -303,10 +335,35 @@ export const layer = (options?: Options) => Effect.orDie, Effect.map((entries) => entries.flat()), ) + const lockdown = ManagedPolicy.statements(managedPolicies) + const integrated = yield* Effect.forEach(integrations.values(), (document) => + supplies(lockdown, document.integrationID, document.connection).pipe( + Effect.map((allowed) => + allowed + ? document + : new ConfigIntegration.Document({ ...document, info: new Info({ policies: document.info.policies }) }), + ), + ), + ) + const wellknownDocuments = yield* Effect.forEach(yield* loadWellknown().pipe(Effect.orDie), (entry) => + supplies(lockdown, entry.integrationID).pipe( + Effect.map((allowed) => + allowed + ? entry.documents + : entry.documents.map( + (document) => + new ConfigIntegration.WellKnownDocument({ + ...document, + info: new Info({ policies: document.info.policies }), + }), + ), + ), + ), + ) return [ ...managedPolicies, - ...integrations.values(), - ...(yield* loadWellknown().pipe(Effect.orDie)), + ...integrated, + ...wellknownDocuments.flat(), ...globalSupplementary, ...explicit, ...direct, diff --git a/packages/core/src/config/integration.ts b/packages/core/src/config/integration.ts index 027ce7753460..d651c3b57a29 100644 --- a/packages/core/src/config/integration.ts +++ b/packages/core/src/config/integration.ts @@ -1,7 +1,7 @@ export * as ConfigIntegration from "./integration.js" import { Option, Schema, SchemaGetter } from "effect" -import { Config } from "@opencode/schema/config" +import { Config, type Entry } from "@opencode/schema/config" import { ConfigMCP } from "@opencode/schema/config/mcp" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ConfigProvider } from "@opencode/schema/config/provider" @@ -15,7 +15,7 @@ export const Policies = Schema.Array(Schema.Unknown).pipe( decode: SchemaGetter.transform((policies) => policies.filter((policy) => { const action = Schema.decodeUnknownOption(Schema.Struct({ action: Schema.String }))(policy) - if (Option.isSome(action) && !Schema.is(ConfigPolicy.Info.fields.action)(action.value.action)) return false + if (Option.isSome(action) && !Schema.is(ConfigPolicy.Action)(action.value.action)) return false const effect = Schema.decodeUnknownOption(Schema.Struct({ effect: Schema.String }))(policy) return Option.isNone(effect) || Schema.is(ConfigPolicy.Effect)(effect.value.effect) }), @@ -49,6 +49,23 @@ export class Document extends Config.Document.extend("Config.Integrati organization: Schema.optional(Schema.String), }) {} +/** + * A config document a well-known origin delivers. `integrationID` is the origin, so `integration.use` decides the + * providers it defines by the origin rather than by provider ID. + */ +export class WellKnownDocument extends Config.Document.extend("Config.WellKnownDocument")({ + integrationID: IntegrationID, +}) {} + +/** The integration whose delivered document last defines each provider. */ +export function suppliers(entries: readonly Entry[]) { + return new Map( + entries + .filter((entry): entry is WellKnownDocument => entry instanceof WellKnownDocument) + .flatMap((document) => Object.keys(document.info.providers ?? {}).map((id) => [id, document.integrationID])), + ) +} + /** * Builds an integration document from the allow-listed keys of a remote config: providers, remote MCP servers, * policies, and the web search selection. A remote source can never load plugins, run commands, or change any diff --git a/packages/core/src/config/managed.ts b/packages/core/src/config/managed.ts new file mode 100644 index 000000000000..7efb7820df61 --- /dev/null +++ b/packages/core/src/config/managed.ts @@ -0,0 +1,6 @@ +export * as ConfigManaged from "./managed.js" + +import { Config } from "@opencode/schema/config" + +/** A document from managed configuration: the system config directory or MDM managed preferences. */ +export class Document extends Config.Document.extend("Config.ManagedDocument")({}) {} diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index 54bce2e1cebc..27463dbb9bd1 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -1,57 +1,102 @@ export * as ConfigPolicyPlugin from "./policy.js" import { define } from "@opencode/plugin/effect/plugin" -import { Effect } from "effect" +import { Effect, Stream } from "effect" +import { Bus } from "../../bus.js" import { Config } from "../../config.js" +import { Credential } from "../../credential.js" import { ManagedPolicy } from "../../managed-policy.js" import { State } from "../../state.js" import { Wildcard } from "../../util/wildcard.js" +import { WebSearch } from "../../websearch.js" +import { ConfigIntegration } from "../integration.js" import { ConfigEntryObserver } from "./entry-observer.js" export const Plugin = define({ id: "opencode.config.policy", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const credentials = yield* Credential.Service + const websearch = yield* WebSearch.Service + const bus = yield* Bus.Service const reload = State.batch( - Effect.all([ctx.provider.reload(), ctx.mcp.reload(), ctx.skill.reload()], { discard: true }), + Effect.all([ctx.provider.reload(), ctx.mcp.reload(), ctx.skill.reload(), websearch.reload()], { + discard: true, + }), ) const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, reload) // Documents reverse so earlier-loaded policy wins: managed policy, then a Console config document, then user // and project files. const policies = () => ManagedPolicy.statements(loaded.entries) + // `integration.use` names the account behind each integration's active connection. Reading stored metadata + // never refreshes a credential. + const current = { accounts: ManagedPolicy.accounts(yield* credentials.all()) } + yield* bus.subscribe([Credential.Event.Updated, Credential.Event.Switched]).pipe( + Stream.runForEach(() => + Effect.gen(function* () { + const next = ManagedPolicy.accounts(yield* credentials.all()) + if ( + next.size === current.accounts.size && + Array.from(next).every(([id, account]) => current.accounts.get(id) === account) + ) + return + current.accounts = next + yield* State.batch(Effect.all([ctx.provider.reload(), websearch.reload()], { discard: true })) + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + const integrationDenied = (statements: ReturnType, integrationID: string) => + ManagedPolicy.decision( + statements, + "integration.use", + ManagedPolicy.integrationResource(integrationID, current.accounts.get(integrationID)), + ) === "deny" const runSync = Effect.runSyncWith(yield* Effect.context()) yield* ctx.provider.transform((providers) => { - const current = policies() + const statements = policies() + // A provider a well-known origin defines belongs to that origin; others to their own integration. + const suppliers = ConfigIntegration.suppliers(loaded.entries) const denied: string[] = [] for (const record of providers.list()) { - const policy = current.findLast( - (policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource), - ) - if (policy?.effect !== "deny") continue + const provider = + ManagedPolicy.decision(statements, "provider.use", record.provider.id) === "deny" || + integrationDenied( + statements, + suppliers.get(record.provider.id) ?? record.provider.integrationID ?? record.provider.id, + ) + if (!provider) continue providers.remove(record.provider.id) // Skip unconfigured catalog entries; deny-all policies would otherwise log the whole catalog. if (record.provider.activation === "enabled") denied.push(record.provider.id) } if (denied.length) runSync(Effect.logDebug("providers denied by policy", { providers: denied })) }) + // Every built-in web search provider shares its integration's ID. + yield* websearch.transform((editor) => { + const statements = policies() + for (const provider of editor.list()) { + if (integrationDenied(statements, provider.id)) editor.remove(provider.id) + } + }) yield* ctx.mcp.transform((servers) => { - const current = policies() + const statements = policies() for (const [name] of servers.list()) { - if (ManagedPolicy.decision(current, "integration.use", `mcp:${name}`) === "deny") servers.remove(name) + if (ManagedPolicy.decision(statements, "mcp.use", name) === "deny") servers.remove(name) } }) yield* ctx.skill.transform((skills) => { - const current = policies() + const statements = policies() for (const skill of skills.list()) { - if (ManagedPolicy.decision(current, "integration.use", `skill:${skill.id}`) === "deny") skills.remove(skill.id) + if (ManagedPolicy.decision(statements, "skill.use", skill.id) === "deny") skills.remove(skill.id) } }) yield* ctx.permission.hook("evaluate", (event) => Effect.sync(() => { - const current = policies() + const statements = policies() const denied = event.resources .map((resource) => - current.findLast( + statements.findLast( (policy) => policy.action === "tool.use" && Wildcard.match(`${event.action}:${resource}`, policy.resource), ), diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 75faaec5f27a..d07637b2dac8 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -12,6 +12,7 @@ import { Model } from "../../model.js" import { Provider } from "../../provider.js" import { Variant } from "../../variant.js" import { ConfigIntegration } from "../integration.js" +import { ConfigManaged } from "../managed.js" import { ConfigEntryObserver } from "./entry-observer.js" export const Plugin = define({ @@ -140,10 +141,21 @@ export const Plugin = define({ // Integration documents are left out: their providers authenticate with the integration's connection, so they add no // integration or method, and the integration's own plugin applies them earlier in the provider chain (`integrate`). +// A provider the Console supplies is shaped only by the Console and managed configuration: user, project, and remote +// documents cannot override any part of it, since it carries the org's token and a repository must not redirect it. function configuredProviders(entries: readonly Entry[]) { + const supplied = new Set( + entries + .filter((entry): entry is ConfigIntegration.Document => entry instanceof ConfigIntegration.Document) + .flatMap((document) => Object.keys(document.info.providers ?? {})), + ) return entries .filter((entry): entry is Document => entry.type === "document" && !(entry instanceof ConfigIntegration.Document)) - .flatMap((file) => Object.entries(file.info.providers ?? {})) + .flatMap((file) => + Object.entries(file.info.providers ?? {}).filter( + ([id]) => !supplied.has(id) || file instanceof ConfigManaged.Document, + ), + ) } /** diff --git a/packages/core/src/managed-policy.ts b/packages/core/src/managed-policy.ts index e7a3b018f9a0..e8bd47e600c6 100644 --- a/packages/core/src/managed-policy.ts +++ b/packages/core/src/managed-policy.ts @@ -4,6 +4,7 @@ import { Document, type Entry } from "@opencode/schema/config" import { ConfigIntegration } from "./config/integration.js" import { Wildcard } from "./util/wildcard.js" import { ConfigPolicy } from "@opencode/schema/config/policy" +import type { Credential } from "./credential.js" /** Every policy statement in config, earliest-loaded first so it wins, each with the message a denial shows. */ export function statements(entries: readonly Entry[]) { @@ -24,13 +25,41 @@ function blocked(organization: string | undefined) { return organization ? `Blocked by ${organization}'s policy` : "Blocked by your organization's policy" } -export function decision( - policies: readonly ConfigPolicy.Info[], - action: ConfigPolicy.Info["action"], +/** The statement deciding `action` on `resource`: the last match, since statements are earliest-loaded last. */ +export function match( + policies: readonly Policy[], + action: ConfigPolicy.Action, resource: string, ) { - return ( - policies.findLast((policy) => policy.action === action && Wildcard.match(resource, policy.resource))?.effect ?? - "allow" + return policies.findLast((policy) => policy.action === action && Wildcard.match(resource, policy.resource)) +} + +export function decision(policies: readonly ConfigPolicy.Info[], action: ConfigPolicy.Action, resource: string) { + return match(policies, action, resource)?.effect ?? "allow" +} + +/** + * The `integration.use` resource for what an integration supplies: `#` when a connection with + * a known account backs it, otherwise the bare integration ID (env keys, local config, the signed-out free tier). + * A bare-ID pattern such as `opencode` therefore matches only connectionless use, never an org account. `#` + * separates the two because integration IDs can be URLs. + */ +export function integrationResource(integrationID: string, account?: string) { + return account ? `${integrationID}#${account}` : integrationID +} + +/** The organization, or failing that the account, a stored credential belongs to. */ +export function account(value: Credential.Value) { + const metadata = value.metadata + if (typeof metadata?.orgID === "string" && metadata.orgID) return metadata.orgID + if (typeof metadata?.accountID === "string" && metadata.accountID) return metadata.accountID + return undefined +} + +/** The account behind each integration's active stored credential. */ +export function accounts(credentials: readonly Credential.Info[]) { + // Credentials list each integration's active credential last. + return new Map( + credentials.map((credential) => [credential.integrationID, account(credential.value)]), ) } diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index 22aae51767fb..b76edff7d772 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -50,13 +50,7 @@ export const make = Effect.fn("PluginModule.make")(function* () { const version = operation.target.lastIndexOf("@") const target = path.isAbsolute(operation.target) || version <= 0 ? operation.target : operation.target.slice(0, version) - if ( - ManagedPolicy.decision( - ManagedPolicy.statements(yield* config.entries()), - "integration.use", - `plugin:${target}`, - ) === "deny" - ) + if (ManagedPolicy.decision(ManagedPolicy.statements(yield* config.entries()), "plugin.use", target) === "deny") return { blocked: true as const } return yield* load(operation, sources, watch, options) }), diff --git a/packages/core/src/websearch.ts b/packages/core/src/websearch.ts index f8f8dd3073be..eeb593730507 100644 --- a/packages/core/src/websearch.ts +++ b/packages/core/src/websearch.ts @@ -76,6 +76,8 @@ type Data = { export type Editor = { add: (provider: ProviderImplementation) => void + list: () => readonly ProviderImplementation[] + remove: (id: ID) => void default: { get: () => Selection | undefined set: (selection: Selection) => void @@ -109,6 +111,8 @@ const layer = Layer.effect( initial: () => ({ providers: new Map() }), editor: (editor) => ({ add: (provider) => editor.providers.set(provider.id, provider), + list: () => Array.from(editor.providers.values()), + remove: (id) => editor.providers.delete(id), default: { get: () => editor.selection, set: (selection) => (editor.selection = selection), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e28cce00d3a5..1aeaa0360fa8 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -217,37 +217,35 @@ describe("Config", () => { // Filesystem sandboxes deny reads outside the project. Simulate a denied discovery root with a // symlink into an unsearchable directory, which makes realpath fail with EACCES. - itWithPermissions( - "treats unreadable discovery roots as absent", - () => - Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( - Effect.flatMap((tmp) => { - const global = path.join(tmp.path, "global") - const home = path.join(global, "home") - const project = path.join(home, "project") - const locked = path.join(tmp.path, "locked") - return Effect.promise(async () => { - await fs.mkdir(path.join(locked, "agents"), { recursive: true }) - await fs.mkdir(project, { recursive: true }) - await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) - await fs.symlink(path.join(locked, "agents"), path.join(home, ".agents")) - await fs.symlink(path.join(locked, "agents"), path.join(project, ".opencode")) - await fs.chmod(locked, 0o000) - }).pipe( - Effect.andThen(Effect.addFinalizer(() => Effect.promise(() => fs.chmod(locked, 0o755)))), - Effect.andThen( - Effect.gen(function* () { - const config = yield* Config.Service - const entries = yield* config.entries() - expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual([ - "project", - ]) - }).pipe(Effect.provide(testLayer(project, global))), - ), - Effect.scoped, - ) - }), - ), + itWithPermissions("treats unreadable discovery roots as absent", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const home = path.join(global, "home") + const project = path.join(home, "project") + const locked = path.join(tmp.path, "locked") + return Effect.promise(async () => { + await fs.mkdir(path.join(locked, "agents"), { recursive: true }) + await fs.mkdir(project, { recursive: true }) + await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" })) + await fs.symlink(path.join(locked, "agents"), path.join(home, ".agents")) + await fs.symlink(path.join(locked, "agents"), path.join(project, ".opencode")) + await fs.chmod(locked, 0o000) + }).pipe( + Effect.andThen(Effect.addFinalizer(() => Effect.promise(() => fs.chmod(locked, 0o755)))), + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + const entries = yield* config.entries() + expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual([ + "project", + ]) + }).pipe(Effect.provide(testLayer(project, global))), + ), + Effect.scoped, + ) + }), + ), ) it.live("discovers the global config directory once when the project walk reaches it", () => @@ -323,9 +321,7 @@ describe("Config", () => { ]) expect(Config.latest(entries, "shell")).toBe("system") expect(Config.latest(entries, "username")).toBe("system-user") - expect( - ManagedPolicy.decision(ManagedPolicy.statements(entries), "tool.use", "shell:ls"), - ).toBe("deny") + expect(ManagedPolicy.decision(ManagedPolicy.statements(entries), "tool.use", "shell:ls")).toBe("deny") }).pipe( Effect.provide( testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, { @@ -566,6 +562,110 @@ describe("Config", () => { ), ) + it.live("keeps only the policies of documents from integrations managed policy denies", () => + Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( + Effect.flatMap((tmp) => { + const system = path.join(tmp.path, "system") + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const allowed = "org_allowed" + const opencode = Integration.ID.make("opencode") + const origin = "https://ai.acme.com" + const credentialID = Credential.ID.create() + let org = "org_personal" + const credentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.mock(Credential.Service)({ + list: () => Effect.succeed([]), + get: (id) => + Effect.succeed( + new Credential.Info({ + id, + integrationID: opencode, + label: "Console", + value: Credential.Key.make({ type: "key", key: "secret", metadata: { orgID: org } }), + }), + ), + }), + deps: [], + }) + const entry: WellKnown.Entry = { origin, integrationID: Integration.ID.make(origin), manifest: {} } + const wellknownNode = makeGlobalNode({ + service: WellKnown.Service, + layer: Layer.mock(WellKnown.Service)({ + entries: () => Effect.succeed([entry]), + snapshot: () => [entry], + cached: () => + Effect.succeed([ + { + mcp: { servers: { wiki: { type: "remote", url: "https://wiki.acme.com/mcp" } } }, + policies: [{ action: "tool.use", resource: "shell:rm*", effect: "deny" }], + }, + ]), + }), + deps: [], + }) + const supplied = (entries: readonly Entry[]) => + entries.flatMap((entry) => + entry.type === "document" && !entry.path + ? [{ servers: Object.keys(entry.info.mcp?.servers ?? {}), policies: entry.info.policies?.length ?? 0 }] + : [], + ) + return Effect.promise(() => + fs.mkdir(system, { recursive: true }).then(() => + fs.writeFile( + path.join(system, "opencode.json"), + JSON.stringify({ + policies: [ + { action: "integration.use", resource: "*", effect: "deny" }, + { action: "integration.use", resource: `opencode#${allowed}`, effect: "allow" }, + ], + }), + ), + ), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + const config = yield* Config.Service + const install = () => + config.integration!( + opencode, + ConfigIntegration.make({ + integrationID: opencode, + connection: { type: "credential", id: credentialID, label: "Console", method: "key" }, + organization: org, + info: decodeInfo({ + mcp: { servers: { docs: { type: "remote", url: "https://docs.acme.com/mcp" } } }, + policies: [{ action: "mcp.use", resource: "github", effect: "deny" }], + }), + }), + ) + yield* install() + // The personal account's servers and the denied well-known origin's config are gone; their policies stay. + expect(supplied(yield* config.entries())).toEqual([ + { servers: [], policies: 1 }, + { servers: [], policies: 1 }, + ]) + org = allowed + yield* config.integration!(opencode, undefined) + yield* install() + expect(supplied(yield* config.entries())).toEqual([ + { servers: ["docs"], policies: 1 }, + { servers: [], policies: 1 }, + ]) + }).pipe( + Effect.provide( + testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, { + system, + }), + ), + ), + ), + ) + }), + ), + ) + it.live("skips project configuration when project discovery is disabled", () => Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe( Effect.flatMap((tmp) => { diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts index e19731679fba..2f03aafe70e4 100644 --- a/packages/core/test/config/normalization.test.ts +++ b/packages/core/test/config/normalization.test.ts @@ -596,6 +596,22 @@ describe("ConfigNormalize", () => { ).toEqual({ policies: [{ action: "provider.use", resource: "*", effect: "deny" }] }) }) + test("converts legacy prefixed integration.use statements from config and MDM files", () => { + expect( + normalized({ + policies: [ + { action: "integration.use", resource: "mcp:github", effect: "deny" }, + { action: "integration.use", resource: "*", effect: "deny" }, + ], + }).encoded, + ).toEqual({ + policies: [ + { action: "mcp.use", resource: "github", effect: "deny" }, + { action: "integration.use", resource: "*", effect: "deny" }, + ], + }) + }) + test("reports unsupported legacy settings without including their values", () => { const secret = "do-not-log-this-value" const result = normalized({ diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index 1340f8ed51d5..9cfa22fca348 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -11,6 +11,9 @@ import { PluginHooks } from "@opencode/core/plugin/hooks" import { PluginHost } from "@opencode/core/plugin/host" import { Provider } from "@opencode/core/provider" import { Session } from "@opencode/core/session" +import { Credential } from "@opencode/core/credential" +import { ConfigIntegration } from "@opencode/core/config/integration" +import { WebSearch } from "@opencode/core/websearch" import { Effect, Schema } from "effect" import { testEffect } from "../lib/effect" import { organizationPolicy } from "../fixture/policy" @@ -31,6 +34,32 @@ const tool = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info effect, }) +const integration = ( + action: "integration.connect" | "integration.use", + effect: ConfigPolicy.Effect, + resource: string, +): ConfigPolicy.Info => ({ action, resource, effect }) + +// The MDM policy that requires one Console organization, as an admin would deploy it. +const orgID = "org_01M3CAP8H43Z49CER6NT0Y3ZF1" +const lockdown = () => + document( + integration("integration.connect", "deny", "*"), + integration("integration.connect", "allow", "opencode"), + integration("integration.use", "deny", "*"), + integration("integration.use", "allow", `opencode#${orgID}`), + ) + +const consoleCredential = (org: string) => + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "access", + refresh: "refresh", + expires: Number.MAX_SAFE_INTEGER, + metadata: { accountID: "acc_personal", orgID: org }, + }) + const addPlugin = Effect.fn(function* (entries: Entry[]) { const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) @@ -227,6 +256,160 @@ describe("ConfigPolicyPlugin.Plugin", () => { ) }) +describe("integration policy", () => { + it.live("requires one Console organization through MDM policy alone", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const credentials = yield* Credential.Service + const websearch = yield* WebSearch.Service + const host = yield* PluginHost.make(yield* Plugin.Service) + const search = (id: string) => ({ + id: WebSearch.ID.make(id), + name: id, + execute: () => Effect.succeed([]), + }) + yield* catalog.transform((catalog) => { + // The signed-out free tier enables OpenCode without any connection. + catalog.update(Provider.ID.opencode, (provider) => { + provider.activation = "enabled" + }) + // An env API key: Anthropic's integration with no account. + catalog.update(Provider.ID.anthropic, (provider) => { + provider.activation = "enabled" + }) + // A provider authored in opencode.json. + catalog.update(Provider.ID.make("my-llm"), (provider) => { + provider.activation = "enabled" + }) + // A provider the Console organization supplies. + catalog.update(Provider.ID.make("acme-gateway"), (provider) => { + provider.integrationID = Integration.ID.make("opencode") + }) + }) + yield* websearch.transform((editor) => { + editor.add(search("opencode")) + editor.add(search("exa")) + }) + yield* ConfigPolicyPlugin.Plugin.effect(host) + const available = () => + catalog.all().pipe(Effect.map((providers) => providers.map((item): string => item.id).toSorted())) + const searches = () => + websearch.providers().pipe(Effect.map((providers) => providers.map((item): string => item.id).toSorted())) + + // Signed out: everything, including the free tier, is denied. + expect(yield* available()).toEqual([]) + expect(yield* searches()).toEqual([]) + + // A personal Console account is a different account of the same integration. + const personal = yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: consoleCredential("org_personal"), + }) + expect(yield* available()).toEqual([]) + + // Only the required organization's connection works, and with it everything that organization supplies. + yield* credentials.create({ integrationID: Integration.ID.make("opencode"), value: consoleCredential(orgID) }) + yield* eventually(available().pipe(Effect.map((ids) => ids.length > 0))) + expect(yield* available()).toEqual(["acme-gateway", "opencode"]) + expect(yield* searches()).toEqual(["opencode"]) + + // Switching back to the personal account revokes it again. + yield* credentials.activate(personal.id) + yield* eventually(available().pipe(Effect.map((ids) => ids.length === 0))) + expect(yield* searches()).toEqual([]) + }).pipe(Effect.provide(Config.testLayer([lockdown()]))), + ) + + it.effect("keeps an integration's account out of a bare-ID allow", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: consoleCredential("org_other"), + }) + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.opencode, () => {}) + catalog.update(Provider.ID.anthropic, () => {}) + }) + yield* addPlugin([ + document( + integration("integration.use", "deny", "*"), + integration("integration.use", "allow", "anthropic"), + integration("integration.use", "allow", "opencode"), + ), + ]) + expect(yield* catalog.get(Provider.ID.anthropic)).toBeDefined() + // `opencode` names only account-less use; a connected account needs `opencode#*` or its own ID. + expect(yield* catalog.get(Provider.ID.opencode)).toBeUndefined() + }), + ) + + for (const effect of ["allow", "deny"] as const) + it.effect(`decides a well-known origin's providers by the origin: ${effect}`, () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const origin = Integration.ID.make("https://ai.acme.com") + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.make("acme-gateway"), (provider) => { + provider.activation = "enabled" + }) + catalog.update(Provider.ID.make("my-llm"), (provider) => { + provider.activation = "enabled" + }) + }) + yield* addPlugin([ + document( + integration("integration.use", "deny", "*"), + integration("integration.use", effect, "https://ai.acme.com"), + ), + new ConfigIntegration.WellKnownDocument({ + type: "document", + integrationID: origin, + info: decode({ + providers: { "acme-gateway": { package: "@opencode/ai/providers/openai-compatible", models: {} } }, + }), + }), + new Document({ + type: "document", + info: decode({ + providers: { "my-llm": { package: "@opencode/ai/providers/openai-compatible", models: {} } }, + }), + }), + ]) + expect(yield* catalog.get(Provider.ID.make("acme-gateway"))).toEqual( + effect === "allow" ? expect.anything() : undefined, + ) + expect(yield* catalog.get(Provider.ID.make("my-llm"))).toBeUndefined() + }), + ) + + it.effect("converts legacy statements in Console config before enforcement sees them", () => + Effect.sync(() => { + const remote = Schema.decodeUnknownSync(ConfigIntegration.Remote)({ + policies: [ + { action: "integration.use", resource: "mcp:*", effect: "deny" }, + { action: "integration.use", resource: "skill:deploy", effect: "deny" }, + { action: "future.action", resource: "*", effect: "deny" }, + ], + }) + expect(remote.policies).toEqual([ + { action: "mcp.use", resource: "*", effect: "deny" }, + { action: "skill.use", resource: "deploy", effect: "deny" }, + ]) + }), + ) +}) + +const eventually = Effect.fnUntraced(function* (condition: Effect.Effect) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (yield* condition) return + yield* Effect.sleep("10 millis") + } + return yield* Effect.die("Timed out waiting for policy reload") +}) + const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect) { for (let attempt = 0; attempt < 200; attempt++) { if (yield* condition) return diff --git a/packages/core/test/fixture/policy.ts b/packages/core/test/fixture/policy.ts index e4737b3d3f3f..f57bfb9a1e8f 100644 --- a/packages/core/test/fixture/policy.ts +++ b/packages/core/test/fixture/policy.ts @@ -7,6 +7,8 @@ import { Skill } from "@opencode/core/skill" import { Info } from "@opencode/schema/config" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ID } from "@opencode/schema/event" +import { Credential } from "@opencode/core/credential" +import { WebSearch } from "@opencode/core/websearch" import { Effect, Schema, Stream } from "effect" import { host } from "../plugin/host" @@ -66,5 +68,30 @@ export const registerIntegrationPolicy = Effect.fn(function* (input: { assert: () => Effect.die("unused permission.assert"), }, }), + ).pipe( + Effect.provideService( + Credential.Service, + Credential.Service.of({ + all: () => Effect.succeed([]), + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + lock: () => Effect.die("unused Credential.lock"), + create: () => Effect.die("unused Credential.create"), + activate: () => Effect.die("unused Credential.activate"), + update: () => Effect.die("unused Credential.update"), + remove: () => Effect.die("unused Credential.remove"), + }), + ), + Effect.provideService( + WebSearch.Service, + WebSearch.Service.of({ + providers: () => Effect.die("unused WebSearch.providers"), + default: () => Effect.die("unused WebSearch.default"), + select: () => Effect.die("unused WebSearch.select"), + query: () => Effect.die("unused WebSearch.query"), + transform: () => Effect.succeed({ dispose: Effect.void }), + reload: () => Effect.void, + }), + ), ) }) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 128606c1f170..16e30dba202e 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -2738,7 +2738,7 @@ for (const modern of [false, true]) { }).pipe( Effect.provide( resourceMcpLayer(server.url, undefined, undefined, { - policies: [{ action: "integration.use", resource: "mcp:*", effect: "deny" }], + policies: [{ action: "mcp.use", resource: "*", effect: "deny" }], }), ), ) @@ -2762,7 +2762,7 @@ for (const source of ["config", "organization"] as const) { mcp: new ConfigMCP.Info({ servers: { resources: new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }) }, }), - policies: [{ action: "integration.use", resource: "mcp:*", effect }], + policies: [{ action: "mcp.use", resource: "*", effect }], }), }) yield* test.setEntries([document("allow")]) @@ -2772,7 +2772,7 @@ for (const source of ["config", "organization"] as const) { expect((yield* service.tools()).length).toBeGreaterThan(0) if (source === "config") yield* test.setEntries([document("deny")]) if (source === "organization") - yield* setOrganizationPolicy([{ action: "integration.use", resource: "mcp:*", effect: "deny" }]) + yield* setOrganizationPolicy([{ action: "mcp.use", resource: "*", effect: "deny" }]) yield* PubSub.publish(updates, { type: Event.Updated.type }) yield* service.servers().pipe( Effect.filterOrFail( diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts index 2c6c58547cb1..ca2fde92dedb 100644 --- a/packages/core/test/plugin/module.test.ts +++ b/packages/core/test/plugin/module.test.ts @@ -417,7 +417,7 @@ it.live("blocks a local plugin before module initialization and honors replaceme ) const modules = yield* PluginModule.make() const operation = { type: "add" as const, target: directory.path, options: {} } - yield* setOrganizationPolicy([{ action: "integration.use", resource: `plugin:${directory.path}`, effect: "deny" }]) + yield* setOrganizationPolicy([{ action: "plugin.use", resource: directory.path, effect: "deny" }]) expect(yield* modules.load(operation)).toEqual({ blocked: true }) expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(false) yield* setOrganizationPolicy(undefined) @@ -475,7 +475,7 @@ for (const target of ["policy-fixture@1.2.3", "@scope/policy-fixture@1.2.3"]) { Effect.gen(function* () { const modules = yield* PluginModule.make() yield* setOrganizationPolicy([ - { action: "integration.use", resource: `plugin:${target.slice(0, target.lastIndexOf("@"))}`, effect: "deny" }, + { action: "plugin.use", resource: target.slice(0, target.lastIndexOf("@")), effect: "deny" }, ]) const operation = { type: "add" as const, target, options: {} } expect(yield* modules.load(operation, { install: false })).toEqual({ blocked: true }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index dbe9dce194b9..83d36a7363b4 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -13,6 +13,7 @@ import { Bus } from "@opencode/core/bus" import { Config } from "@opencode/core/config" import { ConfigIntegration } from "@opencode/core/config/integration" import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy" +import { ConfigManaged } from "@opencode/core/config/managed" import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider" import { ConfigWebSearchPlugin } from "@opencode/core/config/plugin/websearch" import { Credential } from "@opencode/core/credential" @@ -1900,7 +1901,7 @@ describe("OpencodePlugin with a Console config", () => { ), ) - it.live("layers Console providers below user config, bound to the Console connection", () => + it.live("keeps Console providers out of user and project config's reach, bound to the Console connection", () => Effect.acquireUseRelease( Effect.sync(() => Bun.serve({ @@ -1929,17 +1930,23 @@ describe("OpencodePlugin with a Console config", () => { const providers = yield* Provider.Service const models = yield* Model.Service const config = yield* Config.Test + const overlay = { + name: "Mine", + settings: { apiKey: "my-key", baseURL: "https://mine.example/v1" }, + headers: { "x-org-id": "mine" }, + models: { chat: { name: "My Chat" }, mine: { name: "My Model" } }, + } yield* config.setEntries([ + new ConfigManaged.Document({ + type: "document", + info: Schema.decodeUnknownSync(Info)({ providers: { remote: { headers: { "x-managed": "true" } } } }), + }), new Document({ type: "document", info: Schema.decodeUnknownSync(Info)({ providers: { - remote: { - name: "Mine", - settings: { apiKey: "my-key" }, - headers: { "x-org-id": "mine" }, - models: { chat: { name: "My Chat" } }, - }, + remote: overlay, + normal: { ...overlay, package: "aisdk:@ai-sdk/openai-compatible" }, }, }), }), @@ -1952,22 +1959,26 @@ describe("OpencodePlugin with a Console config", () => { yield* OpencodePlugin.effect(host) yield* ConfigProviderPlugin.Plugin.effect(host) + // Managed configuration may still shape a Console provider; a project or user file cannot. const provider = required(yield* providers.get(Provider.ID.make("remote"))) - expect(provider.name).toBe("Mine") + expect(provider.name).toBe("Console") expect(provider.integrationID).toBe(Integration.ID.make("opencode")) - expect(provider.headers).toEqual({ "x-org-id": "mine", "x-console": "true" }) + expect(provider.headers).toEqual({ "x-org-id": "console", "x-console": "true", "x-managed": "true" }) expect(provider.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "console", - apiKey: "my-key", provider: "remote", }) const chat = required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("chat"))) - expect(chat.name).toBe("My Chat") + expect(chat.name).toBe("Console Chat") expect(chat.settings).toMatchObject({ temperature: 0.1 }) - expect(required(yield* models.get(Provider.ID.make("remote"), Model.ID.make("other"))).name).toBe( - "Console Other", - ) + expect(yield* models.get(Provider.ID.make("remote"), Model.ID.make("mine"))).toBeUndefined() + + // Providers the Console does not supply stay overridable. + const normal = required(yield* providers.get(Provider.ID.make("normal"))) + expect(normal.headers).toEqual({ "x-org-id": "mine" }) + expect(normal.settings).toMatchObject({ baseURL: "https://mine.example/v1", apiKey: "my-key" }) + expect(required(yield* models.get(Provider.ID.make("normal"), Model.ID.make("chat"))).name).toBe("My Chat") }), (server) => Effect.promise(() => server.stop(true)), ), diff --git a/packages/core/test/plugin/supervisor-reload.test.ts b/packages/core/test/plugin/supervisor-reload.test.ts index 36b6a0544bd5..1e6f7ead6765 100644 --- a/packages/core/test/plugin/supervisor-reload.test.ts +++ b/packages/core/test/plugin/supervisor-reload.test.ts @@ -355,7 +355,7 @@ it.live("applies organization integration changes to running external plugins", configuration, JSON.stringify({ plugins: [root], - experimental: { policies: [{ action: "integration.use", resource: "plugin:*", effect: "deny" }] }, + experimental: { policies: [{ action: "plugin.use", resource: "*", effect: "deny" }] }, }), ) }) @@ -377,7 +377,7 @@ it.live("applies organization integration changes to running external plugins", expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(true) yield* config.integration?.( Integration.ID.make("opencode"), - organizationPolicy([{ action: "integration.use", resource: `plugin:${root}`, effect: "deny" }]), + organizationPolicy([{ action: "plugin.use", resource: root, effect: "deny" }]), ) ?? Effect.void yield* commands.get("policy-greet").pipe( Effect.flatMap((command) => (command ? Effect.fail("pending") : Effect.void)), diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 30e763d1f215..3f24d6f1c508 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -65,7 +65,7 @@ const locations = makeGlobalNode({ ref.directory === blocked.directory ? [ organizationPolicy([ - { action: "integration.use", resource: "skill:effect", effect: "deny" }, + { action: "skill.use", resource: "effect", effect: "deny" }, ]), ] : [], diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 2ea250f3badd..6a56c29d8646 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -44,13 +44,13 @@ describe("Skill", () => { new Document({ type: "document", info: { - policies: [{ action: "integration.use", resource: "skill:*", effect: "allow" }], + policies: [{ action: "skill.use", resource: "*", effect: "allow" }], }, }), ]) yield* setOrganizationPolicy([ - { action: "integration.use", resource: "*", effect: "deny" }, - { action: "integration.use", resource: "skill:team:review", effect: "allow" }, + { action: "skill.use", resource: "*", effect: "deny" }, + { action: "skill.use", resource: "team:review", effect: "allow" }, ]) yield* registerIntegrationPolicy({ skill: skills, events: bus.subscribe() }) expect(yield* skills.list()).toEqual([info("team:review", "Review")]) @@ -61,7 +61,7 @@ describe("Skill", () => { expect(guidance.text).toContain("team:review") expect(guidance.text).not.toContain("deploy") - yield* setOrganizationPolicy([{ action: "integration.use", resource: "skill:team:review", effect: "deny" }]) + yield* setOrganizationPolicy([{ action: "skill.use", resource: "team:review", effect: "deny" }]) yield* bus.publish(Event.Updated, {}) yield* waitUntil(skills.get(Skill.ID.make("team:review")).pipe(Effect.map((skill) => skill === undefined))) expect(yield* skills.get(Skill.ID.make("team:review"))).toBeUndefined() @@ -81,12 +81,12 @@ describe("Skill", () => { yield* config.setEntries([ new Document({ type: "document", - info: { policies: [{ action: "integration.use", resource: "skill:*", effect: "deny" }] }, + info: { policies: [{ action: "skill.use", resource: "*", effect: "deny" }] }, }), new Document({ type: "document", info: { - policies: [{ action: "integration.use", resource: "skill:review", effect: "allow" }], + policies: [{ action: "skill.use", resource: "review", effect: "allow" }], }, }), ]) @@ -112,7 +112,7 @@ describe("Skill", () => { Stream.runForEach(() => skills.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))), Effect.forkScoped({ startImmediately: true }), ) - yield* setOrganizationPolicy([{ action: "integration.use", resource: "skill:review", effect: "deny" }]) + yield* setOrganizationPolicy([{ action: "skill.use", resource: "review", effect: "deny" }]) yield* bus.publish(Event.Updated, {}) expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([]) }), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 003cdb38855d..63758053ea69 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -9,6 +9,7 @@ import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/core/session" import { Skill } from "@opencode/core/skill" import { Config } from "@opencode/core/config" +import { Bus } from "@opencode/core/bus" import { SkillTool } from "@opencode/core/tool/plugin/skill" import { Tool } from "@opencode/core/tool" import { tmpdir } from "./fixture/tmpdir" @@ -56,7 +57,7 @@ describe("SkillTool", () => { ).toEqual({ status: "error", error: { type: "tool.execution", message: "Unable to load skill private" } }) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode, Skill.node, Config.node]), [ + AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode, Skill.node, Config.node, Bus.node]), [ Config.node.replace(Config.testLayer()), Permission.node.replace(permissionLayer({ assert: () => Effect.die("Blocked skill reached authorization") })), Image.node.replace(imagePassthrough), diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 7b32cff5f78d..7f49ab4a9884 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -8474,7 +8474,7 @@ } } }, - "description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.", + "description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists, and with an invalid request when an `integration.connect` policy denies the integration.", "summary": "Create credential", "requestBody": { "content": { @@ -15974,7 +15974,15 @@ "properties": { "action": { "type": "string", - "enum": ["provider.use", "tool.use", "integration.use"] + "enum": [ + "provider.use", + "tool.use", + "integration.connect", + "integration.use", + "mcp.use", + "skill.use", + "plugin.use" + ] }, "resource": { "type": "string" @@ -16319,7 +16327,15 @@ "properties": { "action": { "type": "string", - "enum": ["provider.use", "tool.use", "integration.use"] + "enum": [ + "provider.use", + "tool.use", + "integration.connect", + "integration.use", + "mcp.use", + "skill.use", + "plugin.use" + ] }, "resource": { "type": "string" diff --git a/packages/protocol/src/groups/credential.ts b/packages/protocol/src/groups/credential.ts index 4123a48a7a43..1655311c4592 100644 --- a/packages/protocol/src/groups/credential.ts +++ b/packages/protocol/src/groups/credential.ts @@ -1,7 +1,7 @@ import { Credential } from "@opencode/schema/credential" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api" -import { ConflictError } from "../errors.js" +import { ConflictError, InvalidRequestError } from "../errors.js" export const CredentialGroup = HttpApiGroup.make("server.credential") .add( @@ -19,13 +19,13 @@ export const CredentialGroup = HttpApiGroup.make("server.credential") HttpApiEndpoint.post("credential.create", "/api/credential", { payload: Credential.CreateInput, success: Schema.Struct({ data: Credential.Entry }), - error: ConflictError, + error: [ConflictError, InvalidRequestError], }).annotateMerge( OpenApi.annotations({ identifier: "credential.create", summary: "Create credential", description: - "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.", + "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists, and with an invalid request when an `integration.connect` policy denies the integration.", }), ), ) diff --git a/packages/schema/src/config/policy.ts b/packages/schema/src/config/policy.ts index a0a071c3d9f8..736c4544810b 100644 --- a/packages/schema/src/config/policy.ts +++ b/packages/schema/src/config/policy.ts @@ -1,13 +1,55 @@ export * as ConfigPolicy from "./policy.js" -import { Schema } from "effect" +import { Schema, SchemaGetter } from "effect" export const Effect = Schema.Literals(["allow", "deny"]) export type Effect = typeof Effect.Type -export const Info = Schema.Struct({ - action: Schema.Literals(["provider.use", "tool.use", "integration.use"]), +/** + * - `provider.use`: resource is a provider ID. + * - `tool.use`: resource is `:`, such as `shell:git *`. + * - `integration.connect`: resource is an integration ID; controls which integrations can be listed and connected. + * - `integration.use`: resource is `#`, or the bare integration ID when the providers, models, + * web search, MCP servers, or config an integration supplies come from no account (env keys, local config, the + * signed-out free tier). + * - `mcp.use`, `skill.use`, `plugin.use`: resource is an MCP server name, skill ID, or plugin target. + */ +export const Action = Schema.Literals([ + "provider.use", + "tool.use", + "integration.connect", + "integration.use", + "mcp.use", + "skill.use", + "plugin.use", +]) +export type Action = typeof Action.Type + +const Statement = Schema.Struct({ + action: Action, resource: Schema.String, effect: Effect, }) + +// Before `mcp.use`, `skill.use` and `plugin.use` existed, `integration.use` covered them with a resource prefix. +// Every policy source decodes through this schema, so this is the only place that knows the legacy form. Only prefixed +// resources convert: a bare `integration.use` `*` keeps its action and now covers providers and accounts. +const legacy = new Map([ + ["mcp", "mcp.use"], + ["skill", "skill.use"], + ["plugin", "plugin.use"], +]) + +export const Info = Statement.pipe( + Schema.decodeTo(Schema.toType(Statement), { + decode: SchemaGetter.transform((policy) => { + if (policy.action !== "integration.use") return policy + const separator = policy.resource.indexOf(":") + const action = separator === -1 ? undefined : legacy.get(policy.resource.slice(0, separator)) + if (!action) return policy + return { ...policy, action, resource: policy.resource.slice(separator + 1) } + }), + encode: SchemaGetter.passthrough(), + }), +) export type Info = typeof Info.Type diff --git a/packages/schema/test/config.test.ts b/packages/schema/test/config.test.ts index 2fa7ab76ee35..3ac721fb2e24 100644 --- a/packages/schema/test/config.test.ts +++ b/packages/schema/test/config.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { Config } from "../src/config.js" import { ConfigAgent } from "../src/config/agent.js" import { ConfigMCP } from "../src/config/mcp.js" +import { ConfigPolicy } from "../src/config/policy.js" import { ConfigProvider } from "../src/config/provider.js" import { Mcp } from "../src/mcp.js" import { Provider } from "../src/provider.js" @@ -29,6 +30,41 @@ describe("Config.Entry", () => { ).toThrow() }) + test("normalizes legacy prefixed integration.use statements into mcp.use, skill.use and plugin.use", () => { + const decode = Schema.decodeUnknownSync(Schema.Array(ConfigPolicy.Info)) + expect( + decode([ + { action: "integration.use", resource: "mcp:*", effect: "deny" }, + { action: "integration.use", resource: "mcp:github", effect: "allow" }, + { action: "integration.use", resource: "skill:team:review", effect: "deny" }, + { action: "integration.use", resource: "plugin:@acme/plugin", effect: "deny" }, + { action: "integration.use", resource: "*", effect: "deny" }, + { action: "integration.use", resource: "opencode#org_01", effect: "allow" }, + { action: "integration.use", resource: "https://ai.acme.com", effect: "allow" }, + { action: "integration.use", resource: "constructor:x", effect: "allow" }, + { action: "integration.connect", resource: "mcp:x", effect: "allow" }, + ]), + ).toEqual([ + { action: "mcp.use", resource: "*", effect: "deny" }, + { action: "mcp.use", resource: "github", effect: "allow" }, + { action: "skill.use", resource: "team:review", effect: "deny" }, + { action: "plugin.use", resource: "@acme/plugin", effect: "deny" }, + // A bare wildcard keeps its action, which now means every integration. + { action: "integration.use", resource: "*", effect: "deny" }, + { action: "integration.use", resource: "opencode#org_01", effect: "allow" }, + { action: "integration.use", resource: "https://ai.acme.com", effect: "allow" }, + { action: "integration.use", resource: "constructor:x", effect: "allow" }, + { action: "integration.connect", resource: "mcp:x", effect: "allow" }, + ]) + // Config documents decode through the same schema, top-level and experimental alike. + const config = Schema.decodeUnknownSync(Config.Info)({ + policies: [{ action: "integration.use", resource: "skill:deploy", effect: "deny" }], + experimental: { policies: [{ action: "integration.use", resource: "plugin:local", effect: "deny" }] }, + }) + expect(config.policies).toEqual([{ action: "skill.use", resource: "deploy", effect: "deny" }]) + expect(config.experimental?.policies).toEqual([{ action: "plugin.use", resource: "local", effect: "deny" }]) + }) + test("accepts directory-only worktree config and omits it when absent", () => { const decode = Schema.decodeUnknownSync(Config.Info) const input = { worktree: { directory: "../worktrees" } } diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 4f6f31a9c0cb..296a1b4a342f 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -1,56 +1,74 @@ import { Credential } from "@opencode/core/credential" +import { FileSystem } from "@opencode/core/filesystem" +import { Location } from "@opencode/core/location" +import { LocationServiceMap } from "@opencode/core/location-services" +import { AbsolutePath } from "@opencode/core/schema" import { ConflictError } from "@opencode/protocol/errors" +import { Global } from "@opencode/util/global" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/http-api" import { Api } from "../api" +import { requireConnectable } from "./integration" export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) => - handlers - .handle( - "credential.list", - Effect.fn(function* () { - const credential = yield* Credential.Service - return { data: entries(yield* credential.all()) } - }), - ) - .handle( - "credential.create", - Effect.fn(function* (ctx) { - const credential = yield* Credential.Service - if (ctx.payload.id && (yield* credential.get(ctx.payload.id))) - return yield* new ConflictError({ - resource: ctx.payload.id, - message: `Credential already exists: ${ctx.payload.id}`, - }) - const created = yield* credential.create(ctx.payload) - const entry = entries(yield* credential.list(created.integrationID)).find((item) => item.id === created.id) - return { data: entry ?? { ...created, active: false } } - }), - ) - .handle( - "credential.update", - Effect.fn(function* (ctx) { - const credential = yield* Credential.Service - yield* credential.update(ctx.params.credentialID, { label: ctx.payload.label }) - return HttpApiSchema.NoContent.make() - }), - ) - .handle( - "credential.activate", - Effect.fn(function* (ctx) { - const credential = yield* Credential.Service - yield* credential.activate(ctx.params.credentialID) - return HttpApiSchema.NoContent.make() - }), - ) - .handle( - "credential.remove", - Effect.fn(function* (ctx) { - const credential = yield* Credential.Service - yield* credential.remove(ctx.params.credentialID) - return HttpApiSchema.NoContent.make() - }), - ), + Effect.gen(function* () { + const global = yield* Global.Service + const locations = yield* LocationServiceMap.Service + // Credentials are global, so `integration.connect` is read from the global config Location, which carries managed + // policy, the Console document, and user config. + const services = locations.get(Location.Ref.make({ directory: AbsolutePath.make(global.config) })) + return handlers + .handle( + "credential.list", + Effect.fn(function* () { + const credential = yield* Credential.Service + return { data: entries(yield* credential.all()) } + }), + ) + .handle( + "credential.create", + Effect.fn(function* (ctx) { + yield* requireConnectable(ctx.payload.integrationID).pipe( + Effect.provide(services), + // The global config directory always exists. + Effect.catchIf((error) => error instanceof FileSystem.DirectoryNotFoundError, Effect.die), + ) + const credential = yield* Credential.Service + if (ctx.payload.id && (yield* credential.get(ctx.payload.id))) + return yield* new ConflictError({ + resource: ctx.payload.id, + message: `Credential already exists: ${ctx.payload.id}`, + }) + const created = yield* credential.create(ctx.payload) + const entry = entries(yield* credential.list(created.integrationID)).find((item) => item.id === created.id) + return { data: entry ?? { ...created, active: false } } + }), + ) + .handle( + "credential.update", + Effect.fn(function* (ctx) { + const credential = yield* Credential.Service + yield* credential.update(ctx.params.credentialID, { label: ctx.payload.label }) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "credential.activate", + Effect.fn(function* (ctx) { + const credential = yield* Credential.Service + yield* credential.activate(ctx.params.credentialID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "credential.remove", + Effect.fn(function* (ctx) { + const credential = yield* Credential.Service + yield* credential.remove(ctx.params.credentialID) + return HttpApiSchema.NoContent.make() + }), + ) + }), ) // Credential listings order each integration's selected credential last. diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index dfdbaa447620..299fe6fad30b 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -11,6 +11,30 @@ import { } from "@opencode/protocol/errors" import { response } from "../location" import { WellKnown } from "@opencode/core/wellknown" +import { Config } from "@opencode/core/config" +import { ManagedPolicy } from "@opencode/core/managed-policy" + +const statements = Effect.fnUntraced(function* () { + const config = yield* Config.Service + return ManagedPolicy.statements(yield* config.entries()) +}) + +// MCP OAuth logins skip integration policy: whether an MCP server is available at all is decided by `mcp.use` and by +// managed policy withholding the documents of denied integrations. +const isMcp = (integration: { readonly metadata?: { readonly source?: unknown } } | undefined) => + integration?.metadata?.source === "mcp" + +// `integration.connect` decides which integrations are listed and can be connected. Credential creation shares it. +export const requireConnectable = Effect.fnUntraced(function* (integrationID: Integration.ID) { + const integrations = yield* Integration.Service + if (isMcp(yield* integrations.get(integrationID))) return + const denied = ManagedPolicy.match(yield* statements(), "integration.connect", integrationID) + if (denied?.effect !== "deny") return + return yield* new InvalidRequestError({ + message: `${denied.message}: connecting ${integrationID} is not allowed`, + kind: "integration_policy", + }) +}) const authorize = (effect: Effect.Effect) => effect.pipe( @@ -32,7 +56,20 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" Effect.fn(function* () { yield* Plugin.awaitActivation const service = yield* Integration.Service - return yield* response(service.list()) + const current = yield* statements() + return yield* response( + service + .list() + .pipe( + Effect.map((integrations) => + integrations.filter( + (integration) => + isMcp(integration) || + ManagedPolicy.decision(current, "integration.connect", integration.id) !== "deny", + ), + ), + ), + ) }), ) .handle( @@ -53,6 +90,8 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" Effect.fn(function* (ctx) { const wellknown = yield* WellKnown.Service const integration = yield* Integration.Service + // Well-known sources are integrations keyed by their origin as entered. + yield* requireConnectable(Integration.ID.make(ctx.payload.url.replace(/\/+$/, ""))) yield* wellknown .add(ctx.payload.url) .pipe( @@ -67,6 +106,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" .handle( "integration.connect.key", Effect.fn(function* (ctx) { + yield* requireConnectable(ctx.params.integrationID) const service = yield* Integration.Service if (!(yield* service.get(ctx.params.integrationID))) return yield* new IntegrationNotFoundError({ @@ -87,6 +127,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" .handle( "integration.connect.external", Effect.fn(function* (ctx) { + yield* requireConnectable(ctx.params.integrationID) const service = yield* Integration.Service if (!(yield* service.get(ctx.params.integrationID))) return yield* new IntegrationNotFoundError({ @@ -107,6 +148,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" .handle( "integration.oauth.connect", Effect.fn(function* (ctx) { + yield* requireConnectable(ctx.params.integrationID) const service = yield* Integration.Service return yield* response( authorize( @@ -200,6 +242,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" .handle( "integration.command.connect", Effect.fn(function* (ctx) { + yield* requireConnectable(ctx.params.integrationID) const service = yield* Integration.Service const integration = yield* service.get(ctx.params.integrationID) if (!integration) diff --git a/packages/server/test/integration-policy.test.ts b/packages/server/test/integration-policy.test.ts new file mode 100644 index 000000000000..bc2f1c9062af --- /dev/null +++ b/packages/server/test/integration-policy.test.ts @@ -0,0 +1,91 @@ +import { expect } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { HttpEffect, HttpRouter, HttpServer } from "effect/http" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { createRoutes } from "../src/routes" + +it.live("integration policy filters /connect and blocks denied integrations and providers", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped("opencode-integration-policy-") + const context = yield* Layer.build( + createRoutes({ + password: "secret", + database: { path: ":memory:" }, + models: { fetch: false }, + fs: { filewatcher: false }, + config: { + directory: tmp.path, + project: false, + content: JSON.stringify({ + providers: { + custom: { + name: "Configured Custom Provider", + package: "@opencode/ai/providers/openai-compatible", + settings: { apiKey: "secret" }, + models: { chat: {} }, + }, + }, + mcp: { servers: { wiki: { type: "remote", url: "http://127.0.0.1:9/mcp" } } }, + policies: [ + { action: "integration.connect", resource: "*", effect: "deny" }, + { action: "integration.connect", resource: "opencode", effect: "allow" }, + { action: "integration.use", resource: "*", effect: "deny" }, + { action: "integration.use", resource: "opencode#org_01M3CAP8H43Z49CER6NT0Y3ZF1", effect: "allow" }, + ], + }), + }, + }).pipe(Layer.provide(HttpServer.layerServices)), + ) + const handler = Context.get(context, HttpRouter.HttpRouter) + .asHttpEffect() + .pipe(HttpEffect.toWebHandlerWith(context)) + const request = (method: "GET" | "POST", route: string, body?: unknown) => + Effect.promise(async (signal) => { + const url = new URL(route, "http://opencode.local") + url.searchParams.set("location[directory]", tmp.path) + const response = await handler( + new Request(url, { + method, + headers: { + authorization: `Basic ${btoa("opencode:secret")}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }), + ) + return { status: response.status, body: response.status === 204 ? undefined : await response.json() } + }) + + const integrations = yield* request("GET", "/api/integration") + expect(integrations.status).toBe(200) + // MCP sign-ins are outside integration policy; `mcp.use` governs MCP servers. + const listed: { id: string; metadata?: { source?: string } }[] = integrations.body.data + expect(listed.filter((integration) => integration.metadata?.source !== "mcp").map((item) => item.id)).toEqual([ + "opencode", + ]) + const mcp = listed.find((integration) => integration.metadata?.source === "mcp") + expect(mcp?.id).toStartWith("mcp_") + const signIn = yield* request("POST", `/api/integration/${encodeURIComponent(mcp?.id ?? "")}/connect/oauth`, { + methodID: mcp?.id, + }) + expect(signIn.body?.kind).not.toBe("integration_policy") + + const denied = yield* request("POST", "/api/integration/anthropic/connect/key", { key: "sk-test" }) + expect(denied.status).toBe(400) + expect(denied.body).toMatchObject({ _tag: "InvalidRequestError", kind: "integration_policy" }) + const credential = yield* request("POST", "/api/credential", { + integrationID: "anthropic", + value: { type: "key", key: "sk-test" }, + }) + expect(credential.status).toBe(400) + expect(credential.body).toMatchObject({ _tag: "InvalidRequestError", kind: "integration_policy" }) + expect((yield* request("GET", "/api/credential")).body.data).toEqual([]) + const wellknown = yield* request("POST", "/api/experimental/integration/wellknown", { url: "https://ai.acme.com" }) + expect(wellknown.body).toMatchObject({ kind: "integration_policy" }) + + // The signed-out free tier and the authored provider are both denied. + expect((yield* request("GET", "/api/provider")).body.data).toEqual([]) + }), +) diff --git a/services/www/src/docs/content/policies.mdx b/services/www/src/docs/content/policies.mdx index 3b49b35b165e..21d4bff87763 100644 --- a/services/www/src/docs/content/policies.mdx +++ b/services/www/src/docs/content/policies.mdx @@ -40,11 +40,11 @@ merges into `policies`: Each statement has three fields: -| Field | Values | Meaning | -| ---------- | --------------------------------------------- | ---------------------------------------------------- | -| `action` | `provider.use`, `tool.use`, `integration.use` | The operation being controlled | -| `resource` | string or wildcard pattern | What the statement applies to; depends on the action | -| `effect` | `allow`, `deny` | The decision when this statement matches | +| Field | Values | Meaning | +| ---------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `action` | `provider.use`, `tool.use`, `integration.connect`, `integration.use`, `mcp.use`, `skill.use`, `plugin.use` | The operation being controlled | +| `resource` | string or wildcard pattern | What the statement applies to; depends on the action | +| `effect` | `allow`, `deny` | The decision when this statement matches | Statements that fail validation are dropped with a warning in the server log and the rest of the list still applies. Check the log after editing a `deny`. @@ -153,21 +153,64 @@ broader `deny`, after which the agent's own rules decide. ## Integrations -`integration.use` controls whether an MCP server, skill, or plugin is loaded. The -resource is the integration kind and its ID: +An integration is anything you connect in `/connect`: the OpenCode Console +(`opencode`), a provider such as `anthropic`, a web search service such as `exa`, +or a well-known config origin such as `https://ai.acme.com`. Signing in to an MCP +server is not affected by integration policies; use `mcp.use` to control MCP +servers. -| Resource | Applies to | -| ----------------- | ----------------------------------------------------------------- | -| `mcp:` | An [MCP server](/mcp-servers) by its configured name | -| `skill:` | A [skill](/skills) by its ID | -| `plugin:` | A [plugin](/plugins) by unversioned package name or absolute path | +`integration.connect` controls which integrations `/connect` lists and which can be +connected. The resource is the integration ID. + +`integration.use` controls everything an integration supplies: its providers and +models, its web search, and the MCP servers and config a Console workspace or +well-known origin delivers. The resource is `#`, where the +account is the connected organization or account ID, such as +`opencode#org_01M3CAP8H43Z49CER6NT0Y3ZF1`. Without a connected account it is the +bare integration ID: the signed-out free tier is `opencode`, an `ANTHROPIC_API_KEY` +environment variable is `anthropic`, and a provider from `opencode.json` is its +provider ID. + +This MDM policy requires one Console workspace and nothing else: ```jsonc { "policies": [ - { "action": "integration.use", "resource": "mcp:*", "effect": "deny" }, - { "action": "integration.use", "resource": "mcp:github", "effect": "allow" }, - { "action": "integration.use", "resource": "plugin:opencode-acme-cli", "effect": "deny" }, + { "action": "integration.connect", "resource": "*", "effect": "deny" }, + { "action": "integration.connect", "resource": "opencode", "effect": "allow" }, + { "action": "integration.use", "resource": "*", "effect": "deny" }, + { "action": "integration.use", "resource": "opencode#org_01M3CAP8H43Z49CER6NT0Y3ZF1", "effect": "allow" }, + ], +} +``` + +`/connect` offers only the Console so members can sign in. The free tier, personal +Console accounts, other organizations, environment API keys, and providers from +`opencode.json` are all unavailable. The workspace's providers, MCP servers, and +web search work, and changing them in the Console needs no new MDM profile. + +Only managed policies can withhold the MCP servers and config an integration +delivers; its own policies keep applying either way. + +A provider the connected Console workspace supplies can't be changed by user, +project, or remote configuration: its settings, headers, models, and package come +only from the Console and managed configuration. Other providers stay configurable +as usual. + +## MCP servers, skills, and plugins + +| Action | Resource | +| ------------ | ----------------------------------------------------------------- | +| `mcp.use` | An [MCP server](/mcp-servers) by its configured name | +| `skill.use` | A [skill](/skills) by its ID | +| `plugin.use` | A [plugin](/plugins) by unversioned package name or absolute path | + +```jsonc +{ + "policies": [ + { "action": "mcp.use", "resource": "*", "effect": "deny" }, + { "action": "mcp.use", "resource": "github", "effect": "allow" }, + { "action": "plugin.use", "resource": "opencode-acme-cli", "effect": "deny" }, ], } ``` @@ -175,6 +218,40 @@ resource is the integration kind and its ID: A denied MCP server or skill is removed, and a denied plugin is not loaded, however it was configured. +### Upgrading from `integration.use` + +Before OpenCode 2.3.0, `integration.use` covered MCP servers, skills, and plugins +through an `mcp:`, `skill:`, or `plugin:` prefix. Prefixed statements still load and +are converted to `mcp.use`, `skill.use`, or `plugin.use`. + +A statement without one of those prefixes, including a bare `*`, now applies to +integrations instead. A broad deny such as + +```jsonc +{ "action": "integration.use", "resource": "*", "effect": "deny" } +``` + +no longer blocks MCP servers, skills, or plugins. Instead it blocks every provider, +including the free tier, unless a later statement allows an integration. To keep +the old meaning, replace it with: + +```jsonc +{ + "policies": [ + { "action": "mcp.use", "resource": "*", "effect": "deny" }, + { "action": "skill.use", "resource": "*", "effect": "deny" }, + { "action": "plugin.use", "resource": "*", "effect": "deny" }, + ], +} +``` + +Exceptions move the same way: `integration.use` `mcp:github` becomes `mcp.use` +`github`, and `skill:team:review` becomes `skill.use` `team:review`. + +Clients older than 2.3.0 ignore `integration.connect`, `integration.use` (in its new +meaning), `mcp.use`, `skill.use`, and `plugin.use`, so require 2.3.0 or newer +wherever you rely on them. + ## Precedence Ordinary settings let the nearest configuration win. Policies reverse that: statements diff --git a/specs/v2/provider-policy.md b/specs/v2/provider-policy.md index edc69cd42efe..a81c56b6674c 100644 --- a/specs/v2/provider-policy.md +++ b/specs/v2/provider-policy.md @@ -15,9 +15,17 @@ resource: provider ID, such as openai or company-ai action: tool.use resource: :, such as shell:sudo * or edit:*.env +action: integration.connect +resource: integration ID, such as opencode, anthropic, or https://ai.acme.com + action: integration.use -resource: mcp:, plugin:, - hosted:, or skill: +resource: #, or the bare integration ID without an account + +action: mcp.use | skill.use | plugin.use +resource: MCP server name, skill ID, or unversioned package / absolute local path + +Legacy integration.use statements with an mcp:, skill:, or plugin: prefix are +converted to the matching *.use action when ConfigPolicy.Info decodes them. ``` Provider configuration and provider policy remain separate: @@ -62,7 +70,14 @@ A provider can be correctly configured and have valid credentials while policy s ```ts interface PolicyInfo { effect: "allow" | "deny" - action: "provider.use" | "tool.use" | "integration.use" + action: + | "provider.use" + | "tool.use" + | "integration.connect" + | "integration.use" + | "mcp.use" + | "skill.use" + | "plugin.use" resource: string } ``` @@ -272,14 +287,14 @@ Skills use their OpenCode IDs, including a namespace when present. Denied skills { "experimental": { "policies": [ - { "action": "integration.use", "resource": "skill:*", "effect": "deny" }, - { "action": "integration.use", "resource": "skill:team:review", "effect": "allow" }, + { "action": "skill.use", "resource": "*", "effect": "deny" }, + { "action": "skill.use", "resource": "team:review", "effect": "allow" }, ], }, } ``` -The protected terminal config policy plugin filters `skill:` through `ctx.skill.transform`, following the provider and MCP catalog pattern. The skill domain does not interpret policy statements. Config and managed policy changes reload the catalog and publish an update after the transformed values are visible. Removing a restriction restores the registered skill without reinstallation. A global integrations allow list also excludes skills unless it explicitly allows their IDs. Skill policy does not remove content already stored in session history or sandbox plugin filesystem access. +The protected terminal config policy plugin filters `skill.use` statements through `ctx.skill.transform`, following the provider and MCP catalog pattern. The skill domain does not interpret policy statements. Config and managed policy changes reload the catalog and publish an update after the transformed values are visible. Removing a restriction restores the registered skill without reinstallation. Skill policy does not remove content already stored in session history or sandbox plugin filesystem access. Plugin hooks are governed by the existing plugin integration resource: blocking an external plugin prevents its import or unloads its active generation, including its hooks. There is no separate hook resource; required built-in policy and Console authentication plugins remain protected. @@ -372,7 +387,7 @@ Equivalent v2 policy: ## MCP Integration Policy -The protected terminal config policy plugin applies `integration.use` statements to `mcp:` using `ctx.mcp.transform`. It removes denied servers after configured and plugin-provided catalog entries are assembled. The MCP domain owns connections and resource cleanup; it does not interpret policy statements. +The protected terminal config policy plugin applies `mcp.use` statements to MCP server names using `ctx.mcp.transform`. It removes denied servers after configured and plugin-provided catalog entries are assembled. The MCP domain owns connections and resource cleanup; it does not interpret policy statements. Plugin activation batches catalog registrations, so denied servers never open an initial connection. Config and managed policy updates reload the catalog. Removing an existing server closes its connection and removes its tools, instructions, prompts, resources, and resource templates. Explicit connect and direct tool, prompt, and resource calls reject removed servers through the ordinary not-found path. Allowing the server again restores its catalog entry and reconnects it. From b6f82bf15e804241abbd40aac94ac457c61b5e8b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:41:26 -0500 Subject: [PATCH 004/134] fix(core): send execute progress at most every 100 ms (#60120) --- packages/core/src/codemode/tool.ts | 27 +++++++- packages/core/test/tool-execute.test.ts | 88 ++++++++++++++++++++++-- packages/core/test/tool-registry.test.ts | 2 +- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index 977099911844..652aaeb9474f 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -10,7 +10,7 @@ import type { Namespace as ToolNamespace, Result, } from "@opencode/schema/tool" -import { Context, Effect, Ref, Schema, Semaphore } from "effect" +import { Clock, Context, Effect, Ref, Schema, Semaphore } from "effect" import { definition, normalizedName } from "../tool/runtime.js" import { CodeModeCatalog } from "./catalog.js" import { CodeModeWeb } from "./web.js" @@ -92,9 +92,26 @@ export const create = ( const files = yield* Ref.make>([]) const calls = yield* Ref.make>([]) const lock = Semaphore.makeUnsafe(1) + // Each update replaces the last, so send at most one per 100 ms; a trailing one carries the latest rows. + // A success drops a pending one when the scope closes, since the result carries the final rows. + const scope = yield* Effect.scope + const publishedAt = yield* Ref.make(Number.NEGATIVE_INFINITY) + const trailing = yield* Ref.make(false) + const publish = Effect.gen(function* () { + yield* Ref.set(trailing, false) + yield* Ref.set(publishedAt, yield* Clock.currentTimeMillis) + yield* context.progress({ toolCalls: yield* Ref.get(calls) }) + }) const record = (update: (items: Array) => Array) => lock.withPermit( - Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))), + Effect.gen(function* () { + yield* Ref.update(calls, update) + if (yield* Ref.get(trailing)) return + const wait = (yield* Ref.get(publishedAt)) + 100 - (yield* Clock.currentTimeMillis) + if (wait <= 0) return yield* publish + yield* Ref.set(trailing, true) + yield* Effect.sleep(wait).pipe(Effect.andThen(lock.withPermit(publish)), Effect.forkIn(scope)) + }), ) const result = yield* runtime(inventory) .execute(code) @@ -117,6 +134,10 @@ export const create = ( }), hooks: progressHooks(record), }), + // A failed or interrupted call keeps its last progress, so send pending rows before it settles. + Effect.onError(() => + lock.withPermit(Ref.get(trailing).pipe(Effect.flatMap((pending) => (pending ? publish : Effect.void)))), + ), ) const toolCalls = yield* Ref.get(calls) const collected = (yield* Ref.get(files)) @@ -147,7 +168,7 @@ export const create = ( content, metadata, } - }), + }).pipe(Effect.scoped), } satisfies Info } diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 532c93d6ed23..d65c55587ea4 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -6,7 +6,9 @@ import { Agent } from "@opencode/schema/agent" import { Session } from "@opencode/schema/session" import { SessionMessage } from "@opencode/schema/session-message" import type { Info } from "@opencode/schema/tool" -import { Effect, Schema } from "effect" +import { Deferred, Effect, Fiber, Schema } from "effect" +import { TestClock } from "effect/testing" +import { it } from "./lib/effect" const context = { sessionID: Session.ID.make("ses_execute"), @@ -189,14 +191,13 @@ test("executions share the prepared runtime of an inventory while keeping per-ca } expect(CodeModeTool.catalog(inventory).tools).toHaveLength(1) - const results = await Effect.runPromise(Effect.all([run("first"), run("second")], { concurrency: "unbounded" })) + const results = await Effect.runPromise( + Effect.all([run("first"), run("second")], { concurrency: "unbounded" }).pipe(Effect.provide(TestClock.layer())), + ) expect(results).toEqual( ["first", "second"].map((id) => ({ output: `${id}:${id}`, - progress: [ - { toolCalls: [{ tool: "echo", status: "running", input: { value: id } }] }, - { toolCalls: [{ tool: "echo", status: "completed", input: { value: id } }] }, - ], + progress: [{ toolCalls: [{ tool: "echo", status: "running", input: { value: id } }] }], })), ) expect( @@ -210,3 +211,78 @@ test("executions share the prepared runtime of an inventory while keeping per-ca ) expect(conversions).toBe(2) }) + +const progressCodeMode = (started: Deferred.Deferred, wait: Effect.Effect) => + createCodeMode( + new Map([ + [ + "step", + { + name: "step", + description: "Step", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "ok" }), + }, + ], + [ + "wait", + { + name: "wait", + description: "Wait", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(wait), Effect.as({ output: "ok" })), + }, + ], + ]), + ) + +const row = (tool: string, status: string) => ({ tool, status }) + +it.effect("execute sends at most one progress update per 100 ms with the latest rows", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const progress: Array = [] + const fiber = yield* progressCodeMode(started, Effect.sleep("250 millis")) + .execute( + { code: "for (const i of [1, 2, 3]) await tools.step({}); await tools.wait({}); return await tools.step({})" }, + { ...context, progress: (update) => Effect.sync(() => progress.push(update)) }, + ) + .pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.await(started) + yield* Effect.yieldNow + expect(progress).toEqual([{ toolCalls: [row("step", "running")] }]) + + yield* TestClock.adjust("100 millis") + const steps = [row("step", "completed"), row("step", "completed"), row("step", "completed")] + expect(progress.slice(1)).toEqual([{ toolCalls: [...steps, row("wait", "running")] }]) + + // The wait ends 150 ms after the trailing update, so its row goes out at once; the last step's never does. + yield* TestClock.adjust("150 millis") + const result = yield* Fiber.join(fiber) + yield* TestClock.adjust("1 second") + expect(progress.slice(2)).toEqual([{ toolCalls: [...steps, row("wait", "completed")] }]) + expect(result.metadata).toEqual({ toolCalls: [...steps, row("wait", "completed"), row("step", "completed")] }) + }), +) + +it.effect("interrupted execute sends its latest progress rows before it settles", () => + Effect.gen(function* () { + const started = yield* Deferred.make() + const progress: Array = [] + const fiber = yield* progressCodeMode(started, Effect.never) + .execute( + { code: "await tools.step({}); await tools.step({}); return await tools.wait({})" }, + { ...context, progress: (update) => Effect.sync(() => progress.push(update)) }, + ) + .pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.await(started) + yield* Fiber.interrupt(fiber) + yield* TestClock.adjust("1 second") + expect(progress).toEqual([ + { toolCalls: [row("step", "running")] }, + { toolCalls: [row("step", "completed"), row("step", "completed"), row("wait", "error")] }, + ]) + }), +) diff --git a/packages/core/test/tool-registry.test.ts b/packages/core/test/tool-registry.test.ts index 3d52fd9fd451..5e534ce9819f 100644 --- a/packages/core/test/tool-registry.test.ts +++ b/packages/core/test/tool-registry.test.ts @@ -1225,11 +1225,11 @@ describe("Tool", () => { }) expect(execution).toMatchObject({ content: [{ type: "text" }] }) + expect(execution.metadata?.toolCalls).toEqual([{ tool: "echo", status: "completed", input: { text: "request" } }]) expect(executed).toEqual(["old:request"]) expect(progress).toEqual([ { toolCalls: [{ tool: "echo", status: "running", input: { text: "request" } }] }, { stage: "old" }, - { toolCalls: [{ tool: "echo", status: "completed", input: { text: "request" } }] }, ]) }), ) From b653a6260f740c26c7b905773e4a61a01f381b37 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:45:19 -0500 Subject: [PATCH 005/134] fix(core): keep permission reasons and feedback in MCP tool failures (#60102) --- packages/core/src/tool/mcp.ts | 13 +++++--- packages/core/test/mcp.test.ts | 56 ++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index a37cdff427bf..7fbce8a31dae 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -105,11 +105,14 @@ export const layer = Layer.effect( ...(content.length === 0 ? {} : { content }), } }).pipe( - Effect.mapError((error) => - error instanceof ToolFailure - ? error - : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), - ), + Effect.mapError((error) => { + if (error instanceof ToolFailure) return error + if (error instanceof Permission.BlockedError) + return new ToolFailure({ message: error.message, error }) + if (error instanceof Permission.CorrectedError) + return new ToolFailure({ message: error.feedback, error }) + return new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}`, error }) + }), ), }) } diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 16e30dba202e..dff5b6af96d8 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -2684,7 +2684,14 @@ it.effect("does not call MCP when permission is blocked", () => Effect.gen(function* () { calls = 0 assertion = yield* Deferred.make() - decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] })) + decision = Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: "demo_search", + resources: ["*"], + reason: "Search is disabled in this workspace", + }), + ) const registry = yield* Tool.Service const registration = yield* McpTool.Service yield* registration.flush @@ -2701,11 +2708,56 @@ it.effect("does not call MCP when permission is blocked", () => input: { code: "return await tools.demo.search({})" }, }, }) - expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }]) + expect(execution.content).toEqual([{ type: "text", text: "Search is disabled in this workspace" }]) expect(execution.metadata).toEqual({ toolCalls: [{ tool: "demo.search", status: "error" }], error: true, }) + expect( + yield* executeTool(registry, { + sessionID: Session.ID.make("ses_mcp_blocked"), + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_blocked_direct", name: "direct_lookup", input: {} }, + }), + ).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Search is disabled in this workspace" }, + }) + expect(calls).toBe(0) + }), +) + +it.effect("returns permission feedback instead of calling MCP", () => + Effect.gen(function* () { + calls = 0 + assertion = yield* Deferred.make() + decision = Effect.fail(new Permission.CorrectedError({ feedback: "Search the docs folder instead" })) + const registry = yield* Tool.Service + const registration = yield* McpTool.Service + yield* registration.flush + const toolSet = yield* registry.snapshot() + + const execution = yield* toolSet.execute({ + sessionID: Session.ID.make("ses_mcp_corrected"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_corrected", + name: "execute", + input: { code: "return await tools.demo.search({})" }, + }, + }) + expect(execution.content).toEqual([{ type: "text", text: "Search the docs folder instead" }]) + expect( + yield* executeTool(registry, { + sessionID: Session.ID.make("ses_mcp_corrected"), + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_corrected_direct", name: "direct_lookup", input: {} }, + }), + ).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Search the docs folder instead" }, + }) expect(calls).toBe(0) }), ) From 4efad23dd8bf5b363cd81feda427152502f11f39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:54:46 -0500 Subject: [PATCH 006/134] release: v2.2.2 (#60337) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline --- .github/release/tag-release.sh | 3 +- CHANGELOG.md | 73 +++++++++++++++++++++++++++ bun.lock | 66 ++++++++++++------------ package.json | 2 +- packages/ai/package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/client/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/function/package.json | 2 +- packages/gui-plugins/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/httpapi-codegen/package.json | 2 +- packages/latex/package.json | 2 +- packages/merman/package.json | 2 +- packages/patchmark/package.json | 2 +- packages/plugin-browser/package.json | 2 +- packages/plugin/package.json | 2 +- packages/plugins/figma/package.json | 2 +- packages/protocol/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/simulation/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/theme/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- services/update/package.json | 2 +- 38 files changed, 143 insertions(+), 69 deletions(-) diff --git a/.github/release/tag-release.sh b/.github/release/tag-release.sh index 29da48314ba0..7100130d3355 100755 --- a/.github/release/tag-release.sh +++ b/.github/release/tag-release.sh @@ -5,7 +5,8 @@ . "$(dirname "$0")/lib.sh" tag="v$OPENCODE_VERSION" -git ls-files -z -- '*package.json' bun.lock | xargs -0 sed -i -E "s/\"version\": \"[^\"]+\"/\"version\": \"$OPENCODE_VERSION\"/g" +# @opencode/pty is versioned separately, by process-daemon-v tags. +git ls-files -z -- '*package.json' bun.lock ':!packages/process-daemon/**' | xargs -0 sed -i -E "s/\"version\": \"[^\"]+\"/\"version\": \"$OPENCODE_VERSION\"/g" if [ -n "${OPENCODE_RELEASE_NOTES:-}" ] && ! grep -qF -x "## $tag" CHANGELOG.md && ! grep -qF "## $tag " CHANGELOG.md; then notes=$(mktemp) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02d5f25d42d..ad77462b9ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ Release notes for OpenCode V2, newest first. +## v2.2.2 — 2026-10-29 + +### Highlights + +- **Image Generation by Default**: When a connected provider supports image models, agents can now use `experimental_image_generate` without additional configuration. Set its preferred model through `experimental.image_generate.model`. ([#60258](https://github.com/anomalyco/opencode/pull/60258)) +- **Faster Worktree Selection**: On the new-session screen, click the current Git branch to open that project’s “Run session in” worktree picker directly. ([#60263](https://github.com/anomalyco/opencode/pull/60263)) +- **Unified Quotes and Annotations**: Timeline quotes, terminal quotes, and browser annotations now share a compact optional-comment editor, while source highlights remain attached to local drafts. ([#60164](https://github.com/anomalyco/opencode/pull/60164)) + +### Core + +#### Improvements + +- Enabled `experimental_image_generate` by default when an image-capable provider is connected. ([#60258](https://github.com/anomalyco/opencode/pull/60258)) +- Recorded the start time of each session’s latest execution. + +#### Bug fixes + +- Rejected persistent terminal requests whose working directory no longer exists. +- Preserved media when sessions are forked or imported. +- Associated image-generation notices with the correct background job. + +### TUI + +#### Bug fixes + +- Preserved captured attachments and their original source paths. Thanks [@OpeOginni](https://github.com/OpeOginni). ([#60222](https://github.com/anomalyco/opencode/pull/60222)) +- Prevented middle-truncated paths from overflowing at very narrow terminal widths. +- Improved Go usage-limit messages with the reset time and account page link. +- Correctly typed and displayed Go and ChatGPT usage-limit errors. + +### Desktop + +#### Improvements + +- Made the Git branch on the new-session screen open the current project’s worktree picker directly. ([#60263](https://github.com/anomalyco/opencode/pull/60263)) +- Unified timeline quotes, terminal quotes, and browser annotations around one optional-comment workflow. ([#60164](https://github.com/anomalyco/opencode/pull/60164)) +- Restored classic question cards by default and added **Settings → Experimental → New question UI** for the redesigned keyboard-oriented dock. ([#59723](https://github.com/anomalyco/opencode/pull/59723)) +- Added a side-panel tab menu and kept close buttons visible when tabs overflow. +- Moved user-message actions into a compact menu. +- Redesigned the active todo pill to show the current task and concurrent work more clearly. +- Added consistent styling for searchable dialogs and composer pills. + +#### Bug fixes + +- Stopped queued prompts from dropping frames as their content grows. +- Counted queued quotes and browser annotations as attachments. +- Reported failures to persist composer attachments through the normal error UI. +- Made Markdown fragment links scroll to the referenced heading. +- Kept each tab’s timeline state when switching directories. +- Ordered the inbox’s **Working** shelf by execution start time. +- Fixed side-panel launchers and automatic closing when the final tab is removed. +- Focused the matching navigation arrow while reviewing changes. +- Preserved pending model labels only when a queued prompt actually changes models. +- Added reset details, an account link, and a composer tray for Go usage limits. + +### SDK + +#### Improvements + +- Added `if` and `until` inputs for `client.experimental.schedule.create`, plus schedule check results and output fields. ([#60137](https://github.com/anomalyco/opencode/pull/60137)) +- Exposed `SessionInfo.time.busy` for the latest execution’s start time. +- Added optional source metadata to prompt file attachments. + +### Extensions + +#### Improvements + +- Added optional `title` and `subtitle` fields to composer notes, source-relative quote positioning, and focus options for composer integrations. ([#60164](https://github.com/anomalyco/opencode/pull/60164)) + +#### Bug fixes + +- Corrected visible flowchart edges connected through Merman subgraphs. + ## v2.2.1 — 2026-10-29 ### Highlights diff --git a/bun.lock b/bun.lock index 42270238d535..52a3afff8141 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/ai": { "name": "@opencode/ai", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "workspace:*", @@ -51,7 +51,7 @@ }, "packages/app": { "name": "@opencode/app", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -105,7 +105,7 @@ }, "packages/cli": { "name": "@opencode/cli", - "version": "2.2.1", + "version": "2.2.2", "bin": { "opencode": "./bin/opencode.cjs", "opencode2": "./bin/opencode2.cjs", @@ -172,7 +172,7 @@ }, "packages/client": { "name": "@opencode/client", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/protocol": "workspace:*", "@opencode/schema": "workspace:*", @@ -198,7 +198,7 @@ }, "packages/codemode": { "name": "@opencode/codemode", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -211,7 +211,7 @@ }, "packages/console/app": { "name": "@opencode/console-app", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -249,7 +249,7 @@ }, "packages/core": { "name": "@opencode/core", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ai-sdk/provider": "3.0.19", "@ai-sdk/provider-utils": "4.0.62", @@ -312,7 +312,7 @@ }, "packages/desktop": { "name": "@opencode/desktop", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@zip.js/zip.js": "2.7.62", "electron-context-menu": "5.0.0", @@ -359,7 +359,7 @@ }, "packages/function": { "name": "@opencode/function", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -375,7 +375,7 @@ }, "packages/gui-plugins": { "name": "@opencode/gui-plugins", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", @@ -422,7 +422,7 @@ }, "packages/http-recorder": { "name": "@opencode/http-recorder", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/platform-node-shared": "catalog:", }, @@ -441,7 +441,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode/httpapi-codegen", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -454,7 +454,7 @@ }, "packages/latex": { "name": "@opencode/latex", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -468,7 +468,7 @@ }, "packages/merman": { "name": "@opencode/merman", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -483,7 +483,7 @@ }, "packages/patchmark": { "name": "@opencode/patchmark", - "version": "2.2.1", + "version": "2.2.2", "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode/plugin", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "workspace:*", @@ -541,7 +541,7 @@ }, "packages/plugin-browser": { "name": "@opencode/plugin-browser", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/plugin": "workspace:*", "@opencode/schema": "workspace:*", @@ -556,7 +556,7 @@ }, "packages/plugins/figma": { "name": "@opencode/plugin-figma", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "gray-matter": "4.0.3", }, @@ -586,7 +586,7 @@ }, "packages/protocol": { "name": "@opencode/protocol", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/schema": "workspace:*", "effect": "catalog:", @@ -601,7 +601,7 @@ }, "packages/schema": { "name": "@opencode/schema", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@standard-schema/spec": "catalog:", "effect": "catalog:", @@ -625,7 +625,7 @@ }, "packages/sdk": { "name": "@opencode/sdk", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -646,7 +646,7 @@ }, "packages/server": { "name": "@opencode/server", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", @@ -669,7 +669,7 @@ }, "packages/session-ui": { "name": "@opencode/session-ui", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -701,7 +701,7 @@ }, "packages/simulation": { "name": "@opencode/simulation", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/ai": "workspace:*", "@opencode/core": "workspace:*", @@ -721,7 +721,7 @@ }, "packages/stats/app": { "name": "@opencode/stats-app", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -747,7 +747,7 @@ }, "packages/stats/core": { "name": "@opencode/stats-core", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", @@ -765,7 +765,7 @@ }, "packages/stats/server": { "name": "@opencode/stats-server", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/platform-node": "catalog:", "@opencode/stats-core": "workspace:*", @@ -800,7 +800,7 @@ }, "packages/theme": { "name": "@opencode/theme", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opentui/core": "catalog:", "effect": "catalog:", @@ -814,7 +814,7 @@ }, "packages/tui": { "name": "@opencode/tui", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -846,7 +846,7 @@ }, "packages/ui": { "name": "@opencode/ui", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -881,7 +881,7 @@ }, "packages/util": { "name": "@opencode/util", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -920,7 +920,7 @@ }, "packages/web": { "name": "@opencode/web", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "@astrojs/cloudflare": "14.1.4", "@astrojs/markdown-remark": "7.3.2", @@ -972,7 +972,7 @@ }, "services/update": { "name": "@opencode/update", - "version": "2.2.1", + "version": "2.2.2", "dependencies": { "jose": "6.0.11", "semver": "catalog:", diff --git a/package.json b/package.json index 7136c0ca8677..3fad5414c6a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode", "description": "AI-powered development tool", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "packageManager": "bun@1.4.4", diff --git a/packages/ai/package.json b/packages/ai/package.json index b10e82f8bdfc..9bbb195c745d 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.1", + "version": "2.2.2", "name": "@opencode/ai", "type": "module", "license": "MIT", diff --git a/packages/app/package.json b/packages/app/package.json index 369440c9af8e..fb38bb74a0fc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/app", - "version": "2.2.1", + "version": "2.2.2", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f8bd2fc9ef9..103348afca67 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/cli", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "bin": { diff --git a/packages/client/package.json b/packages/client/package.json index 361076246821..efebffd9f592 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/client", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 1b547b23a963..28e173cda345 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/codemode", - "version": "2.2.1", + "version": "2.2.2", "description": "Effect-native confined code execution over schema-described tools", "type": "module", "license": "MIT", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 712e98d4710c..60f436f8d5ec 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/console-app", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index c114050243e2..84407731178e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.1", + "version": "2.2.2", "name": "@opencode/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4cd02665da9f..4b57b47b0253 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode/desktop", "private": true, - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/function/package.json b/packages/function/package.json index 41916310513a..6d8a54c9df6b 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/function", - "version": "2.2.1", + "version": "2.2.2", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/gui-plugins/package.json b/packages/gui-plugins/package.json index cd213b143d88..5ba59955cbba 100644 --- a/packages/gui-plugins/package.json +++ b/packages/gui-plugins/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/gui-plugins", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 803fa118f1ce..c70f4b35527b 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.1", + "version": "2.2.2", "name": "@opencode/http-recorder", "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index a56f5d968cc2..cf2015924231 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/httpapi-codegen", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "exports": { diff --git a/packages/latex/package.json b/packages/latex/package.json index 3091519e2dba..921fae62697b 100644 --- a/packages/latex/package.json +++ b/packages/latex/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/latex", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "exports": { diff --git a/packages/merman/package.json b/packages/merman/package.json index d80b1410cc85..fccd9d342d7c 100644 --- a/packages/merman/package.json +++ b/packages/merman/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/merman", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "exports": { diff --git a/packages/patchmark/package.json b/packages/patchmark/package.json index f45910398b8b..23f38e391308 100644 --- a/packages/patchmark/package.json +++ b/packages/patchmark/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/patchmark", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/plugin-browser/package.json b/packages/plugin-browser/package.json index a70c13b551f3..1367521064a8 100644 --- a/packages/plugin-browser/package.json +++ b/packages/plugin-browser/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin-browser", - "version": "2.2.1", + "version": "2.2.2", "description": "OpenCode's desktop browser plugin", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 74c629eb8a5a..5b16ac8a38c1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugins/figma/package.json b/packages/plugins/figma/package.json index 6ce570455b71..b860b546204f 100644 --- a/packages/plugins/figma/package.json +++ b/packages/plugins/figma/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin-figma", - "version": "2.2.1", + "version": "2.2.2", "description": "Figma MCP integration and official Figma skills for OpenCode", "type": "module", "license": "MIT", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b0c78b59180e..58f4aa02fb78 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/protocol", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/schema/package.json b/packages/schema/package.json index b493304dd922..5e2e06f59901 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/schema", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f01beb787e4e..938c187f15f8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.2.1", + "version": "2.2.2", "name": "@opencode/sdk", "type": "module", "license": "MIT", diff --git a/packages/server/package.json b/packages/server/package.json index 2550ae4d2e4a..d963e27d1f65 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/server", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index fbf83cebbbac..b576be349105 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/session-ui", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/simulation/package.json b/packages/simulation/package.json index c18b4cc81816..6de737b65792 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/simulation", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index d23e0d74cf52..58e626040adc 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/stats-app", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index bbbf8644e007..40efe77253ae 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/stats-core", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 8451a906e8f5..0d2796ccf6ca 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/stats-server", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/theme/package.json b/packages/theme/package.json index 693600d6d647..c19db0aa25d1 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/theme", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/tui/package.json b/packages/tui/package.json index a0276f11f62b..d29a505ee0b6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/tui", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index b70c55cc5d04..2e89e07cc57f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/ui", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/util/package.json b/packages/util/package.json index 3079f1d08709..4a0e4d61f41b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/util", - "version": "2.2.1", + "version": "2.2.2", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 8bf92792cfef..e2374117693f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode/web", "type": "module", "license": "MIT", - "version": "2.2.1", + "version": "2.2.2", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 61a69eff0e32..a26081bb2b9c 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "2.2.1", + "version": "2.2.2", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/services/update/package.json b/services/update/package.json index e9d3f3fa6fd9..d9683397308b 100644 --- a/services/update/package.json +++ b/services/update/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/update", - "version": "2.2.1", + "version": "2.2.2", "private": true, "type": "module", "scripts": { From bc7da10f3d828662cfe9eceff5388cd4c794163f Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:58:23 +0100 Subject: [PATCH 007/134] fix(app): keep question options and submit controls reachable on mobile (#60335) --- .../session-question-dock.spec.ts | 84 ++++++++++ .../src/session/question-switch.stories.tsx | 158 ++++++++++++++++++ .../session-question-dock-classic.tsx | 39 ++++- .../session-question-dock-experimental.tsx | 60 +++++-- .../question-prompt-experimental.css | 12 +- .../src/components/question-prompt.css | 11 ++ 6 files changed, 346 insertions(+), 18 deletions(-) diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts index c7d07611b7a2..f2df5f7cb259 100644 --- a/packages/app/component-tests/session-question-dock.spec.ts +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -80,6 +80,90 @@ story("keeps classic long answer lists inside the panel and scrolls them", async await expect(question.getByRole("radio", { name: "One pull request", exact: true })).toBeFocused() }) +story.describe("on a 393x852 mobile viewport", () => { + story.use({ hasTouch: true, isMobile: true, viewport: { width: 393, height: 852 } }) + + for (const experimental of [false, true]) { + story( + `keeps navigation visible and scrolls long prompts and 12 options (${experimental ? "experimental" : "classic"})`, + async ({ mount, page }) => { + if (experimental) { + await page.addInitScript(() => + localStorage.setItem("settings.v3", JSON.stringify({ general: { newQuestionUI: true } })), + ) + } + + const component = await mount("session-question-switch--long-prompt") + expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true) + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + const dismiss = question.getByRole("button", { name: "Dismiss", exact: true }) + const next = question.getByRole("button", { name: "Next", exact: true }) + await expect(dismiss).toBeInViewport({ ratio: 1 }) + await expect(next).toBeInViewport({ ratio: 1 }) + + const twelfth = question.getByRole("radio", { name: /12\. Clean slate rebuild/ }) + await twelfth.scrollIntoViewIfNeeded() + await expect(twelfth).toBeInViewport({ ratio: 1 }) + await twelfth.tap() + await expect(twelfth).toBeChecked() + await expect(next).toBeInViewport({ ratio: 1 }) + + await next.tap() + + const back = question.getByRole("button", { name: "Back", exact: true }) + const submit = question.getByRole("button", { name: "Submit", exact: true }) + await expect(back).toBeInViewport({ ratio: 1 }) + await expect(submit).toBeInViewport({ ratio: 1 }) + + const detailsSlot = experimental ? '[data-slot="question-details-frame"]' : '[data-slot="question-details"]' + + const layout = await question.evaluate((root, selector) => { + const details = root.querySelector(selector)! + const options = root.querySelector('[data-slot="question-options"]')! + const footer = root.querySelector('[data-slot="question-footer"]')! + const detailsRect = details.getBoundingClientRect() + const optionsRect = options.getBoundingClientRect() + const footerRect = footer.getBoundingClientRect() + + details.scrollTop = details.scrollHeight + const scrolledPrompt = details.scrollTop > 0 + + return { + promptScrollHeight: details.scrollHeight, + promptClientHeight: details.clientHeight, + scrolledPrompt, + optionsHeight: optionsRect.height, + optionsBelowPrompt: optionsRect.top >= detailsRect.bottom - 0.5, + footerBelowOptions: footerRect.top >= optionsRect.bottom - 9.5, + dockFitsViewport: root.getBoundingClientRect().bottom <= innerHeight + 0.5, + } + }, detailsSlot) + + expect(layout.promptScrollHeight).toBeGreaterThan(852) + expect(layout.promptClientHeight).toBeGreaterThan(48) + expect(layout.promptScrollHeight).toBeGreaterThan(layout.promptClientHeight) + expect(layout.scrolledPrompt).toBe(true) + expect(layout.optionsHeight).toBeGreaterThan(80) + expect(layout.optionsBelowPrompt).toBe(true) + expect(layout.footerBelowOptions).toBe(true) + expect(layout.dockFitsViewport).toBe(true) + + const lastOption = question.getByRole("radio", { name: /Export a diagnostic bundle/ }) + await lastOption.scrollIntoViewIfNeeded() + await expect(lastOption).toBeInViewport({ ratio: 1 }) + await lastOption.tap() + await expect(lastOption).toBeChecked() + await expect(submit).toBeInViewport({ ratio: 1 }) + + await submit.tap() + await expect(question).toBeHidden() + }, + ) + } +}) + story( "minimizes and restores classic questions immediately for reduced motion and keyboard actions", async ({ mount, page }) => { diff --git a/packages/app/src/session/question-switch.stories.tsx b/packages/app/src/session/question-switch.stories.tsx index 760523a33f6a..4fd21095177f 100644 --- a/packages/app/src/session/question-switch.stories.tsx +++ b/packages/app/src/session/question-switch.stories.tsx @@ -167,3 +167,161 @@ export const LongList = { /> ), } + +const longMobileOptions = [ + { + value: "opt-1", + label: "1. Incremental schema migration with dual-read verification (Recommended)", + description: + "Backfill every existing session row in bounded batches while reading from both tables and logging any mismatch before cutover.", + }, + { + value: "opt-2", + label: "2. Blue-green SQLite snapshot swap on daemon restart", + description: + "Build a fresh projected database beside the live file during idle windows and atomically swap file descriptors once parity holds.", + }, + { + value: "opt-3", + label: "3. Lazy per-session hydration on first transcript open", + description: + "Leave historical sessions untouched at startup and upgrade each transcript inside a single write transaction when opened.", + }, + { + value: "opt-4", + label: "4. Shadow journal replay from durable event log", + description: + "Replay ordered session aggregate events into a new read-model table in the background without blocking active prompt drains.", + }, + { + value: "opt-5", + label: "5. Read-only archive boundary for legacy schema versions", + description: + "Keep older transcripts viewable through a frozen decoder while writing all newly admitted prompts exclusively to the v4 tables.", + }, + { + value: "opt-6", + label: "6. Checkpoint compaction before running structural DDL", + description: + "Truncate the write-ahead log, vacuum dead pages, and verify foreign-key integrity prior to adding the new composite indexes.", + }, + { + value: "opt-7", + label: "7. Partitioned attachment metadata with external blob refs", + description: + "Move inline base64 tool outputs into content-addressed files on disk so mobile queries never scan oversized row payloads.", + }, + { + value: "opt-8", + label: "8. Staged rollout gated by workspace recovery marker", + description: + "Enable the new projection only after all claimed top-level sessions finish draining cleanly without pending inbox items.", + }, + { + value: "opt-9", + label: "9. Deterministic fixture verification before live migration", + description: + "Clone the workspace header and sample fifty recent conversations into a temporary file to benchmark query plans first.", + }, + { + value: "opt-10", + label: "10. Reversible trigger mirror between v3 and v4 columns", + description: + "Install temporary SQLite triggers that keep old and new columns synchronized so an older binary can still open the workspace.", + }, + { + value: "opt-11", + label: "11. Offline CLI migration subcommand with progress bar", + description: + "Require explicit operator confirmation via opencode db migrate and refuse automatic background writes on startup.", + }, + { + value: "opt-12", + label: "12. Clean slate rebuild of derived read projections only", + description: + "Drop disposable summary caches and rebuild every read projection directly from the canonical event history on boot.", + }, +] + +const tallMobilePrompt = [ + "Before I run the workspace storage migration on this repository, please review the full rollout plan and confirm how failure recovery should behave if the connection drops mid-run.", + "Step 1 inspects the active SQLite write-ahead log, verifies that no other local coordinator currently holds a busy execution claim, and records the starting event sequence number in a durable checkpoint row.", + "Step 2 scans session_inbox for any unconsumed user or synthetic items so steered prompts admitted right before shutdown remain preserved in exact enqueue order without duplicate delivery.", + "Step 3 creates the new normalized message part tables beside the existing transcript projection, keeping every original row intact until row counts, byte checksums, and foreign-key constraints match across all sessions.", + "Step 4 streams large diff and terminal output blobs out of inline JSON columns into content-addressed storage files, preventing multi-megabyte rows from stalling mobile timeline queries over remote tunnels.", + "Step 5 rebuilds the full-text search index and session summary projections in slices of twenty-five conversations per transaction, yielding between slices so interactive HTTP requests and live SSE updates stay responsive.", + "Step 6 validates cursor pagination from both ends of the longest session timeline, checking that sticky tool headers, collapsed reasoning blocks, and pending permission or question docks restore to identical state.", + "Step 7 writes a reversible compatibility view so older CLI processes inspecting the same directory fail gracefully with a clear upgrade message rather than corrupting partially migrated state.", + "Step 8 runs PRAGMA integrity_check and compares aggregate token, cost, and tool-call totals before and after the projection swap to catch any silent truncation in historical metadata.", + "Step 9 retains the pre-migration backup snapshot in the cache directory for seven days so you can roll back with a single command if any downstream script or plugin depends on the legacy layout.", + "Step 10 revalidates workspace permissions, active MCP server tool bindings, and pending form or elicitation state so an in-flight agent session resumes cleanly without re-prompting for completed answers.", + "Step 11 checks disk free space on both the database volume and temporary directory before committing the final rename, ensuring low-storage mobile hosts and containers never wedge mid-checkpoint.", + "Step 12 emits a structured migration summary event with elapsed wall time, migrated row counts, reclaimed page bytes, and fallback instructions for operators monitoring remote headless servers.", + "If a power loss, SIGKILL, or mobile network timeout interrupts any of these steps before the final checkpoint commits, how should the server handle the unfinished migration when it starts back up?", +].join(" ") + +// Twelve verbose options followed by a question prompt taller than a phone viewport. +export const LongPrompt = { + render: () => ( + + ), +} diff --git a/packages/app/src/session/requests/session-question-dock-classic.tsx b/packages/app/src/session/requests/session-question-dock-classic.tsx index f7379ad3deff..5d5dedb05906 100644 --- a/packages/app/src/session/requests/session-question-dock-classic.tsx +++ b/packages/app/src/session/requests/session-question-dock-classic.tsx @@ -122,6 +122,7 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit onCleanup(observeReducedMotion(() => setStore("reducedMotion", prefersReducedMotion()))) let root: HTMLDivElement | undefined + let detailsRef: HTMLDivElement | undefined let optionsRef: HTMLDivElement | undefined let customRef: HTMLButtonElement | undefined let customInput: HTMLInputElement | undefined @@ -197,7 +198,10 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit const dock = root.closest('[data-component="session-composer-dock"]') // In the app this row can have zero height and overflow upward; the containing panel bounds the question. const column = bounds(dock?.parentElement) - const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight) + const parent = dock?.parentElement?.getBoundingClientRect() + const edge = parent && parent.width > 0 ? parent.bottom : Infinity + const view = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : Infinity + const bottom = Math.min(window.innerHeight, view, column?.bottom ?? Infinity, edge) const around = dock instanceof HTMLElement @@ -232,7 +236,14 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit focusFrame = requestAnimationFrame(() => { focusFrame = undefined const el = next === options().length ? customRef : optsRef[next] - el?.focus() + el?.focus({ preventScroll: true }) + const row = el?.closest('[data-slot="question-option"]') + + if (!row || !optionsRef) return + optionsRef.scrollTop = Math.min( + row.offsetTop, + Math.max(optionsRef.scrollTop, row.offsetTop + row.offsetHeight - optionsRef.clientHeight), + ) }) } @@ -247,9 +258,14 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit }) } - update() + measure() makeEventListener(window, "resize", update) + if (window.visualViewport) { + makeEventListener(window.visualViewport, "resize", update) + makeEventListener(window.visualViewport, "scroll", update) + } + const dock = root?.closest('[data-component="session-composer-dock"]') const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') createResizeObserver([dock, scroller], update) @@ -527,7 +543,12 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit setStore("tab", tab) setStore("editing", false) - if (optionsRef) setStore("optionsHeight", optionsRef.scrollHeight) + if (detailsRef) detailsRef.scrollTop = 0 + + if (optionsRef) { + optionsRef.scrollTop = 0 + setStore("optionsHeight", optionsRef.scrollHeight) + } if (!store.minimized) focus(pickFocus(tab)) } @@ -547,6 +568,8 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit const minimize = () => { if (sending()) return + + if (optionsRef) setStore("optionsHeight", optionsRef.offsetHeight) setStore("editing", false) setStore("minimized", true) } @@ -606,7 +629,11 @@ export const SessionQuestionDockClassic: Component<{ request: FormInfo; onSubmit />
  • -
    +
    (detailsRef = el)} + >
    0.1 }} style={{ - "max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`, + "max-height": hidden() > 0 ? `${Math.max(0, store.optionsHeight * (1 - hidden()))}px` : undefined, opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`, visibility: optionsOff() ? "hidden" : "visible", }} diff --git a/packages/app/src/session/requests/session-question-dock-experimental.tsx b/packages/app/src/session/requests/session-question-dock-experimental.tsx index 31118f2374ea..42add84d42e4 100644 --- a/packages/app/src/session/requests/session-question-dock-experimental.tsx +++ b/packages/app/src/session/requests/session-question-dock-experimental.tsx @@ -348,10 +348,12 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS // grows past it and springs back. const limit = { max: Infinity, chrome: 0 } const cap = () => limit.max - limit.chrome + const detailsCap = () => Math.max(48, (limit.max - 120) * 0.45) + const shownDetails = (details = content.details) => Math.min(details, detailsCap()) // The room the answers box has now, while the question text springs to its height, and once it is there. - const room = () => cap() - boxes.details.value - const settled = (details = content.details) => cap() - details - const scroll = { top: 0, view: 0 } + const room = () => Math.max(0, cap() - boxes.details.value) + const settled = (details = content.details) => Math.max(0, cap() - shownDetails(details)) + const scroll = { details: 0, top: 0, view: 0 } const measure = () => { if (!root) return @@ -371,7 +373,10 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS // The dock sits at the foot of the column it shares with the timeline, in the app inside a zero-height row that it // overflows upward. Grown past the column it spills out of it, so the column bounds it, not the dock's own edge. const column = bounds(dock?.parentElement) - const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight) + const parent = dock?.parentElement?.getBoundingClientRect() + const edge = parent && parent.width > 0 ? parent.bottom : Infinity + const view = window.visualViewport ? window.visualViewport.offsetTop + window.visualViewport.height : Infinity + const bottom = Math.min(window.innerHeight, view, column?.bottom ?? Infinity, edge) // Whatever the dock holds besides the question, above it or below, takes from the same height. const around = @@ -388,6 +393,8 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS limit.chrome = root.offsetHeight - optionsRef.offsetHeight - detailsFrame.offsetHeight root.style.setProperty("--question-prompt-max-height", `${limit.max}px`) + if (content.details > 0) boxes.details.snap(shownDetails()) + if (content.list > 0) boxes.list.snap(Math.min(content.list, settled())) paint() } @@ -452,6 +459,11 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS makeEventListener(window, "resize", requestMeasure) + if (window.visualViewport) { + makeEventListener(window.visualViewport, "resize", requestMeasure) + makeEventListener(window.visualViewport, "scroll", requestMeasure) + } + const dock = root?.closest('[data-component="session-composer-dock"]') const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') createResizeObserver([dock, scroller], requestMeasure) @@ -485,12 +497,17 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS // The answers box opens only as far as the room the new question text leaves it at rest. ;(["details", "list"] as const).forEach((key) => { const spring = boxes[key] - const size = key === "list" ? Math.min(content.list, settled()) : content.details + const size = key === "list" ? Math.min(content.list, settled()) : shownDetails() if (spring.target === size) return - if (fresh[key] || !questionMotion()) spring.snap(size) - else tune(spring, resize()).set(size) + if (fresh[key] || !questionMotion()) { + spring.snap(size) + + return + } + + tune(spring, resize()).set(size) }) wake() }) @@ -795,7 +812,7 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS ...track(live, -dir * reach, moving, options, v.launch()), tab: store.tab, // The answers box scrolls back to the top for the new question, so the copy keeps its place on screen. - els: [copy(detailsRef, detailsFrame), copy(listRef, optionsTrack, -scroll.top)], + els: [copy(detailsRef, detailsFrame, -scroll.details), copy(listRef, optionsTrack, -scroll.top)], details: content.details, list: content.list, scroll: scroll.top, @@ -828,9 +845,10 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS const sized = questionMotion() if (detailsFrame && content.details > 0) { - const shown = boxes.details.value + const shown = Math.min(boxes.details.value, detailsCap()) detailsFrame.style.height = sized ? `${shown.toFixed(2)}px` : "" - mask(detailsFrame, 0, Math.max(content.details, ghost?.details ?? 0) - shown, shown, sliding) + const above = store.minimized ? 0 : scroll.details + mask(detailsFrame, above, Math.max(content.details, ghost?.details ?? 0) - above - shown, shown, sliding) } if (optionsRef && content.list > 0) { @@ -981,6 +999,8 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS }) optsRef.length = options().length + if (detailsFrame) detailsFrame.scrollTop = scroll.details = 0 + if (optionsRef) optionsRef.scrollTop = scroll.top = 0 const reduced = !questionMotion() @@ -1137,7 +1157,24 @@ export const SessionQuestionDockExperimental: Component<{ request: FormInfo; onS />
    -
    (detailsFrame = el)}> +
    (detailsFrame = el)} + onScroll={(event) => { + scroll.details = event.currentTarget.scrollTop + + if (!detailsFrame) return + const shown = Math.min(boxes.details.value, detailsCap()) + mask( + detailsFrame, + scroll.details, + Math.max(content.details, ghost?.details ?? 0) - scroll.details - shown, + shown, + !!(into || ghost), + ) + }} + >
    (detailsRef = el)}>
    (optionsRef = el)} data-slot="question-options" + data-scrollable role={multi() ? "group" : "radiogroup"} aria-label={question()?.question} inert={store.minimized} diff --git a/packages/session-ui/src/components/question-prompt-experimental.css b/packages/session-ui/src/components/question-prompt-experimental.css index b54036dbba90..c2fe574baec9 100644 --- a/packages/session-ui/src/components/question-prompt-experimental.css +++ b/packages/session-ui/src/components/question-prompt-experimental.css @@ -37,6 +37,7 @@ gap: 16px; flex: 1; min-block-size: 0; + overflow: clip; padding: 8px; } @@ -45,6 +46,7 @@ flex-direction: column; gap: 4px; flex-shrink: 0; + min-block-size: 0; min-inline-size: 0; padding-inline: 10px; } @@ -88,11 +90,19 @@ [data-slot="question-details-frame"] { --question-gutter: 10px; position: relative; + min-block-size: 0; min-inline-size: 0; + max-block-size: max(48px, calc((var(--question-prompt-max-height, 100dvh) - 120px) * 0.45)); margin-inline: calc(-1 * var(--question-gutter)); padding-inline: var(--question-gutter); - overflow: clip; + overflow-x: clip; + overflow-y: auto; + scrollbar-width: none; mask-composite: intersect; + + &::-webkit-scrollbar { + display: none; + } } /* A question switching away shrinks toward its top, and the next one grows from there. */ diff --git a/packages/session-ui/src/components/question-prompt.css b/packages/session-ui/src/components/question-prompt.css index d4d8ff0a3fb2..1762cb73c354 100644 --- a/packages/session-ui/src/components/question-prompt.css +++ b/packages/session-ui/src/components/question-prompt.css @@ -19,6 +19,7 @@ gap: 16px; flex: 1; min-block-size: 0; + overflow: clip; padding: 8px 12px 12px; background-color: var(--v2-background-bg-base); border-radius: 10px; @@ -30,6 +31,7 @@ flex-direction: column; gap: 4px; flex-shrink: 0; + min-block-size: 0; min-inline-size: 0; } @@ -71,7 +73,15 @@ display: flex; flex-direction: column; gap: 8px; + min-block-size: 0; min-inline-size: 0; + max-block-size: max(48px, calc((var(--question-prompt-max-height, 100dvh) - 120px) * 0.45)); + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } [data-slot="question-text"] { @@ -166,6 +176,7 @@ } [data-slot="question-options"] { + position: relative; display: flex; flex-direction: column; gap: 6px; From 085d6f7b4b1d5e79feba44aa872c52de4fb57e70 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:07:21 +0100 Subject: [PATCH 008/134] fix(tui): resolve default cursor style across config reloads (#60302) --- packages/tui/src/component/prompt/index.tsx | 6 +- packages/tui/src/config/index.tsx | 12 ++- packages/tui/src/routes/session/form.tsx | 4 +- .../tui/src/routes/session/permission.tsx | 2 +- packages/tui/src/ui/dialog-prompt.tsx | 2 +- packages/tui/src/ui/dialog-select.tsx | 2 +- .../tui/test/cli/tui/dialog-prompt.test.tsx | 75 ++++++++++++++++++- packages/tui/test/config-v2.test.tsx | 9 +++ 8 files changed, 93 insertions(+), 19 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index a28149ea1d46..8f5ae83aea65 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -353,7 +353,7 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return input.cursorColor = disabled() ? theme.background.raised.base : theme.text.base - if (config.cursor) input.cursorStyle = config.cursor + input.cursorStyle = config.cursor }) const [store, setStore] = createStore<{ @@ -1773,7 +1773,7 @@ export function Prompt(props: PromptProps) { focusedTextColor={muted() ? theme.text.muted : theme.text.base} minHeight={1} maxHeight={maxHeight()} - cursorStyle={config.cursor} + cursorStyle={{ ...config.cursor }} onContentChange={() => { const value = input.plainText setStore("prompt", "text", value) @@ -1832,7 +1832,7 @@ export function Prompt(props: PromptProps) { // setTimeout is a workaround and needs to be addressed properly if (!input || input.isDestroyed) return input.cursorColor = disabled() ? theme.background.raised.base : theme.text.base - if (config.cursor) input.cursorStyle = config.cursor + input.cursorStyle = config.cursor }, 0) }} onMouseDown={(r: MouseEvent) => { diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ec23025336d4..a37021f904fa 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -267,7 +267,7 @@ export type Resolved = Omit