@@ -359,6 +359,118 @@ func TestResolvedArgs_RealShell(t *testing.T) {
362+// TestLSPConfig_ResolvedArgs_RealShell exercises both success and
363+// failure for l.Args symmetrically with MCP args. Args are ordered so
364+// error messages must identify a positional index, not a key.
365+func TestLSPConfig_ResolvedArgs_RealShell(t *testing.T) {
368+ t.Run("success expands $VAR in each element", func(t *testing.T) {
370+ l := LSPConfig{Args: []string{"--root", "$HOME", "--flag", "literal"}}
371+ r := realShellResolver(map[string]string{"HOME": "/home/tester"})
372+ got, err := l.ResolvedArgs(r)
373+ require.NoError(t, err)
374+ require.Equal(t, []string{"--root", "/home/tester", "--flag", "literal"}, got)
377+ t.Run("failure identifies offending index", func(t *testing.T) {
379+ l := LSPConfig{Args: []string{"--root", "$(false)"}}
380+ orig := slices.Clone(l.Args)
382+ got, err := l.ResolvedArgs(realShellResolver(nil))
383+ require.Error(t, err)
385+ require.Contains(t, err.Error(), "arg 1")
386+ require.Equal(t, orig, l.Args, "receiver Args must be preserved")
389+ t.Run("nil args returns nil, no error", func(t *testing.T) {
392+ got, err := l.ResolvedArgs(realShellResolver(nil))
393+ require.NoError(t, err)
398+// TestLSPConfig_ResolvedEnv_RealShell pins the LSP env contract:
399+// success expands $VAR, failure wraps with the key name, and the
400+// receiver map is never mutated. The shape is map[string]string
401+// (not the MCP []string form) because powernap.ClientConfig.Environment
402+// takes a map directly.
403+func TestLSPConfig_ResolvedEnv_RealShell(t *testing.T) {
406+ t.Run("success expands $VAR", func(t *testing.T) {
408+ l := LSPConfig{Env: map[string]string{"GOPATH": "$HOME/go"}}
409+ r := realShellResolver(map[string]string{"HOME": "/home/tester"})
410+ got, err := l.ResolvedEnv(r)
411+ require.NoError(t, err)
412+ require.Equal(t, map[string]string{"GOPATH": "/home/tester/go"}, got)
415+ t.Run("failure identifies offending key", func(t *testing.T) {
417+ l := LSPConfig{Env: map[string]string{
418+ "GOPATH": "$(false)",
421+ orig := maps.Clone(l.Env)
423+ got, err := l.ResolvedEnv(realShellResolver(nil))
424+ require.Error(t, err)
426+ require.Contains(t, err.Error(), `env "GOPATH"`)
427+ require.Equal(t, orig, l.Env, "receiver Env must be preserved")
430+ t.Run("idempotent and non-mutating", func(t *testing.T) {
432+ l := LSPConfig{Env: map[string]string{
436+ orig := maps.Clone(l.Env)
437+ r := realShellResolver(nil)
439+ first, err := l.ResolvedEnv(r)
440+ require.NoError(t, err)
441+ second, err := l.ResolvedEnv(r)
442+ require.NoError(t, err)
443+ require.Equal(t, first, second)
444+ require.Equal(t, orig, l.Env, "receiver Env must be preserved")
448+// TestLSPConfig_IdentityResolver pins the client-mode contract: both
449+// ResolvedArgs and ResolvedEnv round-trip the template verbatim under
450+// IdentityResolver and never error on unset variables. Local
451+// expansion would double-expand when the server does its own — this
452+// has to stay a pure pass-through.
453+func TestLSPConfig_IdentityResolver(t *testing.T) {
457+ Args: []string{"--root", "$LSP_ROOT", "$(vault read -f lsp)"},
458+ Env: map[string]string{
459+ "GOPATH": "$HOME/go",
460+ "TOKEN": "$(cat /run/secrets/x)",
463+ r := IdentityResolver()
465+ args, err := l.ResolvedArgs(r)
466+ require.NoError(t, err)
467+ require.Equal(t, l.Args, args)
469+ envs, err := l.ResolvedEnv(r)
470+ require.NoError(t, err)
471+ require.Equal(t, l.Env, envs)
362// TestMCPConfig_IdentityResolver pins the client-mode contract: every
474// TestMCPConfig_IdentityResolver pins the client-mode contract: every
363// Resolved* method round-trips the template verbatim and never errors
475// Resolved* method round-trips the template verbatim and never errors
364// on unset variables. Local expansion would double-expand when the
476// on unset variables. Local expansion would double-expand when the