Skip to content

Commit 20f444e

Browse files
authored
Merge branch 'master' into improve-user-context-file-range-send-to-llm
2 parents 527490e + b09c6ed commit 20f444e

File tree

11 files changed

+115
-705
lines changed

11 files changed

+115
-705
lines changed

AGENTS.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,13 @@ Code Style
2525
- Unit tests that use file paths and uris should rely on `h/file-path` and `h/file-uri` to avoid windows issues with slashes.
2626

2727
Secrets Management
28-
- `src/eca/secrets/netrc.clj` - Netrc format parser (multi-line format: machine/login/password/port keywords)
29-
- `src/eca/secrets/authinfo.clj` - Authinfo format parser (single-line format: space-separated key-value pairs)
3028
- `src/eca/secrets.clj` - Main secrets manager for credential file operations:
31-
- File discovery and priority order (.authinfo.gpg → .netrc.gpg → .authinfo → _authinfo → .netrc → _netrc)
29+
- File discovery and priority order (`:netrcFile <FILE>` config → .netrc → _netrc)
3230
- Cross-platform path construction using io/file (handles / vs \ separators automatically)
33-
- GPG decryption with caching (5-second TTL) and timeout (30s, configurable via GPG_TIMEOUT env var)
3431
- keyRc format parsing: [login@]machine[:port] (named after Unix "rc" config file tradition)
3532
- Credential matching logic (exact login match when specified, first match otherwise)
3633
- Permission validation (Unix: warns if not 0600; Windows: skipped)
3734
- Authentication flow: config `key` → credential files `keyRc` → env var `keyEnv` → OAuth
38-
- Security: passwords never logged; GPG decryption via clojure.java.process; cache with short TTL; subprocess timeout protection
3935

4036
Notes
4137
- CI runs: bb test and bb integration-test. Ensure these pass locally before PRs.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Unreleased
44
- Improved file contexts: now use :lines-range
55

6+
- BREAKING ECA now only supports standard plain-text netrc as credential file reading. Drop authinfo and gpg decryption support. Users can choose to pass in their own provisioned netrc file from various secure source with `:netrcFile` in ECA config.
7+
68
## 0.74.0
79

810
- Improved `eca_edit_file` to automatically handle whitespace and indentation differences in single-occurrence edits.

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
515515
systemPromptFile?: string;
516516
};
517517
otlp?: {[key: string]: string};
518+
netrcFile?: string;
518519
}
519520
```
520521

@@ -530,6 +531,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
530531
"ollama": {"url": "http://localhost:11434"}
531532
},
532533
"defaultModel": null, // let ECA decides the default model.
534+
"netrcFile": null, // search ~/.netrc or ~/_netrc when null.
533535
"hooks": {},
534536
"rules" : [],
535537
"commands" : [],

docs/models.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,9 @@ Only set this when your provider uses a different path or expects query paramete
148148

149149
### Credential File Authentication
150150

151-
Use `keyRc` in your provider config to read credentials from `~/.authinfo(.gpg)` or `~/.netrc(.gpg)` without storing keys directly in config or env vars.
151+
ECA also supports standard plain-text .netrc file format for reading credentials.
152+
153+
Use `keyRc` in your provider config to read credentials from `~/.netrc` without storing keys directly in config or env vars.
152154

153155
Example:
154156

@@ -163,13 +165,20 @@ Example:
163165

164166
keyRc lookup specification format: `[login@]machine[:port]` (e.g., `api.openai.com`, `[email protected]`, `api.custom.com:8443`).
165167

168+
ECA by default search .netrc file stored in user's home directory. You can also provide the path to the actual file to use with `:netrcFile` in ECA config.
169+
170+
Tip for those wish to store their credentials encrypted with tools like gpg or age:
171+
172+
```bash
173+
# via secure tempororay file
174+
gpg --batch -q -d ./netrc.gpg > /tmp/netrc.$$ && chmod 600 /tmp/netrc.$$ && ECA_CONFIG='{"netrcFile": "/tmp/netrc.$$"}' eca server && shred -u /tmp/netrc.$$
175+
```
176+
166177
Further reading on credential file formats:
167-
- [Emacs authinfo documentation](https://www.gnu.org/software/emacs/manual/html_node/auth/Help-for-users.html)
168178
- [Curl Netrc documentation](https://everything.curl.dev/usingcurl/netrc)
169179
- [GNU Inetutils .netrc documentation](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html)
170180

171181
Notes:
172-
- Preferred files are GPG-encrypted (`~/.authinfo.gpg` / `~/.netrc.gpg`); plaintext variants are supported.
173182
- Authentication priority (short): `key` > `keyRc files` > `keyEnv` > OAuth.
174183
- All providers with API key auth can use credential files.
175184

src/eca/config.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
:maxEntriesPerDir 50}}
122122
:completion {:model "openai/gpt-4.1"
123123
:systemPromptFile "prompts/inline_completion.md"}
124+
:netrcFile nil
124125
:env "prod"})
125126

126127

src/eca/features/commands.clj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@
139139

140140
(defn ^:private doctor-msg [db config]
141141
(let [model (llm-api/default-model db config)
142-
cred-check (secrets/check-credential-files)
142+
cred-check (secrets/check-credential-files (:netrcFile config))
143143
existing-files (filter :exists (:files cred-check))]
144144
(multi-str (str "ECA version: " (config/eca-version))
145145
""
@@ -173,7 +173,7 @@
173173
(System/getenv)))
174174
""
175175
(if (seq existing-files)
176-
(str "Credential files (GPG available: " (:gpg-available cred-check) "):"
176+
(str "Credential files:"
177177
(reduce
178178
(fn [s file-info]
179179
(str s "\n " (:path file-info) ":"
@@ -189,7 +189,7 @@
189189
(str "\n " (:suggestion file-info)))))
190190
""
191191
existing-files))
192-
(str "Credential files: None found (GPG available: " (:gpg-available cred-check) ")")))))
192+
"Credential files: None found"))))
193193

194194
(defn handle-command! [command args {:keys [chat-id db* config messenger full-model instructions user-messages metrics]}]
195195
(let [db @db*

src/eca/llm_util.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
(when-let [key (:api-key provider-auth)]
101101
[:auth/oauth key])
102102
(when-let [key (some-> (get-in config [:providers (name provider) :keyRc])
103-
(secrets/get-credential))]
103+
(secrets/get-credential (:netrcFile config)))]
104104
[:auth/token key])
105105
(when-let [key (some-> (get-in config [:providers (name provider) :keyEnv])
106106
config/get-env)]

src/eca/main.clj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[borkdude.dynaload]
77
[clojure.string :as string]
88
[eca.config :as config]
9+
[eca.secrets :as secrets]
910
[eca.logger :as logger]
1011
[eca.server :as server]
1112
[eca.proxy :as proxy]))
@@ -42,7 +43,7 @@
4243
(def log-levels #{"error" "warn" "info" "debug"})
4344

4445
(def cli-spec
45-
{:order [:help :version :verbose :config-file]
46+
{:order [:help :version :verbose :config-file :log-level]
4647
:spec {:help {:alias :h
4748
:desc "Print the available commands and its options"}
4849
:version {:desc "Print eca version"}

src/eca/secrets.clj

Lines changed: 41 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
(:require
33
[br.dev.zz.parc :as parc]
44
[clojure.java.io :as io]
5-
[clojure.java.process :as process]
65
[clojure.string :as string]
76
[eca.logger :as logger]
87
[eca.shared :as shared])
@@ -15,9 +14,6 @@
1514

1615
(def ^:private logger-tag "[secrets]")
1716

18-
(defn ^:private netrc-path? [path]
19-
(string/includes? (string/lower-case (.getName (io/file path))) "netrc"))
20-
2117
(defn ^:private normalize-port
2218
[port]
2319
(cond
@@ -28,15 +24,15 @@
2824

2925
(defn credential-file-paths
3026
"Returns ordered list of credential file paths to check."
31-
[]
32-
(let [home (System/getProperty "user.home")
33-
files [".authinfo" "_authinfo" ".netrc" "_netrc"]]
34-
(mapcat (fn [filename]
35-
(let [path (str (io/file home filename))]
36-
[(str path ".gpg") path]))
37-
files)))
38-
39-
(defn ^:private validate-permissions [^File file]
27+
[netrc-file]
28+
(if (some? netrc-file)
29+
[netrc-file]
30+
(let [home (System/getProperty "user.home")
31+
files [".netrc" "_netrc"]]
32+
(->> files
33+
(mapv #(str (io/file home %)))))))
34+
35+
(defn ^:private validate-permissions [^File file & {:keys [warn?] :or {warn? false}}]
4036
(try
4137
;; On Unix systems, check if file is readable by others
4238
(when-not (string/includes? (System/getProperty "os.name") "Windows")
@@ -45,98 +41,34 @@
4541
(if (or (contains? perms PosixFilePermission/GROUP_READ)
4642
(contains? perms PosixFilePermission/OTHERS_READ))
4743
(do
48-
(logger/warn logger-tag "Credential file has insecure permissions:"
49-
(.getPath file)
50-
"- should be 0600 (readable only by owner)")
44+
(when warn?
45+
(logger/warn logger-tag "Credential file has insecure permissions:"
46+
(.getPath file)
47+
"- should be 0600 (readable only by owner)"))
5148
false)
5249
true)))
5350
true
5451
(catch Exception _e
5552
;; If permission check fails (e.g., on Windows), just proceed
5653
true)))
5754

58-
(defn gpg-available?
59-
"Checks if gpg command is available on system."
60-
[]
61-
(try (string? (process/exec "gpg" "--version"))
62-
(catch Exception _e
63-
false)))
64-
65-
;; Cache for GPG decryption results (5-second TTL)
66-
(def ^:private gpg-cache (atom {}))
67-
68-
(defn ^:private gpg-cache-key [^File file]
69-
(str (.getPath file) ":" (.lastModified file)))
70-
71-
(defn ^:private get-cached-gpg [cache-key]
72-
(when-let [{:keys [content timestamp]} (@gpg-cache cache-key)]
73-
(when (< (- (System/currentTimeMillis) timestamp) 5000) ; 5-second TTL
74-
(logger/debug logger-tag "GPG cache hit for" cache-key)
75-
content)))
76-
77-
(defn ^:private cache-gpg-result! [cache-key content]
78-
(swap! gpg-cache assoc cache-key {:content content
79-
:timestamp (System/currentTimeMillis)})
80-
content)
81-
82-
(defn decrypt-gpg
83-
"Decrypts a .gpg file using gpg command. Returns decrypted content or nil on failure.
84-
Timeout: 30 seconds (configurable via GPG_TIMEOUT env var)."
85-
[file-path]
86-
(try
87-
(let [file (io/file file-path)
88-
cache-key (gpg-cache-key file)]
89-
;; Check cache first
90-
(or (get-cached-gpg cache-key)
91-
;; Not in cache, decrypt using clojure.java.process directly
92-
(let [timeout-seconds (try
93-
(Long/parseLong (System/getenv "GPG_TIMEOUT"))
94-
(catch Exception _e 30)) ; default 30 seconds
95-
timeout-ms (* timeout-seconds 1000)
96-
proc (process/start {:out :pipe :err :pipe}
97-
"gpg" "--quiet" "--batch" "--decrypt" file-path)
98-
exit (deref (process/exit-ref proc) timeout-ms ::timeout)]
99-
(if (= exit ::timeout)
100-
(do
101-
(.destroy proc)
102-
(logger/warn logger-tag "GPG decryption timed out after" timeout-seconds "seconds for" file-path)
103-
nil)
104-
(let [out (slurp (process/stdout proc))
105-
err (slurp (process/stderr proc))]
106-
(if (zero? exit)
107-
(do
108-
(logger/debug logger-tag "GPG decryption successful for" file-path)
109-
(cache-gpg-result! cache-key out))
110-
(do
111-
(logger/warn logger-tag "GPG decryption failed for" file-path
112-
"- exit code:" exit)
113-
(when-not (string/blank? err)
114-
(logger/debug logger-tag "GPG error:" err))
115-
nil)))))))
116-
(catch Exception e
117-
(logger/warn logger-tag "GPG command failed for" file-path ":" (.getMessage e))
118-
nil)))
119-
120-
(defn ^:private parse-credentials [path content]
55+
(defn ^:private parse-credentials [content]
12156
(let [entries (parc/->netrc (StringReader. (or content "")))]
122-
(cond->> entries
123-
(netrc-path? path)
124-
(map (fn [entry]
125-
(cond-> entry
126-
(contains? entry :port)
127-
(update :port normalize-port)))))))
57+
(map (fn [entry]
58+
(cond-> entry
59+
(contains? entry :port)
60+
(update :port normalize-port)))
61+
entries)))
12862

12963
(defn ^:private load-credentials-from-file* [^String file-path]
13064
(try
13165
(logger/debug logger-tag "Checking credential file:" file-path)
13266
(let [file (io/file file-path)]
133-
(when (and (.exists file) (.isFile file) (.canRead file))
134-
(let [content (if (string/ends-with? file-path ".gpg")
135-
(when (gpg-available?) (decrypt-gpg file-path))
136-
(do (validate-permissions file)
137-
(slurp file)))]
67+
(when (and (.exists file) (.canRead file))
68+
(validate-permissions file :warn? true)
69+
(let [content (slurp file)]
13870
(when (seq content)
139-
(when-let [credentials (seq (parse-credentials file-path content))]
71+
(when-let [credentials (seq (parse-credentials content))]
14072
(logger/debug logger-tag "Loaded" (count credentials) "credentials from" file-path)
14173
(vec credentials))))))
14274
(catch Exception e
@@ -146,9 +78,9 @@
14678
(def ^:private load-credentials-from-file
14779
(shared/memoize-by-file-last-modified load-credentials-from-file*))
14880

149-
(defn ^:private load-all-credentials []
81+
(defn ^:private load-all-credentials [netrc-file]
15082
(vec (mapcat #(or (load-credentials-from-file %) [])
151-
(credential-file-paths))))
83+
(credential-file-paths netrc-file))))
15284

15385
(defn parse-key-rc
15486
"Parses keyRc value in format [login@]machine[:port].
@@ -179,70 +111,40 @@
179111
"Retrieves password for keyRc specifier from credential files.
180112
Format: [login@]machine[:port]
181113
Returns password string or nil if not found."
182-
[key-rc]
183-
(when-let [spec (parse-key-rc key-rc)]
184-
(when-let [credentials (load-all-credentials)]
185-
(when-let [matched (first (filter #(match-credential % spec) credentials))]
186-
(logger/debug logger-tag "Found credential for" key-rc)
187-
(:password matched)))))
114+
([key-rc]
115+
(get-credential key-rc nil))
116+
([key-rc netrc-file]
117+
(when-let [spec (parse-key-rc key-rc)]
118+
(when-let [credentials (load-all-credentials netrc-file)]
119+
(when-let [matched (first (filter #(match-credential % spec) credentials))]
120+
(logger/debug logger-tag "Found credential for" key-rc)
121+
(:password matched))))))
188122

189123
(defn check-credential-files
190124
"Performs diagnostic checks on credential files for /doctor command.
191125
Returns a map with:
192-
- :gpg-available - boolean indicating if GPG is available
193126
- :files - vector of file check results, each containing:
194127
- :path - file path
195128
- :exists - boolean
196129
- :readable - boolean (if exists)
197130
- :permissions-secure - boolean (Unix only, if exists)
198-
- :is-gpg - boolean
199131
- :credentials-count - number of valid credentials (if readable)
200-
- :parse-error - error message (if parse fails)
201-
- :suggestion - optional security suggestion"
202-
[]
203-
(let [gpg-avail? (gpg-available?)
204-
file-paths (credential-file-paths)
132+
- :parse-error - error message (if parse fails)"
133+
[netrc-file]
134+
(let [file-paths (credential-file-paths netrc-file)
205135
file-checks (for [path file-paths]
206136
(let [file (io/file path)
207137
exists (.exists file)
208-
is-file (and exists (.isFile file))
209-
readable (and is-file (.canRead file))
210-
is-gpg (or (string/ends-with? path ".authinfo.gpg")
211-
(string/ends-with? path ".netrc.gpg"))
212-
is-plaintext (and (not is-gpg)
213-
(or (string/ends-with? path ".authinfo")
214-
(string/ends-with? path "_authinfo")
215-
(string/ends-with? path ".netrc")
216-
(string/ends-with? path "_netrc")))]
138+
readable (.canRead file)]
217139
(cond-> {:path path
218-
:exists exists
219-
:is-gpg is-gpg}
140+
:exists exists}
220141
;; Check readability
221142
exists (assoc :readable readable)
222143

223144
;; Check permissions (Unix only, plaintext only)
224-
(and is-plaintext readable)
145+
readable
225146
(assoc :permissions-secure
226-
(try
227-
(when-not (string/includes? (System/getProperty "os.name") "Windows")
228-
(let [file-path (.toPath file)
229-
perms (Files/getPosixFilePermissions
230-
file-path
231-
(into-array LinkOption []))]
232-
(not (or (contains? perms PosixFilePermission/GROUP_READ)
233-
(contains? perms PosixFilePermission/OTHERS_READ)))))
234-
(catch Exception _e
235-
true))) ; On Windows or permission check failure, consider secure
236-
237-
;; Add GPG suggestion for plaintext files
238-
(and is-plaintext readable)
239-
(assoc :suggestion
240-
(str "Consider encrypting with GPG: "
241-
"gpg --output "
242-
(if (string/ends-with? path ".netrc")
243-
"~/.netrc.gpg"
244-
"~/.authinfo.gpg")
245-
" --symmetric " path))
147+
(validate-permissions file))
246148

247149
;; Try to parse and count credentials
248150
readable
@@ -252,5 +154,4 @@
252154
{:credentials-count (count credentials)})
253155
(catch Exception e
254156
{:parse-error (.getMessage e)}))))))]
255-
{:gpg-available gpg-avail?
256-
:files (vec file-checks)}))
157+
{:files (vec file-checks)}))

0 commit comments

Comments
 (0)