Skip to content

Commit 252c50a

Browse files
ericdalloeca-agent
andcommitted
Add ${cmd:...} dynamic string interpolation backend #430
Resolves config values by running an arbitrary shell command and using its trimmed stdout (e.g. `${cmd:pass show eca/key}`, `${cmd:op read op://vault/Item/credential}`), removing the need for LaunchAgent workarounds to inject secrets into GUI-launched ECA Desktop. On macOS the user's interactive login shell is queried once for $PATH (`$SHELL -ilc`) and the result cached, so Homebrew/mise/asdf shims and anything sourced from .zshrc/.zprofile resolve correctly. Falls back to prepending /opt/homebrew/bin, /usr/local/bin and ~/.local/bin if the shell query fails (timeout, unsupported shell, etc.). Closes #430 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent 881d2b4 commit 252c50a

7 files changed

Lines changed: 467 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Improve rules with frontmatter filters, condition variables, path-scoped loading, enforcement support, and clearer documentation. #222
66
- `preToolCall` hooks now receive `approval: "ask"` for the native `ask_user` tool so notification hooks (e.g. matching `.approval == "ask"`) also fire when the chat is blocked waiting for a user answer, regardless of trust mode.
7+
- New `${cmd:some command}` dynamic string backend that resolves to the trimmed stdout of a shell command, useful for password managers like `pass` or `op`. On macOS the user's interactive shell `$PATH` is queried once so GUI-launched ECA picks up Homebrew, `mise`/`asdf` shims, etc. #430
78

89
## 0.129.2
910

docs/config.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -482,17 +482,17 @@
482482
},
483483
"url": {
484484
"type": "string",
485-
"description": "Base URL for the provider's API. Supports dynamic strings like ${env:VAR}.",
486-
"markdownDescription": "Base URL for the provider's API. Supports dynamic strings like ${env:VAR}.",
485+
"description": "Base URL for the provider's API. Supports dynamic strings like ${env:VAR} or ${cmd:command}.",
486+
"markdownDescription": "Base URL for the provider's API. Supports dynamic strings like `${env:VAR}` or `${cmd:command}`.",
487487
"format": "uri"
488488
},
489489
"key": {
490490
"type": [
491491
"string",
492492
"null"
493493
],
494-
"description": "API key for authentication. Supports dynamic strings like ${env:MY_API_KEY}.",
495-
"markdownDescription": "API key for authentication. Supports dynamic strings like ${env:MY_API_KEY}."
494+
"description": "API key for authentication. Supports dynamic strings like ${env:MY_API_KEY}, ${netrc:host} or ${cmd:pass show eca/api-key}.",
495+
"markdownDescription": "API key for authentication. Supports dynamic strings like `${env:MY_API_KEY}`, `${netrc:host}` or `${cmd:pass show eca/api-key}`."
496496
},
497497
"keyRc": {
498498
"type": "string",

docs/config/introduction.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ It's possible to retrieve content of any configs with a string value using the `
6060
- `env`: `${env:MY_ENV}` to get a system env value with support for default values: `${env:MY_ENV:foo}`
6161
- `classpath`: `${classpath:path/to/eca/file}` to get a file content from [ECA's classpath](https://github.com/editor-code-assistant/eca/tree/master/resources)
6262
- `netrc`: Support Unix RC [credential files](./models.md#credential-file-authentication)
63+
- `cmd`: `${cmd:some command}` to run a command via the platform shell (`bash -c` on POSIX, PowerShell on Windows) and use its trimmed stdout — useful for password managers like `${cmd:pass show eca/api-key}` or `${cmd:op read op://vault/Item/credential}`. On non-zero exit/timeout the value falls back to an empty string and a warning is logged. The command itself cannot contain `}` (the closing brace ends the placeholder); for commands like `awk '{print $1}' …`, wrap them in a small script or shell function.
64+
65+
!!! info "macOS GUI launches"
66+
67+
When ECA runs from Finder/Dock (e.g. via ECA Desktop) the inherited `PATH` is minimal and Homebrew, `mise`/`asdf` shims, etc. are not visible. To fix this, on macOS the `cmd` backend spawns the user's interactive login shell once (`$SHELL -ilc '…'`) and reuses the captured `$PATH` for subsequent `${cmd:...}` resolutions — so anything sourced from `.zshrc`/`.zprofile`/`.bash_profile` is picked up automatically. If the shell query fails or your shell isn't bash/zsh/sh/dash/ksh, ECA falls back to prepending `/opt/homebrew/bin`, `/usr/local/bin` and `~/.local/bin`. As a last resort, use an absolute path (e.g. `${cmd:/opt/custom/bin/my-tool ...}`).
68+
69+
Linux GUI launches can have a similar (less severe) `PATH` gap, but shell-`PATH` discovery is currently macOS-only — Linux users with the same problem should use absolute paths in `${cmd:...}` for now.
6370

6471
!!! info Markdown support
6572

docs/config/models.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,8 @@ Schema:
314314
| Option | Type | Description | Required |
315315
|-----------------------------------|---------|--------------------------------------------------------------------------------------------------------------|----------|
316316
| `api` | string | The API schema to use (`"openai-responses"`, `"openai-chat"`, or `"anthropic"`) | Yes |
317-
| `url` | string | API URL (with support for env like `${env:MY_URL}`) | No* |
318-
| `key` | string | API key (with support for `${env:MY_KEY}` or `{netrc:api.my-provider.com}` | No* |
317+
| `url` | string | API URL (with support for dynamic strings like `${env:MY_URL}` or `${cmd:...}`) | No* |
318+
| `key` | string | API key (with support for dynamic strings like `${env:MY_KEY}`, `${netrc:api.my-provider.com}` or `${cmd:pass show eca/key}`) | No* |
319319
| `completionUrlRelativePath` | string | Optional override for the completion endpoint path (see defaults below and examples like Azure) | No |
320320
| `thinkTagStart` | string | Optional override the think start tag tag for openai-chat (Default: "<think>") api | No |
321321
| `thinkTagEnd` | string | Optional override the think end tag for openai-chat (Default: "</think>") api | No |

src/eca/interpolation.clj

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,19 @@
33
- `${env:SOME-ENV:default-value}`: environment variable with optional default
44
- `${file:/some/path}`: file content (relative paths resolved from cwd)
55
- `${classpath:path/to/file}`: classpath resource content
6-
- `${netrc:api.provider.com}`: credential from Unix netrc files"
6+
- `${netrc:api.provider.com}`: credential from Unix netrc files
7+
- `${cmd:some command}`: stdout of an arbitrary shell command
8+
9+
For `${cmd:...}` on macOS, the inherited PATH for GUI-launched apps
10+
(Finder/Dock) is minimal, so commands like `pass` and `op` won't resolve.
11+
We spawn the user's interactive login shell once and capture its `$PATH`
12+
-- this picks up everything sourced from `.zshrc`, `.zprofile`,
13+
`.bash_profile`, etc. (Homebrew, mise/asdf shims, custom directories).
14+
The query result is cached for the process lifetime; on failure we fall
15+
back to a hardcoded augmentation with Homebrew/user-local paths."
716
(:require
817
[babashka.fs :as fs]
18+
[babashka.process :as p]
919
[clojure.java.io :as io]
1020
[clojure.string :as string]
1121
[eca.logger :as logger]
@@ -17,12 +27,186 @@
1727

1828
(defn get-env [env] (System/getenv env))
1929

30+
(def ^:private cmd-default-timeout-ms 30000)
31+
(def ^:private shell-query-timeout-ms 5000)
32+
(def ^:private shell-query-delim "__ECA_PATH_DELIM__")
33+
34+
(def ^:private mac-default-path-entries
35+
["/opt/homebrew/bin" "/usr/local/bin"])
36+
37+
(def ^:private supported-query-shells
38+
#{"bash" "zsh" "sh" "dash" "ksh"})
39+
40+
(defn ^:private mac? [^String os-name]
41+
(boolean (and os-name (string/starts-with? os-name "Mac"))))
42+
43+
(defn ^:private windows? [^String os-name]
44+
(boolean (and os-name (string/starts-with? os-name "Windows"))))
45+
46+
(defn ^:private posix-shell-name [^String shell-path]
47+
(some-> shell-path
48+
(string/replace #".*/" "")
49+
string/lower-case))
50+
51+
(defn supported-query-shell?
52+
"True when the shell's basename is a POSIX shell we know how to query
53+
(bash/zsh/sh/dash/ksh). Other shells (notably fish) use different syntax
54+
for `$PATH` expansion and are skipped -- we fall back to hardcoded
55+
augmentation for those users."
56+
[shell-path]
57+
(contains? supported-query-shells (posix-shell-name shell-path)))
58+
59+
(defn augment-path
60+
"Pure helper. On macOS, returns a PATH string with Homebrew, /usr/local/bin
61+
and `<home>/.local/bin` prepended (only entries not already present in
62+
`existing-path`). On other OSes returns `existing-path` unchanged."
63+
[os-name existing-path home path-separator]
64+
(if (mac? os-name)
65+
(let [sep (or path-separator ":")
66+
existing (or existing-path "")
67+
existing-set (set (string/split existing
68+
(re-pattern (java.util.regex.Pattern/quote sep))))
69+
extras (cond-> (vec mac-default-path-entries)
70+
home (conj (str home "/.local/bin")))
71+
missing (vec (remove existing-set extras))]
72+
(cond
73+
(empty? missing) existing
74+
(string/blank? existing) (string/join sep missing)
75+
:else (str (string/join sep missing) sep existing)))
76+
existing-path))
77+
78+
(defn run-process!
79+
"Spawns `cmd-vector` via babashka.process with `extra-env` merged into the
80+
child env, deref'd with `timeout-ms`. Returns {:exit :out :err}.
81+
On timeout destroys the process tree and throws ex-info.
82+
83+
Public so tests can `with-redefs` it without spawning real subprocesses."
84+
[cmd-vector extra-env timeout-ms]
85+
(let [proc (p/process {:cmd cmd-vector
86+
:out :string
87+
:err :string
88+
:continue true
89+
:extra-env extra-env})
90+
result (deref proc timeout-ms ::timeout)]
91+
(if (= result ::timeout)
92+
(do
93+
(p/destroy-tree proc)
94+
(throw (ex-info "Command timed out"
95+
{:cmd cmd-vector :timeout-ms timeout-ms})))
96+
result)))
97+
98+
(defn query-user-shell-path*
99+
"Spawns `shell-path` as `<shell> -ilc 'printf DELIM%sDELIM \"$PATH\"'` (an
100+
interactive login shell, which sources the user's rc/profile files) and
101+
extracts the captured PATH from between the delimiters. Returns the PATH
102+
string or nil on any failure (unsupported shell, non-zero exit, missing
103+
delimiter, exception/timeout). Empty PATH is treated as failure.
104+
105+
Public for testing -- the production path goes through `query-user-shell-path`
106+
which reads `$SHELL` from the environment."
107+
[shell-path]
108+
(try
109+
(when (supported-query-shell? shell-path)
110+
(let [delim shell-query-delim
111+
script (str "printf '" delim "%s" delim "' \"$PATH\"")
112+
{:keys [exit out]} (run-process! [shell-path "-ilc" script]
113+
{}
114+
shell-query-timeout-ms)
115+
quoted-delim (java.util.regex.Pattern/quote delim)]
116+
(when (and (zero? exit) (string? out))
117+
(when-let [[_ path] (re-find (re-pattern (str "(?s)" quoted-delim "(.*?)" quoted-delim))
118+
out)]
119+
(when (seq path) path)))))
120+
(catch Exception e
121+
(logger/debug logger-tag "Shell PATH query failed:" (.getMessage e))
122+
nil)))
123+
124+
(defn query-user-shell-path
125+
"Reads `$SHELL` from the environment (defaulting to /bin/bash) and queries it
126+
for the user's PATH. Returns the PATH string or nil on failure. Public for
127+
test redefinition."
128+
[]
129+
(query-user-shell-path* (or (System/getenv "SHELL") "/bin/bash")))
130+
131+
(defonce ^:private user-shell-path-cache* (atom ::unset))
132+
133+
(defn user-shell-path
134+
"Returns the user's interactive shell PATH or nil. Lazily queried on first
135+
call and cached for the process lifetime. Both successful and unsuccessful
136+
results are cached -- we don't retry the shell query on every miss."
137+
[]
138+
(let [v @user-shell-path-cache*]
139+
(if (= v ::unset)
140+
(let [p (query-user-shell-path)]
141+
(reset! user-shell-path-cache* (or p ::miss))
142+
p)
143+
(when-not (= v ::miss) v))))
144+
145+
(defn reset-shell-path-cache!
146+
"Clears the cached shell PATH. Test helper."
147+
[]
148+
(reset! user-shell-path-cache* ::unset))
149+
150+
(defn effective-path
151+
"Returns the PATH to use for the cmd resolver child process.
152+
153+
On macOS, prefers the user's interactive shell PATH (queried once and cached);
154+
falls back to augmenting the inherited PATH with hardcoded Homebrew/user-local
155+
entries when the shell query is unavailable. On other OSes returns
156+
`existing-path` unchanged."
157+
[os-name existing-path home sep]
158+
(if (mac? os-name)
159+
(or (user-shell-path)
160+
(augment-path os-name existing-path home sep))
161+
existing-path))
162+
163+
(defn ^:private build-cmd-vector [^String os-name cmd-string]
164+
(if (windows? os-name)
165+
["powershell.exe" "-NoProfile" "-Command" cmd-string]
166+
["bash" "-c" cmd-string]))
167+
168+
(defn resolve-cmd
169+
"Runs `cmd-string` via the platform shell (bash on POSIX, PowerShell on Windows)
170+
and returns its stdout with trailing whitespace trimmed.
171+
172+
On non-zero exit, exception, or timeout: logs a warning and returns \"\" --
173+
matching the failure mode of the other dynamic string backends so a missing
174+
secret does not crash config load."
175+
[cmd-string]
176+
(try
177+
(let [os-name (System/getProperty "os.name")
178+
existing-path (System/getenv "PATH")
179+
home (System/getProperty "user.home")
180+
sep (System/getProperty "path.separator")
181+
new-path (effective-path os-name existing-path home sep)
182+
extra-env (cond-> {}
183+
(and new-path (not= new-path existing-path))
184+
(assoc "PATH" new-path))
185+
{:keys [exit out err]} (run-process! (build-cmd-vector os-name cmd-string)
186+
extra-env
187+
cmd-default-timeout-ms)]
188+
(if (zero? exit)
189+
(some-> out string/trimr)
190+
(do
191+
(logger/warn logger-tag
192+
"Command exited non-zero:"
193+
{:cmd cmd-string
194+
:exit exit
195+
:err (some-> err string/trim)})
196+
"")))
197+
(catch Exception e
198+
(logger/warn logger-tag
199+
"Command failed:"
200+
{:cmd cmd-string :error (.getMessage e)})
201+
"")))
202+
20203
(defn replace-dynamic-strings
21204
"Given a string and a current working directory, look for patterns replacing its content:
22205
- `${env:SOME-ENV:default-value}`: Replace with a env falling back to a optional default value
23206
- `${file:/some/path}`: Replace with a file content checking from cwd if relative
24207
- `${classpath:path/to/file}`: Replace with a file content found checking classpath
25-
- `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/config/models/#credential-file-authentication)"
208+
- `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/config/models/#credential-file-authentication)
209+
- `${cmd:some command}`: Replace with the trimmed stdout of an arbitrary shell command"
26210
[s cwd config]
27211
(some-> s
28212
(string/replace #"\$\{env:([^:}]+)(?::([^}]*))?\}"
@@ -53,4 +237,11 @@
53237
(or (secrets/get-credential key-rc (get config "netrcFile")) "")
54238
(catch Exception e
55239
(logger/warn logger-tag "Error reading netrc credential:" (.getMessage e))
240+
""))))
241+
(string/replace #"\$\{cmd:([^}]+)\}"
242+
(fn [[_match cmd-string]]
243+
(try
244+
(or (resolve-cmd cmd-string) "")
245+
(catch Exception e
246+
(logger/warn logger-tag "Error executing cmd:" (.getMessage e))
56247
""))))))

test/eca/config_test.clj

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@
469469
(is (= "password1 and password2"
470470
(interpolation/replace-dynamic-strings "${netrc:api1.example.com} and ${netrc:api2.example.com}" "/tmp" {})))))
471471

472-
(testing "handles mixed env, file, classpath, and netrc patterns"
472+
(testing "handles mixed env, file, classpath, netrc and cmd patterns"
473473
(with-redefs [interpolation/get-env (fn [env-var]
474474
(when (= env-var "TEST_VAR") "env-value"))
475475
fs/expand-home identity
@@ -485,9 +485,11 @@
485485
secrets/get-credential (fn [key-rc _]
486486
(when (= key-rc "api.example.com")
487487
"netrc-password"))
488+
interpolation/resolve-cmd (fn [cmd-string]
489+
(when (= cmd-string "echo cmd-value") "cmd-value"))
488490
logger/warn (fn [& _] nil)]
489-
(is (= "env-value and file-value and classpath-value and netrc-password"
490-
(interpolation/replace-dynamic-strings "${env:TEST_VAR} and ${file:/file.txt} and ${classpath:resource.txt} and ${netrc:api.example.com}" "/tmp" {})))))
491+
(is (= "env-value and file-value and classpath-value and netrc-password and cmd-value"
492+
(interpolation/replace-dynamic-strings "${env:TEST_VAR} and ${file:/file.txt} and ${classpath:resource.txt} and ${netrc:api.example.com} and ${cmd:echo cmd-value}" "/tmp" {})))))
491493

492494
(testing "handles netrc pattern within longer strings"
493495
(with-redefs [secrets/get-credential (fn [key-rc _]
@@ -508,7 +510,45 @@
508510
"api_service.example.com" "password2"
509511
nil))]
510512
(is (= "password1" (interpolation/replace-dynamic-strings "${netrc:api-gateway.example-corp.com}" "/tmp" {})))
511-
(is (= "password2" (interpolation/replace-dynamic-strings "${netrc:api_service.example.com}" "/tmp" {}))))))
513+
(is (= "password2" (interpolation/replace-dynamic-strings "${netrc:api_service.example.com}" "/tmp" {})))))
514+
515+
(testing "replaces cmd pattern with the resolver result"
516+
(with-redefs [interpolation/resolve-cmd (fn [cmd-string]
517+
(case cmd-string
518+
"pass show eca/api-key" "sk-abc123"
519+
"echo hello" "hello"
520+
""))]
521+
(is (= "sk-abc123" (interpolation/replace-dynamic-strings "${cmd:pass show eca/api-key}" "/tmp" {})))
522+
(is (= "Bearer sk-abc123" (interpolation/replace-dynamic-strings "Bearer ${cmd:pass show eca/api-key}" "/tmp" {})))))
523+
524+
(testing "cmd pattern supports values with `://` (1Password op references)"
525+
(with-redefs [interpolation/resolve-cmd (fn [cmd-string]
526+
(when (= cmd-string "op read op://vault/Item/credential")
527+
"op-secret"))]
528+
(is (= "op-secret"
529+
(interpolation/replace-dynamic-strings "${cmd:op read op://vault/Item/credential}" "/tmp" {})))))
530+
531+
(testing "cmd pattern returns empty string when resolver returns nil/empty"
532+
(with-redefs [interpolation/resolve-cmd (constantly "")]
533+
(is (= "" (interpolation/replace-dynamic-strings "${cmd:false}" "/tmp" {})))
534+
(is (= "prefix suffix"
535+
(interpolation/replace-dynamic-strings "prefix ${cmd:false} suffix" "/tmp" {})))))
536+
537+
(testing "cmd pattern returns empty string when resolver throws"
538+
(with-redefs [logger/warn (fn [& _] nil)
539+
interpolation/resolve-cmd (fn [_] (throw (ex-info "boom" {})))]
540+
(is (= "" (interpolation/replace-dynamic-strings "${cmd:explode}" "/tmp" {})))
541+
(is (= "prefix suffix"
542+
(interpolation/replace-dynamic-strings "prefix ${cmd:explode} suffix" "/tmp" {})))))
543+
544+
(testing "handles multiple cmd patterns in one string"
545+
(with-redefs [interpolation/resolve-cmd (fn [cmd-string]
546+
(case cmd-string
547+
"echo a" "value-a"
548+
"echo b" "value-b"
549+
""))]
550+
(is (= "value-a and value-b"
551+
(interpolation/replace-dynamic-strings "${cmd:echo a} and ${cmd:echo b}" "/tmp" {}))))))
512552

513553
(deftest config-schema-test
514554
(testing "docs/config.json is a valid JSON schema"

0 commit comments

Comments
 (0)