@mmerterden/multi-agent-pipeline 13.4.0 → 13.5.1

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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,73 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [13.5.1] - 2026-07-28
20
+
21
+ ### Fixed
22
+
23
+ - `ios-coding-standard`'s local lint runner defaulted its scratch root to
24
+ `~/.claude/local/...`. On a Copilot- or Codex-only machine that made the script create
25
+ a stray `.claude` directory belonging to a host that is not installed, just to hold
26
+ lint output. It now picks whichever host tree exists and falls back to `$TMPDIR`.
27
+ Found by the Codex sync verification, which flags any `$HOME/.claude/...` reference
28
+ that reaches the Codex tree.
29
+
30
+ ## [13.5.0] - 2026-07-28
31
+
32
+ ### Fixed
33
+
34
+ - **Every fetcher died on a Copilot-only or Codex-only install.** Eleven runtime scripts
35
+ loaded `credential-store-resolver.sh` as `. "$HOME/.claude/lib/..." || . <next> || {
36
+ error }`, all of them under `set -e`. Sourcing a file that does not exist aborts the
37
+ shell outright - `||` included - so on a host without `~/.claude/lib` the chain reached
38
+ neither its later candidates nor its error branch: bare exit 1, no message. Reordering
39
+ does not help, because whichever candidate is absent aborts at that point. All eleven
40
+ now check `[ -f ]` before sourcing and decide on `$CRED_STORE`, and the resolver ends
41
+ with `resolve_credential_store || :` so sourcing it can never trip a caller's `set -e`.
42
+ - The resolver only knew about `~/.claude` and `~/.copilot`. A Codex-only install could
43
+ not locate the credential helper at all, while the file sat in `~/.codex/lib`.
44
+ - **Copilot CLI registered no MCP server.** Codex had registration from the day its
45
+ installer was written; Copilot never did, so it carried all 257 pipeline skills and
46
+ could not call one of the 80 dev-toolkit tools those skills depend on. Both hosts now
47
+ go through the shared `install/_mcp-register.mjs`, which also treats Copilot's
48
+ `already exists` (exit 1, unlike Codex's replace-and-exit-0) as the desired end state
49
+ rather than reporting a working registration as a failed install. Uninstall deregisters
50
+ on both.
51
+ - **`figma-mcp-refresh.sh` called macOS `security` directly**, so silent Figma MCP token
52
+ renewal - the path that keeps every Figma consumer on Tier 1 - never worked on Linux or
53
+ Windows, and surfaced as an unexplained expired-token prompt. It now goes through
54
+ `credential-store.sh` with the `set <key> -` stdin form, which keeps the secret off
55
+ argv on every platform, the property the old `security -i` call was preserving.
56
+ - **`phase-tracker.sh` hashed with a bare `shasum`** - a perl script absent from slim
57
+ Linux images. Where it was missing, OTel trace and span ids came back empty: valid
58
+ JSON, useless telemetry, no error. Same for `post-pr-review.sh`, where an empty
59
+ fingerprint made every finding look new and re-posted the whole review each iteration.
60
+ - `audit-log.sh` fell back to printing `unhashed:<url>` when no hasher was available,
61
+ writing the plaintext remote - credentials included, if the URL carried any - into the
62
+ audit log that hashing exists to protect. It emits a marker instead.
63
+ - `smoke-write-state.sh` counted an honest exit-2 lock timeout as data loss, because it
64
+ read the final state without looking at exit codes. That made it fail once inside a
65
+ fully loaded suite run while passing 12/12 in isolation - a flake that read as a
66
+ data-loss bug and would eventually have been "fixed" by lowering the writer count,
67
+ removing the regression detection the test exists for. It now asserts the real
68
+ invariant: no writer reports success while losing its update.
69
+ - `smoke-token-preflight.sh` asserted the literal `security add-generic-password`, which
70
+ pinned the implementation to macOS. It now asserts the portable route and fails if a
71
+ direct platform keychain call comes back.
72
+ - `multi-agent-refs/rules.md` pointed at `~/.claude/scripts/vercel-deploy.sh`; the
73
+ wrapper installs to `lib/` with the other shell libraries.
74
+
75
+ ### Added
76
+
77
+ - **`smoke-shell-portability.sh`** (8 assertions) over every shell file that ships to a
78
+ user tree: single-platform hashing without a fallback, unguarded BSD/GNU-divergent
79
+ constructs, hardcoded absolute home paths, direct platform keychain calls, and the
80
+ `.`-chain-under-`set -e` trap above. It found the `post-pr-review.sh` hasher and the
81
+ `figma-mcp-refresh.sh` keychain call on its first run.
82
+ - `smoke-install-layout.sh --update-fixture` regenerates the layout fixture from the tree
83
+ the gate itself installs, probe plugin included. The documented hand-rolled recipe
84
+ omitted it, so following it baked a three-file shortfall into the fixture.
85
+
19
86
  ## [13.4.0] - 2026-07-28
20
87
 
21
88
  ### Added
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Register the companion MCP server with a host CLI.
3
+ *
4
+ * WHY THIS IS SHARED
5
+ *
6
+ * Codex got MCP registration when its installer was written; Copilot never did. The
7
+ * result was a host that carried all 257 pipeline skills and could not call a single
8
+ * one of the 80 dev-toolkit tools those skills depend on - `design-check`, every
9
+ * `ios_*` / `android_*` simulator call, the archive audits. Nothing failed at install
10
+ * time, and the skills only fail when a run actually reaches for a tool.
11
+ *
12
+ * Both CLIs take the same shape (`<cli> mcp add <name> -- <command> [args...]`) and
13
+ * both write their own user-level config, so the difference was purely that one
14
+ * installer called it and the other did not. One implementation, two callers.
15
+ *
16
+ * @module install/_mcp-register
17
+ */
18
+
19
+ import { execFileSync } from "child_process";
20
+ import { existsSync } from "fs";
21
+ import { join } from "path";
22
+ import { homedir } from "os";
23
+
24
+ import { isDryRun } from "./_common.mjs";
25
+
26
+ export const MCP_SERVER_NAME = "dev-toolkit";
27
+ export const MCP_SERVER_PACKAGE = "@mmerterden/dev-toolkit-mcp";
28
+
29
+ /**
30
+ * Where each host's CLI might live.
31
+ *
32
+ * A bare name is resolved through PATH by execFileSync. The explicit paths matter for
33
+ * Copilot: `copilot` is frequently a *shell alias* for `gh copilot`, and an alias is
34
+ * invisible to execFileSync - so a PATH-only lookup reports "not on PATH" on a machine
35
+ * where the user runs `copilot` every day. `gh` installs the real binary under
36
+ * ~/.local/share/gh/copilot/.
37
+ */
38
+ const CLI_CANDIDATES = Object.freeze({
39
+ codex: ["codex"],
40
+ copilot: ["copilot", join(homedir(), ".local", "share", "gh", "copilot", "copilot")],
41
+ });
42
+
43
+ /**
44
+ * First candidate that exists as a file, or the first bare name to let PATH resolution
45
+ * try. Returning a bare name rather than null keeps the "not on PATH" diagnosis in one
46
+ * place: the execFileSync catch.
47
+ *
48
+ * @param {"codex"|"copilot"} host
49
+ * @returns {string}
50
+ */
51
+ function resolveCli(host) {
52
+ const candidates = CLI_CANDIDATES[host] || [host];
53
+ for (const c of candidates) {
54
+ if (c.includes("/") && existsSync(c)) return c;
55
+ }
56
+ return candidates[0];
57
+ }
58
+
59
+ /**
60
+ * Register the MCP server. Idempotent from the caller's point of view, though the two
61
+ * CLIs get there differently:
62
+ *
63
+ * - `codex mcp add` replaces an existing entry and exits 0.
64
+ * - `copilot mcp add` refuses, printing `Server "<name>" already exists` and exiting 1.
65
+ *
66
+ * So a non-zero exit is not automatically a failure. An already-registered server is
67
+ * the desired end state, and reporting it as "skipped MCP registration (Command
68
+ * failed)" would send the user to fix something that is already correct - which is
69
+ * exactly what the first version of this did.
70
+ *
71
+ * The existing entry is left alone rather than removed and re-added: a user may have
72
+ * added env vars or switched the command to a local checkout, and an installer has no
73
+ * business discarding that.
74
+ *
75
+ * Never fatal: a missing CLI or a failed write must not fail the install, because the
76
+ * rest of the tree is still correct and useful. It prints the manual command instead.
77
+ *
78
+ * @param {"codex"|"copilot"} host
79
+ * @param {string} label - host label for log lines
80
+ * @returns {{registered: boolean, alreadyPresent?: boolean, reason?: string}}
81
+ */
82
+ export function registerMcpServer(host, label) {
83
+ const cli = resolveCli(host);
84
+ const manual = `${host} mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_SERVER_PACKAGE}`;
85
+
86
+ if (isDryRun()) {
87
+ console.log(` [dry-run] would run: ${manual}`);
88
+ return { registered: false, reason: "dry-run" };
89
+ }
90
+
91
+ // Opt-out for hermetic installs. Registration shells out to the host CLI, so its
92
+ // result depends on whether that binary is present - which makes the install layout
93
+ // non-deterministic across machines and CI. The layout smoke sets this so its
94
+ // fingerprint stays stable.
95
+ if (process.env.MULTI_AGENT_SKIP_MCP_REGISTER === "1") {
96
+ console.log(` -> ${label}: skipped MCP registration (MULTI_AGENT_SKIP_MCP_REGISTER=1)`);
97
+ return { registered: false, reason: "opted out" };
98
+ }
99
+
100
+ try {
101
+ // Bounded: an installer must never hang on a child process. This is a local config
102
+ // write and returns in milliseconds.
103
+ execFileSync(cli, ["mcp", "add", MCP_SERVER_NAME, "--", "npx", "-y", MCP_SERVER_PACKAGE], {
104
+ stdio: "pipe",
105
+ timeout: 20_000,
106
+ });
107
+ console.log(` -> ${label}: registered the ${MCP_SERVER_NAME} MCP server`);
108
+ return { registered: true };
109
+ } catch (e) {
110
+ // The CLI writes its diagnosis to stderr, which execFileSync parks on the error
111
+ // rather than in `message`. Read both, or "already exists" is invisible.
112
+ const out = `${e?.stderr?.toString() ?? ""}\n${e?.stdout?.toString() ?? ""}\n${e?.message ?? ""}`;
113
+ if (/already exists/i.test(out)) {
114
+ console.log(` -> ${label}: ${MCP_SERVER_NAME} MCP server already registered, left as-is`);
115
+ return { registered: true, alreadyPresent: true };
116
+ }
117
+ const why =
118
+ e?.code === "ENOENT"
119
+ ? `${host} not on PATH`
120
+ : (out.split("\n").find((l) => l.trim()) || "unknown error").trim();
121
+ console.log(` -> ${label}: skipped MCP registration (${why})`);
122
+ console.log(` run manually: ${manual}`);
123
+ return { registered: false, reason: why };
124
+ }
125
+ }
package/install/codex.mjs CHANGED
@@ -19,7 +19,6 @@
19
19
 
20
20
  import { existsSync, readFileSync, readdirSync, rmSync } from "fs";
21
21
  import { join } from "path";
22
- import { execFileSync } from "child_process";
23
22
 
24
23
  import {
25
24
  copyDir,
@@ -32,6 +31,7 @@ import {
32
31
  writeFile,
33
32
  } from "./_common.mjs";
34
33
  import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
34
+ import { registerMcpServer } from "./_mcp-register.mjs";
35
35
  import { installCodexAgents } from "./_codex-agents.mjs";
36
36
  import { existingSkillNames, installAuthoredPluginSkills } from "./_plugin-skills.mjs";
37
37
  import { generateCodexInstructions } from "./_codex-instructions.mjs";
@@ -43,9 +43,8 @@ export const AGENTS_MD_START_MARKER = "# Multi-Agent Development Pipeline";
43
43
  /** Explicit end marker written after the pipeline section. */
44
44
  export const AGENTS_MD_END_MARKER = "<!-- multi-agent-pipeline:codex-instructions:end -->";
45
45
 
46
- /** MCP server the pipeline's `/multi-agent:test` + design-check flows depend on. */
47
- export const MCP_SERVER_NAME = "dev-toolkit";
48
- const MCP_SERVER_PACKAGE = "@mmerterden/dev-toolkit-mcp";
46
+ /** Re-exported for the uninstaller and the Codex install smoke. */
47
+ export { MCP_SERVER_NAME } from "./_mcp-register.mjs";
49
48
 
50
49
  /**
51
50
  * `$HOME/.claude/...` path rewrites applied to every file installed into the
@@ -200,7 +199,7 @@ export function installCodex(ctx) {
200
199
  installTree("rules", pipelineSrc, CODEX_RULES, useSymlinks);
201
200
  writeAgentsMd(join(CODEX_DIR, "AGENTS.md"));
202
201
  ensureSharedPreferences(home, pipelineSrc);
203
- registerMcpServer();
202
+ registerMcpServer("codex", "Codex CLI");
204
203
 
205
204
  console.log("");
206
205
  }
@@ -482,37 +481,4 @@ function ensureSharedPreferences(home, pipelineSrc) {
482
481
  * it. Hand-merging TOML here would be the riskiest write in this installer for
483
482
  * no benefit.
484
483
  */
485
- function registerMcpServer() {
486
- if (isDryRun()) {
487
- console.log(
488
- ` [dry-run] would run: codex mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_SERVER_PACKAGE}`,
489
- );
490
- return;
491
- }
492
- // Opt-out for hermetic installs. Registration shells out to `codex`, so its
493
- // result depends on whether the binary is present - which makes the install
494
- // layout non-deterministic across machines and CI. The layout smoke sets this
495
- // so its fingerprint stays stable.
496
- if (process.env.MULTI_AGENT_SKIP_MCP_REGISTER === "1") {
497
- console.log(" -> skipped MCP registration (MULTI_AGENT_SKIP_MCP_REGISTER=1)");
498
- return;
499
- }
500
- try {
501
- // Bounded: an installer must never hang on a child process. `codex mcp add`
502
- // is a local config write and returns in milliseconds.
503
- execFileSync("codex", ["mcp", "add", MCP_SERVER_NAME, "--", "npx", "-y", MCP_SERVER_PACKAGE], {
504
- stdio: "pipe",
505
- timeout: 20_000,
506
- });
507
- console.log(` -> registered the ${MCP_SERVER_NAME} MCP server`);
508
- } catch (e) {
509
- const why =
510
- e?.code === "ENOENT" ? "codex not on PATH" : (e?.message || "unknown error").split("\n")[0];
511
- console.log(` -> skipped MCP registration (${why})`);
512
- console.log(
513
- ` run manually: codex mcp add ${MCP_SERVER_NAME} -- npx -y ${MCP_SERVER_PACKAGE}`,
514
- );
515
- }
516
- }
517
-
518
484
  export { generateCodexInstructions };
@@ -26,6 +26,7 @@ import { DEV_ONLY_SCRIPTS, countDevOnlyFiles } from "./_dev-only-files.mjs";
26
26
  import { generateCopilotInstructions } from "./_copilot-instructions.mjs";
27
27
  import { legacyTrailingContent, mergeManagedBlock } from "./_managed-block.mjs";
28
28
  import { existingSkillNames, installAuthoredPluginSkills } from "./_plugin-skills.mjs";
29
+ import { registerMcpServer } from "./_mcp-register.mjs";
29
30
 
30
31
  /**
31
32
  * @param {{
@@ -56,6 +57,13 @@ export function installCopilot(ctx) {
56
57
  installSchemas(pipelineSrc, COPILOT_SCHEMAS, useSymlinks);
57
58
  installLib(pipelineSrc, COPILOT_LIB, useSymlinks);
58
59
  installSkills({ home, pipelineSrc, dest: COPILOT_SKILLS, indexOnly, useSymlinks, platformFlag });
60
+
61
+ // Copilot never registered the companion MCP server, while Codex did from the day its
62
+ // installer was written. So Copilot carried every pipeline skill and could not call a
63
+ // single one of the tools those skills depend on: design-check, the ios_* / android_*
64
+ // simulator calls, the archive audits. Nothing failed at install time - the skills
65
+ // only fail once a run reaches for a tool that is not there.
66
+ registerMcpServer("copilot", "Copilot CLI");
59
67
  }
60
68
 
61
69
  /** Start of the pipeline-managed span in copilot-instructions.md. */
package/install/index.mjs CHANGED
@@ -182,11 +182,16 @@ function printSummary(opts) {
182
182
  console.log("");
183
183
  }
184
184
 
185
- console.log(" For UI testing, also install the mobile MCP server:");
185
+ console.log(" For UI testing, the mobile MCP server:");
186
186
  if (forCodex) {
187
- console.log(" Codex: registered automatically (codex mcp add dev-toolkit)");
187
+ console.log(" Codex CLI: registered automatically (codex mcp add dev-toolkit)");
188
+ }
189
+ if (forCopilot) {
190
+ console.log(" Copilot CLI: registered automatically (copilot mcp add dev-toolkit)");
191
+ }
192
+ if (forClaude) {
193
+ console.log(" Claude Code: add it yourself - claude mcp add dev-toolkit -- npx -y @mmerterden/dev-toolkit-mcp");
188
194
  }
189
- console.log(" Otherwise add to your MCP config: npx @mmerterden/dev-toolkit-mcp");
190
195
  console.log("");
191
196
  console.log(" Uninstall everything: npx @mmerterden/multi-agent-pipeline uninstall");
192
197
  console.log(" (Personal access tokens in keychain are preserved.)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "13.4.0",
3
+ "version": "13.5.1",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -69,10 +69,21 @@ except Exception:
69
69
  # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
70
70
  if [ -z "${CRED_STORE:-}" ]; then
71
71
  # shellcheck disable=SC1090,SC1091
72
- . "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
73
- || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
74
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
75
- || true
72
+ # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
73
+ # `||` included, so a `.`-chain reaches neither its later candidates nor its error
74
+ # branch. The loop also covers all three hosts - the chain it replaced knew only
75
+ # .claude and .copilot, so a Codex-only install could not resolve at all.
76
+ for _cred_resolver in \
77
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
78
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
79
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
80
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
81
+ [ -f "$_cred_resolver" ] || continue
82
+ # shellcheck source=/dev/null
83
+ . "$_cred_resolver" 2>/dev/null || true
84
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
85
+ done
86
+ unset _cred_resolver
76
87
  fi
77
88
  services=""
78
89
  if [ -n "${CRED_STORE:-}" ]; then
@@ -111,6 +111,14 @@ probe_http() {
111
111
  }
112
112
 
113
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
+
114
122
  probe_basic() {
115
123
  local url="$1" u="$2" p="$3" out
116
124
  out=$(printf 'url = "%s"\nuser = "%s:%s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
@@ -125,6 +133,10 @@ probe_basic() {
125
133
  classify_status() {
126
134
  case "$1" in
127
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" ;;
128
140
  401|403) echo "auth-rejected" ;;
129
141
  404) echo "reachable" ;; # endpoint answered; the probe path may just not exist
130
142
  000|"") echo "unreachable" ;;
@@ -185,11 +197,22 @@ probe_one() {
185
197
  fortify)
186
198
  host=$(pref global.hosts.fortify)
187
199
  [ -n "$host" ] || { echo "no-host-configured"; return; }
188
- status=$(probe_http "https://${host}/api/v1/projects?limit=1" "Authorization" "Bearer $tok") ;;
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 ;;
189
209
  graylog)
190
210
  host=$(pref global.hosts.graylog)
191
211
  [ -n "$host" ] || { echo "no-host-configured"; return; }
192
- status=$(probe_http "https://${host}/api/system" "Authorization" "Bearer $tok") ;;
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") ;;
193
216
  jenkins)
194
217
  host=$(pref global.hosts.jenkins)
195
218
  [ -n "$host" ] || { echo "no-host-configured"; return; }
@@ -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
@@ -20,7 +20,6 @@ set -euo pipefail
20
20
 
21
21
  PREFS_FILE="${PREFS_FILE:-$HOME/.claude/multi-agent-preferences.json}"
22
22
  TOKEN_ENDPOINT="https://api.figma.com/v1/oauth/token"
23
- ACCOUNT="${USER:-$(whoami)}"
24
23
 
25
24
  say() { printf '%s\n' "$*"; }
26
25
 
@@ -30,7 +29,36 @@ KEY_NAME=$(jq -r '.global.keychainMapping.figma_mcp // empty' "$PREFS_FILE")
30
29
  [ -n "$KEY_NAME" ] || { say "figma-mcp-refresh: keychainMapping.figma_mcp not set"; exit 2; }
31
30
 
32
31
  REFRESH_KEY="${KEY_NAME}_Refresh"
33
- REFRESH_TOKEN=$(security find-generic-password -s "$REFRESH_KEY" -w 2>/dev/null || true)
32
+
33
+ # Locate the resolver with an existence check, not a `.`-chain.
34
+ #
35
+ # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
36
+ # so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
37
+ # nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
38
+ # so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
39
+ # message. Reordering does not help: whichever candidate is absent aborts at that point.
40
+ # Checking for the file before sourcing it is the only safe form.
41
+ for _cred_resolver in \
42
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
43
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
44
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
45
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
46
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
47
+ [ -f "$_cred_resolver" ] || continue
48
+ # shellcheck source=/dev/null
49
+ . "$_cred_resolver" 2>/dev/null || true
50
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
51
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
52
+ # candidate that does not resolve - the very case the loop exists to survive.
53
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
54
+ done
55
+ unset _cred_resolver
56
+ if [ -z "${CRED_STORE:-}" ]; then
57
+ say "figma-mcp-refresh: credential helper not found - run the pipeline installer"
58
+ exit 2
59
+ fi
60
+
61
+ REFRESH_TOKEN=$("$CRED_STORE" get "$REFRESH_KEY" 2>/dev/null || true)
34
62
  [ -n "$REFRESH_TOKEN" ] || { say "figma-mcp-refresh: no refresh token at '$REFRESH_KEY'"; exit 2; }
35
63
 
36
64
  SCRIPT_PATH=$(jq -r '.global.tokenScripts.figma_mcp // empty' "$PREFS_FILE")
@@ -69,17 +97,13 @@ if [ -z "$NEW_ACCESS" ]; then
69
97
  exit 1
70
98
  fi
71
99
 
72
- # Keychain write: the add-generic-password command is fed to `security -i`
73
- # over stdin instead of the direct `security add-generic-password -w <secret>`
74
- # argv form, so the secret never shows up in process listings. printf is a
75
- # bash builtin, so no argv exposure there either. Attributes match the
76
- # previous direct call (-T "" -U). Figma tokens are URL-safe, so embedding in
77
- # the quoted command string is safe.
100
+ # Keychain write through credential-store.sh with the `-` form, which reads the secret
101
+ # from stdin: the value never lands on any argv (visible to ps), which is the property
102
+ # the previous `security -i` call was carefully preserving. The store does the same thing
103
+ # per platform, so that property now holds on Linux and Windows as well.
78
104
  save_keychain_secret() {
79
105
  local service="$1" secret="$2"
80
- security delete-generic-password -s "$service" -a "$ACCOUNT" >/dev/null 2>&1 || true
81
- printf 'add-generic-password -U -s "%s" -a "%s" -T "" -w "%s"\n' \
82
- "$service" "$ACCOUNT" "$secret" | security -i >/dev/null
106
+ printf '%s' "$secret" | "$CRED_STORE" set "$service" - >/dev/null
83
107
  }
84
108
 
85
109
  # Save new access token first, then the rotated refresh token - order matters so a
@@ -42,13 +42,24 @@ set -euo pipefail
42
42
  # --- Globals ----------------------------------------------------------------
43
43
 
44
44
  PREFS="$HOME/.claude/multi-agent-preferences.json"
45
- # Locate the credential helper via the resolver so Copilot-only installs work.
46
- # The helper is optional here: the FIGMA_PAT env fallback still applies when
47
- # no helper is installed, so a resolver miss must not abort the script.
48
- # shellcheck disable=SC1090,SC1091
49
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
50
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
51
- || true
45
+ # Locate the resolver with an existence check, not a `.`-chain: sourcing a missing file
46
+ # aborts the shell under `set -e`, `||` included, so a chain skips both its later
47
+ # candidates and its trailing `|| true`. A missing store is tolerated here - CRED_STORE
48
+ # simply stays empty and the caller falls back to an env-supplied token.
49
+ for _cred_resolver in \
50
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
51
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
52
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
53
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
54
+ [ -f "$_cred_resolver" ] || continue
55
+ # shellcheck source=/dev/null
56
+ . "$_cred_resolver" 2>/dev/null || true
57
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
58
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
59
+ # candidate that does not resolve - the very case the loop exists to survive.
60
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
61
+ done
62
+ unset _cred_resolver
52
63
  CRED_STORE="${CRED_STORE:-}"
53
64
  FIGMA_API="https://api.figma.com/v1"
54
65
  PARALLEL_DL=4
@@ -113,10 +113,25 @@ fetch_jira() {
113
113
  # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
114
114
  if [ -z "${CRED_STORE:-}" ]; then
115
115
  # shellcheck disable=SC1090,SC1091
116
- . "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
117
- || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
118
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
119
- || { echo "ERR: credential helper not found" >&2; return 1; }
116
+ # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
117
+ # `||` included, so a `.`-chain reaches neither its later candidates nor its error
118
+ # branch. The loop also covers all three hosts - the chain it replaced knew only
119
+ # .claude and .copilot, so a Codex-only install could not resolve at all.
120
+ for _cred_resolver in \
121
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
122
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
123
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
124
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
125
+ [ -f "$_cred_resolver" ] || continue
126
+ # shellcheck source=/dev/null
127
+ . "$_cred_resolver" 2>/dev/null || true
128
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
129
+ done
130
+ unset _cred_resolver
131
+ if [ -z "${CRED_STORE:-}" ]; then
132
+ echo "ERR: credential helper not found" >&2
133
+ return 1
134
+ fi
120
135
  fi
121
136
  local token
122
137
  token=$("$CRED_STORE" get "$JIRA_TOKEN_KEY" 2>/dev/null || true)
@@ -104,7 +104,14 @@ echo "post-pr-review: decision=$DECISION (blocking=$ACCEPTED_BLOCKING, important
104
104
  # when the suggested fix wording changes slightly between iterations.
105
105
  finding_fingerprint() {
106
106
  local path="$1" line="$2" issue="$3"
107
- printf '%s|%s|%s' "$path" "$line" "$issue" | shasum -a 256 | cut -c1-16
107
+ # sha256sum (coreutils) or shasum (perl), whichever this platform has. A bare
108
+ # `shasum` produced an empty fingerprint on slim Linux images, which made every
109
+ # finding look new and re-posted the whole review on each iteration.
110
+ if command -v sha256sum >/dev/null 2>&1; then
111
+ printf '%s|%s|%s' "$path" "$line" "$issue" | sha256sum | cut -c1-16
112
+ else
113
+ printf '%s|%s|%s' "$path" "$line" "$issue" | shasum -a 256 | cut -c1-16
114
+ fi
108
115
  }
109
116
 
110
117
  render_inline_body() {
@@ -307,9 +314,25 @@ post_bitbucket_server() {
307
314
  # Credential Manager / Linux libsecret) - sourced lazily so this script keeps
308
315
  # working when run from either ~/.claude/lib or ~/.copilot/lib.
309
316
  # shellcheck disable=SC1090
310
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
311
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
312
- || { echo "post-pr-review: credential helper not found" >&2; return 4; }
317
+ # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
318
+ # `||` included, so a `.`-chain reaches neither its later candidates nor its error
319
+ # branch. The loop also covers all three hosts - the chain it replaced knew only
320
+ # .claude and .copilot, so a Codex-only install could not resolve at all.
321
+ for _cred_resolver in \
322
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
323
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
324
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
325
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
326
+ [ -f "$_cred_resolver" ] || continue
327
+ # shellcheck source=/dev/null
328
+ . "$_cred_resolver" 2>/dev/null || true
329
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
330
+ done
331
+ unset _cred_resolver
332
+ if [ -z "${CRED_STORE:-}" ]; then
333
+ echo "post-pr-review: credential helper not found" >&2
334
+ return 4
335
+ fi
313
336
  resolve_credential_store || return 4
314
337
 
315
338
  auth_user=$("$CRED_STORE" get "$user_key" 2>/dev/null || true)
@@ -142,10 +142,25 @@ print(json.dumps(out))
142
142
  # A pre-set CRED_STORE (tests, custom installs) is honored as-is.
143
143
  if [ -z "${CRED_STORE:-}" ]; then
144
144
  # shellcheck disable=SC1090,SC1091
145
- . "$(cd "$(dirname "$0")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
146
- || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
147
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
148
- || { echo "ERR: credential helper not found" >&2; exit 3; }
145
+ # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
146
+ # `||` included, so a `.`-chain reaches neither its later candidates nor its error
147
+ # branch. The loop also covers all three hosts - the chain it replaced knew only
148
+ # .claude and .copilot, so a Codex-only install could not resolve at all.
149
+ for _cred_resolver in \
150
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
151
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
152
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
153
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
154
+ [ -f "$_cred_resolver" ] || continue
155
+ # shellcheck source=/dev/null
156
+ . "$_cred_resolver" 2>/dev/null || true
157
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
158
+ done
159
+ unset _cred_resolver
160
+ if [ -z "${CRED_STORE:-}" ]; then
161
+ echo "ERR: credential helper not found" >&2
162
+ exit 3
163
+ fi
149
164
  fi
150
165
  BB_USER="${BB_USER:-${BB_USER_KEY:+$("$CRED_STORE" get "$BB_USER_KEY" 2>/dev/null || true)}}"
151
166
  BB_TOKEN=$("$CRED_STORE" get "$BB_TOKEN_KEY" 2>/dev/null || true)
@@ -115,10 +115,21 @@ cmd_deploy() {
115
115
  # The resolver ships alongside this wrapper in lib/; fall back to the
116
116
  # per-CLI install locations when running from a different cwd layout.
117
117
  # shellcheck source=/dev/null
118
- . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/credential-store-resolver.sh" 2>/dev/null \
119
- || . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
120
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
121
- || true
118
+ # Existence check before sourcing: `. <missing>` aborts the shell under `set -e`,
119
+ # `||` included, so a `.`-chain reaches neither its later candidates nor its error
120
+ # branch. The loop also covers all three hosts - the chain it replaced knew only
121
+ # .claude and .copilot, so a Codex-only install could not resolve at all.
122
+ for _cred_resolver in \
123
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
124
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
125
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
126
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
127
+ [ -f "$_cred_resolver" ] || continue
128
+ # shellcheck source=/dev/null
129
+ . "$_cred_resolver" 2>/dev/null || true
130
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
131
+ done
132
+ unset _cred_resolver
122
133
  echo "ERROR: VERCEL_TOKEN not set. Resolve via env or keychain ('${CRED_STORE:-credential-store.sh} get <vercel-key>')." >&2
123
134
  echo "Hint: vercel CLI accepts the token via env var; do NOT pass --token= on argv (leaks on retry)." >&2
124
135
  exit 1
@@ -78,7 +78,7 @@ Rules that make this actionable rather than decorative:
78
78
  Failures remain non-fatal at Phase 1 by default - the agent still runs analysis. What
79
79
  changed is that the user learns about it while the choice is still theirs to make.
80
80
 
81
- **Graylog specifics.** `fetch-graylog.sh` degrades to empty on any network/VPN failure: it emits a normalized empty object and exits `0`, so an unreachable host reads as `{status:"skipped", reason:"vpn-unreachable"}` and never blocks. Only a genuine auth rejection on a reachable host exits `3` (marked `failed`, still non-fatal here). `state.graylogContext` is prepended to the analysis prompt inside the **Referenced External Sources** section as diagnostic context that is **advisory only** - the agent may use it to orient on a reported error, but code remains ground truth, and there is no Phase 4 review gate for logs.
81
+ **Graylog specifics.** `fetch-graylog.sh` degrades to empty on any network/VPN failure: it emits a normalized empty object and exits `0`, so an unreachable host reads as `{status:"skipped", reason:"vpn-unreachable"}` and never blocks. **Exiting `0` is not permission to stay quiet.** The payload carries `degraded: true` and a `degradeReason`; when it does, log one line naming the host and the reason, and carry it into the Phase 7 report. Logs are advisory, so this never asks a question and never blocks - but "I searched the logs and found nothing" and "I never reached the log server" are different statements, and only one of them is true. Only a genuine auth rejection on a reachable host exits `3` (marked `failed`, still non-fatal here). `state.graylogContext` is prepended to the analysis prompt inside the **Referenced External Sources** section as diagnostic context that is **advisory only** - the agent may use it to orient on a reported error, but code remains ground truth, and there is no Phase 4 review gate for logs.
82
82
 
83
83
  ## Prompt injection shape
84
84
 
@@ -48,12 +48,18 @@ json_escape() {
48
48
  hash_url() {
49
49
  local url="$1"
50
50
  [ -z "$url" ] && { echo ""; return; }
51
- if command -v shasum >/dev/null 2>&1; then
52
- printf '%s' "$url" | shasum -a 256 | awk '{print $1}'
53
- elif command -v sha256sum >/dev/null 2>&1; then
51
+ # sha256sum first: it is the coreutils tool, present on Linux and in slim containers
52
+ # where `shasum` (a perl script) is not.
53
+ if command -v sha256sum >/dev/null 2>&1; then
54
54
  printf '%s' "$url" | sha256sum | awk '{print $1}'
55
+ elif command -v shasum >/dev/null 2>&1; then
56
+ printf '%s' "$url" | shasum -a 256 | awk '{print $1}'
55
57
  else
56
- echo "unhashed:$url" # last-resort fallback (should never happen on macOS/Linux)
58
+ # The previous fallback printed `unhashed:<url>`, writing the plaintext remote into
59
+ # the audit log - the exact thing this hashing exists to prevent, and it would have
60
+ # carried any credentials embedded in the remote URL with it. Losing one field beats
61
+ # leaking one.
62
+ echo "unhashable-no-sha256-tool"
57
63
  fi
58
64
  }
59
65
 
@@ -53,11 +53,33 @@ case "$CHOICE" in
53
53
  ;;
54
54
  esac
55
55
 
56
- # Locate the credential helper via the resolver so Copilot-only installs work.
57
- # shellcheck disable=SC1090,SC1091
58
- . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
59
- || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
60
- || { echo "Hata: credential-store bulunamadi. Kurulum: npx @mmerterden/multi-agent-pipeline install"; exit 1; }
56
+ # Locate the resolver with an existence check, not a `.`-chain.
57
+ #
58
+ # Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
59
+ # so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
60
+ # nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
61
+ # so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
62
+ # message. Reordering does not help: whichever candidate is absent aborts at that point.
63
+ # Checking for the file before sourcing it is the only safe form.
64
+ for _cred_resolver in \
65
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
66
+ "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
67
+ "$HOME/.claude/lib/credential-store-resolver.sh" \
68
+ "$HOME/.copilot/lib/credential-store-resolver.sh" \
69
+ "$HOME/.codex/lib/credential-store-resolver.sh"; do
70
+ [ -f "$_cred_resolver" ] || continue
71
+ # shellcheck source=/dev/null
72
+ . "$_cred_resolver" 2>/dev/null || true
73
+ # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
74
+ # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
75
+ # candidate that does not resolve - the very case the loop exists to survive.
76
+ if [ -n "${CRED_STORE:-}" ]; then break; fi
77
+ done
78
+ unset _cred_resolver
79
+ if [ -z "${CRED_STORE:-}" ]; then
80
+ echo "Hata: credential-store bulunamadi. Kurulum: npx @mmerterden/multi-agent-pipeline install"
81
+ exit 1
82
+ fi
61
83
  CRED="$CRED_STORE"
62
84
 
63
85
  "$CRED" delete "$SERVICE_NAME" >/dev/null 2>&1 || true
@@ -193,6 +193,25 @@ MA_DECODE_JS
193
193
  printf '%s' "$out"
194
194
  }
195
195
 
196
+ # Portable SHA-256 over stdin, printing the same "<hex> -" shape as either tool.
197
+ #
198
+ # GNU coreutils ships sha256sum; macOS ships shasum (a perl script). Neither is
199
+ # universal - Alpine and slim container images typically have sha256sum but no perl, and
200
+ # some hardened macOS setups have neither on a restricted PATH. Trying both keeps the
201
+ # tracker working on all three supported platforms, and the no-hasher branch emits an
202
+ # obviously-synthetic value rather than an empty string, so a broken environment shows
203
+ # up as a visibly fake id instead of a silently blank field.
204
+ sha256_hex() {
205
+ if command -v sha256sum >/dev/null 2>&1; then
206
+ sha256sum
207
+ elif command -v shasum >/dev/null 2>&1; then
208
+ shasum -a 256
209
+ else
210
+ cat >/dev/null
211
+ printf '%s -\n' "0000000000000000000000000000000000000000000000000000000000nohash"
212
+ fi
213
+ }
214
+
196
215
  # OTel-compatible span emission - v6.1.0+, opt-in via $MULTI_AGENT_OTEL_SPANS.
197
216
  # Appends one JSON line to otel-spans.jsonl. Never fatal on failure.
198
217
  #
@@ -218,9 +237,14 @@ emit_otel_span() {
218
237
  local spans_file="${TRACKER_DIR}/otel-spans.jsonl"
219
238
 
220
239
  # Deterministic 128-bit trace_id from task_id, 64-bit span_id from event+phase+timestamp.
240
+ #
241
+ # sha256_hex, not `shasum` directly: this runs on every phase on every host, and
242
+ # `shasum` is a perl script that is absent from minimal Linux images and many
243
+ # containers. Without a fallback the ids came back empty and the span was written with
244
+ # blank trace/span - valid JSON, useless telemetry, and no error to notice.
221
245
  local trace_id span_id
222
- trace_id=$(printf "%s" "$task_id" | shasum -a 256 | awk '{print substr($1,1,32)}')
223
- span_id=$(printf "%s:%s:%s" "$event" "$phase_id" "$now_ns" | shasum -a 256 | awk '{print substr($1,1,16)}')
246
+ trace_id=$(printf "%s" "$task_id" | sha256_hex | awk '{print substr($1,1,32)}')
247
+ span_id=$(printf "%s:%s:%s" "$event" "$phase_id" "$now_ns" | sha256_hex | awk '{print substr($1,1,16)}')
224
248
 
225
249
  jq -nc \
226
250
  --arg trace "$trace_id" --arg span "$span_id" \
@@ -38,6 +38,7 @@
38
38
  import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
39
39
  import { execFileSync } from "child_process";
40
40
  import { join } from "path";
41
+ import { homedir } from "os";
41
42
  import { pathToFileURL } from "url";
42
43
  import { createInterface } from "readline";
43
44
 
@@ -157,21 +158,38 @@ function removeGeneratedCodexAgents(agentsDir) {
157
158
  * uninstall failure. Never edits `config.toml` directly - Codex keeps
158
159
  * marketplace and plugin state in the same file.
159
160
  */
160
- function deregisterCodexMcpServer() {
161
+ function deregisterMcpServer(host, cliPath) {
162
+ const cli = cliPath || host;
161
163
  if (dryRun) {
162
- report("would run", "codex mcp remove dev-toolkit");
164
+ report("would run", `${host} mcp remove dev-toolkit`);
163
165
  return;
164
166
  }
165
167
  try {
166
- execFileSync("codex", ["mcp", "remove", "dev-toolkit"], { stdio: "pipe", timeout: 20_000 });
167
- console.log(" removed: dev-toolkit MCP registration");
168
+ execFileSync(cli, ["mcp", "remove", "dev-toolkit"], { stdio: "pipe", timeout: 20_000 });
169
+ console.log(` removed: dev-toolkit MCP registration (${host})`);
168
170
  } catch {
169
171
  console.log(
170
- " note: could not run `codex mcp remove dev-toolkit` (codex not on PATH, or not registered)",
172
+ ` note: could not run \`${host} mcp remove dev-toolkit\` (${host} not on PATH, or not registered)`,
171
173
  );
172
174
  }
173
175
  }
174
176
 
177
+ function deregisterCodexMcpServer() {
178
+ deregisterMcpServer("codex");
179
+ }
180
+
181
+ /**
182
+ * Copilot registers the same server, so leaving it behind would keep a stale entry
183
+ * pointing at a package the user just removed.
184
+ *
185
+ * `copilot` is often a shell alias for `gh copilot`, invisible to execFileSync, so the
186
+ * real binary path is tried when the bare name is not resolvable.
187
+ */
188
+ function deregisterCopilotMcpServer() {
189
+ const ghCopilot = join(homedir(), ".local", "share", "gh", "copilot", "copilot");
190
+ deregisterMcpServer("copilot", existsSync(ghCopilot) ? ghCopilot : undefined);
191
+ }
192
+
175
193
  /**
176
194
  * Remove every directory under `parent` whose name matches a predicate.
177
195
  * @param {string} parent
@@ -475,6 +493,7 @@ export async function main() {
475
493
  if (forCopilot && HOME) {
476
494
  console.log("");
477
495
  console.log(" [Copilot CLI] Removing...");
496
+ deregisterCopilotMcpServer();
478
497
  const COP = join(HOME, ".copilot");
479
498
  rmIfExists(join(COP, "scripts"));
480
499
  // Pipeline-managed trees the installer lays down alongside scripts/.
@@ -16,7 +16,19 @@
16
16
  set -euo pipefail
17
17
 
18
18
  SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
- WORK_ROOT="${IOS_STANDARD_WORK_ROOT:-$HOME/.claude/local/ios-coding-standard}"
19
+ # Scratch root. `~/.claude` is one host's tree: defaulting there made a Copilot- or
20
+ # Codex-only machine create a stray .claude directory just to hold lint output. Prefer
21
+ # whichever host tree already exists, and fall back to a neutral location.
22
+ if [ -n "${IOS_STANDARD_WORK_ROOT:-}" ]; then
23
+ WORK_ROOT="$IOS_STANDARD_WORK_ROOT"
24
+ else
25
+ WORK_ROOT=""
26
+ for _host_dir in "$HOME/.claude" "$HOME/.copilot" "$HOME/.codex"; do
27
+ [ -d "$_host_dir" ] && { WORK_ROOT="$_host_dir/local/ios-coding-standard"; break; }
28
+ done
29
+ unset _host_dir
30
+ [ -n "$WORK_ROOT" ] || WORK_ROOT="${TMPDIR:-/tmp}/ios-coding-standard"
31
+ fi
20
32
  DRAFT_CONFIG="$SKILL_DIR/swiftlint.draft.yml"
21
33
 
22
34
  MODULE_PATH="${1:-}"