fix(ui): hide environment variable values and mask inputs as password (#284)

## Summary

- Display only environment variable names in instance info panel (hide
values)
- Display only environment variable names in logs view (hide values)
- Change environment variable value inputs to password type in settings
editor

## Motivation

Environment variable values (which may contain sensitive data like API
keys, tokens, or secrets) were displayed in plain text across multiple
UI surfaces. This change:

1. **Reduces visual exposure** — instance info and logs view now show
only variable names
2. **Masks input fields** — the settings editor uses `type="password"`
for value inputs, preventing shoulder-surfing and accidental exposure in
screenshots or screen recordings

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
bluelovers 2026-05-27 22:24:13 +08:00 committed by GitHub
parent cb3d4841e1
commit 7f431aba8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 126 additions and 13 deletions

View file

@ -1,5 +1,5 @@
import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid"
import { Plus, Trash2, Key, Globe, Shield, ShieldOff } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
@ -14,10 +14,13 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
isSecureEnvVar,
toggleSecureEnvVar,
} = useConfig()
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("")
const [newVarSecure, setNewVarSecure] = createSignal(true)
const entries = () => Object.entries(envVars())
@ -27,10 +30,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
if (!key) return
addEnvironmentVariable(key, value)
addEnvironmentVariable(key, value, newVarSecure())
setEnvVars({ ...envVars(), [key]: value })
setNewKey("")
setNewValue("")
setNewVarSecure(true)
}
function handleRemoveVariable(key: string) {
@ -45,6 +49,10 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
updateEnvironmentVariables(updated)
}
function handleSecureToggle(key: string) {
toggleSecureEnvVar(key)
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@ -81,14 +89,29 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
title={t("envEditor.fields.name.readOnlyTitle")}
/>
<input
type="text"
type={isSecureEnvVar(key) ? "password" : "text"}
value={value}
disabled={props.disabled}
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={t("envEditor.fields.value.placeholder")}
autocomplete={isSecureEnvVar(key) ? "new-password" : "off"}
spellcheck={isSecureEnvVar(key) ? false : undefined}
autocapitalize={isSecureEnvVar(key) ? "off" : undefined}
/>
</div>
<button
onClick={() => handleSecureToggle(key)}
disabled={props.disabled}
class={`p-1.5 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isSecureEnvVar(key)
? "icon-accent-hover text-accent"
: "icon-muted icon-accent-hover"
}`}
title={isSecureEnvVar(key) ? t("envEditor.fields.secure.enabled") : t("envEditor.fields.secure.disabled")}
>
{isSecureEnvVar(key) ? <Shield class="w-3.5 h-3.5" /> : <ShieldOff class="w-3.5 h-3.5" />}
</button>
<button
onClick={() => handleRemoveVariable(key)}
disabled={props.disabled}
@ -117,15 +140,28 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
placeholder={t("envEditor.fields.name.placeholder")}
/>
<input
type="text"
type={newVarSecure() ? "password" : "text"}
value={newValue()}
onInput={(e) => setNewValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={t("envEditor.fields.value.placeholder")}
autocomplete="new-password"
/>
</div>
<button
onClick={() => setNewVarSecure(!newVarSecure())}
disabled={props.disabled}
class={`p-1.5 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
newVarSecure()
? "icon-accent-hover text-accent"
: "icon-muted icon-accent-hover"
}`}
title={newVarSecure() ? t("envEditor.fields.secure.enabled") : t("envEditor.fields.secure.disabled")}
>
{newVarSecure() ? <Shield class="w-3.5 h-3.5" /> : <ShieldOff class="w-3.5 h-3.5" />}
</button>
<button
onClick={handleAddVariable}
disabled={props.disabled || !newKey().trim()}

View file

@ -3,6 +3,7 @@ import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
import { useConfig } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
@ -18,6 +19,7 @@ const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const { isSecureEnvVar } = useConfig()
const metadataContext = useOptionalInstanceMetadataContext()
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
@ -156,8 +158,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
{key}
</span>
<span class="text-xs font-mono flex-1 text-secondary" title={value}>
{value}
<span class="text-xs font-mono flex-1 text-secondary" title={isSecureEnvVar(key) ? t("envEditor.fields.secure.masked") : value}>
{isSecureEnvVar(key) ? "***" : value}
</span>
</div>
)}

View file

@ -2,6 +2,7 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, c
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { useConfig } from "../stores/preferences"
interface LogsViewProps {
instanceId: string
@ -11,6 +12,7 @@ const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean
const LogsView: Component<LogsViewProps> = (props) => {
const { t } = useI18n()
const { isSecureEnvVar } = useConfig()
let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
@ -112,10 +114,12 @@ const LogsView: Component<LogsViewProps> = (props) => {
{([key, value]) => (
<div class="env-var-item">
<span class="env-var-key">{key}</span>
<span class="env-var-separator">=</span>
<span class="env-var-value" title={value}>
{value}
</span>
<Show when={!isSecureEnvVar(key)}>
<span class="env-var-separator">=</span>
<span class="env-var-value" title={value}>
{value}
</span>
</Show>
</div>
)}
</For>

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "Variable name",
"envEditor.fields.name.readOnlyTitle": "Variable name (read-only)",
"envEditor.fields.value.placeholder": "Variable value",
"envEditor.fields.secure.enabled": "Value masked (secure)",
"envEditor.fields.secure.disabled": "Click to mask value",
"envEditor.fields.secure.masked": "Value hidden (secure)",
"envEditor.actions.remove.title": "Remove variable",
"envEditor.actions.add.title": "Add variable",
"envEditor.empty": "No environment variables configured. Add variables above to customize the OpenCode environment.",

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "Nombre de la variable",
"envEditor.fields.name.readOnlyTitle": "Nombre de la variable (solo lectura)",
"envEditor.fields.value.placeholder": "Valor de la variable",
"envEditor.fields.secure.enabled": "Valor oculto (seguro)",
"envEditor.fields.secure.disabled": "Haz clic para ocultar el valor",
"envEditor.fields.secure.masked": "Valor oculto (seguro)",
"envEditor.actions.remove.title": "Quitar variable",
"envEditor.actions.add.title": "Agregar variable",
"envEditor.empty": "No hay variables de entorno configuradas. Agrega variables arriba para personalizar el entorno de OpenCode.",

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "Nom de la variable",
"envEditor.fields.name.readOnlyTitle": "Nom de la variable (lecture seule)",
"envEditor.fields.value.placeholder": "Valeur de la variable",
"envEditor.fields.secure.enabled": "Valeur masquée (sécurisé)",
"envEditor.fields.secure.disabled": "Cliquez pour masquer la valeur",
"envEditor.fields.secure.masked": "Valeur masquée (sécurisé)",
"envEditor.actions.remove.title": "Supprimer la variable",
"envEditor.actions.add.title": "Ajouter une variable",
"envEditor.empty": "Aucune variable d'environnement configurée. Ajoutez des variables ci-dessus pour personnaliser l'environnement OpenCode.",

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "שם משתנה",
"envEditor.fields.name.readOnlyTitle": "שם משתנה (לקריאה בלבד)",
"envEditor.fields.value.placeholder": "ערך משתנה",
"envEditor.fields.secure.enabled": "הערך מוסתר (מאובטח)",
"envEditor.fields.secure.disabled": "לחץ להסתרת הערך",
"envEditor.fields.secure.masked": "הערך מוסתר (מאובטח)",
"envEditor.actions.remove.title": "הסר משתנה",
"envEditor.actions.add.title": "הוסף משתנה",
"envEditor.empty": "לא הוגדרו משתני סביבה. הוסף משתנים למעלה להתאמת סביבת OpenCode.",

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "変数名",
"envEditor.fields.name.readOnlyTitle": "変数名 (読み取り専用)",
"envEditor.fields.value.placeholder": "変数値",
"envEditor.fields.secure.enabled": "値は非表示(セキュア)",
"envEditor.fields.secure.disabled": "クリックして値を非表示",
"envEditor.fields.secure.masked": "値は非表示(セキュア)",
"envEditor.actions.remove.title": "変数を削除",
"envEditor.actions.add.title": "変数を追加",
"envEditor.empty": "環境変数が設定されていません。上で変数を追加して OpenCode 環境をカスタマイズしてください。",

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "Имя переменной",
"envEditor.fields.name.readOnlyTitle": "Имя переменной (только чтение)",
"envEditor.fields.value.placeholder": "Значение переменной",
"envEditor.fields.secure.enabled": "Значение скрыто (безопасно)",
"envEditor.fields.secure.disabled": "Нажмите, чтобы скрыть значение",
"envEditor.fields.secure.masked": "Значение скрыто (безопасно)",
"envEditor.actions.remove.title": "Удалить переменную",
"envEditor.actions.add.title": "Добавить переменную",
"envEditor.empty": "Переменные окружения не настроены. Добавьте переменные выше, чтобы настроить окружение OpenCode.",

View file

@ -43,6 +43,9 @@ export const settingsMessages = {
"envEditor.fields.name.placeholder": "变量名",
"envEditor.fields.name.readOnlyTitle": "变量名(只读)",
"envEditor.fields.value.placeholder": "变量值",
"envEditor.fields.secure.enabled": "值已隐藏(安全)",
"envEditor.fields.secure.disabled": "点击隐藏值",
"envEditor.fields.secure.masked": "值已隐藏(安全)",
"envEditor.actions.remove.title": "移除变量",
"envEditor.actions.add.title": "添加变量",
"envEditor.empty": "未配置环境变量。在上方添加变量以自定义 OpenCode 环境。",

View file

@ -101,6 +101,7 @@ interface ServerConfigBucket {
listeningMode?: ListeningMode
logLevel?: ServerLogLevel
environmentVariables?: Record<string, string>
secureEnvVars?: string[]
opencodeBinary?: string
speech?: Partial<SpeechSettings>
}
@ -310,7 +311,7 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
function normalizeServerConfig(
input?: ServerConfigBucket | null,
): Required<Pick<ServerConfigBucket, "listeningMode" | "logLevel" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
): Required<Pick<ServerConfigBucket, "listeningMode" | "logLevel" | "environmentVariables" | "opencodeBinary" | "secureEnvVars">> & { speech: SpeechSettings } {
const source = input ?? {}
const listeningMode = source.listeningMode === "all" ? "all" : "local"
const logLevel =
@ -319,8 +320,14 @@ function normalizeServerConfig(
: "DEBUG"
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
const environmentVariables = normalizeRecord(source.environmentVariables)
const secureEnvVars = normalizeSecureEnvVars(source.secureEnvVars)
const speech = normalizeSpeechSettings(source.speech)
return { listeningMode, logLevel, opencodeBinary, environmentVariables, speech }
return { listeningMode, logLevel, opencodeBinary, environmentVariables, secureEnvVars, speech }
}
function normalizeSecureEnvVars(input?: unknown): string[] {
if (!Array.isArray(input)) return []
return input.filter((item): item is string => typeof item === "string" && item.length > 0)
}
function getModelKey(model: { providerId: string; modelId: string }): string {
@ -469,9 +476,29 @@ function updateEnvironmentVariables(envVars: Record<string, string>): void {
)
}
function addEnvironmentVariable(key: string, value: string): void {
function addEnvironmentVariable(key: string, value: string, secure: boolean = true): void {
const current = serverSettings().environmentVariables
updateEnvironmentVariables({ ...current, [key]: value })
const secureList = serverSettings().secureEnvVars
const upperKey = key.toUpperCase()
const exists = secureList.some((name) => name.toUpperCase() === upperKey)
if (secure) {
if (!exists) {
const next = [...secureList, key]
void patchConfigOwner("server", { secureEnvVars: next }).catch((error) =>
log.error("Failed to add secure env var", error),
)
}
} else {
if (exists) {
const next = secureList.filter((name) => name.toUpperCase() !== upperKey)
void patchConfigOwner("server", { secureEnvVars: next }).catch((error) =>
log.error("Failed to remove secure env var", error),
)
}
}
}
function removeEnvironmentVariable(key: string): void {
@ -480,6 +507,23 @@ function removeEnvironmentVariable(key: string): void {
updateEnvironmentVariables(rest)
}
function isSecureEnvVar(key: string): boolean {
const secureList = serverSettings().secureEnvVars
return secureList.some((name) => name.toUpperCase() === key.toUpperCase())
}
function toggleSecureEnvVar(key: string): void {
const secureList = serverSettings().secureEnvVars
const upperKey = key.toUpperCase()
const exists = secureList.some((name) => name.toUpperCase() === upperKey)
const next = exists
? secureList.filter((name) => name.toUpperCase() !== upperKey)
: [...secureList, key]
void patchConfigOwner("server", { secureEnvVars: next }).catch((error) =>
log.error("Failed to update secure env vars", error),
)
}
function updateLastUsedBinary(path: string): void {
const target = path && path.trim().length > 0 ? path : "opencode"
void patchConfigOwner("server", { opencodeBinary: target }).catch((error) => log.error("Failed to set default binary", error))
@ -724,6 +768,8 @@ interface ConfigContextValue {
updateEnvironmentVariables: typeof updateEnvironmentVariables
addEnvironmentVariable: typeof addEnvironmentVariable
removeEnvironmentVariable: typeof removeEnvironmentVariable
isSecureEnvVar: typeof isSecureEnvVar
toggleSecureEnvVar: typeof toggleSecureEnvVar
updateLastUsedBinary: typeof updateLastUsedBinary
updateLogLevel: typeof updateLogLevel
updateSpeechSettings: typeof updateSpeechSettings
@ -780,6 +826,8 @@ const configContextValue: ConfigContextValue = {
updateEnvironmentVariables,
addEnvironmentVariable,
removeEnvironmentVariable,
isSecureEnvVar,
toggleSecureEnvVar,
updateLastUsedBinary,
updateLogLevel,
updateSpeechSettings,
@ -869,6 +917,8 @@ export {
updateEnvironmentVariables,
addEnvironmentVariable,
removeEnvironmentVariable,
isSecureEnvVar,
toggleSecureEnvVar,
updateLastUsedBinary,
updateLogLevel,
updateSpeechSettings,