From c87c0e88f28209b185203c6b3e6d21a41be8e13f Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:03:36 +0200 Subject: [PATCH 1/8] feat(desktop): add env editor to agent settings, seed trust through env wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Agents had no way to edit an agent's env (host_agent_configs.env_json), so users wrapped the command as `env CLAUDE_CONFIG_DIR=… claude`. That form broke folder-trust seeding, which only looked at the command's first word. - Add an Environment Variables editor (key/value rows, add/remove, save on blur) to the agent detail page. Invalid names are flagged and never saved. - The Command field no longer folds env into its text, and saving it merges any inline `VAR=value` prefix into the existing env instead of replacing it, so a command edit can't drop env (previously a command with shell syntax saved env: {}). - resolveTrustFamily sees through `[env] [VAR=value …] `, and the trust target honours those inline assignments, so `env CLAUDE_CONFIG_DIR=/p claude` seeds /p/.claude.json. Refs #9309 Co-Authored-By: Claude Opus 5.5 --- .../src/renderer/lib/agent-launch-command.ts | 2 +- .../components/AgentDetail/AgentDetail.tsx | 36 +++-- .../AgentEnvEditor/AgentEnvEditor.tsx | 141 ++++++++++++++++++ .../components/AgentEnvEditor/index.ts | 1 + .../shared/seed-agent-trust.test.ts | 64 +++++++- .../shared/seed-agent-trust.ts | 64 ++++++-- packages/i18n/locales/cs/messages.po | 15 ++ packages/i18n/locales/de/messages.po | 15 ++ packages/i18n/locales/en/messages.po | 15 ++ packages/i18n/locales/es/messages.po | 15 ++ packages/i18n/locales/fr/messages.po | 15 ++ packages/i18n/locales/id/messages.po | 15 ++ packages/i18n/locales/it/messages.po | 15 ++ packages/i18n/locales/ja/messages.po | 15 ++ packages/i18n/locales/ko/messages.po | 15 ++ packages/i18n/locales/nl/messages.po | 15 ++ packages/i18n/locales/pl/messages.po | 15 ++ packages/i18n/locales/pt-BR/messages.po | 15 ++ packages/i18n/locales/ru/messages.po | 15 ++ packages/i18n/locales/tr/messages.po | 15 ++ packages/i18n/locales/uk/messages.po | 15 ++ packages/i18n/locales/vi/messages.po | 15 ++ packages/i18n/locales/zh-CN/messages.po | 15 ++ packages/i18n/locales/zh-TW/messages.po | 15 ++ 24 files changed, 553 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts diff --git a/apps/desktop/src/renderer/lib/agent-launch-command.ts b/apps/desktop/src/renderer/lib/agent-launch-command.ts index bb45bebddab..bd3e5be7067 100644 --- a/apps/desktop/src/renderer/lib/agent-launch-command.ts +++ b/apps/desktop/src/renderer/lib/agent-launch-command.ts @@ -24,7 +24,7 @@ interface LinkedPresetLaunch { commands: string[]; } -function areEnvMapsEqual( +export function areEnvMapsEqual( left: Record, right: Record, ): boolean { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index 8c52e7ca0dc..30cab202e59 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -24,6 +24,7 @@ import { useMutation } from "@tanstack/react-query"; import { Info, RotateCcw, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { + areEnvMapsEqual, getAgentCommandText, isAgentCommandPatchChanged, parseAgentCommandText, @@ -33,6 +34,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { AgentEnvEditor } from "../AgentEnvEditor"; import { AgentDetailHeader, AgentLaunchFields, @@ -85,7 +87,9 @@ export function AgentDetail({ }); const [label, setLabel] = useState(config.label); - const [commandText, setCommandText] = useState(getAgentCommandText(config)); + const [commandText, setCommandText] = useState( + getAgentCommandText({ command: config.command, args: config.args }), + ); const [promptArgsText, setPromptArgsText] = useState( joinArgs(config.promptArgs), ); @@ -102,11 +106,7 @@ export function AgentDetail({ useEffect(() => { setLabel(config.label); setCommandText( - getAgentCommandText({ - command: config.command, - args: config.args, - env: config.env, - }), + getAgentCommandText({ command: config.command, args: config.args }), ); setPromptArgsText(joinArgs(config.promptArgs)); setResumeArgsText(joinArgs(config.resumeArgs ?? [])); @@ -116,7 +116,6 @@ export function AgentDetail({ config.label, config.command, config.args, - config.env, config.promptArgs, config.resumeArgs, config.forkArgs, @@ -219,7 +218,8 @@ export function AgentDetail({ }; const handleCommandBlur = () => { - const patch = parseAgentCommandText(commandText); + const parsed = parseAgentCommandText(commandText); + const patch = { ...parsed, env: { ...config.env, ...parsed.env } }; const { command } = patch; if (command.length === 0) { toast.error( @@ -227,7 +227,9 @@ export function AgentDetail({ message: "Command cannot be empty", }), ); - setCommandText(getAgentCommandText(config)); + setCommandText( + getAgentCommandText({ command: config.command, args: config.args }), + ); return; } if (isAgentCommandPatchChanged(config, patch)) { @@ -235,6 +237,10 @@ export function AgentDetail({ } }; + const handleEnvCommit = (env: Record) => { + if (!areEnvMapsEqual(env, config.env)) updateMutation.mutate({ env }); + }; + const handlePromptArgsBlur = () => { const args = parseArgs(promptArgsText); const changed = @@ -324,6 +330,18 @@ export function AgentDetail({ onPromptTransportChange={handleTransportChange} /> +
+ +
+ {isHooksSetupTarget ? (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx new file mode 100644 index 00000000000..4390afeb291 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx @@ -0,0 +1,141 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Plus, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { areEnvMapsEqual } from "renderer/lib/agent-launch-command"; + +const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; + +interface EnvRow { + id: string; + key: string; + value: string; +} + +function toRows(env: Record): EnvRow[] { + return Object.entries(env).map(([key, value]) => ({ + id: crypto.randomUUID(), + key, + value, + })); +} + +function isInvalidKey(key: string): boolean { + return key.trim().length > 0 && !ENV_KEY.test(key.trim()); +} + +function toEnv(rows: readonly EnvRow[]): Record { + const env: Record = {}; + for (const row of rows) { + const key = row.key.trim(); + if (ENV_KEY.test(key)) env[key] = row.value; + } + return env; +} + +interface AgentEnvEditorProps { + idPrefix: string; + env: Record; + onCommit: (env: Record) => void; +} + +export function AgentEnvEditor({ + idPrefix, + env, + onCommit, +}: AgentEnvEditorProps) { + const { t } = useLingui(); + const variableNameLabel = t({ message: "Variable name" }); + const variableValueLabel = t({ message: "Variable value" }); + const [rows, setRows] = useState(() => toRows(env)); + + useEffect(() => { + setRows((current) => + areEnvMapsEqual(toEnv(current), env) ? current : toRows(env), + ); + }, [env]); + + const commit = (next: readonly EnvRow[]) => { + if (next.some((row) => isInvalidKey(row.key))) return; + onCommit(toEnv(next)); + }; + + const updateRow = (id: string, patch: Partial) => + setRows((current) => + current.map((row) => (row.id === id ? { ...row, ...patch } : row)), + ); + + const removeRow = (id: string) => { + const next = rows.filter((row) => row.id !== id); + setRows(next); + commit(next); + }; + + const addRow = () => + setRows((current) => [ + ...current, + { id: crypto.randomUUID(), key: "", value: "" }, + ]); + + return ( +
+

+ + Set when this agent launches, e.g. CLAUDE_CONFIG_DIR to + use another Claude login. + +

+ {rows.length === 0 ? ( +

+ No variables set yet. +

+ ) : ( + rows.map((row) => ( +
+ updateRow(row.id, { key: e.target.value })} + onBlur={() => commit(rows)} + aria-label={variableNameLabel} + aria-invalid={isInvalidKey(row.key)} + placeholder={variableNameLabel} + spellCheck={false} + /> + updateRow(row.id, { value: e.target.value })} + onBlur={() => commit(rows)} + aria-label={variableValueLabel} + placeholder={variableValueLabel} + spellCheck={false} + /> + +
+ )) + )} + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts new file mode 100644 index 00000000000..c64e0e10a1b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts @@ -0,0 +1 @@ +export { AgentEnvEditor } from "./AgentEnvEditor"; diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts index db223b659be..58e59b38acf 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, @@ -6,20 +7,34 @@ import { mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../../../../db"; +import * as schema from "../../../../db/schema"; import { resolveTrustFamily, + seedAgentFolderTrust, seedAgyFolderTrust, seedClaudeFolderTrust, seedCodexFolderTrust, } from "./seed-agent-trust"; +function createTestDb(): HostDb { + const db = drizzle(new Database(":memory:"), { schema }); + migrate(db, { + migrationsFolder: resolve(import.meta.dir, "../../../../../drizzle"), + }); + return db as unknown as HostDb; +} + let dir: string; beforeEach(() => { @@ -61,6 +76,32 @@ describe("resolveTrustFamily", () => { ).toBe("codex"); }); + test("sees through env wrappers and inline assignments", () => { + expect( + resolveTrustFamily({ + presetId: "custom", + command: + "env CLAUDE_CONFIG_DIR=/p claude --dangerously-skip-permissions", + }), + ).toBe("claude"); + expect( + resolveTrustFamily({ + presetId: "custom", + command: "/usr/bin/env", + args: ["CODEX_HOME=/p", "A=1", "codex"], + }), + ).toBe("codex"); + expect( + resolveTrustFamily({ + presetId: "custom", + command: "CLAUDE_CONFIG_DIR=/p claude", + }), + ).toBe("claude"); + expect( + resolveTrustFamily({ presetId: "custom", command: "env my-agent" }), + ).toBe(null); + }); + test("returns null for providers without a known trust store", () => { expect(resolveTrustFamily({ presetId: "gemini", command: "gemini" })).toBe( null, @@ -162,6 +203,27 @@ describe("seedClaudeFolderTrust", () => { }); }); +describe("seedAgentFolderTrust", () => { + test("seeds the CLAUDE_CONFIG_DIR named by an env wrapper", async () => { + const profile = join(dir, "second-profile"); + const session = join(dir, "session"); + mkdirSync(profile); + mkdirSync(session); + await seedAgentFolderTrust(createTestDb(), session, { + presetId: "custom", + command: "env", + args: [`CLAUDE_CONFIG_DIR=${profile}`, "claude", "--verbose"], + env: { CLAUDE_CONFIG_DIR: join(dir, "overridden") }, + }); + const state = JSON.parse( + readFileSync(join(profile, ".claude.json"), "utf-8"), + ); + expect(state.projects[realpathSync(session)].hasTrustDialogAccepted).toBe( + true, + ); + }); +}); + describe("seedAgyFolderTrust", () => { test("appends to trustedWorkspaces, preserving other keys", async () => { const file = join(dir, "settings.json"); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts index ed6ecb84d08..0f2ddd28e2f 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts @@ -35,25 +35,56 @@ import { resolveDefaultAccountEnv } from "../../usage/default-account"; type TrustFamily = "claude" | "codex" | "agy"; +type LaunchCommand = { command: string; args?: readonly string[] }; + +type AgentLaunchConfig = LaunchCommand & { + presetId: string; + env: Record; +}; + interface TrustTarget { family: TrustFamily; file: string; } +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s; + +function programName(token: string): string { + return (token.split(/[\\/]/).pop() ?? "").toLowerCase().replace(/\.exe$/, ""); +} + +/** + * Splits `[env] [VAR=value ...] ` into the program name and the + * inline assignments, which override the process env at launch. + */ +function unwrapEnvCommand(config: LaunchCommand): { + executable: string; + env: Record; +} { + const tokens = [ + ...config.command.trim().split(/\s+/), + ...(config.args ?? []), + ]; + let index = programName(tokens[0] ?? "") === "env" ? 1 : 0; + const env: Record = {}; + for (; index < tokens.length; index++) { + const match = ENV_ASSIGNMENT.exec(tokens[index] ?? ""); + if (!match?.[1]) break; + env[match[1]] = match[2] ?? ""; + } + return { executable: programName(tokens[index] ?? ""), env }; +} + /** * Provider family of an agent config, from its preset id or launch * executable. The executable check covers custom presets that still run the - * stock `claude`/`codex` binaries (possibly via absolute paths or wrappers - * named after them). + * stock `claude`/`codex` binaries (possibly via absolute paths, `env` + * wrappers, or wrappers named after them). */ -export function resolveTrustFamily(config: { - presetId: string; - command: string; -}): TrustFamily | null { - const [token = ""] = config.command.trim().split(/\s+/); - const executable = (token.split(/[\\/]/).pop() ?? "") - .toLowerCase() - .replace(/\.exe$/, ""); +export function resolveTrustFamily( + config: LaunchCommand & { presetId: string }, +): TrustFamily | null { + const { executable } = unwrapEnvCommand(config); for (const family of ["claude", "codex", "agy"] as const) { if (config.presetId === family || executable === family) return family; } @@ -64,14 +95,15 @@ export function resolveTrustFamily(config: { * Trust-store file for one agent config, mirroring launch-time resolution: * per-agent env wins over the host-default account selection * (`{...accountEnv, ...config.env}` in buildTerminalAgentLaunch, and the - * agent wrapper's pointer-file fallback reads the same DB-backed selection). + * agent wrapper's pointer-file fallback reads the same DB-backed selection), + * and an `env VAR=value` wrapper in the command wins over both. * Claude keeps state inside a custom CLAUDE_CONFIG_DIR but next door at * `~/.claude.json` for the default home; Codex always uses * `$CODEX_HOME/config.toml`. */ function resolveTrustTarget( db: HostDb, - config: { presetId: string; command: string; env: Record }, + config: AgentLaunchConfig, ): TrustTarget | null { const family = resolveTrustFamily(config); if (family === null) return null; @@ -81,7 +113,11 @@ function resolveTrustTarget( file: join(homedir(), ".gemini", "antigravity-cli", "settings.json"), }; } - const env = { ...resolveDefaultAccountEnv(db, family), ...config.env }; + const env = { + ...resolveDefaultAccountEnv(db, family), + ...config.env, + ...unwrapEnvCommand(config).env, + }; if (family === "claude") { const configDir = env.CLAUDE_CONFIG_DIR; return { @@ -238,7 +274,7 @@ function normalizeFolderPath(path: string): string { export async function seedAgentFolderTrust( db: HostDb, folderPath: string, - config: { presetId: string; command: string; env: Record }, + config: AgentLaunchConfig, ): Promise { try { const target = resolveTrustTarget(db, config); diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 261347d9891..46b688f30b7 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -2145,6 +2145,9 @@ msgstr "Přidat do postranního panelu" msgid "Add to workspace" msgstr "Přidat do pracovního prostoru" +msgid "Add variable" +msgstr "Přidat proměnnou" + msgid "Add what it does" msgstr "Přidat, co dělá" @@ -15613,6 +15616,9 @@ msgstr "odebrat propojení s PR" msgid "Remove trigger" msgstr "Odebrat spouštěč" +msgid "Remove variable" +msgstr "Odebrat proměnnou" + msgid "Remove workspace" msgstr "Odebrat pracovní prostor" @@ -17279,6 +17285,9 @@ msgstr "Připravit tento projekt pro Paradev" msgid "Set up your organization to get started" msgstr "Nastavte si organizaci a můžete začít" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Nastaví se při spuštění tohoto agenta, např. <0>CLAUDE_CONFIG_DIR pro použití jiného přihlášení Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Když nainstalovaný plugin vrátíte na Dostupné, všem zůstane jejich kopie; pak ji mohou odebrat. Nedostupné ho odebere." @@ -20809,6 +20818,12 @@ msgstr "uživatel@host, ssh://uživatel@host:2222 nebo alias z konfigurace ssh" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId neodpovídá projektu pracovního prostoru" +msgid "Variable name" +msgstr "Název proměnné" + +msgid "Variable value" +msgstr "Hodnota proměnné" + msgid "Variables" msgstr "Proměnné" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 31dea11a80c..a85208634a3 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -2145,6 +2145,9 @@ msgstr "Zur Seitenleiste hinzufügen" msgid "Add to workspace" msgstr "Zum Arbeitsbereich hinzufügen" +msgid "Add variable" +msgstr "Variable hinzufügen" + msgid "Add what it does" msgstr "Beschreiben, was er macht" @@ -15613,6 +15616,9 @@ msgstr "den PR-Link entfernen" msgid "Remove trigger" msgstr "Auslöser entfernen" +msgid "Remove variable" +msgstr "Variable entfernen" + msgid "Remove workspace" msgstr "Arbeitsbereich entfernen" @@ -17279,6 +17285,9 @@ msgstr "Dieses Projekt für Paradev einrichten" msgid "Set up your organization to get started" msgstr "Richte deine Organisation ein, um loszulegen" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Wird beim Start dieses Agenten gesetzt, z. B. <0>CLAUDE_CONFIG_DIR, um ein anderes Claude-Login zu verwenden." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Wird ein installiertes Plugin wieder auf Verfügbar gesetzt, behalten alle ihre Kopie und können sie dann entfernen. Nicht verfügbar entfernt es." @@ -20809,6 +20818,12 @@ msgstr "user@host, ssh://user@host:2222 oder ein Alias aus der ssh-Konfiguration msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId passt nicht zum Projekt des Arbeitsbereichs" +msgid "Variable name" +msgstr "Variablenname" + +msgid "Variable value" +msgstr "Variablenwert" + msgid "Variables" msgstr "Variablen" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 5e05d7ffaaf..6a743bc2814 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -2145,6 +2145,9 @@ msgstr "Add to sidebar" msgid "Add to workspace" msgstr "Add to workspace" +msgid "Add variable" +msgstr "Add variable" + msgid "Add what it does" msgstr "Add what it does" @@ -15613,6 +15616,9 @@ msgstr "remove the PR link" msgid "Remove trigger" msgstr "Remove trigger" +msgid "Remove variable" +msgstr "Remove variable" + msgid "Remove workspace" msgstr "Remove workspace" @@ -17279,6 +17285,9 @@ msgstr "Set up this project for Paradev" msgid "Set up your organization to get started" msgstr "Set up your organization to get started" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." @@ -20809,6 +20818,12 @@ msgstr "user@host, ssh://user@host:2222, or an ssh config alias" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId does not match the workspace's project" +msgid "Variable name" +msgstr "Variable name" + +msgid "Variable value" +msgstr "Variable value" + msgid "Variables" msgstr "Variables" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 869f48abc92..1b76025e331 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -2145,6 +2145,9 @@ msgstr "Añadir a la barra lateral" msgid "Add to workspace" msgstr "Añadir al espacio de trabajo" +msgid "Add variable" +msgstr "Añadir variable" + msgid "Add what it does" msgstr "Añadir qué hace" @@ -15613,6 +15616,9 @@ msgstr "quitar el enlace a la PR" msgid "Remove trigger" msgstr "Eliminar disparador" +msgid "Remove variable" +msgstr "Quitar variable" + msgid "Remove workspace" msgstr "Quitar el espacio de trabajo" @@ -17279,6 +17285,9 @@ msgstr "Preparar este proyecto para Paradev" msgid "Set up your organization to get started" msgstr "Configurar la organización para empezar" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Se define al iniciar este agente, p. ej. <0>CLAUDE_CONFIG_DIR para usar otro inicio de sesión de Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Si vuelves a poner un plugin instalado en Disponible, todos conservan su copia y luego pueden quitarla. No disponible lo quita." @@ -20809,6 +20818,12 @@ msgstr "usuario@host, ssh://usuario@host:2222 o un alias de la configuración de msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId no coincide con el proyecto del espacio de trabajo" +msgid "Variable name" +msgstr "Nombre de la variable" + +msgid "Variable value" +msgstr "Valor de la variable" + msgid "Variables" msgstr "Variables" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 49b51d684ee..ac424d1e065 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -2145,6 +2145,9 @@ msgstr "Ajouter à la barre latérale" msgid "Add to workspace" msgstr "Ajouter à un espace de travail" +msgid "Add variable" +msgstr "Ajouter une variable" + msgid "Add what it does" msgstr "Ajouter ce qu’il fait" @@ -15613,6 +15616,9 @@ msgstr "retirer le lien de la PR" msgid "Remove trigger" msgstr "Supprimer le déclencheur" +msgid "Remove variable" +msgstr "Supprimer la variable" + msgid "Remove workspace" msgstr "Supprimer l'espace de travail" @@ -17279,6 +17285,9 @@ msgstr "Configurer ce projet pour Paradev" msgid "Set up your organization to get started" msgstr "Configurez votre organisation pour commencer" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Définie au lancement de cet agent, par ex. <0>CLAUDE_CONFIG_DIR pour utiliser une autre connexion Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Repasser un plugin installé en Disponible laisse sa copie à chacun ; ils peuvent ensuite la supprimer. Indisponible le retire." @@ -20809,6 +20818,12 @@ msgstr "user@hôte, ssh://user@hôte:2222 ou un alias de configuration ssh" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId ne correspond pas au projet de l'espace de travail" +msgid "Variable name" +msgstr "Nom de la variable" + +msgid "Variable value" +msgstr "Valeur de la variable" + msgid "Variables" msgstr "Variables" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 1387a444104..e2ff6c2a460 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -2145,6 +2145,9 @@ msgstr "Tambahkan ke sidebar" msgid "Add to workspace" msgstr "Tambahkan ke workspace" +msgid "Add variable" +msgstr "Tambah variabel" + msgid "Add what it does" msgstr "Tambahkan apa yang dilakukannya" @@ -15613,6 +15616,9 @@ msgstr "menghapus tautan PR" msgid "Remove trigger" msgstr "Hapus pemicu" +msgid "Remove variable" +msgstr "Hapus variabel" + msgid "Remove workspace" msgstr "Hapus workspace" @@ -17279,6 +17285,9 @@ msgstr "Siapkan proyek ini untuk Paradev" msgid "Set up your organization to get started" msgstr "Siapkan organisasi Anda untuk memulai" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Diatur saat agen ini dijalankan, mis. <0>CLAUDE_CONFIG_DIR untuk memakai login Claude lain." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Mengembalikan plugin terpasang ke Tersedia membuat semua orang tetap memiliki salinannya; mereka lalu bisa menghapusnya. Tidak tersedia akan menghapusnya." @@ -20809,6 +20818,12 @@ msgstr "user@host, ssh://user@host:2222, atau alias dari konfigurasi ssh" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId tidak cocok dengan proyek workspace" +msgid "Variable name" +msgstr "Nama variabel" + +msgid "Variable value" +msgstr "Nilai variabel" + msgid "Variables" msgstr "Variabel" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index a7e3a2944be..391c731cc13 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -2145,6 +2145,9 @@ msgstr "Aggiungi alla barra laterale" msgid "Add to workspace" msgstr "Aggiungi al workspace" +msgid "Add variable" +msgstr "Aggiungi variabile" + msgid "Add what it does" msgstr "Aggiungi cosa fa" @@ -15613,6 +15616,9 @@ msgstr "rimuovere il link alla PR" msgid "Remove trigger" msgstr "Rimuovi trigger" +msgid "Remove variable" +msgstr "Rimuovi variabile" + msgid "Remove workspace" msgstr "Rimuovi il workspace" @@ -17279,6 +17285,9 @@ msgstr "Configura questo progetto per Paradev" msgid "Set up your organization to get started" msgstr "Configura la tua organizzazione per iniziare" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Impostata all'avvio di questo agente, ad es. <0>CLAUDE_CONFIG_DIR per usare un altro accesso Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Riportare un plugin installato su Disponibile lascia a tutti la propria copia; potranno poi rimuoverla. Non disponibile lo rimuove." @@ -20809,6 +20818,12 @@ msgstr "utente@host, ssh://utente@host:2222 o un alias della configurazione ssh" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId non corrisponde al progetto del workspace" +msgid "Variable name" +msgstr "Nome variabile" + +msgid "Variable value" +msgstr "Valore variabile" + msgid "Variables" msgstr "Variabili" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index 1a3178d862f..4b233e65bd1 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -2145,6 +2145,9 @@ msgstr "サイドバーに追加" msgid "Add to workspace" msgstr "ワークスペースに追加" +msgid "Add variable" +msgstr "変数を追加" + msgid "Add what it does" msgstr "役割を追加" @@ -15613,6 +15616,9 @@ msgstr "PRリンクを削除する" msgid "Remove trigger" msgstr "トリガーを削除" +msgid "Remove variable" +msgstr "変数を削除" + msgid "Remove workspace" msgstr "ワークスペースを削除" @@ -17279,6 +17285,9 @@ msgstr "このプロジェクトをParadev向けにセットアップ" msgid "Set up your organization to get started" msgstr "組織をセットアップして始めましょう" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "このエージェントの起動時に設定されます。例: 別の Claude ログインを使うには <0>CLAUDE_CONFIG_DIR。" + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "インストール済みのプラグインを「利用可能」に戻しても、全員のコピーは残り、各自で削除できるようになります。「利用不可」にすると削除されます。" @@ -20809,6 +20818,12 @@ msgstr "user@host、ssh://user@host:2222、または ssh config のエイリア msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectIdがワークスペースのプロジェクトと一致しません" +msgid "Variable name" +msgstr "変数名" + +msgid "Variable value" +msgstr "変数の値" + msgid "Variables" msgstr "変数" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 8deba6dd0c1..e4afa279cd4 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -2145,6 +2145,9 @@ msgstr "사이드바에 추가" msgid "Add to workspace" msgstr "워크스페이스에 추가" +msgid "Add variable" +msgstr "변수 추가" + msgid "Add what it does" msgstr "하는 일 추가" @@ -15613,6 +15616,9 @@ msgstr "PR 링크를 제거할" msgid "Remove trigger" msgstr "트리거 제거" +msgid "Remove variable" +msgstr "변수 제거" + msgid "Remove workspace" msgstr "워크스페이스 제거" @@ -17279,6 +17285,9 @@ msgstr "이 프로젝트를 Paradev에 맞게 설정하기" msgid "Set up your organization to get started" msgstr "조직을 설정하고 시작하세요" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "이 에이전트를 실행할 때 설정됩니다. 예: 다른 Claude 로그인을 사용하려면 <0>CLAUDE_CONFIG_DIR." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "설치된 플러그인을 다시 사용 가능으로 바꾸면 모두의 사본은 그대로 남고, 이후 각자 제거할 수 있습니다. 사용 불가로 바꾸면 제거됩니다." @@ -20809,6 +20818,12 @@ msgstr "user@host, ssh://user@host:2222 또는 ssh 설정 별칭" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId가 워크스페이스의 프로젝트와 일치하지 않습니다" +msgid "Variable name" +msgstr "변수 이름" + +msgid "Variable value" +msgstr "변수 값" + msgid "Variables" msgstr "변수" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 580dc23cfac..295343bd57b 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -2145,6 +2145,9 @@ msgstr "Aan zijbalk toevoegen" msgid "Add to workspace" msgstr "Toevoegen aan werkruimte" +msgid "Add variable" +msgstr "Variabele toevoegen" + msgid "Add what it does" msgstr "Toevoegen wat hij doet" @@ -15613,6 +15616,9 @@ msgstr "de PR-koppeling verwijderen" msgid "Remove trigger" msgstr "Trigger verwijderen" +msgid "Remove variable" +msgstr "Variabele verwijderen" + msgid "Remove workspace" msgstr "Werkruimte verwijderen" @@ -17279,6 +17285,9 @@ msgstr "Dit project instellen voor Paradev" msgid "Set up your organization to get started" msgstr "Stel je organisatie in om te beginnen" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Ingesteld wanneer deze agent start, bijv. <0>CLAUDE_CONFIG_DIR om een andere Claude-login te gebruiken." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Zet je een geïnstalleerde plugin terug op Beschikbaar, dan houdt iedereen zijn kopie en kan die daarna verwijderen. Niet beschikbaar verwijdert hem." @@ -20809,6 +20818,12 @@ msgstr "gebruiker@host, ssh://gebruiker@host:2222 of een alias uit je ssh-config msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId komt niet overeen met het project van de werkruimte" +msgid "Variable name" +msgstr "Variabelenaam" + +msgid "Variable value" +msgstr "Variabelewaarde" + msgid "Variables" msgstr "Variabelen" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 1498aa918ad..4ac814dd0af 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -2145,6 +2145,9 @@ msgstr "Dodaj do paska bocznego" msgid "Add to workspace" msgstr "Dodaj do obszaru roboczego" +msgid "Add variable" +msgstr "Dodaj zmienną" + msgid "Add what it does" msgstr "Dodaj, co robi" @@ -15613,6 +15616,9 @@ msgstr "usunąć powiązania z PR" msgid "Remove trigger" msgstr "Usuń wyzwalacz" +msgid "Remove variable" +msgstr "Usuń zmienną" + msgid "Remove workspace" msgstr "Usuń obszar roboczy" @@ -17279,6 +17285,9 @@ msgstr "Skonfiguruj ten projekt pod Paradev" msgid "Set up your organization to get started" msgstr "Skonfiguruj organizację, aby zacząć" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Ustawiana przy uruchomieniu tego agenta, np. <0>CLAUDE_CONFIG_DIR, aby użyć innego logowania Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Przywrócenie zainstalowanej wtyczki do stanu Dostępna zostawia wszystkim ich kopię; mogą ją potem usunąć. Niedostępna ją usuwa." @@ -20809,6 +20818,12 @@ msgstr "użytkownik@host, ssh://użytkownik@host:2222 lub alias z konfiguracji s msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId nie pasuje do projektu obszaru roboczego" +msgid "Variable name" +msgstr "Nazwa zmiennej" + +msgid "Variable value" +msgstr "Wartość zmiennej" + msgid "Variables" msgstr "Zmienne" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index f38914349b9..bcef7976561 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -2145,6 +2145,9 @@ msgstr "Adicionar à barra lateral" msgid "Add to workspace" msgstr "Adicionar à área de trabalho" +msgid "Add variable" +msgstr "Adicionar variável" + msgid "Add what it does" msgstr "Adicionar o que ele faz" @@ -15613,6 +15616,9 @@ msgstr "remover o vínculo do PR" msgid "Remove trigger" msgstr "Remover gatilho" +msgid "Remove variable" +msgstr "Remover variável" + msgid "Remove workspace" msgstr "Remover área de trabalho" @@ -17279,6 +17285,9 @@ msgstr "Configurar este projeto para o Paradev" msgid "Set up your organization to get started" msgstr "Configure sua organização para começar" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Definida quando este agente é iniciado, por ex. <0>CLAUDE_CONFIG_DIR para usar outro login do Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Voltar um plugin instalado para Disponível mantém a cópia de cada um; depois eles podem removê-la. Indisponível o remove." @@ -20809,6 +20818,12 @@ msgstr "usuário@host, ssh://usuário@host:2222 ou um alias da configuração do msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId não corresponde ao projeto da área de trabalho" +msgid "Variable name" +msgstr "Nome da variável" + +msgid "Variable value" +msgstr "Valor da variável" + msgid "Variables" msgstr "Variáveis" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 117c1e57862..5daad422fe6 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -2145,6 +2145,9 @@ msgstr "Добавить на боковую панель" msgid "Add to workspace" msgstr "Добавить в рабочую область" +msgid "Add variable" +msgstr "Добавить переменную" + msgid "Add what it does" msgstr "Добавить, что он делает" @@ -15613,6 +15616,9 @@ msgstr "отвязать PR" msgid "Remove trigger" msgstr "Удалить триггер" +msgid "Remove variable" +msgstr "Удалить переменную" + msgid "Remove workspace" msgstr "Удалить рабочую область" @@ -17279,6 +17285,9 @@ msgstr "Настроить этот проект под Paradev" msgid "Set up your organization to get started" msgstr "Настройте организацию, чтобы начать" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Задаётся при запуске этого агента, например <0>CLAUDE_CONFIG_DIR, чтобы использовать другой вход в Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Если вернуть установленный плагин в «Доступен», у всех останется их копия, и её можно будет удалить. «Недоступен» удаляет его." @@ -20809,6 +20818,12 @@ msgstr "user@host, ssh://user@host:2222 или псевдоним из конф msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId не совпадает с проектом рабочей области" +msgid "Variable name" +msgstr "Имя переменной" + +msgid "Variable value" +msgstr "Значение переменной" + msgid "Variables" msgstr "Переменные" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 64aa524be97..5f416ccb8e2 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -2145,6 +2145,9 @@ msgstr "Kenar çubuğuna ekle" msgid "Add to workspace" msgstr "Çalışma alanına ekle" +msgid "Add variable" +msgstr "Değişken ekle" + msgid "Add what it does" msgstr "Ne yaptığını ekle" @@ -15613,6 +15616,9 @@ msgstr "PR bağlantısını kaldırma" msgid "Remove trigger" msgstr "Tetikleyiciyi kaldır" +msgid "Remove variable" +msgstr "Değişkeni kaldır" + msgid "Remove workspace" msgstr "Çalışma alanını kaldır" @@ -17279,6 +17285,9 @@ msgstr "Bu projeyi Paradev için kur" msgid "Set up your organization to get started" msgstr "Başlamak için kuruluşunuzu kurun" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Bu ajan başlatıldığında ayarlanır; ör. başka bir Claude girişi kullanmak için <0>CLAUDE_CONFIG_DIR." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Yüklü bir eklentiyi yeniden Kullanılabilir yapmak herkesin kopyasını korur; sonra kaldırabilirler. Kullanılamaz yapmak onu kaldırır." @@ -20809,6 +20818,12 @@ msgstr "kullanıcı@host, ssh://kullanıcı@host:2222 veya bir ssh yapılandırm msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId, çalışma alanının projesiyle eşleşmiyor" +msgid "Variable name" +msgstr "Değişken adı" + +msgid "Variable value" +msgstr "Değişken değeri" + msgid "Variables" msgstr "Değişkenler" diff --git a/packages/i18n/locales/uk/messages.po b/packages/i18n/locales/uk/messages.po index c2bc526e949..a057b679b59 100644 --- a/packages/i18n/locales/uk/messages.po +++ b/packages/i18n/locales/uk/messages.po @@ -2139,6 +2139,9 @@ msgstr "Додати на бічну панель" msgid "Add to workspace" msgstr "Додати до робочого простору" +msgid "Add variable" +msgstr "Додати змінну" + msgid "Add what it does" msgstr "Опишіть, що він робить" @@ -15607,6 +15610,9 @@ msgstr "прибрати посилання на PR" msgid "Remove trigger" msgstr "Видалити тригер" +msgid "Remove variable" +msgstr "Видалити змінну" + msgid "Remove workspace" msgstr "Видалити робочий простір" @@ -17273,6 +17279,9 @@ msgstr "Налаштуй цей проєкт для Paradev" msgid "Set up your organization to get started" msgstr "Щоб почати, налаштуйте свою організацію" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Задається під час запуску цього агента, наприклад <0>CLAUDE_CONFIG_DIR, щоб використати інший вхід у Claude." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Якщо повернути встановлений плагін у стан «Доступний», у всіх залишиться їхня копія, і її можна буде видалити. «Недоступний» видаляє його." @@ -20803,6 +20812,12 @@ msgstr "user@host, ssh://user@host:2222 або аліас із конфігур msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId не відповідає проєкту робочого простору" +msgid "Variable name" +msgstr "Ім'я змінної" + +msgid "Variable value" +msgstr "Значення змінної" + msgid "Variables" msgstr "Змінні" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 5ced0b32651..987957e9634 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -2145,6 +2145,9 @@ msgstr "Thêm vào thanh bên" msgid "Add to workspace" msgstr "Thêm vào workspace" +msgid "Add variable" +msgstr "Thêm biến" + msgid "Add what it does" msgstr "Thêm việc nó làm" @@ -15613,6 +15616,9 @@ msgstr "gỡ liên kết PR" msgid "Remove trigger" msgstr "Xóa trình kích hoạt" +msgid "Remove variable" +msgstr "Xóa biến" + msgid "Remove workspace" msgstr "Gỡ workspace" @@ -17279,6 +17285,9 @@ msgstr "Thiết lập dự án này cho Paradev" msgid "Set up your organization to get started" msgstr "Thiết lập tổ chức của bạn để bắt đầu" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "Được đặt khi tác tử này khởi chạy, ví dụ <0>CLAUDE_CONFIG_DIR để dùng một đăng nhập Claude khác." + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "Đặt lại một plugin đã cài thành Khả dụng thì mọi người vẫn giữ bản của mình; sau đó họ có thể gỡ. Không khả dụng sẽ gỡ nó." @@ -20809,6 +20818,12 @@ msgstr "user@host, ssh://user@host:2222 hoặc một bí danh trong cấu hình msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId không khớp với dự án của workspace" +msgid "Variable name" +msgstr "Tên biến" + +msgid "Variable value" +msgstr "Giá trị biến" + msgid "Variables" msgstr "Biến" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 7cc0ee52238..3b8c081a361 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -2145,6 +2145,9 @@ msgstr "添加到侧边栏" msgid "Add to workspace" msgstr "添加到工作区" +msgid "Add variable" +msgstr "添加变量" + msgid "Add what it does" msgstr "添加它的用途" @@ -15613,6 +15616,9 @@ msgstr "移除PR链接" msgid "Remove trigger" msgstr "移除触发器" +msgid "Remove variable" +msgstr "移除变量" + msgid "Remove workspace" msgstr "移除工作区" @@ -17279,6 +17285,9 @@ msgstr "为Paradev配置这个项目" msgid "Set up your organization to get started" msgstr "设置你的组织即可开始" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "在此智能体启动时设置,例如用 <0>CLAUDE_CONFIG_DIR 来使用另一个 Claude 登录。" + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "把已安装的插件改回“可用”后,每个人的副本仍会保留,之后可以自行移除。改为“不可用”会将其移除。" @@ -20809,6 +20818,12 @@ msgstr "user@host、ssh://user@host:2222 或 ssh 配置中的别名" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId与该工作区的项目不匹配" +msgid "Variable name" +msgstr "变量名" + +msgid "Variable value" +msgstr "变量值" + msgid "Variables" msgstr "变量" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 721a9580c76..9beff402449 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -2145,6 +2145,9 @@ msgstr "新增到側邊欄" msgid "Add to workspace" msgstr "新增到工作區" +msgid "Add variable" +msgstr "新增變數" + msgid "Add what it does" msgstr "新增它的用途" @@ -15613,6 +15616,9 @@ msgstr "移除PR連結" msgid "Remove trigger" msgstr "移除觸發條件" +msgid "Remove variable" +msgstr "移除變數" + msgid "Remove workspace" msgstr "移除工作區" @@ -17279,6 +17285,9 @@ msgstr "為Paradev配置這個專案" msgid "Set up your organization to get started" msgstr "設定你的組織即可開始" +msgid "Set when this agent launches, e.g. <0>CLAUDE_CONFIG_DIR to use another Claude login." +msgstr "在此代理程式啟動時設定,例如用 <0>CLAUDE_CONFIG_DIR 來使用另一個 Claude 登入。" + msgid "Setting an installed plugin back to Available keeps everyone's copy; they can then remove it. Unavailable removes it." msgstr "把已安裝的外掛改回「可用」後,每個人的副本仍會保留,之後可以自行移除。改為「不可用」會將其移除。" @@ -20809,6 +20818,12 @@ msgstr "user@host、ssh://user@host:2222 或 ssh 設定中的別名" msgid "v2ProjectId does not match the workspace's project" msgstr "v2ProjectId與該工作區的專案不匹配" +msgid "Variable name" +msgstr "變數名稱" + +msgid "Variable value" +msgstr "變數值" + msgid "Variables" msgstr "變數" From 0ad7b651f2375f0317d456f4e3b649760cbd9bee Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:26:45 +0200 Subject: [PATCH 2/8] fix(desktop): harden agent env editor saves and env-wrapper parsing Address review feedback on #9310: - Env editor ignores echoes of its own saves (also out of order), so text typed while a save is in flight is not reset. - Each row remembers its last saved entry. An invalid, blank, or duplicate name keeps that entry instead of blocking other saves or dropping the variable. Duplicates are flagged; the first row wins. Removals always save. - Build the env map with Object.fromEntries so `__proto__` is kept as a key. - Command saves send env only when the command has an inline VAR=value prefix, so a command edit cannot resend an older env. - Trust seeding tokenizes raw command strings with shell-quote (quoted values, stops at shell operators) and honours `env -i` / `-u NAME` / `--unset=NAME`. Co-Authored-By: Claude Opus 5.5 --- .../components/AgentDetail/AgentDetail.tsx | 11 ++-- .../AgentEnvEditor/AgentEnvEditor.tsx | 61 ++++++++++++++---- bun.lock | 2 + packages/host-service/package.json | 2 + .../shared/seed-agent-trust.test.ts | 39 ++++++++++- .../shared/seed-agent-trust.ts | 64 +++++++++++++++---- 6 files changed, 149 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index 30cab202e59..c1904289c13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -219,8 +219,9 @@ export function AgentDetail({ const handleCommandBlur = () => { const parsed = parseAgentCommandText(commandText); - const patch = { ...parsed, env: { ...config.env, ...parsed.env } }; - const { command } = patch; + const hasInlineEnv = Object.keys(parsed.env).length > 0; + const env = hasInlineEnv ? { ...config.env, ...parsed.env } : config.env; + const { command, args } = parsed; if (command.length === 0) { toast.error( t({ @@ -232,8 +233,10 @@ export function AgentDetail({ ); return; } - if (isAgentCommandPatchChanged(config, patch)) { - updateMutation.mutate(patch); + if (isAgentCommandPatchChanged(config, { command, args, env })) { + updateMutation.mutate( + hasInlineEnv ? { command, args, env } : { command, args }, + ); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx index 4390afeb291..fe7b9ab3572 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx @@ -2,7 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { Plus, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { areEnvMapsEqual } from "renderer/lib/agent-launch-command"; const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -11,6 +11,7 @@ interface EnvRow { id: string; key: string; value: string; + saved: { key: string; value: string } | null; } function toRows(env: Record): EnvRow[] { @@ -18,20 +19,39 @@ function toRows(env: Record): EnvRow[] { id: crypto.randomUUID(), key, value, + saved: { key, value }, })); } -function isInvalidKey(key: string): boolean { - return key.trim().length > 0 && !ENV_KEY.test(key.trim()); +function envIdentity(env: Record): string { + return JSON.stringify( + Object.entries(env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); } -function toEnv(rows: readonly EnvRow[]): Record { - const env: Record = {}; +function acceptedRowIds(rows: readonly EnvRow[]): Set { + const seenKeys = new Set(); + const ids = new Set(); for (const row of rows) { const key = row.key.trim(); - if (ENV_KEY.test(key)) env[key] = row.value; + if (!ENV_KEY.test(key) || seenKeys.has(key)) continue; + seenKeys.add(key); + ids.add(row.id); + } + return ids; +} + +/** Rows whose name is not accepted keep their last saved entry. */ +function toEnv(rows: readonly EnvRow[]): Record { + const accepted = acceptedRowIds(rows); + const entries = new Map(); + for (const row of rows) { + const entry = accepted.has(row.id) + ? { key: row.key.trim(), value: row.value } + : row.saved; + if (entry && !entries.has(entry.key)) entries.set(entry.key, entry.value); } - return env; + return Object.fromEntries(entries); } interface AgentEnvEditorProps { @@ -49,18 +69,37 @@ export function AgentEnvEditor({ const variableNameLabel = t({ message: "Variable name" }); const variableValueLabel = t({ message: "Variable value" }); const [rows, setRows] = useState(() => toRows(env)); + const committedEnvs = useRef(new Set()); + const accepted = acceptedRowIds(rows); useEffect(() => { + if (committedEnvs.current.has(envIdentity(env))) return; + committedEnvs.current.clear(); setRows((current) => areEnvMapsEqual(toEnv(current), env) ? current : toRows(env), ); }, [env]); const commit = (next: readonly EnvRow[]) => { - if (next.some((row) => isInvalidKey(row.key))) return; - onCommit(toEnv(next)); + const nextEnv = toEnv(next); + const nextAccepted = acceptedRowIds(next); + committedEnvs.current.add(envIdentity(nextEnv)); + setRows((current) => + current.map((row) => { + const sent = next.find((candidate) => candidate.id === row.id); + const unchanged = + sent && sent.key === row.key && sent.value === row.value; + return unchanged && nextAccepted.has(row.id) + ? { ...row, saved: { key: row.key.trim(), value: row.value } } + : row; + }), + ); + onCommit(nextEnv); }; + const isInvalid = (row: EnvRow) => + !accepted.has(row.id) && (row.key.trim().length > 0 || row.saved !== null); + const updateRow = (id: string, patch: Partial) => setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)), @@ -75,7 +114,7 @@ export function AgentEnvEditor({ const addRow = () => setRows((current) => [ ...current, - { id: crypto.randomUUID(), key: "", value: "" }, + { id: crypto.randomUUID(), key: "", value: "", saved: null }, ]); return ( @@ -100,7 +139,7 @@ export function AgentEnvEditor({ onChange={(e) => updateRow(row.id, { key: e.target.value })} onBlur={() => commit(rows)} aria-label={variableNameLabel} - aria-invalid={isInvalidKey(row.key)} + aria-invalid={isInvalid(row)} placeholder={variableNameLabel} spellCheck={false} /> diff --git a/bun.lock b/bun.lock index 9195ba0caf0..5a2b4a3d749 100644 --- a/bun.lock +++ b/bun.lock @@ -1049,6 +1049,7 @@ "partysocket": "1.3.0", "pi-acp": "0.0.34", "semver": "7.7.4", + "shell-quote": "1.11.0", "simple-git": "4.0.2", "superjson": "2.2.6", "tree-kill": "1.2.2", @@ -1062,6 +1063,7 @@ "@types/mime-types": "3.0.1", "@types/node": "24.12.0", "@types/semver": "7.7.1", + "@types/shell-quote": "1.7.5", "bun-types": "1.4.3", "drizzle-kit": "0.31.8", "tsx": "4.21.0", diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 8c16b7b0041..b1b6ba46f90 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -108,6 +108,7 @@ "partysocket": "1.3.0", "pi-acp": "0.0.34", "semver": "7.7.4", + "shell-quote": "1.11.0", "simple-git": "4.0.2", "superjson": "2.2.6", "tree-kill": "1.2.2", @@ -121,6 +122,7 @@ "@types/mime-types": "3.0.1", "@types/node": "24.12.0", "@types/semver": "7.7.1", + "@types/shell-quote": "1.7.5", "bun-types": "1.4.3", "drizzle-kit": "0.31.8", "tsx": "4.21.0", diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts index 58e59b38acf..920377a7230 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts @@ -13,7 +13,7 @@ import { symlinkSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; @@ -21,6 +21,7 @@ import type { HostDb } from "../../../../db"; import * as schema from "../../../../db/schema"; import { resolveTrustFamily, + resolveTrustTarget, seedAgentFolderTrust, seedAgyFolderTrust, seedClaudeFolderTrust, @@ -222,6 +223,42 @@ describe("seedAgentFolderTrust", () => { true, ); }); + + test("reads a quoted CLAUDE_CONFIG_DIR from a raw command string", async () => { + const profile = join(dir, "profile with spaces"); + const session = join(dir, "session"); + mkdirSync(profile); + mkdirSync(session); + await seedAgentFolderTrust(createTestDb(), session, { + presetId: "custom", + command: `env CLAUDE_CONFIG_DIR='${profile}' claude --verbose && true`, + env: {}, + }); + const state = JSON.parse( + readFileSync(join(profile, ".claude.json"), "utf-8"), + ); + expect(state.projects[realpathSync(session)].hasTrustDialogAccepted).toBe( + true, + ); + }); +}); + +describe("resolveTrustTarget", () => { + test("env -i and -u drop the inherited CLAUDE_CONFIG_DIR", () => { + const target = (args: string[]) => + resolveTrustTarget(createTestDb(), { + presetId: "claude", + command: "env", + args: [...args, "claude"], + env: { CLAUDE_CONFIG_DIR: "/agent-profile" }, + })?.file; + const defaultFile = join(homedir(), ".claude.json"); + expect(target([])).toBe("/agent-profile/.claude.json"); + expect(target(["-i"])).toBe(defaultFile); + expect(target(["-u", "CLAUDE_CONFIG_DIR"])).toBe(defaultFile); + expect(target(["--unset=CLAUDE_CONFIG_DIR"])).toBe(defaultFile); + expect(target(["-i", "CLAUDE_CONFIG_DIR=/p"])).toBe("/p/.claude.json"); + }); }); describe("seedAgyFolderTrust", () => { diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts index 0f2ddd28e2f..7e74f0e18dc 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts @@ -30,6 +30,7 @@ import { import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { resolveWriteTarget } from "@superset/agent-setup"; +import { parse } from "shell-quote"; import type { HostDb } from "../../../../db"; import { resolveDefaultAccountEnv } from "../../usage/default-account"; @@ -53,26 +54,60 @@ function programName(token: string): string { return (token.split(/[\\/]/).pop() ?? "").toLowerCase().replace(/\.exe$/, ""); } +function commandTokens(config: LaunchCommand): string[] { + const command = config.command.trim(); + const args = config.args ?? []; + if (!/\s/.test(command)) return [command, ...args]; + const tokens: string[] = []; + for (const token of parse(command, (key) => `$${key}`)) { + if (typeof token === "string") tokens.push(token); + else if ("op" in token && token.op === "glob") tokens.push(token.pattern); + else break; + } + return [...tokens, ...args]; +} + /** - * Splits `[env] [VAR=value ...] ` into the program name and the - * inline assignments, which override the process env at launch. + * Splits `[env [-i] [-u NAME] ...] [VAR=value ...] ` into the program + * name and how the wrapper changes the inherited env at launch. */ function unwrapEnvCommand(config: LaunchCommand): { executable: string; env: Record; + clearsInherited: boolean; + unset: string[]; } { - const tokens = [ - ...config.command.trim().split(/\s+/), - ...(config.args ?? []), - ]; - let index = programName(tokens[0] ?? "") === "env" ? 1 : 0; + const tokens = commandTokens(config); + let index = 0; + let clearsInherited = false; + const unset: string[] = []; + if (programName(tokens[0] ?? "") === "env") { + for (index = 1; index < tokens.length; index++) { + const token = tokens[index] ?? ""; + if (token === "-i" || token === "-" || token === "--ignore-environment") { + clearsInherited = true; + } else if ((token === "-u" || token === "--unset") && tokens[index + 1]) { + unset.push(tokens[++index] ?? ""); + } else if (token.startsWith("--unset=")) { + unset.push(token.slice("--unset=".length)); + } else { + if (token === "--") index++; + break; + } + } + } const env: Record = {}; for (; index < tokens.length; index++) { const match = ENV_ASSIGNMENT.exec(tokens[index] ?? ""); if (!match?.[1]) break; env[match[1]] = match[2] ?? ""; } - return { executable: programName(tokens[index] ?? ""), env }; + return { + executable: programName(tokens[index] ?? ""), + env, + clearsInherited, + unset, + }; } /** @@ -101,7 +136,7 @@ export function resolveTrustFamily( * `~/.claude.json` for the default home; Codex always uses * `$CODEX_HOME/config.toml`. */ -function resolveTrustTarget( +export function resolveTrustTarget( db: HostDb, config: AgentLaunchConfig, ): TrustTarget | null { @@ -113,11 +148,12 @@ function resolveTrustTarget( file: join(homedir(), ".gemini", "antigravity-cli", "settings.json"), }; } - const env = { - ...resolveDefaultAccountEnv(db, family), - ...config.env, - ...unwrapEnvCommand(config).env, - }; + const wrapper = unwrapEnvCommand(config); + const inherited: Record = wrapper.clearsInherited + ? {} + : { ...resolveDefaultAccountEnv(db, family), ...config.env }; + for (const name of wrapper.unset) delete inherited[name]; + const env = { ...inherited, ...wrapper.env }; if (family === "claude") { const configDir = env.CLAUDE_CONFIG_DIR; return { From dd9fbd999f04e44e78b0a91ed55626b9cabe50ef Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:38:17 +0200 Subject: [PATCH 3/8] fix(desktop): settle env editor saves after persistence Address the second review round on #9310: - Accepted rows take precedence over saved fallbacks of invalid rows with the same name. - A row's saved entry advances only after the host confirms the save. - After a command save, reset the command field from the saved config so an inline VAR=value prefix moves to the env editor and can't re-add a variable removed there. - Cover env options inside a raw command string in tests. Co-Authored-By: Claude Opus 5.5 --- .../components/AgentDetail/AgentDetail.tsx | 17 ++++++++-- .../AgentEnvEditor/AgentEnvEditor.tsx | 33 ++++++++++--------- .../shared/seed-agent-trust.test.ts | 17 ++++++++++ 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index c1904289c13..fc7b0af4517 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -236,12 +236,25 @@ export function AgentDetail({ if (isAgentCommandPatchChanged(config, { command, args, env })) { updateMutation.mutate( hasInlineEnv ? { command, args, env } : { command, args }, + { + onSuccess: (updated) => + setCommandText( + getAgentCommandText({ + command: updated.command, + args: updated.args, + }), + ), + }, ); } }; - const handleEnvCommit = (env: Record) => { - if (!areEnvMapsEqual(env, config.env)) updateMutation.mutate({ env }); + const handleEnvCommit = ( + env: Record, + onSaved: () => void, + ) => { + if (areEnvMapsEqual(env, config.env)) onSaved(); + else updateMutation.mutate({ env }, { onSuccess: onSaved }); }; const handlePromptArgsBlur = () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx index fe7b9ab3572..4c60338bc05 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx @@ -46,10 +46,12 @@ function toEnv(rows: readonly EnvRow[]): Record { const accepted = acceptedRowIds(rows); const entries = new Map(); for (const row of rows) { - const entry = accepted.has(row.id) - ? { key: row.key.trim(), value: row.value } - : row.saved; - if (entry && !entries.has(entry.key)) entries.set(entry.key, entry.value); + if (accepted.has(row.id)) entries.set(row.key.trim(), row.value); + } + for (const row of rows) { + if (!accepted.has(row.id) && row.saved && !entries.has(row.saved.key)) { + entries.set(row.saved.key, row.saved.value); + } } return Object.fromEntries(entries); } @@ -57,7 +59,7 @@ function toEnv(rows: readonly EnvRow[]): Record { interface AgentEnvEditorProps { idPrefix: string; env: Record; - onCommit: (env: Record) => void; + onCommit: (env: Record, onSaved: () => void) => void; } export function AgentEnvEditor({ @@ -84,17 +86,18 @@ export function AgentEnvEditor({ const nextEnv = toEnv(next); const nextAccepted = acceptedRowIds(next); committedEnvs.current.add(envIdentity(nextEnv)); - setRows((current) => - current.map((row) => { - const sent = next.find((candidate) => candidate.id === row.id); - const unchanged = - sent && sent.key === row.key && sent.value === row.value; - return unchanged && nextAccepted.has(row.id) - ? { ...row, saved: { key: row.key.trim(), value: row.value } } - : row; - }), + onCommit(nextEnv, () => + setRows((current) => + current.map((row) => { + const sent = next.find((candidate) => candidate.id === row.id); + const unchanged = + sent && sent.key === row.key && sent.value === row.value; + return unchanged && nextAccepted.has(row.id) + ? { ...row, saved: { key: row.key.trim(), value: row.value } } + : row; + }), + ), ); - onCommit(nextEnv); }; const isInvalid = (row: EnvRow) => diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts index 920377a7230..1e52195bc6c 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts @@ -259,6 +259,23 @@ describe("resolveTrustTarget", () => { expect(target(["--unset=CLAUDE_CONFIG_DIR"])).toBe(defaultFile); expect(target(["-i", "CLAUDE_CONFIG_DIR=/p"])).toBe("/p/.claude.json"); }); + + test("reads env options from a raw command string", () => { + const target = (command: string) => + resolveTrustTarget(createTestDb(), { + presetId: "custom", + command, + env: { CLAUDE_CONFIG_DIR: "/agent-profile" }, + })?.file; + const defaultFile = join(homedir(), ".claude.json"); + expect(target("env -u CLAUDE_CONFIG_DIR claude --verbose")).toBe( + defaultFile, + ); + expect(target("env --ignore-environment claude")).toBe(defaultFile); + expect(target("env -i CLAUDE_CONFIG_DIR=/p claude")).toBe( + "/p/.claude.json", + ); + }); }); describe("seedAgyFolderTrust", () => { From e305bfc29e18a50252d52b2dd4cd84872b9625c8 Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:53:58 +0200 Subject: [PATCH 4/8] fix(desktop): serialize agent env editor saves Address the third review round on #9310: - The env editor sends one save at a time and queues the latest rows behind an in-flight save. Each save compares against the env last sent, not the persisted prop, so a quick revert is never skipped. - Resync from the `env` prop only for a change that is not this editor's own echo. This replaces the set of committed envs, which could hide a later external change such as Restore defaults. - Advance a row's saved entry from the submitted values once the save resolves. - Rows that keep their saved name claim it before renamed or new rows, so a rename onto an existing name is flagged instead of overwriting it. - Keep text typed in the command field while its save is in flight. - Parse the attached `env -uNAME` form. Co-Authored-By: Claude Opus 5.5 --- .../components/AgentDetail/AgentDetail.tsx | 22 ++--- .../AgentEnvEditor/AgentEnvEditor.tsx | 84 ++++++++++++------- .../shared/seed-agent-trust.test.ts | 2 + .../shared/seed-agent-trust.ts | 2 + 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index fc7b0af4517..9e13bc27944 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -24,7 +24,6 @@ import { useMutation } from "@tanstack/react-query"; import { Info, RotateCcw, Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { - areEnvMapsEqual, getAgentCommandText, isAgentCommandPatchChanged, parseAgentCommandText, @@ -238,24 +237,21 @@ export function AgentDetail({ hasInlineEnv ? { command, args, env } : { command, args }, { onSuccess: (updated) => - setCommandText( - getAgentCommandText({ - command: updated.command, - args: updated.args, - }), + setCommandText((current) => + current === commandText + ? getAgentCommandText({ + command: updated.command, + args: updated.args, + }) + : current, ), }, ); } }; - const handleEnvCommit = ( - env: Record, - onSaved: () => void, - ) => { - if (areEnvMapsEqual(env, config.env)) onSaved(); - else updateMutation.mutate({ env }, { onSuccess: onSaved }); - }; + const handleEnvCommit = (env: Record) => + updateMutation.mutateAsync({ env }); const handlePromptArgsBlur = () => { const args = parseArgs(promptArgsText); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx index 4c60338bc05..85a88523895 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx @@ -23,21 +23,18 @@ function toRows(env: Record): EnvRow[] { })); } -function envIdentity(env: Record): string { - return JSON.stringify( - Object.entries(env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), - ); -} - +/** Rows that keep their saved name claim it before renamed or new rows. */ function acceptedRowIds(rows: readonly EnvRow[]): Set { - const seenKeys = new Set(); + const takenKeys = new Set(); const ids = new Set(); - for (const row of rows) { + const accept = (row: EnvRow) => { const key = row.key.trim(); - if (!ENV_KEY.test(key) || seenKeys.has(key)) continue; - seenKeys.add(key); + if (ids.has(row.id) || !ENV_KEY.test(key) || takenKeys.has(key)) return; + takenKeys.add(key); ids.add(row.id); - } + }; + for (const row of rows) if (row.saved?.key === row.key.trim()) accept(row); + for (const row of rows) accept(row); return ids; } @@ -56,10 +53,20 @@ function toEnv(rows: readonly EnvRow[]): Record { return Object.fromEntries(entries); } +function markSaved(rows: EnvRow[], submitted: readonly EnvRow[]): EnvRow[] { + const accepted = acceptedRowIds(submitted); + return rows.map((row) => { + const sent = submitted.find((candidate) => candidate.id === row.id); + return sent && accepted.has(row.id) + ? { ...row, saved: { key: sent.key.trim(), value: sent.value } } + : row; + }); +} + interface AgentEnvEditorProps { idPrefix: string; env: Record; - onCommit: (env: Record, onSaved: () => void) => void; + onCommit: (env: Record) => Promise; } export function AgentEnvEditor({ @@ -71,33 +78,49 @@ export function AgentEnvEditor({ const variableNameLabel = t({ message: "Variable name" }); const variableValueLabel = t({ message: "Variable value" }); const [rows, setRows] = useState(() => toRows(env)); - const committedEnvs = useRef(new Set()); + const rowsRef = useRef(rows); + rowsRef.current = rows; + const saves = useRef({ inFlight: false, queued: false, lastSent: env }); const accepted = acceptedRowIds(rows); useEffect(() => { - if (committedEnvs.current.has(envIdentity(env))) return; - committedEnvs.current.clear(); + const state = saves.current; + if (state.inFlight || areEnvMapsEqual(env, state.lastSent)) return; + state.lastSent = env; setRows((current) => areEnvMapsEqual(toEnv(current), env) ? current : toRows(env), ); }, [env]); + const send = (submitted: readonly EnvRow[]) => { + const state = saves.current; + const nextEnv = toEnv(submitted); + if (areEnvMapsEqual(nextEnv, state.lastSent)) { + setRows((current) => markSaved(current, submitted)); + return; + } + const previous = state.lastSent; + state.inFlight = true; + state.lastSent = nextEnv; + onCommit(nextEnv) + .then( + () => setRows((current) => markSaved(current, submitted)), + () => { + state.lastSent = previous; + }, + ) + .finally(() => { + state.inFlight = false; + if (state.queued) { + state.queued = false; + send(rowsRef.current); + } + }); + }; + const commit = (next: readonly EnvRow[]) => { - const nextEnv = toEnv(next); - const nextAccepted = acceptedRowIds(next); - committedEnvs.current.add(envIdentity(nextEnv)); - onCommit(nextEnv, () => - setRows((current) => - current.map((row) => { - const sent = next.find((candidate) => candidate.id === row.id); - const unchanged = - sent && sent.key === row.key && sent.value === row.value; - return unchanged && nextAccepted.has(row.id) - ? { ...row, saved: { key: row.key.trim(), value: row.value } } - : row; - }), - ), - ); + if (saves.current.inFlight) saves.current.queued = true; + else send(next); }; const isInvalid = (row: EnvRow) => @@ -110,6 +133,7 @@ export function AgentEnvEditor({ const removeRow = (id: string) => { const next = rows.filter((row) => row.id !== id); + rowsRef.current = next; setRows(next); commit(next); }; diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts index 1e52195bc6c..85b38ca1432 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts @@ -257,6 +257,7 @@ describe("resolveTrustTarget", () => { expect(target(["-i"])).toBe(defaultFile); expect(target(["-u", "CLAUDE_CONFIG_DIR"])).toBe(defaultFile); expect(target(["--unset=CLAUDE_CONFIG_DIR"])).toBe(defaultFile); + expect(target(["-uCLAUDE_CONFIG_DIR"])).toBe(defaultFile); expect(target(["-i", "CLAUDE_CONFIG_DIR=/p"])).toBe("/p/.claude.json"); }); @@ -272,6 +273,7 @@ describe("resolveTrustTarget", () => { defaultFile, ); expect(target("env --ignore-environment claude")).toBe(defaultFile); + expect(target("env -uCLAUDE_CONFIG_DIR claude")).toBe(defaultFile); expect(target("env -i CLAUDE_CONFIG_DIR=/p claude")).toBe( "/p/.claude.json", ); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts index 7e74f0e18dc..d956bd22416 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts @@ -90,6 +90,8 @@ function unwrapEnvCommand(config: LaunchCommand): { unset.push(tokens[++index] ?? ""); } else if (token.startsWith("--unset=")) { unset.push(token.slice("--unset=".length)); + } else if (/^-u./.test(token)) { + unset.push(token.slice(2)); } else { if (token === "--") index++; break; From d3cc7a2556ee8693dfc53bcc79786b023aead9f8 Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:14:52 +0200 Subject: [PATCH 5/8] fix(desktop): make the env editor the only env writer Address the fourth review round on #9310: - Command saves send only command and args. Inline VAR=value prefixes are handed to the env editor (assign), which saves them through its serialized queue, so the two fields can no longer race on the full env map. - An external env change that arrives while an editor save is in flight is kept and applied once that save settles, instead of being dropped. Echoes of this editor's own saves in the current burst are still ignored. Co-Authored-By: Claude Opus 5.5 --- .../components/AgentDetail/AgentDetail.tsx | 38 +++--- .../AgentEnvEditor/AgentEnvEditor.tsx | 110 +++++++++++++++--- .../components/AgentEnvEditor/index.ts | 2 +- 3 files changed, 115 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index 9e13bc27944..87ab4bb5f13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -22,7 +22,7 @@ import { Switch } from "@superset/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useMutation } from "@tanstack/react-query"; import { Info, RotateCcw, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getAgentCommandText, isAgentCommandPatchChanged, @@ -33,7 +33,7 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { AgentEnvEditor } from "../AgentEnvEditor"; +import { AgentEnvEditor, type AgentEnvEditorHandle } from "../AgentEnvEditor"; import { AgentDetailHeader, AgentLaunchFields, @@ -85,6 +85,7 @@ export function AgentDetail({ ), }); + const envEditorRef = useRef(null); const [label, setLabel] = useState(config.label); const [commandText, setCommandText] = useState( getAgentCommandText({ command: config.command, args: config.args }), @@ -217,10 +218,11 @@ export function AgentDetail({ }; const handleCommandBlur = () => { - const parsed = parseAgentCommandText(commandText); - const hasInlineEnv = Object.keys(parsed.env).length > 0; - const env = hasInlineEnv ? { ...config.env, ...parsed.env } : config.env; - const { command, args } = parsed; + const { + command, + args, + env: inlineEnv, + } = parseAgentCommandText(commandText); if (command.length === 0) { toast.error( t({ @@ -232,21 +234,14 @@ export function AgentDetail({ ); return; } - if (isAgentCommandPatchChanged(config, { command, args, env })) { - updateMutation.mutate( - hasInlineEnv ? { command, args, env } : { command, args }, - { - onSuccess: (updated) => - setCommandText((current) => - current === commandText - ? getAgentCommandText({ - command: updated.command, - args: updated.args, - }) - : current, - ), - }, - ); + if (Object.keys(inlineEnv).length > 0) { + envEditorRef.current?.assign(inlineEnv); + setCommandText(getAgentCommandText({ command, args })); + } + if ( + isAgentCommandPatchChanged(config, { command, args, env: config.env }) + ) { + updateMutation.mutate({ command, args }); } }; @@ -348,6 +343,7 @@ export function AgentDetail({ })} > ): EnvRow[] { })); } +function envIdentity(env: Record): string { + return JSON.stringify( + Object.entries(env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); +} + /** Rows that keep their saved name claim it before renamed or new rows. */ function acceptedRowIds(rows: readonly EnvRow[]): Set { const takenKeys = new Set(); @@ -63,16 +77,64 @@ function markSaved(rows: EnvRow[], submitted: readonly EnvRow[]): EnvRow[] { }); } +function withAssignments( + rows: readonly EnvRow[], + assignments: Record, +): EnvRow[] { + const remaining = new Map(Object.entries(assignments)); + const updated = rows.map((row) => { + const key = row.key.trim(); + if (!remaining.has(key)) return row; + const value = remaining.get(key) ?? ""; + remaining.delete(key); + return { ...row, value }; + }); + const added = [...remaining].map(([key, value]) => ({ + id: crypto.randomUUID(), + key, + value, + saved: null, + })); + return [...updated, ...added]; +} + +interface SaveState { + inFlight: boolean; + queued: boolean; + lastSent: Record; + sentInBurst: Set; + external: Record | null; +} + +function applyExternal( + state: SaveState, + setRows: Dispatch>, + external: Record, +) { + state.lastSent = external; + state.sentInBurst.clear(); + setRows((current) => + areEnvMapsEqual(toEnv(current), external) ? current : toRows(external), + ); +} + +export interface AgentEnvEditorHandle { + /** Sets variables, e.g. inline `VAR=value` prefixes from the command field. */ + assign: (assignments: Record) => void; +} + interface AgentEnvEditorProps { idPrefix: string; env: Record; onCommit: (env: Record) => Promise; + ref?: Ref; } export function AgentEnvEditor({ idPrefix, env, onCommit, + ref, }: AgentEnvEditorProps) { const { t } = useLingui(); const variableNameLabel = t({ message: "Variable name" }); @@ -80,16 +142,23 @@ export function AgentEnvEditor({ const [rows, setRows] = useState(() => toRows(env)); const rowsRef = useRef(rows); rowsRef.current = rows; - const saves = useRef({ inFlight: false, queued: false, lastSent: env }); + const saves = useRef({ + inFlight: false, + queued: false, + lastSent: env, + sentInBurst: new Set(), + external: null, + }); const accepted = acceptedRowIds(rows); useEffect(() => { const state = saves.current; - if (state.inFlight || areEnvMapsEqual(env, state.lastSent)) return; - state.lastSent = env; - setRows((current) => - areEnvMapsEqual(toEnv(current), env) ? current : toRows(env), - ); + const isOwnEcho = + areEnvMapsEqual(env, state.lastSent) || + state.sentInBurst.has(envIdentity(env)); + if (isOwnEcho) return; + if (state.inFlight) state.external = env; + else applyExternal(state, setRows, env); }, [env]); const send = (submitted: readonly EnvRow[]) => { @@ -102,6 +171,7 @@ export function AgentEnvEditor({ const previous = state.lastSent; state.inFlight = true; state.lastSent = nextEnv; + state.sentInBurst.add(envIdentity(nextEnv)); onCommit(nextEnv) .then( () => setRows((current) => markSaved(current, submitted)), @@ -111,18 +181,32 @@ export function AgentEnvEditor({ ) .finally(() => { state.inFlight = false; - if (state.queued) { - state.queued = false; - send(rowsRef.current); - } + const { external, queued } = state; + state.external = null; + state.queued = false; + if (external) applyExternal(state, setRows, external); + else if (queued) send(rowsRef.current); }); }; const commit = (next: readonly EnvRow[]) => { - if (saves.current.inFlight) saves.current.queued = true; - else send(next); + if (saves.current.inFlight) { + saves.current.queued = true; + return; + } + saves.current.sentInBurst.clear(); + send(next); }; + useImperativeHandle(ref, () => ({ + assign: (assignments) => { + const next = withAssignments(rowsRef.current, assignments); + rowsRef.current = next; + setRows(next); + commit(next); + }, + })); + const isInvalid = (row: EnvRow) => !accepted.has(row.id) && (row.key.trim().length > 0 || row.saved !== null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts index c64e0e10a1b..348af517423 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/index.ts @@ -1 +1 @@ -export { AgentEnvEditor } from "./AgentEnvEditor"; +export { AgentEnvEditor, type AgentEnvEditorHandle } from "./AgentEnvEditor"; From 8d7ce95121bb6e97243c07170df9d25a63b79383 Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:39:20 +0200 Subject: [PATCH 6/8] fix(desktop): adopt saved env only when the editor is idle and clean Address the fifth review round on #9310: - Drop the echo heuristics (sent-in-burst set, deferred external env). A full-config response from another save can carry an older env, so value matching cannot tell an echo from an external change. The editor now adopts the saved env only when no save is pending and no row has unsaved edits; otherwise its rows stay the source of truth and queued edits are always sent. - Restore defaults, the only in-app writer of env besides the editor, remounts the editor explicitly. - Inline assignments from the command field update the accepted row for a name, not a rejected duplicate above it. Co-Authored-By: Claude Opus 5.5 --- .../components/AgentDetail/AgentDetail.tsx | 3 + .../AgentEnvEditor/AgentEnvEditor.tsx | 73 ++++++------------- 2 files changed, 25 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index 87ab4bb5f13..5b31caed9b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -86,6 +86,7 @@ export function AgentDetail({ }); const envEditorRef = useRef(null); + const [envEditorKey, setEnvEditorKey] = useState(0); const [label, setLabel] = useState(config.label); const [commandText, setCommandText] = useState( getAgentCommandText({ command: config.command, args: config.args }), @@ -193,6 +194,7 @@ export function AgentDetail({ }, onSuccess: (updated) => { onChanged(updated); + setEnvEditorKey((key) => key + 1); const updatedLabel = updated.label; toast.success( t({ @@ -343,6 +345,7 @@ export function AgentDetail({ })} > ): EnvRow[] { })); } -function envIdentity(env: Record): string { - return JSON.stringify( - Object.entries(env).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), - ); -} - /** Rows that keep their saved name claim it before renamed or new rows. */ function acceptedRowIds(rows: readonly EnvRow[]): Set { const takenKeys = new Set(); @@ -82,9 +74,10 @@ function withAssignments( assignments: Record, ): EnvRow[] { const remaining = new Map(Object.entries(assignments)); + const accepted = acceptedRowIds(rows); const updated = rows.map((row) => { const key = row.key.trim(); - if (!remaining.has(key)) return row; + if (!accepted.has(row.id) || !remaining.has(key)) return row; const value = remaining.get(key) ?? ""; remaining.delete(key); return { ...row, value }; @@ -98,23 +91,11 @@ function withAssignments( return [...updated, ...added]; } -interface SaveState { - inFlight: boolean; - queued: boolean; - lastSent: Record; - sentInBurst: Set; - external: Record | null; -} - -function applyExternal( - state: SaveState, - setRows: Dispatch>, - external: Record, -) { - state.lastSent = external; - state.sentInBurst.clear(); - setRows((current) => - areEnvMapsEqual(toEnv(current), external) ? current : toRows(external), +function hasUnsavedEdits(rows: readonly EnvRow[]): boolean { + return rows.some((row) => + row.saved === null + ? row.key.trim().length > 0 || row.value.length > 0 + : row.key.trim() !== row.saved.key || row.value !== row.saved.value, ); } @@ -142,23 +123,19 @@ export function AgentEnvEditor({ const [rows, setRows] = useState(() => toRows(env)); const rowsRef = useRef(rows); rowsRef.current = rows; - const saves = useRef({ - inFlight: false, - queued: false, - lastSent: env, - sentInBurst: new Set(), - external: null, - }); + const saves = useRef({ inFlight: false, queued: false, lastSent: env }); const accepted = acceptedRowIds(rows); + // While a save is pending or rows hold unsaved edits, the rows are the + // source of truth: a full-config response from another save can carry an + // older env. useEffect(() => { const state = saves.current; - const isOwnEcho = - areEnvMapsEqual(env, state.lastSent) || - state.sentInBurst.has(envIdentity(env)); - if (isOwnEcho) return; - if (state.inFlight) state.external = env; - else applyExternal(state, setRows, env); + if (state.inFlight || state.queued) return; + if (hasUnsavedEdits(rowsRef.current)) return; + if (areEnvMapsEqual(env, state.lastSent)) return; + state.lastSent = env; + setRows(toRows(env)); }, [env]); const send = (submitted: readonly EnvRow[]) => { @@ -171,7 +148,6 @@ export function AgentEnvEditor({ const previous = state.lastSent; state.inFlight = true; state.lastSent = nextEnv; - state.sentInBurst.add(envIdentity(nextEnv)); onCommit(nextEnv) .then( () => setRows((current) => markSaved(current, submitted)), @@ -181,21 +157,16 @@ export function AgentEnvEditor({ ) .finally(() => { state.inFlight = false; - const { external, queued } = state; - state.external = null; - state.queued = false; - if (external) applyExternal(state, setRows, external); - else if (queued) send(rowsRef.current); + if (state.queued) { + state.queued = false; + send(rowsRef.current); + } }); }; const commit = (next: readonly EnvRow[]) => { - if (saves.current.inFlight) { - saves.current.queued = true; - return; - } - saves.current.sentInBurst.clear(); - send(next); + if (saves.current.inFlight) saves.current.queued = true; + else send(next); }; useImperativeHandle(ref, () => ({ From 0072c7bfd01c1e723a8e3963d11672c170860c9f Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:48:27 +0200 Subject: [PATCH 7/8] fix(desktop): re-check env adoption when editor rows become clean An external env change that arrived while rows had unsaved edits was never applied if the user then reverted those edits. Run the idle-and-clean adoption check on row changes too, not only on env changes. Co-Authored-By: Claude Opus 5.5 --- .../components/AgentEnvEditor/AgentEnvEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx index 7562f573ba4..dd639193e26 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentEnvEditor/AgentEnvEditor.tsx @@ -132,11 +132,11 @@ export function AgentEnvEditor({ useEffect(() => { const state = saves.current; if (state.inFlight || state.queued) return; - if (hasUnsavedEdits(rowsRef.current)) return; + if (hasUnsavedEdits(rows)) return; if (areEnvMapsEqual(env, state.lastSent)) return; state.lastSent = env; setRows(toRows(env)); - }, [env]); + }, [env, rows]); const send = (submitted: readonly EnvRow[]) => { const state = saves.current; From 97df93aa899682bf181eb76cfa65679c39da33ca Mon Sep 17 00:00:00 2001 From: asaf <49835614+lacherogwu@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:52:41 +0200 Subject: [PATCH 8/8] refactor: move trust change to #9394 The env-wrapper folder-trust fix for host-service now lives in its own PR (#9394). This PR keeps only the agent env editor. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 2 - packages/host-service/package.json | 2 - .../shared/seed-agent-trust.test.ts | 122 +----------------- .../shared/seed-agent-trust.ts | 104 +++------------ 4 files changed, 17 insertions(+), 213 deletions(-) diff --git a/bun.lock b/bun.lock index 5a2b4a3d749..9195ba0caf0 100644 --- a/bun.lock +++ b/bun.lock @@ -1049,7 +1049,6 @@ "partysocket": "1.3.0", "pi-acp": "0.0.34", "semver": "7.7.4", - "shell-quote": "1.11.0", "simple-git": "4.0.2", "superjson": "2.2.6", "tree-kill": "1.2.2", @@ -1063,7 +1062,6 @@ "@types/mime-types": "3.0.1", "@types/node": "24.12.0", "@types/semver": "7.7.1", - "@types/shell-quote": "1.7.5", "bun-types": "1.4.3", "drizzle-kit": "0.31.8", "tsx": "4.21.0", diff --git a/packages/host-service/package.json b/packages/host-service/package.json index b1b6ba46f90..8c16b7b0041 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -108,7 +108,6 @@ "partysocket": "1.3.0", "pi-acp": "0.0.34", "semver": "7.7.4", - "shell-quote": "1.11.0", "simple-git": "4.0.2", "superjson": "2.2.6", "tree-kill": "1.2.2", @@ -122,7 +121,6 @@ "@types/mime-types": "3.0.1", "@types/node": "24.12.0", "@types/semver": "7.7.1", - "@types/shell-quote": "1.7.5", "bun-types": "1.4.3", "drizzle-kit": "0.31.8", "tsx": "4.21.0", diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts index 85b38ca1432..db223b659be 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.test.ts @@ -1,4 +1,3 @@ -import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, @@ -7,35 +6,20 @@ import { mkdtempSync, readdirSync, readFileSync, - realpathSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import type { HostDb } from "../../../../db"; -import * as schema from "../../../../db/schema"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resolveTrustFamily, - resolveTrustTarget, - seedAgentFolderTrust, seedAgyFolderTrust, seedClaudeFolderTrust, seedCodexFolderTrust, } from "./seed-agent-trust"; -function createTestDb(): HostDb { - const db = drizzle(new Database(":memory:"), { schema }); - migrate(db, { - migrationsFolder: resolve(import.meta.dir, "../../../../../drizzle"), - }); - return db as unknown as HostDb; -} - let dir: string; beforeEach(() => { @@ -77,32 +61,6 @@ describe("resolveTrustFamily", () => { ).toBe("codex"); }); - test("sees through env wrappers and inline assignments", () => { - expect( - resolveTrustFamily({ - presetId: "custom", - command: - "env CLAUDE_CONFIG_DIR=/p claude --dangerously-skip-permissions", - }), - ).toBe("claude"); - expect( - resolveTrustFamily({ - presetId: "custom", - command: "/usr/bin/env", - args: ["CODEX_HOME=/p", "A=1", "codex"], - }), - ).toBe("codex"); - expect( - resolveTrustFamily({ - presetId: "custom", - command: "CLAUDE_CONFIG_DIR=/p claude", - }), - ).toBe("claude"); - expect( - resolveTrustFamily({ presetId: "custom", command: "env my-agent" }), - ).toBe(null); - }); - test("returns null for providers without a known trust store", () => { expect(resolveTrustFamily({ presetId: "gemini", command: "gemini" })).toBe( null, @@ -204,82 +162,6 @@ describe("seedClaudeFolderTrust", () => { }); }); -describe("seedAgentFolderTrust", () => { - test("seeds the CLAUDE_CONFIG_DIR named by an env wrapper", async () => { - const profile = join(dir, "second-profile"); - const session = join(dir, "session"); - mkdirSync(profile); - mkdirSync(session); - await seedAgentFolderTrust(createTestDb(), session, { - presetId: "custom", - command: "env", - args: [`CLAUDE_CONFIG_DIR=${profile}`, "claude", "--verbose"], - env: { CLAUDE_CONFIG_DIR: join(dir, "overridden") }, - }); - const state = JSON.parse( - readFileSync(join(profile, ".claude.json"), "utf-8"), - ); - expect(state.projects[realpathSync(session)].hasTrustDialogAccepted).toBe( - true, - ); - }); - - test("reads a quoted CLAUDE_CONFIG_DIR from a raw command string", async () => { - const profile = join(dir, "profile with spaces"); - const session = join(dir, "session"); - mkdirSync(profile); - mkdirSync(session); - await seedAgentFolderTrust(createTestDb(), session, { - presetId: "custom", - command: `env CLAUDE_CONFIG_DIR='${profile}' claude --verbose && true`, - env: {}, - }); - const state = JSON.parse( - readFileSync(join(profile, ".claude.json"), "utf-8"), - ); - expect(state.projects[realpathSync(session)].hasTrustDialogAccepted).toBe( - true, - ); - }); -}); - -describe("resolveTrustTarget", () => { - test("env -i and -u drop the inherited CLAUDE_CONFIG_DIR", () => { - const target = (args: string[]) => - resolveTrustTarget(createTestDb(), { - presetId: "claude", - command: "env", - args: [...args, "claude"], - env: { CLAUDE_CONFIG_DIR: "/agent-profile" }, - })?.file; - const defaultFile = join(homedir(), ".claude.json"); - expect(target([])).toBe("/agent-profile/.claude.json"); - expect(target(["-i"])).toBe(defaultFile); - expect(target(["-u", "CLAUDE_CONFIG_DIR"])).toBe(defaultFile); - expect(target(["--unset=CLAUDE_CONFIG_DIR"])).toBe(defaultFile); - expect(target(["-uCLAUDE_CONFIG_DIR"])).toBe(defaultFile); - expect(target(["-i", "CLAUDE_CONFIG_DIR=/p"])).toBe("/p/.claude.json"); - }); - - test("reads env options from a raw command string", () => { - const target = (command: string) => - resolveTrustTarget(createTestDb(), { - presetId: "custom", - command, - env: { CLAUDE_CONFIG_DIR: "/agent-profile" }, - })?.file; - const defaultFile = join(homedir(), ".claude.json"); - expect(target("env -u CLAUDE_CONFIG_DIR claude --verbose")).toBe( - defaultFile, - ); - expect(target("env --ignore-environment claude")).toBe(defaultFile); - expect(target("env -uCLAUDE_CONFIG_DIR claude")).toBe(defaultFile); - expect(target("env -i CLAUDE_CONFIG_DIR=/p claude")).toBe( - "/p/.claude.json", - ); - }); -}); - describe("seedAgyFolderTrust", () => { test("appends to trustedWorkspaces, preserving other keys", async () => { const file = join(dir, "settings.json"); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts index d956bd22416..ed6ecb84d08 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/seed-agent-trust.ts @@ -30,98 +30,30 @@ import { import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { resolveWriteTarget } from "@superset/agent-setup"; -import { parse } from "shell-quote"; import type { HostDb } from "../../../../db"; import { resolveDefaultAccountEnv } from "../../usage/default-account"; type TrustFamily = "claude" | "codex" | "agy"; -type LaunchCommand = { command: string; args?: readonly string[] }; - -type AgentLaunchConfig = LaunchCommand & { - presetId: string; - env: Record; -}; - interface TrustTarget { family: TrustFamily; file: string; } -const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s; - -function programName(token: string): string { - return (token.split(/[\\/]/).pop() ?? "").toLowerCase().replace(/\.exe$/, ""); -} - -function commandTokens(config: LaunchCommand): string[] { - const command = config.command.trim(); - const args = config.args ?? []; - if (!/\s/.test(command)) return [command, ...args]; - const tokens: string[] = []; - for (const token of parse(command, (key) => `$${key}`)) { - if (typeof token === "string") tokens.push(token); - else if ("op" in token && token.op === "glob") tokens.push(token.pattern); - else break; - } - return [...tokens, ...args]; -} - -/** - * Splits `[env [-i] [-u NAME] ...] [VAR=value ...] ` into the program - * name and how the wrapper changes the inherited env at launch. - */ -function unwrapEnvCommand(config: LaunchCommand): { - executable: string; - env: Record; - clearsInherited: boolean; - unset: string[]; -} { - const tokens = commandTokens(config); - let index = 0; - let clearsInherited = false; - const unset: string[] = []; - if (programName(tokens[0] ?? "") === "env") { - for (index = 1; index < tokens.length; index++) { - const token = tokens[index] ?? ""; - if (token === "-i" || token === "-" || token === "--ignore-environment") { - clearsInherited = true; - } else if ((token === "-u" || token === "--unset") && tokens[index + 1]) { - unset.push(tokens[++index] ?? ""); - } else if (token.startsWith("--unset=")) { - unset.push(token.slice("--unset=".length)); - } else if (/^-u./.test(token)) { - unset.push(token.slice(2)); - } else { - if (token === "--") index++; - break; - } - } - } - const env: Record = {}; - for (; index < tokens.length; index++) { - const match = ENV_ASSIGNMENT.exec(tokens[index] ?? ""); - if (!match?.[1]) break; - env[match[1]] = match[2] ?? ""; - } - return { - executable: programName(tokens[index] ?? ""), - env, - clearsInherited, - unset, - }; -} - /** * Provider family of an agent config, from its preset id or launch * executable. The executable check covers custom presets that still run the - * stock `claude`/`codex` binaries (possibly via absolute paths, `env` - * wrappers, or wrappers named after them). + * stock `claude`/`codex` binaries (possibly via absolute paths or wrappers + * named after them). */ -export function resolveTrustFamily( - config: LaunchCommand & { presetId: string }, -): TrustFamily | null { - const { executable } = unwrapEnvCommand(config); +export function resolveTrustFamily(config: { + presetId: string; + command: string; +}): TrustFamily | null { + const [token = ""] = config.command.trim().split(/\s+/); + const executable = (token.split(/[\\/]/).pop() ?? "") + .toLowerCase() + .replace(/\.exe$/, ""); for (const family of ["claude", "codex", "agy"] as const) { if (config.presetId === family || executable === family) return family; } @@ -132,15 +64,14 @@ export function resolveTrustFamily( * Trust-store file for one agent config, mirroring launch-time resolution: * per-agent env wins over the host-default account selection * (`{...accountEnv, ...config.env}` in buildTerminalAgentLaunch, and the - * agent wrapper's pointer-file fallback reads the same DB-backed selection), - * and an `env VAR=value` wrapper in the command wins over both. + * agent wrapper's pointer-file fallback reads the same DB-backed selection). * Claude keeps state inside a custom CLAUDE_CONFIG_DIR but next door at * `~/.claude.json` for the default home; Codex always uses * `$CODEX_HOME/config.toml`. */ -export function resolveTrustTarget( +function resolveTrustTarget( db: HostDb, - config: AgentLaunchConfig, + config: { presetId: string; command: string; env: Record }, ): TrustTarget | null { const family = resolveTrustFamily(config); if (family === null) return null; @@ -150,12 +81,7 @@ export function resolveTrustTarget( file: join(homedir(), ".gemini", "antigravity-cli", "settings.json"), }; } - const wrapper = unwrapEnvCommand(config); - const inherited: Record = wrapper.clearsInherited - ? {} - : { ...resolveDefaultAccountEnv(db, family), ...config.env }; - for (const name of wrapper.unset) delete inherited[name]; - const env = { ...inherited, ...wrapper.env }; + const env = { ...resolveDefaultAccountEnv(db, family), ...config.env }; if (family === "claude") { const configDir = env.CLAUDE_CONFIG_DIR; return { @@ -312,7 +238,7 @@ function normalizeFolderPath(path: string): string { export async function seedAgentFolderTrust( db: HostDb, folderPath: string, - config: AgentLaunchConfig, + config: { presetId: string; command: string; env: Record }, ): Promise { try { const target = resolveTrustTarget(db, config);