diff --git a/bun.lock b/bun.lock index a2f2c9d875f..327c332989b 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 03c2b7c701b..1a9d3404b10 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -116,6 +116,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", @@ -129,6 +130,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 db223b659be..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 @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { chmodSync, @@ -6,20 +7,35 @@ import { mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +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 { 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(() => { @@ -61,6 +77,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 +204,82 @@ 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 ed6ecb84d08..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 @@ -30,30 +30,98 @@ 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 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 +132,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( +export 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 +150,12 @@ function resolveTrustTarget( file: join(homedir(), ".gemini", "antigravity-cli", "settings.json"), }; } - const env = { ...resolveDefaultAccountEnv(db, family), ...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 { @@ -238,7 +312,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);