From 09f3842072a6740de726a84de3ec4ed7551ef0c5 Mon Sep 17 00:00:00 2001 From: Braden Ream <51544548+Bradenream@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:03:42 -0400 Subject: [PATCH 1/2] fix: let an explicit --agent-mode override agent detection Execute calls InitAgentMode before cobra parses flags, to keep the explorer TUI away from agents. That call can only see the environment, and its answer was final: InitAgentMode checked the flag only after a CompareAndSwap that every later call failed. So --agent-mode=false under Claude Code still printed JSON envelopes, --agent-mode in a plain shell still printed human errors, and the flag's help ("Use --agent-mode=false to disable") was untrue. InitAgentMode now checks for an explicitly set --agent-mode before that early return, so the call from PersistentPreRunE, the first to see the parsed flags, applies it. The environment is still checked only once. The explorer check is unchanged: it only runs when vf has no arguments, so no flag can be present. --- internal/output/agentmode.go | 26 ++++++--- internal/output/agentmode_test.go | 59 +++++++++++++++++++ test/agent-mode-flag.test.ts | 94 +++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 internal/output/agentmode_test.go create mode 100644 test/agent-mode-flag.test.ts diff --git a/internal/output/agentmode.go b/internal/output/agentmode.go index 653e9f51..d4814115 100644 --- a/internal/output/agentmode.go +++ b/internal/output/agentmode.go @@ -40,22 +40,30 @@ var agentEnvVars = []string{ } // InitAgentMode detects and caches agent mode state for the lifetime of the -// process. In production, each CLI invocation is a separate process, so this -// is evaluated exactly once. For in-process test scenarios with multiple -// command executions, call ResetAgentMode() between runs to re-evaluate. +// process. In production, each CLI invocation is a separate process, so the +// environment is checked exactly once, while an explicit --agent-mode is +// applied by any call that can see it. For in-process test scenarios with +// multiple command executions, call ResetAgentMode() between runs to +// re-evaluate. // // Checks --agent-mode flag first (explicit override), then auto-detects // from well-known AI agent environment variables. func InitAgentMode(cmd *cobra.Command) { - // CompareAndSwap ensures only the first caller runs detection; subsequent - // calls return immediately without touching agentMode. - if !agentDetected.CompareAndSwap(false, true) { + // Explicit flag takes priority: --agent-mode=false overrides env vars. It is + // checked before the CompareAndSwap below because Execute calls this once + // before cobra parses flags, to keep the explorer TUI away from agents. + // That first call can only see the environment, and used to settle the + // mode for good, so the flag was ignored. The second call, from + // PersistentPreRunE, is the first that can see the flag. + if flagVal, changed := flagutil.GetBoolFlag(cmd, "agent-mode"); changed { + agentDetected.Store(true) + agentMode.Store(flagVal) return } - // Explicit flag takes priority: --agent-mode=false overrides env vars. - if flagVal, changed := flagutil.GetBoolFlag(cmd, "agent-mode"); changed { - agentMode.Store(flagVal) + // CompareAndSwap ensures only the first caller runs detection; subsequent + // calls return immediately without touching agentMode. + if !agentDetected.CompareAndSwap(false, true) { return } diff --git a/internal/output/agentmode_test.go b/internal/output/agentmode_test.go new file mode 100644 index 00000000..a9e9690e --- /dev/null +++ b/internal/output/agentmode_test.go @@ -0,0 +1,59 @@ +package output + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// commandTree returns a root with the global --agent-mode flag and a child, +// the two commands Execute and PersistentPreRunE pass to InitAgentMode. +func commandTree() (root, child *cobra.Command) { + root = &cobra.Command{Use: "vf"} + root.PersistentFlags().Bool("agent-mode", false, "") + child = &cobra.Command{Use: "workspace", Run: func(*cobra.Command, []string) {}} + root.AddCommand(child) + return root, child +} + +// Execute calls InitAgentMode before cobra parses flags, so it sees only the +// environment; PersistentPreRunE calls it again once flags are parsed. An +// explicit --agent-mode must win on that second call, in both directions. +func TestAnExplicitAgentModeFlagWinsOverTheEnvironment(t *testing.T) { + cases := []struct { + name string + underAnAI bool + flag string + want bool + }{ + {"--agent-mode=false under an agent", true, "false", false}, + {"--agent-mode outside an agent", false, "true", true}, + {"no flag under an agent", true, "", true}, + {"no flag outside an agent", false, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, name := range agentEnvVars { + t.Setenv(name, "") + } + if tc.underAnAI { + t.Setenv("CLAUDECODE", "1") + } + ResetAgentMode() + t.Cleanup(ResetAgentMode) + root, child := commandTree() + + InitAgentMode(root) // as Execute does, before flags are parsed + if tc.flag != "" { + if err := root.PersistentFlags().Set("agent-mode", tc.flag); err != nil { + t.Fatal(err) + } + } + InitAgentMode(child) // as PersistentPreRunE does, after parsing + + if got := IsAgentMode(); got != tc.want { + t.Errorf("IsAgentMode() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/test/agent-mode-flag.test.ts b/test/agent-mode-flag.test.ts new file mode 100644 index 00000000..102f78a4 --- /dev/null +++ b/test/agent-mode-flag.test.ts @@ -0,0 +1,94 @@ +// Tests for the --agent-mode flag (internal/output/agentmode.go): an explicit +// value wins over agent detection from the environment, in both directions. +// +// Execute settles agent mode once before cobra parses flags, from the +// environment alone, and that first answer used to be final, so the flag did +// nothing: --agent-mode=false under Claude Code still printed JSON envelopes, +// and --agent-mode in a plain shell still printed human errors. +// +// Requires: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); + +// Every variable that puts the CLI into agent mode. Mirrors the list in +// internal/output/agentmode.go; each case sets the one it needs. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'CODEX', 'AIDER', 'CLINE', + 'WINDSURF_AGENT', 'GITHUB_COPILOT', 'AMAZON_Q', 'GEMINI_CODE_ASSIST', + 'SRC_CODY', 'FORCE_AGENT_MODE', +]; + +let home: string; + +beforeAll(() => { + // vf keeps credentials under HOME; an empty one keeps the developer's out. + home = fs.mkdtempSync(path.join(os.tmpdir(), 'vf-agent-mode-home-')); +}); + +afterAll(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +function run(args: string[], env: Record = {}) { + const cleared: Record = Object.fromEntries(AGENT_ENV_VARS.map((name) => [name, undefined])); + return execa({ + reject: false, + timeout: 20_000, + stdin: 'ignore', + env: { ...cleared, HOME: home, VF_TOKEN: '', ...env }, + extendEnv: true, + })(VF, args); +} + +// No token and an unroutable server: vf fails its preflight before sending +// anything, and reports it in whichever mode is in effect. +const NO_TOKEN = ['workspace', 'list', '--server-url', 'http://127.0.0.1:1']; + +/** In agent mode stderr is the JSON envelope and nothing else. */ +const isEnvelope = (stderr: string) => stderr.trimStart().startsWith('{'); + +describe('--agent-mode', () => { + it('turns agent mode off under an agent', async () => { + const result = await run([...NO_TOKEN, '--agent-mode=false'], { CLAUDECODE: '1' }); + + expect(result.exitCode).toBe(1); + expect(isEnvelope(result.stderr), result.stderr).toBe(false); + expect(result.stderr).toContain('API Error'); + }); + + it('turns agent mode on outside an agent', async () => { + const result = await run([...NO_TOKEN, '--agent-mode']); + + expect(result.exitCode).toBe(1); + expect(isEnvelope(result.stderr), result.stderr).toBe(true); + expect(JSON.parse(result.stderr).error_type).toBe('authentication_error'); + }); + + it('leaves detection from the environment in charge when it is not given', async () => { + const underAnAgent = await run(NO_TOKEN, { CLAUDECODE: '1' }); + const inAPlainShell = await run(NO_TOKEN); + + expect(isEnvelope(underAnAgent.stderr), underAnAgent.stderr).toBe(true); + expect(isEnvelope(inAPlainShell.stderr), inAPlainShell.stderr).toBe(false); + }); + + // A bad flag value is reported after the command has run, so this checks the + // flag reaches that path too. + it('also decides how a bad flag value is reported', async () => { + const result = await run( + ['agent', 'update', '--project-id', 'p', '--environment-alias', 'main', '--dry-run', '--token', 'vfp_x', + '--llm', 'gpt-4', '--agent-mode=false'], + { CLAUDECODE: '1' }, + ); + + expect(result.exitCode).toBe(1); + expect(isEnvelope(result.stderr), result.stderr).toBe(false); + expect(result.stderr).toContain('invalid value for --llm'); + }); +}); From 925a6f6a47e3a99ddfa00727dd59a6f836c1f1fa Mon Sep 17 00:00:00 2001 From: Braden Ream <51544548+Bradenream@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:47:03 -0400 Subject: [PATCH 2/2] test: keep the agent-mode tests away from a Linux keyring The no-token cases ran vf with an empty HOME, which hides the macOS Keychain, but on Linux vf reaches the keyring over the D-Bus session bus. A developer with a token saved there would have had it found, and the cases would have taken a different path. They now point DBUS_SESSION_BUS_ADDRESS at a socket that does not exist, as test/setup.ts does for the whole suite in #37. Copilot raised this in review. --- test/agent-mode-flag.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/agent-mode-flag.test.ts b/test/agent-mode-flag.test.ts index 102f78a4..02f93d56 100644 --- a/test/agent-mode-flag.test.ts +++ b/test/agent-mode-flag.test.ts @@ -41,7 +41,16 @@ function run(args: string[], env: Record = {}) { reject: false, timeout: 20_000, stdin: 'ignore', - env: { ...cleared, HOME: home, VF_TOKEN: '', ...env }, + env: { + ...cleared, + HOME: home, + VF_TOKEN: '', + // The no-token cases must find no token anywhere. An empty HOME hides the + // macOS Keychain; on Linux the keyring is reached over D-Bus, so point + // the session bus at a socket that does not exist. + DBUS_SESSION_BUS_ADDRESS: `unix:path=${path.join(home, 'no-session-bus')}`, + ...env, + }, extendEnv: true, })(VF, args); }