@mmerterden/multi-agent-pipeline 13.3.0 → 13.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/install/_mcp-register.mjs +125 -0
  3. package/install/codex.mjs +4 -38
  4. package/install/copilot.mjs +8 -0
  5. package/install/index.mjs +8 -3
  6. package/package.json +1 -1
  7. package/pipeline/commands/multi-agent/setup/SKILL.md +2 -1
  8. package/pipeline/lib/account-resolver.sh +15 -4
  9. package/pipeline/lib/credential-inventory.sh +374 -0
  10. package/pipeline/lib/credential-store-resolver.sh +34 -5
  11. package/pipeline/lib/fetch-confluence.sh +27 -5
  12. package/pipeline/lib/fetch-crashlytics.sh +27 -5
  13. package/pipeline/lib/fetch-figma-annotations.sh +18 -4
  14. package/pipeline/lib/fetch-fortify.sh +27 -5
  15. package/pipeline/lib/fetch-graylog.sh +27 -5
  16. package/pipeline/lib/figma-mcp-refresh.sh +35 -11
  17. package/pipeline/lib/figma-screenshot.sh +18 -7
  18. package/pipeline/lib/issue-fetcher.sh +19 -4
  19. package/pipeline/lib/post-pr-review.sh +27 -4
  20. package/pipeline/lib/repo-cache.sh +19 -4
  21. package/pipeline/lib/vercel-deploy.sh +15 -4
  22. package/pipeline/multi-agent-refs/features/external-context-injection.md +44 -3
  23. package/pipeline/multi-agent-refs/keychain.md +63 -0
  24. package/pipeline/multi-agent-refs/phases/phase-0-init.md +51 -18
  25. package/pipeline/multi-agent-refs/rules.md +3 -3
  26. package/pipeline/scripts/audit-log.sh +10 -4
  27. package/pipeline/scripts/keychain-save.sh +27 -5
  28. package/pipeline/scripts/phase-tracker.sh +26 -2
  29. package/pipeline/scripts/phase0-exit-gate.mjs +52 -0
  30. package/pipeline/scripts/uninstall.mjs +24 -5
  31. package/pipeline/skills/shared/core/multi-agent/SKILL.md +19 -0
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env bash
2
+ # credential-inventory.sh - what the pipeline can reach right now, and what it cannot.
3
+ #
4
+ # WHY THIS EXISTS
5
+ #
6
+ # A run asked the user to paste a Crashlytics stack trace by hand while a Firebase
7
+ # service-account JSON sat in the Keychain, mapped as `firebase`, fully valid. Nothing
8
+ # was broken - the pipeline simply never asked itself "do I already hold a credential
9
+ # that answers this?" before asking the user. From the user's side that is worse than a
10
+ # failure: they had supplied the key precisely so this would not happen.
11
+ #
12
+ # One command, so there is no excuse for asking blind. Run it before any question that
13
+ # requests data an external system holds, and let the answer shape the question:
14
+ #
15
+ # - credential present -> ask for the *pointer* (the issue URL), not the payload,
16
+ # and say you will fetch it
17
+ # - credential missing -> say which logical key is unmapped and offer the Save Flow
18
+ # - credential dead -> say it is expired and offer to refresh it
19
+ #
20
+ # SAFETY
21
+ #
22
+ # Values are never printed, never stored in a variable that reaches stdout, and never
23
+ # logged. Presence is probed through `credential-store.sh get` with stdout discarded, so
24
+ # the secret goes to /dev/null and only the exit status is read.
25
+ #
26
+ # Usage:
27
+ # credential-inventory.sh # human-readable table
28
+ # credential-inventory.sh --json # machine-readable, for state files
29
+ # credential-inventory.sh --probe # also verify each configured service answers
30
+ # credential-inventory.sh --key firebase # single logical key, exit 0 present / 1 not
31
+ #
32
+ # PRESENT IS NOT THE SAME AS WORKING
33
+ #
34
+ # Without --probe this reports what is configured. With --probe each configured
35
+ # credential makes one cheap authenticated request, and the verdict distinguishes the
36
+ # three failures that need three different user actions:
37
+ #
38
+ # reachable the service answered and accepted the credential
39
+ # auth-rejected 401/403 - the credential is dead, the user must refresh it
40
+ # unreachable no response at all - on a corporate host, almost always the VPN
41
+ # no-host-configured a token exists but its host was never recorded in preferences
42
+ # well-formed structurally valid, liveness not checkable without more input
43
+ # not-probeable no cheap probe exists for this credential type
44
+ #
45
+ # Only configured credentials are probed. An unmapped key is a capability the user chose
46
+ # not to enable, not a problem to report.
47
+ #
48
+ # Exit codes: 0 = inventory produced (or the queried key is present), 1 = queried key
49
+ # absent, 3 = usage error.
50
+
51
+ set -uo pipefail
52
+
53
+ PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
54
+
55
+ STORE=""
56
+ for c in "$HOME/.claude/lib/credential-store.sh" "$HOME/.copilot/lib/credential-store.sh" \
57
+ "$HOME/.codex/lib/credential-store.sh" "$(dirname "$0")/credential-store.sh"; do
58
+ [ -f "$c" ] && { STORE="$c"; break; }
59
+ done
60
+
61
+ MODE="table"
62
+ QUERY=""
63
+ PROBE=0
64
+ TIMEOUT="${MULTI_AGENT_PROBE_TIMEOUT:-6}"
65
+ while [ $# -gt 0 ]; do
66
+ case "$1" in
67
+ --json) MODE="json"; shift ;;
68
+ --probe) PROBE=1; shift ;;
69
+ --key) QUERY="${2:-}"; shift 2 || shift ;;
70
+ -h|--help)
71
+ echo "usage: $0 [--json] [--probe] [--key <logical-key>]" >&2; exit 3 ;;
72
+ *) echo "ERR: unexpected arg $1" >&2; exit 3 ;;
73
+ esac
74
+ done
75
+
76
+ pref() { # pref <dotted.path> -> value or empty
77
+ [ -f "$PREFS" ] || return 0
78
+ python3 - "$PREFS" "$1" <<'PYPREF' 2>/dev/null
79
+ import json, sys
80
+ try:
81
+ d = json.load(open(sys.argv[1]))
82
+ except Exception:
83
+ sys.exit(0)
84
+ for k in sys.argv[2].split("."):
85
+ if not isinstance(d, dict):
86
+ sys.exit(0)
87
+ d = d.get(k)
88
+ if d is None:
89
+ sys.exit(0)
90
+ print(d if not isinstance(d, (dict, list)) else "")
91
+ PYPREF
92
+ }
93
+
94
+ # An authenticated request whose credential never appears in argv.
95
+ #
96
+ # `curl -H "Authorization: Bearer $TOK"` puts the secret in the process command line,
97
+ # where any `ps` on the machine can read it. `--config -` takes both the header and the
98
+ # URL from stdin instead, so the token exists only in the pipe. Same reason the Vercel
99
+ # wrapper refuses a `--token=` argv.
100
+ #
101
+ # probe_http <url> <header-name> <header-value> -> prints the HTTP status
102
+ probe_http() {
103
+ local url="$1" hname="$2" hval="$3" out
104
+ # No `|| echo 000` here: curl already writes `000` through write-out when it never
105
+ # got a response, and exits non-zero as well. Appending produced `000000`, which fell
106
+ # through classify_status into a bogus `unexpected-000000` verdict for what was simply
107
+ # a closed VPN.
108
+ out=$(printf 'url = "%s"\nheader = "%s: %s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
109
+ "$url" "$hname" "$hval" "$TIMEOUT" | curl --config - 2>/dev/null)
110
+ printf '%s' "${out:-000}"
111
+ }
112
+
113
+ # probe_basic <url> <user> <pass> -> prints the HTTP status
114
+ # probe_basic_hdr <url> <user> <pass> <header-name> <header-value> -> HTTP status
115
+ probe_basic_hdr() {
116
+ local url="$1" u="$2" pw="$3" hname="$4" hval="$5" out
117
+ out=$(printf 'url = "%s"\nuser = "%s:%s"\nheader = "%s: %s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
118
+ "$url" "$u" "$pw" "$hname" "$hval" "$TIMEOUT" | curl --config - 2>/dev/null)
119
+ printf '%s' "${out:-000}"
120
+ }
121
+
122
+ probe_basic() {
123
+ local url="$1" u="$2" p="$3" out
124
+ out=$(printf 'url = "%s"\nuser = "%s:%s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
125
+ "$url" "$u" "$p" "$TIMEOUT" | curl --config - 2>/dev/null)
126
+ printf '%s' "${out:-000}"
127
+ }
128
+
129
+ # Map an HTTP status onto the vocabulary the pipeline reasons about. `000` is curl's
130
+ # "never got a response" - DNS failure, refused connection, timeout - which on a
131
+ # corporate host is almost always a closed VPN, and is a completely different user
132
+ # action from a rejected credential.
133
+ classify_status() {
134
+ case "$1" in
135
+ 2??) echo "reachable" ;;
136
+ # A redirect means the host answered. Corporate services commonly bounce an
137
+ # unauthenticated API call to an SSO login page, which classify as auth-rejected
138
+ # rather than reachable: the request did not get its data.
139
+ 30?) echo "auth-rejected" ;;
140
+ 401|403) echo "auth-rejected" ;;
141
+ 404) echo "reachable" ;; # endpoint answered; the probe path may just not exist
142
+ 000|"") echo "unreachable" ;;
143
+ 5??) echo "server-error" ;;
144
+ # Anything that is not a three-digit status means the probe itself misbehaved.
145
+ # Saying so beats inventing a service verdict from a malformed value.
146
+ *) echo "probe-error" ;;
147
+ esac
148
+ }
149
+
150
+ # Reachability for one logical key. Never prints a credential; the value is piped
151
+ # straight into curl's stdin config and discarded.
152
+ #
153
+ # Hosts always come from preferences - a self-hosted Jira / Bitbucket / Confluence /
154
+ # Fortify / Graylog address is deployment-specific and must never be baked in. Only
155
+ # genuinely global public APIs are named literally. Host keys live at
156
+ # `global.hosts.<service>` per prefs.schema.json - an earlier version of this probe
157
+ # guessed `global.<service>Host` and reported no-host-configured for every
158
+ # self-hosted service the user had actually configured.
159
+ probe_one() {
160
+ local key="$1" tok host status
161
+ tok=$(bash "$STORE" get "$key" 2>/dev/null) || { echo "no-credential"; return; }
162
+ [ -n "$tok" ] || { echo "no-credential"; return; }
163
+
164
+ case "$key" in
165
+ jira)
166
+ host=$(pref global.hosts.jira)
167
+ [ -n "$host" ] || { echo "no-host-configured"; return; }
168
+ status=$(probe_http "https://${host}/rest/api/2/myself" "Authorization" "Bearer $tok") ;;
169
+ confluence)
170
+ host=$(pref global.hosts.confluence)
171
+ [ -n "$host" ] || { echo "no-host-configured"; return; }
172
+ status=$(probe_http "https://${host}/rest/api/user/current" "Authorization" "Bearer $tok") ;;
173
+ bitbucket_token)
174
+ host=$(pref global.hosts.bitbucket)
175
+ [ -n "$host" ] || { echo "no-host-configured"; return; }
176
+ local bbuser bbkey
177
+ bbkey=$(pref global.keychainMapping.bitbucket_user)
178
+ bbuser=$([ -n "$bbkey" ] && bash "$STORE" get bitbucket_user 2>/dev/null || echo "")
179
+ if [ -n "$bbuser" ]; then
180
+ status=$(probe_basic "https://${host}/rest/api/1.0/repos?limit=1" "$bbuser" "$tok")
181
+ else
182
+ status=$(probe_http "https://${host}/rest/api/1.0/repos?limit=1" "Authorization" "Bearer $tok")
183
+ fi ;;
184
+ github)
185
+ status=$(probe_http "https://api.github.com/user" "Authorization" "Bearer $tok") ;;
186
+ figma)
187
+ status=$(probe_http "https://api.figma.com/v1/me" "X-Figma-Token" "$tok") ;;
188
+ figma_mcp)
189
+ # An OAuth token for the Figma MCP server, not a REST PAT. Sending it to
190
+ # api.figma.com returns 403 for a perfectly healthy token, which is worse than
191
+ # not probing: it would send the user to regenerate something that works.
192
+ # Liveness for this one belongs to figma-mcp-refresh.sh, which owns the grant.
193
+ printf '%s' "$tok" | grep -q . && echo "not-probeable" || echo "malformed"
194
+ return ;;
195
+ npm)
196
+ status=$(probe_http "https://registry.npmjs.org/-/whoami" "Authorization" "Bearer $tok") ;;
197
+ fortify)
198
+ host=$(pref global.hosts.fortify)
199
+ [ -n "$host" ] || { echo "no-host-configured"; return; }
200
+ # Shapes copied from fetch-fortify.sh, which already works against SSC: the API
201
+ # lives under /ssc/, and SSC accepts either a Bearer token or its own
202
+ # `FortifyToken <base64>` scheme. Probing the wrong base path returned a 302 to
203
+ # the login page and read as a broken credential.
204
+ status=$(probe_http "https://${host}/ssc/api/v1/projects?limit=1" "Authorization" "Bearer $tok")
205
+ if [ "$(classify_status "$status")" != "reachable" ]; then
206
+ status=$(probe_http "https://${host}/ssc/api/v1/projects?limit=1" \
207
+ "Authorization" "FortifyToken $(printf '%s' "$tok" | base64 | tr -d '\n')")
208
+ fi ;;
209
+ graylog)
210
+ host=$(pref global.hosts.graylog)
211
+ [ -n "$host" ] || { echo "no-host-configured"; return; }
212
+ # Graylog PATs authenticate as basic auth with the literal password "token", and
213
+ # the API rejects requests without X-Requested-By. Both per fetch-graylog.sh.
214
+ status=$(probe_basic_hdr "https://${host}/api/system" "$tok" "token" \
215
+ "X-Requested-By" "multi-agent-pipeline") ;;
216
+ jenkins)
217
+ host=$(pref global.hosts.jenkins)
218
+ [ -n "$host" ] || { echo "no-host-configured"; return; }
219
+ status=$(probe_http "https://${host}/api/json" "Authorization" "Bearer $tok") ;;
220
+ firebase)
221
+ # A service-account JSON, not a bearer token: an OAuth exchange would be needed
222
+ # to reach Crashlytics, and the fetcher does that per issue. Verifying the shape
223
+ # is honest about what is known - well-formed, not proven reachable.
224
+ if printf '%s' "$tok" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("project_id") and d.get("private_key") else 1)' 2>/dev/null; then
225
+ echo "well-formed"; return
226
+ fi
227
+ echo "malformed"; return ;;
228
+ appstore_connect_private_key)
229
+ printf '%s' "$tok" | grep -q "BEGIN PRIVATE KEY" && echo "well-formed" || echo "malformed"
230
+ return ;;
231
+ *)
232
+ echo "not-probeable"; return ;;
233
+ esac
234
+ classify_status "$status"
235
+ }
236
+
237
+ # What each logical key unlocks, in the pipeline's own terms. This is the column that
238
+ # turns an inventory into an actionable question: "I hold `firebase`, so give me the
239
+ # issue URL and I will pull the stack trace" is only sayable if the capability is
240
+ # written down somewhere.
241
+ capability_of() {
242
+ case "$1" in
243
+ jira) echo "read the ticket, its comments and its linked issues; post the Phase 7 comment" ;;
244
+ bitbucket_token) echo "read the repo, open and update pull requests" ;;
245
+ bitbucket_user) echo "identify the PR author (paired with bitbucket_token)" ;;
246
+ github) echo "read issues, open pull requests, read Actions runs" ;;
247
+ confluence) echo "read linked pages and publish the analysis document" ;;
248
+ firebase) echo "pull a Crashlytics issue: stack frames, affected versions, device spread (needs the issue URL)" ;;
249
+ fortify) echo "pull a static-analysis finding and its remediation guidance" ;;
250
+ graylog) echo "pull request logs by transaction or conversation id (advisory)" ;;
251
+ figma|figma_mcp) echo "fetch design context, screenshots and Code Connect mappings" ;;
252
+ jenkins) echo "read build results" ;;
253
+ npm) echo "publish to the npm registry" ;;
254
+ appstore_connect_key_id|appstore_connect_issuer_id|appstore_connect_private_key)
255
+ echo "validate an archive against App Store rules before submission" ;;
256
+ claude_oauth_token|claude_oauth_token_fallback)
257
+ echo "run headless review and analysis passes" ;;
258
+ *) echo "(no capability recorded for this key)" ;;
259
+ esac
260
+ }
261
+
262
+ mapping_keys() {
263
+ [ -f "$PREFS" ] || return 0
264
+ python3 - "$PREFS" <<'PY' 2>/dev/null
265
+ import json, sys
266
+ try:
267
+ p = json.load(open(sys.argv[1]))
268
+ except Exception:
269
+ sys.exit(0)
270
+ m = (p.get("global") or {}).get("keychainMapping") or {}
271
+ for k, v in m.items():
272
+ print(f"{k}\t{'1' if v else '0'}")
273
+ PY
274
+ }
275
+
276
+ # Presence probe. The value lands in /dev/null; only the exit status is observed.
277
+ probe() {
278
+ [ -n "$STORE" ] || return 2
279
+ bash "$STORE" get "$1" >/dev/null 2>&1
280
+ }
281
+
282
+ if [ -n "$QUERY" ]; then
283
+ probe "$QUERY"
284
+ rc=$?
285
+ case "$rc" in
286
+ 0)
287
+ if [ "$PROBE" = "1" ]; then
288
+ echo "$QUERY: present, $(probe_one "$QUERY") - can $(capability_of "$QUERY")"
289
+ else
290
+ echo "$QUERY: present - can $(capability_of "$QUERY")"
291
+ fi
292
+ exit 0 ;;
293
+ 2) echo "$QUERY: no credential helper on this host" >&2; exit 1 ;;
294
+ *) echo "$QUERY: NOT AVAILABLE - onboard it via /multi-agent:setup before relying on it" >&2; exit 1 ;;
295
+ esac
296
+ fi
297
+
298
+ ROWS=""
299
+ while IFS=$'\t' read -r key mapped; do
300
+ [ -z "$key" ] && continue
301
+ if [ "$mapped" != "1" ]; then
302
+ state="unmapped"
303
+ reach="not-probed"
304
+ elif probe "$key"; then
305
+ state="present"
306
+ # Only configured credentials are probed. A key the user never onboarded is not a
307
+ # problem to report - it is a capability they chose not to enable.
308
+ reach=$([ "$PROBE" = "1" ] && probe_one "$key" || echo "not-probed")
309
+ else
310
+ state="mapped-but-missing"
311
+ reach="not-probed"
312
+ fi
313
+ ROWS="${ROWS}${key}\t${state}\t${reach}\t$(capability_of "$key")\n"
314
+ done < <(mapping_keys)
315
+
316
+ if [ -z "$ROWS" ]; then
317
+ if [ "$MODE" = "json" ]; then
318
+ echo '{"status":"empty","reason":"no keychainMapping in preferences","credentials":[]}'
319
+ else
320
+ echo "no keychainMapping found in $PREFS - run /multi-agent:setup" >&2
321
+ fi
322
+ exit 0
323
+ fi
324
+
325
+ if [ "$MODE" = "json" ]; then
326
+ # The rows travel through a temp file, not a pipe. `python3 - <<EOF` takes its SCRIPT
327
+ # from stdin, so a piped payload never reaches sys.stdin: --json came back with empty
328
+ # arrays while the table mode looked correct. Caught by smoke-credential-awareness.
329
+ ROWS_FILE="$(mktemp)"
330
+ trap 'rm -f "$ROWS_FILE"' EXIT
331
+ printf '%b' "$ROWS" > "$ROWS_FILE"
332
+ python3 - "$ROWS_FILE" <<'PYJSON'
333
+ import json, sys
334
+ rows = []
335
+ with open(sys.argv[1]) as fh:
336
+ for line in fh:
337
+ line = line.rstrip("\n")
338
+ if not line:
339
+ continue
340
+ parts = line.split("\t")
341
+ if len(parts) < 4:
342
+ continue
343
+ rows.append({
344
+ "logical": parts[0],
345
+ "state": parts[1],
346
+ "reachability": parts[2],
347
+ "capability": parts[3],
348
+ })
349
+
350
+ # `usable` is presence only, so a caller that never probed still gets a meaningful
351
+ # answer. `reachable` is the stronger claim and exists only after --probe.
352
+ OK_REACH = {"reachable", "well-formed"}
353
+ BLOCKED = {"auth-rejected", "malformed"}
354
+ probed = [r for r in rows if r["reachability"] != "not-probed"]
355
+ print(json.dumps({
356
+ "status": "ok",
357
+ "probed": bool(probed),
358
+ "usable": [r["logical"] for r in rows if r["state"] == "present"],
359
+ "needsAttention": [r["logical"] for r in rows if r["state"] != "present"],
360
+ # Separated on purpose: a dead token is the user's to refresh, an unreachable host
361
+ # is usually a closed VPN, and a missing host is a setup gap. Collapsing them into
362
+ # one "failed" bucket is what made the pipeline ask blind questions.
363
+ "reachable": [r["logical"] for r in probed if r["reachability"] in OK_REACH],
364
+ "authRejected": [r["logical"] for r in probed if r["reachability"] in BLOCKED],
365
+ "unreachable": [r["logical"] for r in probed if r["reachability"] == "unreachable"],
366
+ "noHostConfigured": [r["logical"] for r in probed if r["reachability"] == "no-host-configured"],
367
+ "credentials": rows,
368
+ }, indent=2))
369
+ PYJSON
370
+ else
371
+ printf '%b' "$ROWS" | awk -F'\t' '
372
+ BEGIN { printf "%-28s %-20s %-20s %s\n", "LOGICAL KEY", "STATE", "REACHABILITY", "UNLOCKS" }
373
+ { printf "%-28s %-20s %-20s %s\n", $1, $2, $3, $4 }'
374
+ fi
@@ -13,15 +13,31 @@
13
13
  # error instead of `bash: $CRED_STORE: command not found`.
14
14
  #
15
15
  # Usage from a skill bash block:
16
- # . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
17
- # || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
18
- # || { echo "credential helper not found - run npx @mmerterden/multi-agent-pipeline install" >&2; exit 1; }
16
+ # for r in "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
17
+ # "$HOME/.claude/lib/credential-store-resolver.sh" \
18
+ # "$HOME/.copilot/lib/credential-store-resolver.sh" \
19
+ # "$HOME/.codex/lib/credential-store-resolver.sh"; do
20
+ # [ -f "$r" ] || continue
21
+ # . "$r" 2>/dev/null || true
22
+ # if [ -n "${CRED_STORE:-}" ]; then break; fi
23
+ # done
24
+ # [ -n "${CRED_STORE:-}" ] || { echo "credential helper not found - run the installer" >&2; exit 1; }
19
25
  # TOKEN=$("$CRED_STORE" get "<KEY>")
26
+ #
27
+ # Two rules, both learned from a silent failure:
28
+ # 1. Check the FILE EXISTS before sourcing it. `. <missing>` aborts the shell under
29
+ # `set -e`, `||` included, so a `.`-chain never reaches its later candidates.
30
+ # 2. Decide on `$CRED_STORE`, never on the source's exit status. Sourcing this file
31
+ # always succeeds by design, so the caller stays alive to report a useful error.
20
32
 
21
33
  resolve_credential_store() {
34
+ # All three supported hosts. Codex was missing here, so a Codex-only install could
35
+ # not resolve the credential helper at all - every fetcher on that host reported
36
+ # "credential helper not found" while the file sat in ~/.codex/lib.
22
37
  local cands=(
23
38
  "$HOME/.claude/lib/credential-store.sh"
24
39
  "$HOME/.copilot/lib/credential-store.sh"
40
+ "$HOME/.codex/lib/credential-store.sh"
25
41
  )
26
42
  for c in "${cands[@]}"; do
27
43
  if [ -x "$c" ]; then
@@ -36,11 +52,13 @@ credential helper not found.
36
52
  The multi-agent pipeline expects one of these to exist:
37
53
  ~/.claude/lib/credential-store.sh (Claude Code installs)
38
54
  ~/.copilot/lib/credential-store.sh (Copilot CLI installs)
55
+ ~/.codex/lib/credential-store.sh (Codex CLI installs)
39
56
 
40
57
  Install with:
41
58
  npx @mmerterden/multi-agent-pipeline install --claude # Claude Code
42
59
  npx @mmerterden/multi-agent-pipeline install --copilot # Copilot CLI
43
- npx @mmerterden/multi-agent-pipeline install --all # both
60
+ npx @mmerterden/multi-agent-pipeline install --codex # Codex CLI
61
+ npx @mmerterden/multi-agent-pipeline install --all # all three
44
62
 
45
63
  Or override `CRED_STORE` to point at a custom path before sourcing this resolver.
46
64
  MSG
@@ -53,5 +71,16 @@ MSG
53
71
  if [ "${BASH_SOURCE[0]:-$0}" = "${0}" ]; then
54
72
  resolve_credential_store && echo "$CRED_STORE"
55
73
  else
56
- resolve_credential_store
74
+ # `|| :` matters, and it is not cosmetic.
75
+ #
76
+ # A sourced file runs in the caller's shell, so a bare failing command at this top
77
+ # level trips the caller's `set -e` DURING the source - before the caller's own
78
+ # `. resolver || fallback` can catch anything. Eleven runtime scripts loaded this
79
+ # resolver that way, all of them with `set -e`, so on any host where the first
80
+ # credential-store candidate was absent (a Copilot-only or Codex-only install) they
81
+ # died with a bare exit 1, no message, and their error branches unreachable.
82
+ #
83
+ # Sourcing therefore always succeeds. Callers MUST decide on `$CRED_STORE` being
84
+ # non-empty, never on the source's exit status - see the usage note above.
85
+ resolve_credential_store || :
57
86
  fi
@@ -126,11 +126,33 @@ except Exception:
126
126
  fi
127
127
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Confluence_Access_Token"
128
128
 
129
- # Locate the credential helper via the resolver so Copilot-only installs work.
130
- # shellcheck disable=SC1090,SC1091
131
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
132
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
133
- || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
129
+ # Locate the resolver with an existence check, not a `.`-chain.
130
+ #
131
+ # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
132
+ # so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
133
+ # nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
134
+ # so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
135
+ # message. Reordering does not help: whichever candidate is absent aborts at that point.
136
+ # Checking for the file before sourcing it is the only safe form.
137
+ for _cred_resolver in \
138
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
139
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
140
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
141
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
142
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
143
+ [ -f "$_cred_resolver" ] || continue
144
+ # shellcheck source=/dev/null
145
+ . "$_cred_resolver" 2>/dev/null || true
146
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
147
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
148
+ # candidate that does not resolve - the very case the loop exists to survive.
149
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
150
+ done
151
+ unset _cred_resolver
152
+ if [ -z "${CRED_STORE:-}" ]; then
153
+ printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2
154
+ exit 2
155
+ fi
134
156
 
135
157
  TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
136
158
  if [ -z "$TOKEN" ]; then
@@ -124,11 +124,33 @@ except Exception:
124
124
  fi
125
125
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Firebase_Access_Json"
126
126
 
127
- # Locate the credential helper via the resolver so Copilot-only installs work.
128
- # shellcheck disable=SC1090,SC1091
129
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
130
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
131
- || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
127
+ # Locate the resolver with an existence check, not a `.`-chain.
128
+ #
129
+ # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
130
+ # so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
131
+ # nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
132
+ # so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
133
+ # message. Reordering does not help: whichever candidate is absent aborts at that point.
134
+ # Checking for the file before sourcing it is the only safe form.
135
+ for _cred_resolver in \
136
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
137
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
138
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
139
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
140
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
141
+ [ -f "$_cred_resolver" ] || continue
142
+ # shellcheck source=/dev/null
143
+ . "$_cred_resolver" 2>/dev/null || true
144
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
145
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
146
+ # candidate that does not resolve - the very case the loop exists to survive.
147
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
148
+ done
149
+ unset _cred_resolver
150
+ if [ -z "${CRED_STORE:-}" ]; then
151
+ printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"firebase","expected_key":"'"$TOKEN_KEY"'"}' >&2
152
+ exit 2
153
+ fi
132
154
 
133
155
  SA_JSON_B64=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
134
156
  if [ -z "$SA_JSON_B64" ]; then
@@ -38,10 +38,24 @@
38
38
  set -uo pipefail
39
39
 
40
40
  PREFS="$HOME/.claude/multi-agent-preferences.json"
41
- # shellcheck disable=SC1090,SC1091
42
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
43
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
44
- || true
41
+ # Locate the resolver with an existence check, not a `.`-chain: sourcing a missing file
42
+ # aborts the shell under `set -e`, `||` included, so a chain skips both its later
43
+ # candidates and its trailing `|| true`. A missing store is tolerated here - CRED_STORE
44
+ # simply stays empty and the caller falls back to an env-supplied token.
45
+ for _cred_resolver in \
46
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
47
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
48
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
49
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
50
+ [ -f "$_cred_resolver" ] || continue
51
+ # shellcheck source=/dev/null
52
+ . "$_cred_resolver" 2>/dev/null || true
53
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
54
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
55
+ # candidate that does not resolve - the very case the loop exists to survive.
56
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
57
+ done
58
+ unset _cred_resolver
45
59
  CRED_STORE="${CRED_STORE:-}"
46
60
  FIGMA_API="https://api.figma.com/v1"
47
61
  HTTP_TIMEOUT=60
@@ -128,11 +128,33 @@ except Exception:
128
128
  fi
129
129
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Fortify_Access_Token"
130
130
 
131
- # Locate the credential helper via the resolver so Copilot-only installs work.
132
- # shellcheck disable=SC1090,SC1091
133
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
134
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
135
- || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"fortify","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
131
+ # Locate the resolver with an existence check, not a `.`-chain.
132
+ #
133
+ # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
134
+ # so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
135
+ # nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
136
+ # so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
137
+ # message. Reordering does not help: whichever candidate is absent aborts at that point.
138
+ # Checking for the file before sourcing it is the only safe form.
139
+ for _cred_resolver in \
140
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
141
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
142
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
143
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
144
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
145
+ [ -f "$_cred_resolver" ] || continue
146
+ # shellcheck source=/dev/null
147
+ . "$_cred_resolver" 2>/dev/null || true
148
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
149
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
150
+ # candidate that does not resolve - the very case the loop exists to survive.
151
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
152
+ done
153
+ unset _cred_resolver
154
+ if [ -z "${CRED_STORE:-}" ]; then
155
+ printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"fortify","expected_key":"'"$TOKEN_KEY"'"}' >&2
156
+ exit 2
157
+ fi
136
158
 
137
159
  TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
138
160
  if [ -z "$TOKEN" ]; then
@@ -118,11 +118,33 @@ except Exception:
118
118
  fi
119
119
  [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Graylog_Access_Token"
120
120
 
121
- # Locate the credential helper via the resolver so Copilot-only installs work.
122
- # shellcheck disable=SC1090,SC1091
123
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
124
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
125
- || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
121
+ # Locate the resolver with an existence check, not a `.`-chain.
122
+ #
123
+ # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
124
+ # so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
125
+ # nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
126
+ # so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
127
+ # message. Reordering does not help: whichever candidate is absent aborts at that point.
128
+ # Checking for the file before sourcing it is the only safe form.
129
+ for _cred_resolver in \
130
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
131
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
132
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
133
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
134
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
135
+ [ -f "$_cred_resolver" ] || continue
136
+ # shellcheck source=/dev/null
137
+ . "$_cred_resolver" 2>/dev/null || true
138
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
139
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
140
+ # candidate that does not resolve - the very case the loop exists to survive.
141
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
142
+ done
143
+ unset _cred_resolver
144
+ if [ -z "${CRED_STORE:-}" ]; then
145
+ printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2
146
+ exit 2
147
+ fi
126
148
 
127
149
  TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
128
150
  if [ -z "$TOKEN" ]; then