1 line
74 KiB
JSON
1 line
74 KiB
JSON
{"repo": "binhsonnguyen/ccc", "files": {"adapters/ptyrunner/ptyrunner_test.go": {"d766ac42a371f9742f1d297dbb60e8eb17ea7331": {"t": 1785746078.3480883, "n": 9343, "h": "cb63a98f80052704", "c": "package ptyrunner\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\n// StartShell must not touch ~/.claude/projects/. We point HOME at a temp\n// dir, pre-seed it with a fake project tree (so we'd notice anything\n// new), spawn a shell briefly, then snapshot the directory listing. The\n// only file allowed to appear is the pre-existing one.\nfunc TestStartShell_DoesNotCreateClaudeJSONL(t *testing.T) {\n\t// Use a temp HOME so the test never touches the user's real\n\t// ~/.claude/projects/. resolveClaude / augmentPath read HOME via\n\t// fallbackBinDirs, which is fine — they only build PATH entries.\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\tprojectsDir := filepath.Join(tmpHome, \".claude\", \"projects\", \"encoded-cwd\")\n\tif err := os.MkdirAll(projectsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir projects: %v\", err)\n\t}\n\tpreExisting := filepath.Join(projectsDir, \"pre-existing.jsonl\")\n\tif err := os.WriteFile(preExisting, []byte(\"{}\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"seed jsonl: %v\", err)\n\t}\n\n\tcwd := t.TempDir()\n\t// argv = nil → default ($SHELL or /bin/bash -i). We force /bin/sh so\n\t// the test doesn't depend on the developer's login shell being\n\t// installed under HOME=tmp.\n\tsess, err := StartShell(cwd, []string{\"/bin/sh\", \"-c\", \"echo hello\"}, nil)\n\tif err != nil {\n\t\tt.Skipf(\"StartShell unavailable in this environment: %v\", err)\n\t\treturn\n\t}\n\t// Drain briefly and let the child exit on its own.\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tif _, err := sess.Master.Read(buf); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\ttime.Sleep(200 * time.Millisecond)\n\t_ = sess.Kill()\n\t_, _ = sess.Wait()\n\n\t// Walk ~/.claude/projects and assert only the pre-existing file is there.\n\tvar found []string\n\troot := filepath.Join(tmpHome, \".claude\", \"projects\")\n\tif err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfound = append(found, p)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Fatalf(\"walk: %v\", err)\n\t}\n\tif len(found) != 1 || found[0] != preExisting {\n\t\tt.Fatalf(\"unexpected files under ~/.claude/projects: %v\", found)\n\t}\n}\n\nfunc localeLines(env []string) []string {\n\tvar out []string\n\tfor _, kv := range env {\n\t\tif strings.HasPrefix(kv, \"LANG=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_ALL=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_CTYPE=\") {\n\t\t\tout = append(out, kv)\n\t\t}\n\t}\n\treturn out\n}\n\n// The launchd bug: empty/absent locale → inject a UTF-8 LANG so children\n// don't fall back to Mac Roman and mangle Vietnamese text.\nfunc TestEnsureUTF8Locale_InjectsWhenAbsent(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"FOO=bar\"})\n\tlines := localeLines(got)\n\tif len(lines) != 1 || lines[0] != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected, got %v\", lines)\n\t}\n}\n\n// An empty LANG= (what we actually measured under launchd) counts as\n// unset and must still get the UTF-8 default.\nfunc TestEnsureUTF8Locale_InjectsWhenEmpty(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"LANG=\", \"LC_ALL=\", \"LC_CTYPE=\"})\n\tfound := false\n\tfor _, kv := range got {\n\t\tif kv == \"LANG=en_US.UTF-8\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected for empty locale; got %v\", localeLines(got))\n\t}\n}\n\n// A user who already set a UTF-8 locale (or any locale at all) must be\n// left untouched — we never override an explicit choice.\nfunc TestEnsureUTF8Locale_RespectsExisting(t *testing.T) {\n\tfor _, existing := range []string{\"LANG=vi_VN.UTF-8\", \"LC_CTYPE=en_GB.UTF-8\", \"LC_ALL=C\"} {\n\t\tgot := ensureUTF8Locale([]string{existing, \"FOO=bar\"})\n\t\tlines := localeLines(got)\n\t\tif len(lines) != 1 || lines[0] != existing {\n\t\t\tt.Fatalf(\"ensureUTF8Locale altered explicit locale %q: %v\", existing, lines)\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0(t *testing.T) {\n\t// NUL-delimited, with a value that itself contains a newline.\n\traw := []byte(\"PATH=/usr/bin:/bin\\x00LANG=en_US.UTF-8\\x00MULTI=line1\\nline2\\x00\")\n\tgot := parseEnv0(raw)\n\twant := []string{\"PATH=/usr/bin:/bin\", \"LANG=en_US.UTF-8\", \"MULTI=line1\\nline2\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"parseEnv0 len=%d want %d: %v\", len(got), len(want), got)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"parseEnv0[%d]=%q want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0_DropsMalformed(t *testing.T) {\n\t// Empty segments, a no-'=' token, and a leading-'=' token must all drop.\n\traw := []byte(\"\\x00noequals\\x00=novalue\\x00OK=1\\x00\")\n\tgot := parseEnv0(raw)\n\tif len(got) != 1 || got[0] != \"OK=1\" {\n\t\tt.Fatalf(\"expected only OK=1, got %v\", got)\n\t}\n}\n\nfunc TestParseEnv0_EmptyReturnsNil(t *testing.T) {\n\tif got := parseEnv0(nil); got != nil {\n\t\tt.Fatalf(\"expected nil for empty input, got %v\", got)\n\t}\n}\n\nfunc TestSetEnv_ReplacesAndAppends(t *testing.T) {\n\tenv := []string{\"A=1\", \"TERM=dumb\", \"B=2\"}\n\tgot := setEnv(env, \"TERM\", \"xterm-256color\")\n\tfound := \"\"\n\tcount := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"TERM=\") {\n\t\t\tfound = kv\n\t\t\tcount++\n\t\t}\n\t}\n\tif count != 1 || found != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"setEnv replace: count=%d found=%q\", count, found)\n\t}\n\t// setEnv must not mutate the caller's slice.\n\tif env[1] != \"TERM=dumb\" {\n\t\tt.Fatalf(\"setEnv mutated input: %v\", env)\n\t}\n\tgot2 := setEnv([]string{\"A=1\"}, \"TERM\", \"x\")\n\tif got2[len(got2)-1] != \"TERM=x\" {\n\t\tt.Fatalf(\"setEnv append: %v\", got2)\n\t}\n}\n\n// buildChildEnv must always force TERM and guarantee a UTF-8 locale +\n// augmented PATH regardless of how thin the base env is.\nfunc TestBuildChildEnv_ForcesTermAndLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"TERM=dumb\"})\n\tvar term, lang, path string\n\tfor _, kv := range got {\n\t\tswitch {\n\t\tcase strings.HasPrefix(kv, \"TERM=\"):\n\t\t\tterm = kv\n\t\tcase strings.HasPrefix(kv, \"LANG=\"):\n\t\t\tlang = kv\n\t\tcase strings.HasPrefix(kv, \"PATH=\"):\n\t\t\tpath = kv\n\t\t}\n\t}\n\tif term != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"TERM not forced: %q\", term)\n\t}\n\tif lang != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"LANG not ensured: %q\", lang)\n\t}\n\tif path == \"\" {\n\t\tt.Fatalf(\"PATH not augmented; env=%v\", got)\n\t}\n}\n\n// A login env that already carries a UTF-8 locale must be preserved.\nfunc TestBuildChildEnv_PreservesExistingLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"LANG=vi_VN.UTF-8\", \"PATH=/usr/bin\"})\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"LANG=\") && kv != \"LANG=vi_VN.UTF-8\" {\n\t\t\tt.Fatalf(\"overrode existing LANG: %q\", kv)\n\t\t}\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n\nfunc TestApplyEnvOps_SetsAndDeletes(t *testing.T) {\n\tbase := []string{\n\t\t\"PATH=/usr/bin\",\n\t\t\"ANTHROPIC_BASE_URL=https://stale.example\",\n\t\t\"ANTHROPIC_MODEL=old\",\n\t}\n\tout := applyEnvOps(base, []EnvOp{\n\t\t{Key: \"ANTHROPIC_BASE_URL\", Value: \"https://api.deepseek.com/anthropic\"}, // overwrite\n\t\t{Key: \"ANTHROPIC_AUTH_TOKEN\", Value: \"sk-x\"}, // add\n\t\t{Key: \"ANTHROPIC_MODEL\", Unset: true}, // delete\n\t})\n\tgot := map[string]bool{}\n\tfor _, kv := range out {\n\t\tgot[kv] = true\n\t}\n\tif !got[\"ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic\"] {\n\t\tt.Error(\"base url not overwritten\")\n\t}\n\tif !got[\"ANTHROPIC_AUTH_TOKEN=sk-x\"] {\n\t\tt.Error(\"token not added\")\n\t}\n\tif !got[\"PATH=/usr/bin\"] {\n\t\tt.Error(\"unrelated var dropped\")\n\t}\n\tfor _, kv := range out {\n\t\tif kv == \"ANTHROPIC_MODEL=old\" || kv == \"ANTHROPIC_MODEL=\" {\n\t\t\tt.Errorf(\"ANTHROPIC_MODEL should be deleted, found %q\", kv)\n\t\t}\n\t}\n}\n\n// Later ops win over earlier ones — the layering contract (global then\n// per-session).\nfunc TestApplyEnvOps_LaterWins(t *testing.T) {\n\tout := applyEnvOps([]string{\"K=base\"}, []EnvOp{\n\t\t{Key: \"K\", Value: \"global\"},\n\t\t{Key: \"K\", Value: \"session\"},\n\t})\n\tif len(out) != 1 || out[0] != \"K=session\" {\n\t\tt.Errorf(\"later op should win, got %v\", out)\n\t}\n}\n\nfunc TestApplyEnvOps_NilIsNoop(t *testing.T) {\n\tbase := []string{\"PATH=/usr/bin\"}\n\tif out := applyEnvOps(base, nil); len(out) != 1 || out[0] != \"PATH=/usr/bin\" {\n\t\tt.Errorf(\"nil overlay changed env: %v\", out)\n\t}\n}\n"}, "dfdbf29bebe782867891d672569f121c45cebc06": {"t": 1785759619.76978, "n": 9343, "h": "cb63a98f80052704", "c": "package ptyrunner\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\n// StartShell must not touch ~/.claude/projects/. We point HOME at a temp\n// dir, pre-seed it with a fake project tree (so we'd notice anything\n// new), spawn a shell briefly, then snapshot the directory listing. The\n// only file allowed to appear is the pre-existing one.\nfunc TestStartShell_DoesNotCreateClaudeJSONL(t *testing.T) {\n\t// Use a temp HOME so the test never touches the user's real\n\t// ~/.claude/projects/. resolveClaude / augmentPath read HOME via\n\t// fallbackBinDirs, which is fine — they only build PATH entries.\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\tprojectsDir := filepath.Join(tmpHome, \".claude\", \"projects\", \"encoded-cwd\")\n\tif err := os.MkdirAll(projectsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir projects: %v\", err)\n\t}\n\tpreExisting := filepath.Join(projectsDir, \"pre-existing.jsonl\")\n\tif err := os.WriteFile(preExisting, []byte(\"{}\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"seed jsonl: %v\", err)\n\t}\n\n\tcwd := t.TempDir()\n\t// argv = nil → default ($SHELL or /bin/bash -i). We force /bin/sh so\n\t// the test doesn't depend on the developer's login shell being\n\t// installed under HOME=tmp.\n\tsess, err := StartShell(cwd, []string{\"/bin/sh\", \"-c\", \"echo hello\"}, nil)\n\tif err != nil {\n\t\tt.Skipf(\"StartShell unavailable in this environment: %v\", err)\n\t\treturn\n\t}\n\t// Drain briefly and let the child exit on its own.\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tif _, err := sess.Master.Read(buf); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\ttime.Sleep(200 * time.Millisecond)\n\t_ = sess.Kill()\n\t_, _ = sess.Wait()\n\n\t// Walk ~/.claude/projects and assert only the pre-existing file is there.\n\tvar found []string\n\troot := filepath.Join(tmpHome, \".claude\", \"projects\")\n\tif err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfound = append(found, p)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Fatalf(\"walk: %v\", err)\n\t}\n\tif len(found) != 1 || found[0] != preExisting {\n\t\tt.Fatalf(\"unexpected files under ~/.claude/projects: %v\", found)\n\t}\n}\n\nfunc localeLines(env []string) []string {\n\tvar out []string\n\tfor _, kv := range env {\n\t\tif strings.HasPrefix(kv, \"LANG=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_ALL=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_CTYPE=\") {\n\t\t\tout = append(out, kv)\n\t\t}\n\t}\n\treturn out\n}\n\n// The launchd bug: empty/absent locale → inject a UTF-8 LANG so children\n// don't fall back to Mac Roman and mangle Vietnamese text.\nfunc TestEnsureUTF8Locale_InjectsWhenAbsent(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"FOO=bar\"})\n\tlines := localeLines(got)\n\tif len(lines) != 1 || lines[0] != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected, got %v\", lines)\n\t}\n}\n\n// An empty LANG= (what we actually measured under launchd) counts as\n// unset and must still get the UTF-8 default.\nfunc TestEnsureUTF8Locale_InjectsWhenEmpty(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"LANG=\", \"LC_ALL=\", \"LC_CTYPE=\"})\n\tfound := false\n\tfor _, kv := range got {\n\t\tif kv == \"LANG=en_US.UTF-8\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected for empty locale; got %v\", localeLines(got))\n\t}\n}\n\n// A user who already set a UTF-8 locale (or any locale at all) must be\n// left untouched — we never override an explicit choice.\nfunc TestEnsureUTF8Locale_RespectsExisting(t *testing.T) {\n\tfor _, existing := range []string{\"LANG=vi_VN.UTF-8\", \"LC_CTYPE=en_GB.UTF-8\", \"LC_ALL=C\"} {\n\t\tgot := ensureUTF8Locale([]string{existing, \"FOO=bar\"})\n\t\tlines := localeLines(got)\n\t\tif len(lines) != 1 || lines[0] != existing {\n\t\t\tt.Fatalf(\"ensureUTF8Locale altered explicit locale %q: %v\", existing, lines)\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0(t *testing.T) {\n\t// NUL-delimited, with a value that itself contains a newline.\n\traw := []byte(\"PATH=/usr/bin:/bin\\x00LANG=en_US.UTF-8\\x00MULTI=line1\\nline2\\x00\")\n\tgot := parseEnv0(raw)\n\twant := []string{\"PATH=/usr/bin:/bin\", \"LANG=en_US.UTF-8\", \"MULTI=line1\\nline2\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"parseEnv0 len=%d want %d: %v\", len(got), len(want), got)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"parseEnv0[%d]=%q want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0_DropsMalformed(t *testing.T) {\n\t// Empty segments, a no-'=' token, and a leading-'=' token must all drop.\n\traw := []byte(\"\\x00noequals\\x00=novalue\\x00OK=1\\x00\")\n\tgot := parseEnv0(raw)\n\tif len(got) != 1 || got[0] != \"OK=1\" {\n\t\tt.Fatalf(\"expected only OK=1, got %v\", got)\n\t}\n}\n\nfunc TestParseEnv0_EmptyReturnsNil(t *testing.T) {\n\tif got := parseEnv0(nil); got != nil {\n\t\tt.Fatalf(\"expected nil for empty input, got %v\", got)\n\t}\n}\n\nfunc TestSetEnv_ReplacesAndAppends(t *testing.T) {\n\tenv := []string{\"A=1\", \"TERM=dumb\", \"B=2\"}\n\tgot := setEnv(env, \"TERM\", \"xterm-256color\")\n\tfound := \"\"\n\tcount := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"TERM=\") {\n\t\t\tfound = kv\n\t\t\tcount++\n\t\t}\n\t}\n\tif count != 1 || found != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"setEnv replace: count=%d found=%q\", count, found)\n\t}\n\t// setEnv must not mutate the caller's slice.\n\tif env[1] != \"TERM=dumb\" {\n\t\tt.Fatalf(\"setEnv mutated input: %v\", env)\n\t}\n\tgot2 := setEnv([]string{\"A=1\"}, \"TERM\", \"x\")\n\tif got2[len(got2)-1] != \"TERM=x\" {\n\t\tt.Fatalf(\"setEnv append: %v\", got2)\n\t}\n}\n\n// buildChildEnv must always force TERM and guarantee a UTF-8 locale +\n// augmented PATH regardless of how thin the base env is.\nfunc TestBuildChildEnv_ForcesTermAndLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"TERM=dumb\"})\n\tvar term, lang, path string\n\tfor _, kv := range got {\n\t\tswitch {\n\t\tcase strings.HasPrefix(kv, \"TERM=\"):\n\t\t\tterm = kv\n\t\tcase strings.HasPrefix(kv, \"LANG=\"):\n\t\t\tlang = kv\n\t\tcase strings.HasPrefix(kv, \"PATH=\"):\n\t\t\tpath = kv\n\t\t}\n\t}\n\tif term != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"TERM not forced: %q\", term)\n\t}\n\tif lang != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"LANG not ensured: %q\", lang)\n\t}\n\tif path == \"\" {\n\t\tt.Fatalf(\"PATH not augmented; env=%v\", got)\n\t}\n}\n\n// A login env that already carries a UTF-8 locale must be preserved.\nfunc TestBuildChildEnv_PreservesExistingLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"LANG=vi_VN.UTF-8\", \"PATH=/usr/bin\"})\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"LANG=\") && kv != \"LANG=vi_VN.UTF-8\" {\n\t\t\tt.Fatalf(\"overrode existing LANG: %q\", kv)\n\t\t}\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n\nfunc TestApplyEnvOps_SetsAndDeletes(t *testing.T) {\n\tbase := []string{\n\t\t\"PATH=/usr/bin\",\n\t\t\"ANTHROPIC_BASE_URL=https://stale.example\",\n\t\t\"ANTHROPIC_MODEL=old\",\n\t}\n\tout := applyEnvOps(base, []EnvOp{\n\t\t{Key: \"ANTHROPIC_BASE_URL\", Value: \"https://api.deepseek.com/anthropic\"}, // overwrite\n\t\t{Key: \"ANTHROPIC_AUTH_TOKEN\", Value: \"sk-x\"}, // add\n\t\t{Key: \"ANTHROPIC_MODEL\", Unset: true}, // delete\n\t})\n\tgot := map[string]bool{}\n\tfor _, kv := range out {\n\t\tgot[kv] = true\n\t}\n\tif !got[\"ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic\"] {\n\t\tt.Error(\"base url not overwritten\")\n\t}\n\tif !got[\"ANTHROPIC_AUTH_TOKEN=sk-x\"] {\n\t\tt.Error(\"token not added\")\n\t}\n\tif !got[\"PATH=/usr/bin\"] {\n\t\tt.Error(\"unrelated var dropped\")\n\t}\n\tfor _, kv := range out {\n\t\tif kv == \"ANTHROPIC_MODEL=old\" || kv == \"ANTHROPIC_MODEL=\" {\n\t\t\tt.Errorf(\"ANTHROPIC_MODEL should be deleted, found %q\", kv)\n\t\t}\n\t}\n}\n\n// Later ops win over earlier ones — the layering contract (global then\n// per-session).\nfunc TestApplyEnvOps_LaterWins(t *testing.T) {\n\tout := applyEnvOps([]string{\"K=base\"}, []EnvOp{\n\t\t{Key: \"K\", Value: \"global\"},\n\t\t{Key: \"K\", Value: \"session\"},\n\t})\n\tif len(out) != 1 || out[0] != \"K=session\" {\n\t\tt.Errorf(\"later op should win, got %v\", out)\n\t}\n}\n\nfunc TestApplyEnvOps_NilIsNoop(t *testing.T) {\n\tbase := []string{\"PATH=/usr/bin\"}\n\tif out := applyEnvOps(base, nil); len(out) != 1 || out[0] != \"PATH=/usr/bin\" {\n\t\tt.Errorf(\"nil overlay changed env: %v\", out)\n\t}\n}\n"}, "977437bccc3c024220d17cc3b7441c1196207c81": {"t": 1785759621.3673027, "n": 8978, "h": "e0b20c54ffaaa0d9", "c": "package ptyrunner\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\n// StartShell must not touch ~/.claude/projects/. We point HOME at a temp\n// dir, pre-seed it with a fake project tree (so we'd notice anything\n// new), spawn a shell briefly, then snapshot the directory listing. The\n// only file allowed to appear is the pre-existing one.\nfunc TestStartShell_DoesNotCreateClaudeJSONL(t *testing.T) {\n\t// Use a temp HOME so the test never touches the user's real\n\t// ~/.claude/projects/. resolveClaude / augmentPath read HOME via\n\t// fallbackBinDirs, which is fine — they only build PATH entries.\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\tprojectsDir := filepath.Join(tmpHome, \".claude\", \"projects\", \"encoded-cwd\")\n\tif err := os.MkdirAll(projectsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir projects: %v\", err)\n\t}\n\tpreExisting := filepath.Join(projectsDir, \"pre-existing.jsonl\")\n\tif err := os.WriteFile(preExisting, []byte(\"{}\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"seed jsonl: %v\", err)\n\t}\n\n\tcwd := t.TempDir()\n\t// argv = nil → default ($SHELL or /bin/bash -i). We force /bin/sh so\n\t// the test doesn't depend on the developer's login shell being\n\t// installed under HOME=tmp.\n\tsess, err := StartShell(cwd, []string{\"/bin/sh\", \"-c\", \"echo hello\"})\n\tif err != nil {\n\t\tt.Skipf(\"StartShell unavailable in this environment: %v\", err)\n\t\treturn\n\t}\n\t// Drain briefly and let the child exit on its own.\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tif _, err := sess.Master.Read(buf); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\ttime.Sleep(200 * time.Millisecond)\n\t_ = sess.Kill()\n\t_, _ = sess.Wait()\n\n\t// Walk ~/.claude/projects and assert only the pre-existing file is there.\n\tvar found []string\n\troot := filepath.Join(tmpHome, \".claude\", \"projects\")\n\tif err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfound = append(found, p)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Fatalf(\"walk: %v\", err)\n\t}\n\tif len(found) != 1 || found[0] != preExisting {\n\t\tt.Fatalf(\"unexpected files under ~/.claude/projects: %v\", found)\n\t}\n}\n\nfunc localeLines(env []string) []string {\n\tvar out []string\n\tfor _, kv := range env {\n\t\tif strings.HasPrefix(kv, \"LANG=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_ALL=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_CTYPE=\") {\n\t\t\tout = append(out, kv)\n\t\t}\n\t}\n\treturn out\n}\n\n// The launchd bug: empty/absent locale → inject a UTF-8 LANG so children\n// don't fall back to Mac Roman and mangle Vietnamese text.\nfunc TestEnsureUTF8Locale_InjectsWhenAbsent(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"FOO=bar\"})\n\tlines := localeLines(got)\n\tif len(lines) != 1 || lines[0] != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected, got %v\", lines)\n\t}\n}\n\n// An empty LANG= (what we actually measured under launchd) counts as\n// unset and must still get the UTF-8 default.\nfunc TestEnsureUTF8Locale_InjectsWhenEmpty(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"LANG=\", \"LC_ALL=\", \"LC_CTYPE=\"})\n\tfound := false\n\tfor _, kv := range got {\n\t\tif kv == \"LANG=en_US.UTF-8\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected for empty locale; got %v\", localeLines(got))\n\t}\n}\n\n// A user who already set a UTF-8 locale (or any locale at all) must be\n// left untouched — we never override an explicit choice.\nfunc TestEnsureUTF8Locale_RespectsExisting(t *testing.T) {\n\tfor _, existing := range []string{\"LANG=vi_VN.UTF-8\", \"LC_CTYPE=en_GB.UTF-8\", \"LC_ALL=C\"} {\n\t\tgot := ensureUTF8Locale([]string{existing, \"FOO=bar\"})\n\t\tlines := localeLines(got)\n\t\tif len(lines) != 1 || lines[0] != existing {\n\t\t\tt.Fatalf(\"ensureUTF8Locale altered explicit locale %q: %v\", existing, lines)\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0(t *testing.T) {\n\t// NUL-delimited, with a value that itself contains a newline.\n\traw := []byte(\"PATH=/usr/bin:/bin\\x00LANG=en_US.UTF-8\\x00MULTI=line1\\nline2\\x00\")\n\tgot := parseEnv0(raw)\n\twant := []string{\"PATH=/usr/bin:/bin\", \"LANG=en_US.UTF-8\", \"MULTI=line1\\nline2\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"parseEnv0 len=%d want %d: %v\", len(got), len(want), got)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"parseEnv0[%d]=%q want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0_DropsMalformed(t *testing.T) {\n\t// Empty segments, a no-'=' token, and a leading-'=' token must all drop.\n\traw := []byte(\"\\x00noequals\\x00=novalue\\x00OK=1\\x00\")\n\tgot := parseEnv0(raw)\n\tif len(got) != 1 || got[0] != \"OK=1\" {\n\t\tt.Fatalf(\"expected only OK=1, got %v\", got)\n\t}\n}\n\nfunc TestParseEnv0_EmptyReturnsNil(t *testing.T) {\n\tif got := parseEnv0(nil); got != nil {\n\t\tt.Fatalf(\"expected nil for empty input, got %v\", got)\n\t}\n}\n\nfunc TestSetEnv_ReplacesAndAppends(t *testing.T) {\n\tenv := []string{\"A=1\", \"TERM=dumb\", \"B=2\"}\n\tgot := setEnv(env, \"TERM\", \"xterm-256color\")\n\tfound := \"\"\n\tcount := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"TERM=\") {\n\t\t\tfound = kv\n\t\t\tcount++\n\t\t}\n\t}\n\tif count != 1 || found != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"setEnv replace: count=%d found=%q\", count, found)\n\t}\n\t// setEnv must not mutate the caller's slice.\n\tif env[1] != \"TERM=dumb\" {\n\t\tt.Fatalf(\"setEnv mutated input: %v\", env)\n\t}\n\tgot2 := setEnv([]string{\"A=1\"}, \"TERM\", \"x\")\n\tif got2[len(got2)-1] != \"TERM=x\" {\n\t\tt.Fatalf(\"setEnv append: %v\", got2)\n\t}\n}\n\n// buildChildEnv must always force TERM and guarantee a UTF-8 locale +\n// augmented PATH regardless of how thin the base env is.\nfunc TestBuildChildEnv_ForcesTermAndLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"TERM=dumb\"})\n\tvar term, lang, path string\n\tfor _, kv := range got {\n\t\tswitch {\n\t\tcase strings.HasPrefix(kv, \"TERM=\"):\n\t\t\tterm = kv\n\t\tcase strings.HasPrefix(kv, \"LANG=\"):\n\t\t\tlang = kv\n\t\tcase strings.HasPrefix(kv, \"PATH=\"):\n\t\t\tpath = kv\n\t\t}\n\t}\n\tif term != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"TERM not forced: %q\", term)\n\t}\n\tif lang != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"LANG not ensured: %q\", lang)\n\t}\n\tif path == \"\" {\n\t\tt.Fatalf(\"PATH not augmented; env=%v\", got)\n\t}\n}\n\n// A login env that already carries a UTF-8 locale must be preserved.\nfunc TestBuildChildEnv_PreservesExistingLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"LANG=vi_VN.UTF-8\", \"PATH=/usr/bin\"})\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"LANG=\") && kv != \"LANG=vi_VN.UTF-8\" {\n\t\t\tt.Fatalf(\"overrode existing LANG: %q\", kv)\n\t\t}\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n\nfunc TestApplyEnvOverlay_SetsAndDeletes(t *testing.T) {\n\tbase := []string{\n\t\t\"PATH=/usr/bin\",\n\t\t\"ANTHROPIC_BASE_URL=https://stale.example\",\n\t\t\"ANTHROPIC_MODEL=old\",\n\t}\n\tout := applyEnvOverlay(base, map[string]string{\n\t\t\"ANTHROPIC_BASE_URL\": \"https://api.deepseek.com/anthropic\", // overwrite\n\t\t\"ANTHROPIC_AUTH_TOKEN\": \"sk-x\", // add\n\t\t\"ANTHROPIC_MODEL\": \"\", // delete\n\t})\n\tgot := map[string]bool{}\n\tfor _, kv := range out {\n\t\tgot[kv] = true\n\t}\n\tif !got[\"ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic\"] {\n\t\tt.Error(\"base url not overwritten\")\n\t}\n\tif !got[\"ANTHROPIC_AUTH_TOKEN=sk-x\"] {\n\t\tt.Error(\"token not added\")\n\t}\n\tif !got[\"PATH=/usr/bin\"] {\n\t\tt.Error(\"unrelated var dropped\")\n\t}\n\tfor _, kv := range out {\n\t\tif kv == \"ANTHROPIC_MODEL=old\" || kv == \"ANTHROPIC_MODEL=\" {\n\t\t\tt.Errorf(\"ANTHROPIC_MODEL should be deleted, found %q\", kv)\n\t\t}\n\t}\n}\n\nfunc TestApplyEnvOverlay_NilIsNoop(t *testing.T) {\n\tbase := []string{\"PATH=/usr/bin\"}\n\tif out := applyEnvOverlay(base, nil); len(out) != 1 || out[0] != \"PATH=/usr/bin\" {\n\t\tt.Errorf(\"nil overlay changed env: %v\", out)\n\t}\n}\n"}, "cc01fa0e0afab33de7966b0860aa2cb5e75bc4be": {"t": 1785759622.534036, "n": 7831, "h": "d481f3c8077d3acb", "c": "package ptyrunner\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\n// StartShell must not touch ~/.claude/projects/. We point HOME at a temp\n// dir, pre-seed it with a fake project tree (so we'd notice anything\n// new), spawn a shell briefly, then snapshot the directory listing. The\n// only file allowed to appear is the pre-existing one.\nfunc TestStartShell_DoesNotCreateClaudeJSONL(t *testing.T) {\n\t// Use a temp HOME so the test never touches the user's real\n\t// ~/.claude/projects/. resolveClaude / augmentPath read HOME via\n\t// fallbackBinDirs, which is fine — they only build PATH entries.\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\tprojectsDir := filepath.Join(tmpHome, \".claude\", \"projects\", \"encoded-cwd\")\n\tif err := os.MkdirAll(projectsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir projects: %v\", err)\n\t}\n\tpreExisting := filepath.Join(projectsDir, \"pre-existing.jsonl\")\n\tif err := os.WriteFile(preExisting, []byte(\"{}\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"seed jsonl: %v\", err)\n\t}\n\n\tcwd := t.TempDir()\n\t// argv = nil → default ($SHELL or /bin/bash -i). We force /bin/sh so\n\t// the test doesn't depend on the developer's login shell being\n\t// installed under HOME=tmp.\n\tsess, err := StartShell(cwd, []string{\"/bin/sh\", \"-c\", \"echo hello\"})\n\tif err != nil {\n\t\tt.Skipf(\"StartShell unavailable in this environment: %v\", err)\n\t\treturn\n\t}\n\t// Drain briefly and let the child exit on its own.\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tif _, err := sess.Master.Read(buf); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\ttime.Sleep(200 * time.Millisecond)\n\t_ = sess.Kill()\n\t_, _ = sess.Wait()\n\n\t// Walk ~/.claude/projects and assert only the pre-existing file is there.\n\tvar found []string\n\troot := filepath.Join(tmpHome, \".claude\", \"projects\")\n\tif err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfound = append(found, p)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Fatalf(\"walk: %v\", err)\n\t}\n\tif len(found) != 1 || found[0] != preExisting {\n\t\tt.Fatalf(\"unexpected files under ~/.claude/projects: %v\", found)\n\t}\n}\n\nfunc localeLines(env []string) []string {\n\tvar out []string\n\tfor _, kv := range env {\n\t\tif strings.HasPrefix(kv, \"LANG=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_ALL=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_CTYPE=\") {\n\t\t\tout = append(out, kv)\n\t\t}\n\t}\n\treturn out\n}\n\n// The launchd bug: empty/absent locale → inject a UTF-8 LANG so children\n// don't fall back to Mac Roman and mangle Vietnamese text.\nfunc TestEnsureUTF8Locale_InjectsWhenAbsent(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"FOO=bar\"})\n\tlines := localeLines(got)\n\tif len(lines) != 1 || lines[0] != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected, got %v\", lines)\n\t}\n}\n\n// An empty LANG= (what we actually measured under launchd) counts as\n// unset and must still get the UTF-8 default.\nfunc TestEnsureUTF8Locale_InjectsWhenEmpty(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"LANG=\", \"LC_ALL=\", \"LC_CTYPE=\"})\n\tfound := false\n\tfor _, kv := range got {\n\t\tif kv == \"LANG=en_US.UTF-8\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected for empty locale; got %v\", localeLines(got))\n\t}\n}\n\n// A user who already set a UTF-8 locale (or any locale at all) must be\n// left untouched — we never override an explicit choice.\nfunc TestEnsureUTF8Locale_RespectsExisting(t *testing.T) {\n\tfor _, existing := range []string{\"LANG=vi_VN.UTF-8\", \"LC_CTYPE=en_GB.UTF-8\", \"LC_ALL=C\"} {\n\t\tgot := ensureUTF8Locale([]string{existing, \"FOO=bar\"})\n\t\tlines := localeLines(got)\n\t\tif len(lines) != 1 || lines[0] != existing {\n\t\t\tt.Fatalf(\"ensureUTF8Locale altered explicit locale %q: %v\", existing, lines)\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0(t *testing.T) {\n\t// NUL-delimited, with a value that itself contains a newline.\n\traw := []byte(\"PATH=/usr/bin:/bin\\x00LANG=en_US.UTF-8\\x00MULTI=line1\\nline2\\x00\")\n\tgot := parseEnv0(raw)\n\twant := []string{\"PATH=/usr/bin:/bin\", \"LANG=en_US.UTF-8\", \"MULTI=line1\\nline2\"}\n\tif len(got) != len(want) {\n\t\tt.Fatalf(\"parseEnv0 len=%d want %d: %v\", len(got), len(want), got)\n\t}\n\tfor i := range want {\n\t\tif got[i] != want[i] {\n\t\t\tt.Fatalf(\"parseEnv0[%d]=%q want %q\", i, got[i], want[i])\n\t\t}\n\t}\n}\n\nfunc TestParseEnv0_DropsMalformed(t *testing.T) {\n\t// Empty segments, a no-'=' token, and a leading-'=' token must all drop.\n\traw := []byte(\"\\x00noequals\\x00=novalue\\x00OK=1\\x00\")\n\tgot := parseEnv0(raw)\n\tif len(got) != 1 || got[0] != \"OK=1\" {\n\t\tt.Fatalf(\"expected only OK=1, got %v\", got)\n\t}\n}\n\nfunc TestParseEnv0_EmptyReturnsNil(t *testing.T) {\n\tif got := parseEnv0(nil); got != nil {\n\t\tt.Fatalf(\"expected nil for empty input, got %v\", got)\n\t}\n}\n\nfunc TestSetEnv_ReplacesAndAppends(t *testing.T) {\n\tenv := []string{\"A=1\", \"TERM=dumb\", \"B=2\"}\n\tgot := setEnv(env, \"TERM\", \"xterm-256color\")\n\tfound := \"\"\n\tcount := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"TERM=\") {\n\t\t\tfound = kv\n\t\t\tcount++\n\t\t}\n\t}\n\tif count != 1 || found != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"setEnv replace: count=%d found=%q\", count, found)\n\t}\n\t// setEnv must not mutate the caller's slice.\n\tif env[1] != \"TERM=dumb\" {\n\t\tt.Fatalf(\"setEnv mutated input: %v\", env)\n\t}\n\tgot2 := setEnv([]string{\"A=1\"}, \"TERM\", \"x\")\n\tif got2[len(got2)-1] != \"TERM=x\" {\n\t\tt.Fatalf(\"setEnv append: %v\", got2)\n\t}\n}\n\n// buildChildEnv must always force TERM and guarantee a UTF-8 locale +\n// augmented PATH regardless of how thin the base env is.\nfunc TestBuildChildEnv_ForcesTermAndLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"TERM=dumb\"})\n\tvar term, lang, path string\n\tfor _, kv := range got {\n\t\tswitch {\n\t\tcase strings.HasPrefix(kv, \"TERM=\"):\n\t\t\tterm = kv\n\t\tcase strings.HasPrefix(kv, \"LANG=\"):\n\t\t\tlang = kv\n\t\tcase strings.HasPrefix(kv, \"PATH=\"):\n\t\t\tpath = kv\n\t\t}\n\t}\n\tif term != \"TERM=xterm-256color\" {\n\t\tt.Fatalf(\"TERM not forced: %q\", term)\n\t}\n\tif lang != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"LANG not ensured: %q\", lang)\n\t}\n\tif path == \"\" {\n\t\tt.Fatalf(\"PATH not augmented; env=%v\", got)\n\t}\n}\n\n// A login env that already carries a UTF-8 locale must be preserved.\nfunc TestBuildChildEnv_PreservesExistingLocale(t *testing.T) {\n\tgot := buildChildEnv([]string{\"LANG=vi_VN.UTF-8\", \"PATH=/usr/bin\"})\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"LANG=\") && kv != \"LANG=vi_VN.UTF-8\" {\n\t\t\tt.Fatalf(\"overrode existing LANG: %q\", kv)\n\t\t}\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n"}, "a3727064627013611782c4d775d5bcd4af31ea68": {"t": 1785759625.340125, "n": 5215, "h": "79e7ebacfd3825ce", "c": "package ptyrunner\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\n// StartShell must not touch ~/.claude/projects/. We point HOME at a temp\n// dir, pre-seed it with a fake project tree (so we'd notice anything\n// new), spawn a shell briefly, then snapshot the directory listing. The\n// only file allowed to appear is the pre-existing one.\nfunc TestStartShell_DoesNotCreateClaudeJSONL(t *testing.T) {\n\t// Use a temp HOME so the test never touches the user's real\n\t// ~/.claude/projects/. resolveClaude / augmentPath read HOME via\n\t// fallbackBinDirs, which is fine — they only build PATH entries.\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\tprojectsDir := filepath.Join(tmpHome, \".claude\", \"projects\", \"encoded-cwd\")\n\tif err := os.MkdirAll(projectsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir projects: %v\", err)\n\t}\n\tpreExisting := filepath.Join(projectsDir, \"pre-existing.jsonl\")\n\tif err := os.WriteFile(preExisting, []byte(\"{}\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"seed jsonl: %v\", err)\n\t}\n\n\tcwd := t.TempDir()\n\t// argv = nil → default ($SHELL or /bin/bash -i). We force /bin/sh so\n\t// the test doesn't depend on the developer's login shell being\n\t// installed under HOME=tmp.\n\tsess, err := StartShell(cwd, []string{\"/bin/sh\", \"-c\", \"echo hello\"})\n\tif err != nil {\n\t\tt.Skipf(\"StartShell unavailable in this environment: %v\", err)\n\t\treturn\n\t}\n\t// Drain briefly and let the child exit on its own.\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tif _, err := sess.Master.Read(buf); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\ttime.Sleep(200 * time.Millisecond)\n\t_ = sess.Kill()\n\t_, _ = sess.Wait()\n\n\t// Walk ~/.claude/projects and assert only the pre-existing file is there.\n\tvar found []string\n\troot := filepath.Join(tmpHome, \".claude\", \"projects\")\n\tif err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfound = append(found, p)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Fatalf(\"walk: %v\", err)\n\t}\n\tif len(found) != 1 || found[0] != preExisting {\n\t\tt.Fatalf(\"unexpected files under ~/.claude/projects: %v\", found)\n\t}\n}\n\nfunc localeLines(env []string) []string {\n\tvar out []string\n\tfor _, kv := range env {\n\t\tif strings.HasPrefix(kv, \"LANG=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_ALL=\") ||\n\t\t\tstrings.HasPrefix(kv, \"LC_CTYPE=\") {\n\t\t\tout = append(out, kv)\n\t\t}\n\t}\n\treturn out\n}\n\n// The launchd bug: empty/absent locale → inject a UTF-8 LANG so children\n// don't fall back to Mac Roman and mangle Vietnamese text.\nfunc TestEnsureUTF8Locale_InjectsWhenAbsent(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"FOO=bar\"})\n\tlines := localeLines(got)\n\tif len(lines) != 1 || lines[0] != \"LANG=en_US.UTF-8\" {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected, got %v\", lines)\n\t}\n}\n\n// An empty LANG= (what we actually measured under launchd) counts as\n// unset and must still get the UTF-8 default.\nfunc TestEnsureUTF8Locale_InjectsWhenEmpty(t *testing.T) {\n\tgot := ensureUTF8Locale([]string{\"LANG=\", \"LC_ALL=\", \"LC_CTYPE=\"})\n\tfound := false\n\tfor _, kv := range got {\n\t\tif kv == \"LANG=en_US.UTF-8\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"expected LANG=en_US.UTF-8 injected for empty locale; got %v\", localeLines(got))\n\t}\n}\n\n// A user who already set a UTF-8 locale (or any locale at all) must be\n// left untouched — we never override an explicit choice.\nfunc TestEnsureUTF8Locale_RespectsExisting(t *testing.T) {\n\tfor _, existing := range []string{\"LANG=vi_VN.UTF-8\", \"LC_CTYPE=en_GB.UTF-8\", \"LC_ALL=C\"} {\n\t\tgot := ensureUTF8Locale([]string{existing, \"FOO=bar\"})\n\t\tlines := localeLines(got)\n\t\tif len(lines) != 1 || lines[0] != existing {\n\t\t\tt.Fatalf(\"ensureUTF8Locale altered explicit locale %q: %v\", existing, lines)\n\t\t}\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n"}, "8f1a254159dbe50ab3b9b325cb62940789d8058f": {"t": 1785759626.522808, "n": 3615, "h": "b3b0cd5917a3a0d4", "c": "package ptyrunner\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\n// StartShell must not touch ~/.claude/projects/. We point HOME at a temp\n// dir, pre-seed it with a fake project tree (so we'd notice anything\n// new), spawn a shell briefly, then snapshot the directory listing. The\n// only file allowed to appear is the pre-existing one.\nfunc TestStartShell_DoesNotCreateClaudeJSONL(t *testing.T) {\n\t// Use a temp HOME so the test never touches the user's real\n\t// ~/.claude/projects/. resolveClaude / augmentPath read HOME via\n\t// fallbackBinDirs, which is fine — they only build PATH entries.\n\ttmpHome := t.TempDir()\n\tt.Setenv(\"HOME\", tmpHome)\n\n\tprojectsDir := filepath.Join(tmpHome, \".claude\", \"projects\", \"encoded-cwd\")\n\tif err := os.MkdirAll(projectsDir, 0o755); err != nil {\n\t\tt.Fatalf(\"mkdir projects: %v\", err)\n\t}\n\tpreExisting := filepath.Join(projectsDir, \"pre-existing.jsonl\")\n\tif err := os.WriteFile(preExisting, []byte(\"{}\\n\"), 0o644); err != nil {\n\t\tt.Fatalf(\"seed jsonl: %v\", err)\n\t}\n\n\tcwd := t.TempDir()\n\t// argv = nil → default ($SHELL or /bin/bash -i). We force /bin/sh so\n\t// the test doesn't depend on the developer's login shell being\n\t// installed under HOME=tmp.\n\tsess, err := StartShell(cwd, []string{\"/bin/sh\", \"-c\", \"echo hello\"})\n\tif err != nil {\n\t\tt.Skipf(\"StartShell unavailable in this environment: %v\", err)\n\t\treturn\n\t}\n\t// Drain briefly and let the child exit on its own.\n\tgo func() {\n\t\tbuf := make([]byte, 1024)\n\t\tfor {\n\t\t\tif _, err := sess.Master.Read(buf); err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\ttime.Sleep(200 * time.Millisecond)\n\t_ = sess.Kill()\n\t_, _ = sess.Wait()\n\n\t// Walk ~/.claude/projects and assert only the pre-existing file is there.\n\tvar found []string\n\troot := filepath.Join(tmpHome, \".claude\", \"projects\")\n\tif err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfound = append(found, p)\n\t\treturn nil\n\t}); err != nil {\n\t\tt.Fatalf(\"walk: %v\", err)\n\t}\n\tif len(found) != 1 || found[0] != preExisting {\n\t\tt.Fatalf(\"unexpected files under ~/.claude/projects: %v\", found)\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n"}, "8feb064c74dbf4200a1fb8d8f28b8333baf53668": {"t": 1785759627.7323277, "n": 1527, "h": "9a9c4e5d2469e488", "c": "package ptyrunner\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestAugmentPath_NoPathInEnv(t *testing.T) {\n\tenv := []string{\"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tbreak\n\t\t}\n\t}\n\tif pathLine == \"\" {\n\t\tt.Fatalf(\"augmentPath did not add a PATH entry; env=%v\", got)\n\t}\n\t// At least one fallback dir should be present.\n\tif !strings.Contains(pathLine, \"/.local/bin\") && !strings.Contains(pathLine, \"/opt/homebrew/bin\") {\n\t\tt.Fatalf(\"PATH lacks expected fallback dirs: %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_PreservesExistingPath(t *testing.T) {\n\tenv := []string{\"PATH=/usr/bin:/bin\", \"FOO=bar\"}\n\tgot := augmentPath(env)\n\tvar pathLine string\n\tpathLines := 0\n\tfor _, kv := range got {\n\t\tif strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tpathLine = kv\n\t\t\tpathLines++\n\t\t}\n\t}\n\tif pathLines != 1 {\n\t\tt.Fatalf(\"expected exactly one PATH entry, got %d: %v\", pathLines, got)\n\t}\n\t// Original entries must still be there.\n\tif !strings.HasSuffix(pathLine, \":/usr/bin:/bin\") {\n\t\tt.Fatalf(\"PATH should end with original path; got %s\", pathLine)\n\t}\n}\n\nfunc TestAugmentPath_NoDuplicates(t *testing.T) {\n\tenv := []string{\"PATH=/opt/homebrew/bin:/usr/bin\"}\n\tgot := augmentPath(env)\n\tfor _, kv := range got {\n\t\tif !strings.HasPrefix(kv, \"PATH=\") {\n\t\t\tcontinue\n\t\t}\n\t\t// /opt/homebrew/bin should appear exactly once.\n\t\tif count := strings.Count(kv, \"/opt/homebrew/bin\"); count != 1 {\n\t\t\tt.Fatalf(\"/opt/homebrew/bin appears %d times in PATH: %s\", count, kv)\n\t\t}\n\t}\n}\n"}}, "internal/envset/envset.go": {"d766ac42a371f9742f1d297dbb60e8eb17ea7331": {"t": 1785746088.2742312, "n": 11021, "h": "f0d92045ee466920", "c": "// Package envset stores named sets of environment variables and resolves\n// them into the overlay that c3-server injects into spawned `claude` / shell\n// PTYs. It generalises the earlier provider-switch feature: an \"env set\" is\n// just a labelled bag of KEY=VALUE (and KEY-unset) entries, and the\n// Anthropic / DeepSeek backends are simply two seeded sets.\n//\n// Two application scopes compose, in order:\n// - GLOBAL active sets (Config.Active) — applied to every new PTY.\n// - PER-SESSION sets (stored on the C3Entry) — layered on top for that one\n// session. Later ops win, so a session set can override a global one.\n//\n// Secrets vs plaintext are kept in separate files, like before:\n// - envsets.json (0644) — set definitions: labels, keys, non-secret\n// values, secret/unset flags. Hand-editable.\n// - envsecrets.json (0600) — secret values, keyed setID → key. Never served\n// back to the browser (the API reports only a hasValue boolean).\n//\n// A Var with Secret=true draws its value from envsecrets.json (empty ⇒ the op\n// is skipped, so an unconfigured secret never clobbers an inherited value).\n// A Var with Unset=true removes the key from the child env — that's how a set\n// strips a stale credential left in the user's shell rc so the intended one\n// wins (Claude Code's ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY outrank\n// CLAUDE_CODE_OAUTH_TOKEN, so the OAuth set unsets the first two).\npackage envset\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\n\t\"github.com/binhsonnguyen/ccc/adapters/ptyrunner\"\n)\n\n// Var is one entry in a set. Exactly one behaviour applies, checked in this\n// order: Unset removes the key; Secret pulls the value from envsecrets.json;\n// otherwise Value is used verbatim.\ntype Var struct {\n\tKey string `json:\"key\"`\n\tValue string `json:\"value,omitempty\"`\n\tSecret bool `json:\"secret,omitempty\"`\n\tUnset bool `json:\"unset,omitempty\"`\n}\n\n// Set is a named collection of env vars.\ntype Set struct {\n\tLabel string `json:\"label\"`\n\tVars []Var `json:\"vars\"`\n}\n\n// Config is the persisted envsets.json shape. Active lists the globally-active\n// set ids in apply order; Order drives the UI listing.\ntype Config struct {\n\tActive []string `json:\"active\"`\n\tOrder []string `json:\"order,omitempty\"`\n\tSets map[string]Set `json:\"sets\"`\n}\n\n// Store reads/writes the env-set config + secrets under the c3 data dir.\n// Writes are serialised by mu and made atomic via temp+rename.\ntype Store struct {\n\tmu sync.Mutex\n}\n\nfunc New() *Store { return &Store{} }\n\nfunc dataFilePath(name string) (string, error) {\n\tif d := os.Getenv(\"XDG_DATA_HOME\"); d != \"\" {\n\t\treturn filepath.Join(d, \"c3\", name), nil\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(home, \".local\", \"share\", \"c3\", name), nil\n}\n\nfunc setsPath() (string, error) { return dataFilePath(\"envsets.json\") }\nfunc secretsPath() (string, error) { return dataFilePath(\"envsecrets.json\") }\n\n// defaultConfig seeds envsets.json on first read: an Anthropic set (OAuth /\n// existing login; unsets the higher-precedence credential vars) and a DeepSeek\n// set (Bearer token + base URL + model mapping). Active is empty so nothing\n// changes until the user activates a set.\nfunc defaultConfig() *Config {\n\treturn &Config{\n\t\tActive: []string{},\n\t\tOrder: []string{\"anthropic\", \"deepseek\"},\n\t\tSets: map[string]Set{\n\t\t\t\"anthropic\": {\n\t\t\t\tLabel: \"Anthropic\",\n\t\t\t\tVars: []Var{\n\t\t\t\t\t{Key: \"ANTHROPIC_BASE_URL\", Unset: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_AUTH_TOKEN\", Unset: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_API_KEY\", Unset: true},\n\t\t\t\t\t// Optional `claude setup-token` OAuth token; leave unset to\n\t\t\t\t\t// use the existing `claude login` session.\n\t\t\t\t\t{Key: \"CLAUDE_CODE_OAUTH_TOKEN\", Secret: true},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"deepseek\": {\n\t\t\t\tLabel: \"DeepSeek\",\n\t\t\t\tVars: []Var{\n\t\t\t\t\t{Key: \"ANTHROPIC_BASE_URL\", Value: \"https://api.deepseek.com/anthropic\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_AUTH_TOKEN\", Secret: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_API_KEY\", Unset: true},\n\t\t\t\t\t{Key: \"CLAUDE_CODE_OAUTH_TOKEN\", Unset: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_MODEL\", Value: \"deepseek-v4-pro[1m]\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Value: \"deepseek-v4-pro[1m]\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_DEFAULT_SONNET_MODEL\", Value: \"deepseek-v4-pro[1m]\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_DEFAULT_HAIKU_MODEL\", Value: \"deepseek-v4-flash\"},\n\t\t\t\t\t{Key: \"CLAUDE_CODE_SUBAGENT_MODEL\", Value: \"deepseek-v4-flash\"},\n\t\t\t\t\t{Key: \"CLAUDE_CODE_EFFORT_LEVEL\", Value: \"max\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// Load returns the persisted config, seeding (not writing) defaults when\n// envsets.json is absent.\nfunc (s *Store) Load() (*Config, error) {\n\tp, err := setsPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tb, err := os.ReadFile(p)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn defaultConfig(), nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar c Config\n\tif err := json.Unmarshal(b, &c); err != nil {\n\t\treturn nil, err\n\t}\n\tif c.Sets == nil {\n\t\tc.Sets = map[string]Set{}\n\t}\n\tif c.Active == nil {\n\t\tc.Active = []string{}\n\t}\n\treturn &c, nil\n}\n\nfunc (s *Store) loadSecrets() (map[string]map[string]string, error) {\n\tp, err := secretsPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tb, err := os.ReadFile(p)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn map[string]map[string]string{}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar m map[string]map[string]string\n\tif err := json.Unmarshal(b, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tif m == nil {\n\t\tm = map[string]map[string]string{}\n\t}\n\treturn m, nil\n}\n\nfunc (s *Store) saveSecrets(m map[string]map[string]string) error {\n\tp, err := secretsPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn writeFileAtomic(p, m, 0o600)\n}\n\nfunc (s *Store) saveConfig(c *Config) error {\n\tp, err := setsPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn writeFileAtomic(p, c, 0o644)\n}\n\n// resolveOps turns the named sets (in order) into ordered env ops. Secret\n// values are pulled from the secrets store; an empty secret is skipped so it\n// can't clobber an inherited value with \"\".\nfunc (s *Store) resolveOps(ids []string, c *Config, secrets map[string]map[string]string) []ptyrunner.EnvOp {\n\tvar ops []ptyrunner.EnvOp\n\tfor _, id := range ids {\n\t\tset, ok := c.Sets[id]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, v := range set.Vars {\n\t\t\tswitch {\n\t\t\tcase v.Unset:\n\t\t\t\tops = append(ops, ptyrunner.EnvOp{Key: v.Key, Unset: true})\n\t\t\tcase v.Secret:\n\t\t\t\tval := secrets[id][v.Key]\n\t\t\t\tif val == \"\" {\n\t\t\t\t\tcontinue // unconfigured secret: leave inherited value alone\n\t\t\t\t}\n\t\t\t\tops = append(ops, ptyrunner.EnvOp{Key: v.Key, Value: val})\n\t\t\tdefault:\n\t\t\t\tops = append(ops, ptyrunner.EnvOp{Key: v.Key, Value: v.Value})\n\t\t\t}\n\t\t}\n\t}\n\treturn ops\n}\n\n// GlobalOverlay resolves the globally-active sets. Wired into\n// ptyrunner.EnvOverlay; read fresh per spawn. Returns nil on error or when no\n// set is active (passthrough).\nfunc (s *Store) GlobalOverlay() []ptyrunner.EnvOp {\n\tc, err := s.Load()\n\tif err != nil || len(c.Active) == 0 {\n\t\treturn nil\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn s.resolveOps(c.Active, c, secrets)\n}\n\n// Resolve resolves an explicit list of set ids (per-session use). Unknown ids\n// are skipped. Returns nil for an empty/nil list.\nfunc (s *Store) Resolve(ids []string) []ptyrunner.EnvOp {\n\tif len(ids) == 0 {\n\t\treturn nil\n\t}\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn s.resolveOps(ids, c, secrets)\n}\n\n// SetActive replaces the globally-active set list. Unknown ids are rejected so\n// the UI can't activate a phantom set.\nfunc (s *Store) SetActive(ids []string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, id := range ids {\n\t\tif _, ok := c.Sets[id]; !ok {\n\t\t\treturn errors.New(\"unknown env set: \" + id)\n\t\t}\n\t}\n\tif ids == nil {\n\t\tids = []string{}\n\t}\n\tc.Active = ids\n\treturn s.saveConfig(c)\n}\n\n// UpsertSet creates or replaces a set definition (labels/keys/non-secret\n// values/flags). Secret values are managed separately via SetSecret.\nfunc (s *Store) UpsertSet(id string, set Set) error {\n\tif id == \"\" {\n\t\treturn errors.New(\"set id is empty\")\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, ok := c.Sets[id]; !ok {\n\t\tc.Order = append(c.Order, id)\n\t}\n\tc.Sets[id] = set\n\treturn s.saveConfig(c)\n}\n\n// DeleteSet removes a set, its secrets, and its membership in Active/Order.\nfunc (s *Store) DeleteSet(id string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdelete(c.Sets, id)\n\tc.Active = without(c.Active, id)\n\tc.Order = without(c.Order, id)\n\tif err := s.saveConfig(c); err != nil {\n\t\treturn err\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn nil // config already saved; secrets cleanup is best-effort\n\t}\n\tif _, ok := secrets[id]; ok {\n\t\tdelete(secrets, id)\n\t\treturn s.saveSecrets(secrets)\n\t}\n\treturn nil\n}\n\n// SetSecret stores (or clears, when value == \"\") a secret value for setID/key.\n// The set must exist and the key must be declared Secret in that set.\nfunc (s *Store) SetSecret(setID, key, value string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tset, ok := c.Sets[setID]\n\tif !ok {\n\t\treturn errors.New(\"unknown env set: \" + setID)\n\t}\n\tif !hasSecretKey(set, key) {\n\t\treturn errors.New(\"key is not a secret in this set: \" + key)\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif value == \"\" {\n\t\tdelete(secrets[setID], key)\n\t\tif len(secrets[setID]) == 0 {\n\t\t\tdelete(secrets, setID)\n\t\t}\n\t} else {\n\t\tif secrets[setID] == nil {\n\t\t\tsecrets[setID] = map[string]string{}\n\t\t}\n\t\tsecrets[setID][key] = value\n\t}\n\treturn s.saveSecrets(secrets)\n}\n\n// hasSecret reports whether a non-empty secret value is stored for setID/key.\nfunc (s *Store) hasSecret(secrets map[string]map[string]string, setID, key string) bool {\n\treturn secrets[setID][key] != \"\"\n}\n\n// HasToken reports whether a non-empty secret value is stored for setID/key.\n// Convenience wrapper around a secrets load (mostly for tests / callers that\n// don't already hold the secrets map).\nfunc (s *Store) HasToken(setID, key string) (bool, error) {\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn s.hasSecret(secrets, setID, key), nil\n}\n\nfunc hasSecretKey(set Set, key string) bool {\n\tfor _, v := range set.Vars {\n\t\tif v.Key == key && v.Secret {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc without(ss []string, id string) []string {\n\tout := ss[:0:0]\n\tfor _, x := range ss {\n\t\tif x != id {\n\t\t\tout = append(out, x)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc writeFileAtomic(path string, v any, perm os.FileMode) error {\n\tif err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {\n\t\treturn err\n\t}\n\tb, err := json.MarshalIndent(v, \"\", \" \")\n\tif err != nil {\n\t\treturn err\n\t}\n\ttmp := path + \".tmp\"\n\tif err := os.WriteFile(tmp, b, perm); err != nil {\n\t\treturn err\n\t}\n\tif err := os.Rename(tmp, path); err != nil {\n\t\t_ = os.Remove(tmp)\n\t\treturn err\n\t}\n\treturn nil\n}\n"}, "dfdbf29bebe782867891d672569f121c45cebc06": {"t": 1785759621.1453094, "n": 11021, "h": "f0d92045ee466920", "c": "// Package envset stores named sets of environment variables and resolves\n// them into the overlay that c3-server injects into spawned `claude` / shell\n// PTYs. It generalises the earlier provider-switch feature: an \"env set\" is\n// just a labelled bag of KEY=VALUE (and KEY-unset) entries, and the\n// Anthropic / DeepSeek backends are simply two seeded sets.\n//\n// Two application scopes compose, in order:\n// - GLOBAL active sets (Config.Active) — applied to every new PTY.\n// - PER-SESSION sets (stored on the C3Entry) — layered on top for that one\n// session. Later ops win, so a session set can override a global one.\n//\n// Secrets vs plaintext are kept in separate files, like before:\n// - envsets.json (0644) — set definitions: labels, keys, non-secret\n// values, secret/unset flags. Hand-editable.\n// - envsecrets.json (0600) — secret values, keyed setID → key. Never served\n// back to the browser (the API reports only a hasValue boolean).\n//\n// A Var with Secret=true draws its value from envsecrets.json (empty ⇒ the op\n// is skipped, so an unconfigured secret never clobbers an inherited value).\n// A Var with Unset=true removes the key from the child env — that's how a set\n// strips a stale credential left in the user's shell rc so the intended one\n// wins (Claude Code's ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY outrank\n// CLAUDE_CODE_OAUTH_TOKEN, so the OAuth set unsets the first two).\npackage envset\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\n\t\"github.com/binhsonnguyen/ccc/adapters/ptyrunner\"\n)\n\n// Var is one entry in a set. Exactly one behaviour applies, checked in this\n// order: Unset removes the key; Secret pulls the value from envsecrets.json;\n// otherwise Value is used verbatim.\ntype Var struct {\n\tKey string `json:\"key\"`\n\tValue string `json:\"value,omitempty\"`\n\tSecret bool `json:\"secret,omitempty\"`\n\tUnset bool `json:\"unset,omitempty\"`\n}\n\n// Set is a named collection of env vars.\ntype Set struct {\n\tLabel string `json:\"label\"`\n\tVars []Var `json:\"vars\"`\n}\n\n// Config is the persisted envsets.json shape. Active lists the globally-active\n// set ids in apply order; Order drives the UI listing.\ntype Config struct {\n\tActive []string `json:\"active\"`\n\tOrder []string `json:\"order,omitempty\"`\n\tSets map[string]Set `json:\"sets\"`\n}\n\n// Store reads/writes the env-set config + secrets under the c3 data dir.\n// Writes are serialised by mu and made atomic via temp+rename.\ntype Store struct {\n\tmu sync.Mutex\n}\n\nfunc New() *Store { return &Store{} }\n\nfunc dataFilePath(name string) (string, error) {\n\tif d := os.Getenv(\"XDG_DATA_HOME\"); d != \"\" {\n\t\treturn filepath.Join(d, \"c3\", name), nil\n\t}\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(home, \".local\", \"share\", \"c3\", name), nil\n}\n\nfunc setsPath() (string, error) { return dataFilePath(\"envsets.json\") }\nfunc secretsPath() (string, error) { return dataFilePath(\"envsecrets.json\") }\n\n// defaultConfig seeds envsets.json on first read: an Anthropic set (OAuth /\n// existing login; unsets the higher-precedence credential vars) and a DeepSeek\n// set (Bearer token + base URL + model mapping). Active is empty so nothing\n// changes until the user activates a set.\nfunc defaultConfig() *Config {\n\treturn &Config{\n\t\tActive: []string{},\n\t\tOrder: []string{\"anthropic\", \"deepseek\"},\n\t\tSets: map[string]Set{\n\t\t\t\"anthropic\": {\n\t\t\t\tLabel: \"Anthropic\",\n\t\t\t\tVars: []Var{\n\t\t\t\t\t{Key: \"ANTHROPIC_BASE_URL\", Unset: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_AUTH_TOKEN\", Unset: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_API_KEY\", Unset: true},\n\t\t\t\t\t// Optional `claude setup-token` OAuth token; leave unset to\n\t\t\t\t\t// use the existing `claude login` session.\n\t\t\t\t\t{Key: \"CLAUDE_CODE_OAUTH_TOKEN\", Secret: true},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"deepseek\": {\n\t\t\t\tLabel: \"DeepSeek\",\n\t\t\t\tVars: []Var{\n\t\t\t\t\t{Key: \"ANTHROPIC_BASE_URL\", Value: \"https://api.deepseek.com/anthropic\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_AUTH_TOKEN\", Secret: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_API_KEY\", Unset: true},\n\t\t\t\t\t{Key: \"CLAUDE_CODE_OAUTH_TOKEN\", Unset: true},\n\t\t\t\t\t{Key: \"ANTHROPIC_MODEL\", Value: \"deepseek-v4-pro[1m]\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Value: \"deepseek-v4-pro[1m]\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_DEFAULT_SONNET_MODEL\", Value: \"deepseek-v4-pro[1m]\"},\n\t\t\t\t\t{Key: \"ANTHROPIC_DEFAULT_HAIKU_MODEL\", Value: \"deepseek-v4-flash\"},\n\t\t\t\t\t{Key: \"CLAUDE_CODE_SUBAGENT_MODEL\", Value: \"deepseek-v4-flash\"},\n\t\t\t\t\t{Key: \"CLAUDE_CODE_EFFORT_LEVEL\", Value: \"max\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// Load returns the persisted config, seeding (not writing) defaults when\n// envsets.json is absent.\nfunc (s *Store) Load() (*Config, error) {\n\tp, err := setsPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tb, err := os.ReadFile(p)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn defaultConfig(), nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar c Config\n\tif err := json.Unmarshal(b, &c); err != nil {\n\t\treturn nil, err\n\t}\n\tif c.Sets == nil {\n\t\tc.Sets = map[string]Set{}\n\t}\n\tif c.Active == nil {\n\t\tc.Active = []string{}\n\t}\n\treturn &c, nil\n}\n\nfunc (s *Store) loadSecrets() (map[string]map[string]string, error) {\n\tp, err := secretsPath()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tb, err := os.ReadFile(p)\n\tif errors.Is(err, fs.ErrNotExist) {\n\t\treturn map[string]map[string]string{}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar m map[string]map[string]string\n\tif err := json.Unmarshal(b, &m); err != nil {\n\t\treturn nil, err\n\t}\n\tif m == nil {\n\t\tm = map[string]map[string]string{}\n\t}\n\treturn m, nil\n}\n\nfunc (s *Store) saveSecrets(m map[string]map[string]string) error {\n\tp, err := secretsPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn writeFileAtomic(p, m, 0o600)\n}\n\nfunc (s *Store) saveConfig(c *Config) error {\n\tp, err := setsPath()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn writeFileAtomic(p, c, 0o644)\n}\n\n// resolveOps turns the named sets (in order) into ordered env ops. Secret\n// values are pulled from the secrets store; an empty secret is skipped so it\n// can't clobber an inherited value with \"\".\nfunc (s *Store) resolveOps(ids []string, c *Config, secrets map[string]map[string]string) []ptyrunner.EnvOp {\n\tvar ops []ptyrunner.EnvOp\n\tfor _, id := range ids {\n\t\tset, ok := c.Sets[id]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, v := range set.Vars {\n\t\t\tswitch {\n\t\t\tcase v.Unset:\n\t\t\t\tops = append(ops, ptyrunner.EnvOp{Key: v.Key, Unset: true})\n\t\t\tcase v.Secret:\n\t\t\t\tval := secrets[id][v.Key]\n\t\t\t\tif val == \"\" {\n\t\t\t\t\tcontinue // unconfigured secret: leave inherited value alone\n\t\t\t\t}\n\t\t\t\tops = append(ops, ptyrunner.EnvOp{Key: v.Key, Value: val})\n\t\t\tdefault:\n\t\t\t\tops = append(ops, ptyrunner.EnvOp{Key: v.Key, Value: v.Value})\n\t\t\t}\n\t\t}\n\t}\n\treturn ops\n}\n\n// GlobalOverlay resolves the globally-active sets. Wired into\n// ptyrunner.EnvOverlay; read fresh per spawn. Returns nil on error or when no\n// set is active (passthrough).\nfunc (s *Store) GlobalOverlay() []ptyrunner.EnvOp {\n\tc, err := s.Load()\n\tif err != nil || len(c.Active) == 0 {\n\t\treturn nil\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn s.resolveOps(c.Active, c, secrets)\n}\n\n// Resolve resolves an explicit list of set ids (per-session use). Unknown ids\n// are skipped. Returns nil for an empty/nil list.\nfunc (s *Store) Resolve(ids []string) []ptyrunner.EnvOp {\n\tif len(ids) == 0 {\n\t\treturn nil\n\t}\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn nil\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn s.resolveOps(ids, c, secrets)\n}\n\n// SetActive replaces the globally-active set list. Unknown ids are rejected so\n// the UI can't activate a phantom set.\nfunc (s *Store) SetActive(ids []string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, id := range ids {\n\t\tif _, ok := c.Sets[id]; !ok {\n\t\t\treturn errors.New(\"unknown env set: \" + id)\n\t\t}\n\t}\n\tif ids == nil {\n\t\tids = []string{}\n\t}\n\tc.Active = ids\n\treturn s.saveConfig(c)\n}\n\n// UpsertSet creates or replaces a set definition (labels/keys/non-secret\n// values/flags). Secret values are managed separately via SetSecret.\nfunc (s *Store) UpsertSet(id string, set Set) error {\n\tif id == \"\" {\n\t\treturn errors.New(\"set id is empty\")\n\t}\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif _, ok := c.Sets[id]; !ok {\n\t\tc.Order = append(c.Order, id)\n\t}\n\tc.Sets[id] = set\n\treturn s.saveConfig(c)\n}\n\n// DeleteSet removes a set, its secrets, and its membership in Active/Order.\nfunc (s *Store) DeleteSet(id string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdelete(c.Sets, id)\n\tc.Active = without(c.Active, id)\n\tc.Order = without(c.Order, id)\n\tif err := s.saveConfig(c); err != nil {\n\t\treturn err\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn nil // config already saved; secrets cleanup is best-effort\n\t}\n\tif _, ok := secrets[id]; ok {\n\t\tdelete(secrets, id)\n\t\treturn s.saveSecrets(secrets)\n\t}\n\treturn nil\n}\n\n// SetSecret stores (or clears, when value == \"\") a secret value for setID/key.\n// The set must exist and the key must be declared Secret in that set.\nfunc (s *Store) SetSecret(setID, key, value string) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\tc, err := s.Load()\n\tif err != nil {\n\t\treturn err\n\t}\n\tset, ok := c.Sets[setID]\n\tif !ok {\n\t\treturn errors.New(\"unknown env set: \" + setID)\n\t}\n\tif !hasSecretKey(set, key) {\n\t\treturn errors.New(\"key is not a secret in this set: \" + key)\n\t}\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif value == \"\" {\n\t\tdelete(secrets[setID], key)\n\t\tif len(secrets[setID]) == 0 {\n\t\t\tdelete(secrets, setID)\n\t\t}\n\t} else {\n\t\tif secrets[setID] == nil {\n\t\t\tsecrets[setID] = map[string]string{}\n\t\t}\n\t\tsecrets[setID][key] = value\n\t}\n\treturn s.saveSecrets(secrets)\n}\n\n// hasSecret reports whether a non-empty secret value is stored for setID/key.\nfunc (s *Store) hasSecret(secrets map[string]map[string]string, setID, key string) bool {\n\treturn secrets[setID][key] != \"\"\n}\n\n// HasToken reports whether a non-empty secret value is stored for setID/key.\n// Convenience wrapper around a secrets load (mostly for tests / callers that\n// don't already hold the secrets map).\nfunc (s *Store) HasToken(setID, key string) (bool, error) {\n\tsecrets, err := s.loadSecrets()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn s.hasSecret(secrets, setID, key), nil\n}\n\nfunc hasSecretKey(set Set, key string) bool {\n\tfor _, v := range set.Vars {\n\t\tif v.Key == key && v.Secret {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc without(ss []string, id string) []string {\n\tout := ss[:0:0]\n\tfor _, x := range ss {\n\t\tif x != id {\n\t\t\tout = append(out, x)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc writeFileAtomic(path string, v any, perm os.FileMode) error {\n\tif err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {\n\t\treturn err\n\t}\n\tb, err := json.MarshalIndent(v, \"\", \" \")\n\tif err != nil {\n\t\treturn err\n\t}\n\ttmp := path + \".tmp\"\n\tif err := os.WriteFile(tmp, b, perm); err != nil {\n\t\treturn err\n\t}\n\tif err := os.Rename(tmp, path); err != nil {\n\t\t_ = os.Remove(tmp)\n\t\treturn err\n\t}\n\treturn nil\n}\n"}}}} |