From 4ea6f437e926c2fa0c8fc7827f2e9f0035bc6903 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 4 May 2026 18:28:35 -0700 Subject: [PATCH 1/3] feat(external-templates): codex tab now includes the bridge-daemon inbound path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex tab in the External Connect modal had a "outbound-tools-only first cut" caveat — operators got the MCP wiring for codex calling platform tools, but there was no documented inbound path. Canvas messages couldn't wake an idle codex session. That gap is now filled by codex-channel-molecule (github.com/Molecule-AI/codex-channel-molecule), shipped today as the codex counterpart to hermes-channel-molecule. The daemon long-polls the platform inbox, runs `codex exec --resume ` per inbound message, captures the assistant reply, routes it back via send_message_to_user / delegate_task, and acks the inbox row. Per-thread session continuity persisted to disk so daemon restarts don't lose conversation context. This commit: - Updates externalCodexTemplate to include `pip install codex-channel-molecule` (step 1) and a foreground `nohup codex-channel-molecule` invocation (step 3) using the same env-var contract as the MCP server (WORKSPACE_ID + PLATFORM_URL + MOLECULE_WORKSPACE_TOKEN). - Adds a "Canvas messages don't wake codex" common-issues entry to the TAB_HELP codex section pointing at the bridge daemon log. - Updates the doc comment to record the upstream deprecation path: when openai/codex#17543 lands, the bridge becomes redundant and the wired MCP server delivers push natively. Verified: TestExternalTemplates_NoMoleculeOrgIDPlaceholder still passes (no MOLECULE_ORG_ID re-introduction); full handlers suite green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/ExternalConnectModal.tsx | 5 ++ .../internal/handlers/external_connection.go | 76 ++++++++++++------- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/canvas/src/components/ExternalConnectModal.tsx b/canvas/src/components/ExternalConnectModal.tsx index a9b1f360..58a23f0e 100644 --- a/canvas/src/components/ExternalConnectModal.tsx +++ b/canvas/src/components/ExternalConnectModal.tsx @@ -132,6 +132,11 @@ const TAB_HELP: Record< check: "TOML rejects duplicate `[mcp_servers.molecule]` tables. Open ~/.codex/config.toml and remove the old block before pasting the new one.", }, + { + symptom: "Canvas messages don't wake codex", + check: + "Step 3 (codex-channel-molecule bridge daemon) is required for inbound push. Check `pgrep -f codex-channel-molecule` and `tail ~/.codex-channel-molecule/daemon.log`.", + }, ], }, openclaw: { diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index b507e6b2..fd574ee0 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -289,35 +289,35 @@ hermes gateway --replace // externalCodexTemplate — for operators whose external agent is a // codex CLI (@openai/codex) session. Wires the molecule_runtime A2A // MCP server into codex's config.toml so the agent can call -// list_peers / delegate_task / send_message_to_user / commit_memory. +// list_peers / delegate_task / send_message_to_user / commit_memory, +// AND surfaces the codex-channel-molecule bridge daemon for inbound +// push parity. // -// Push parity caveat: codex's MCP client doesn't forward arbitrary -// notifications/* from configured MCP servers (verified by reading -// codex-rs/codex-mcp/src/connection_manager.rs in openai/codex). So -// this snippet gives outbound tools but NOT mid-turn push from -// inbound A2A. For full push parity on a codex external, the -// equivalent of hermes-channel-molecule would be needed — a bridge -// daemon that long-polls the platform inbox and calls codex's -// turn/steer RPC. Tracked separately; this snippet is the -// outbound-tool-only first cut. -const externalCodexTemplate = `# Codex MCP config — outbound tool path. For operators whose external -# agent is a codex CLI (@openai/codex) session. -# -# This wires the molecule platform's A2A MCP server into codex so -# the agent can call list_peers / delegate_task / send_message_to_user -# / commit_memory. Inbound A2A (canvas messages, peer-initiated tasks) -# does NOT push into the running codex turn yet — codex's MCP runtime -# doesn't route arbitrary notifications/* from configured MCP servers. -# For inbound delivery into a codex session, pair with the Python SDK -# tab for now. +// Push parity: +// - Outbound (codex calls platform tools) — works via the wired +// MCP server (step 2 below). +// - Inbound (canvas messages and peer-initiated tasks wake the +// codex agent) — works via codex-channel-molecule (step 3), +// which long-polls the platform inbox and runs `codex exec +// --resume ` per inbound message. Each turn is a fresh +// subprocess but per-thread session continuity is preserved on +// disk so conversation context survives. +// +// Long-term: when openai/codex#17543 lands (codex MCP runtime routes +// inbound notifications/* into the active session as Op::UserInput), +// the bridge daemon becomes redundant — the wired MCP server in +// step 2 will deliver push natively. Until then, run both. +const externalCodexTemplate = `# Codex external setup — outbound tools (MCP) + inbound push (bridge). +# For operators whose external agent is a codex CLI (@openai/codex) +# session. -# 1. Install codex CLI + the workspace runtime wheel: +# 1. Install codex CLI, the workspace runtime, and the bridge daemon: npm install -g @openai/codex@^0.57 -pip install molecule-ai-workspace-runtime +pip install molecule-ai-workspace-runtime codex-channel-molecule -# 2. Edit ~/.codex/config.toml and add the block below. {{PLATFORM_URL}} -# and {{WORKSPACE_ID}} are stamped server-side; paste your auth -# token for MOLECULE_WORKSPACE_TOKEN before saving. +# 2. Wire the molecule MCP server into codex's config.toml — this is +# the OUTBOUND path (codex calls list_peers / delegate_task / +# send_message_to_user / commit_memory). # # Don't append blindly — TOML rejects duplicate # [mcp_servers.molecule] tables, so re-running on an existing @@ -338,7 +338,31 @@ mkdir -p ~/.codex # PLATFORM_URL = "{{PLATFORM_URL}}" # MOLECULE_WORKSPACE_TOKEN = "" -# 3. Run codex — the molecule tools are now available to the agent: +# 3. Run the bridge daemon as a durable background process — this +# is the INBOUND path. Long-polls the platform inbox and runs +# "codex exec --resume " per inbound canvas/peer message, +# routes the assistant reply back via send_message_to_user / +# delegate_task. Per-thread session continuity persisted to +# ~/.codex-channel-molecule/sessions.json so conversation context +# survives daemon restarts. +# +# Same env-var contract as the MCP server above. +# +# Without this daemon, codex still works for outbound calls but +# canvas messages won't wake an idle session — codex's MCP runtime +# doesn't yet route notifications/* into the chat loop (tracked +# upstream at openai/codex#17543; when that lands, the bridge +# becomes redundant). + +WORKSPACE_ID="{{WORKSPACE_ID}}" \ +PLATFORM_URL="{{PLATFORM_URL}}" \ +MOLECULE_WORKSPACE_TOKEN="" \ +nohup codex-channel-molecule > ~/.codex-channel-molecule/daemon.log 2>&1 & +disown + +# 4. Run codex itself for interactive use — molecule tools are +# available to the agent, and the bridge wakes a non-interactive +# codex turn for any inbound canvas/peer message: codex ` From dfd0bc528cd07879246924d1aa2d2ca755e5c75b Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 4 May 2026 18:29:23 -0700 Subject: [PATCH 2/3] fix(external-templates): codex-channel-molecule via git+ URL (not on PyPI yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the pattern hermes-channel-molecule uses (line 256). Drops the broken `pip install codex-channel-molecule` which would 404. PyPI publish workflow is a separate piece of work — until then, git+https install is the path operators get. Co-Authored-By: Claude Opus 4.7 (1M context) --- workspace-server/internal/handlers/external_connection.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index fd574ee0..249e74a7 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -313,7 +313,8 @@ const externalCodexTemplate = `# Codex external setup — outbound tools (MCP) + # 1. Install codex CLI, the workspace runtime, and the bridge daemon: npm install -g @openai/codex@^0.57 -pip install molecule-ai-workspace-runtime codex-channel-molecule +pip install molecule-ai-workspace-runtime +pip install 'git+https://github.com/Molecule-AI/codex-channel-molecule.git' # 2. Wire the molecule MCP server into codex's config.toml — this is # the OUTBOUND path (codex calls list_peers / delegate_task / From 463316772bf8661b8b5cb0e8a84798329b3acfac Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 4 May 2026 18:29:38 -0700 Subject: [PATCH 3/3] fix(workflows): rewrite curl status-capture to prevent exit-code pollution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-05-04 redeploy-tenants-on-main run for sha 2b862f6 emitted "HTTP 000000" and failed the deploy. Root cause: when curl exits non- zero (connection reset → 56, --fail-with-body 4xx/5xx → 22), the `-w '%{http_code}'` already wrote a status to stdout; the inline `|| echo "000"` then fires AND appends another "000" to the captured substitution stdout. Result: HTTP_CODE="<000>" — fails string comparisons against "200" while looking superficially right. Same class of bug the synth-E2E §7c gate hit twice (PRs #2779/#2783 + #2797). Memory feedback_curl_status_capture_pollution.md. Mass fix in 8 workflows: route -w into a tempfile so curl's exit code can't pollute stdout. Wrap with set +e/-e so the non-zero curl exit doesn't trip the outer pipeline. redeploy-tenants-on-main.yml (production-critical, caught the bug) redeploy-tenants-on-staging.yml (sibling) sweep-stale-e2e-orgs.yml (cleanup loop) e2e-staging-sanity.yml (E2E safety-net teardown) e2e-staging-saas.yml e2e-staging-external.yml e2e-staging-canvas.yml canary-staging.yml Plus a new lint workflow `lint-curl-status-capture.yml` that runs on every PR/push touching `.github/workflows/**`. Multi-line aware: collapses bash `\` continuations, then matches the buggy $(curl ... -w '%{http_code}' ... || echo "000") subshell shape. Distinguishes from the SAFE $(cat tempfile || echo "000") shape (cat with missing file emits empty stdout, no pollution). Verified: - All 8 workflows pass the lint locally - A known-bad injection is caught - A known-safe cat-fallback passes through - yaml.safe_load clean on all changed files Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/canary-staging.yml | 10 +- .github/workflows/e2e-staging-canvas.yml | 10 +- .github/workflows/e2e-staging-external.yml | 10 +- .github/workflows/e2e-staging-saas.yml | 10 +- .github/workflows/e2e-staging-sanity.yml | 10 +- .../workflows/lint-curl-status-capture.yml | 94 +++++++++++++++++++ .../workflows/redeploy-tenants-on-main.yml | 18 +++- .../workflows/redeploy-tenants-on-staging.yml | 16 +++- .github/workflows/sweep-stale-e2e-orgs.yml | 9 +- 9 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/lint-curl-status-capture.yml diff --git a/.github/workflows/canary-staging.yml b/.github/workflows/canary-staging.yml index 5f1384dc..c2a4d7f4 100644 --- a/.github/workflows/canary-staging.yml +++ b/.github/workflows/canary-staging.yml @@ -295,12 +295,16 @@ jobs: # See molecule-controlplane#420. leaks=() for slug in $orgs; do - code=$(curl -sS -o /tmp/canary-cleanup.out -w "%{http_code}" \ + # Tempfile-routed -w + set +e/-e prevents curl-exit-code + # pollution of the captured status (lint-curl-status-capture.yml). + set +e + curl -sS -o /tmp/canary-cleanup.out -w "%{http_code}" \ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"confirm\":\"$slug\"}" \ - || echo "000") + -d "{\"confirm\":\"$slug\"}" >/tmp/canary-cleanup.code 2>/dev/null + set -e + code=$(cat /tmp/canary-cleanup.code 2>/dev/null || echo "000") if [ "$code" = "200" ] || [ "$code" = "204" ]; then echo "[teardown] deleted $slug (HTTP $code)" else diff --git a/.github/workflows/e2e-staging-canvas.yml b/.github/workflows/e2e-staging-canvas.yml index 6c59e72a..1c5b15ff 100644 --- a/.github/workflows/e2e-staging-canvas.yml +++ b/.github/workflows/e2e-staging-canvas.yml @@ -192,12 +192,16 @@ jobs: # cleanup miss shouldn't fail-flag the canvas test when the # actual smoke check passed; the sweeper is the safety net. # See molecule-controlplane#420. - code=$(curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \ + # Tempfile-routed -w + set +e/-e prevents curl-exit-code + # pollution of the captured status (lint-curl-status-capture.yml). + set +e + curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"confirm\":\"$slug\"}" \ - || echo "000") + -d "{\"confirm\":\"$slug\"}" >/tmp/canvas-cleanup.code 2>/dev/null + set -e + code=$(cat /tmp/canvas-cleanup.code 2>/dev/null || echo "000") if [ "$code" = "200" ] || [ "$code" = "204" ]; then echo "[teardown] deleted $slug (HTTP $code)" else diff --git a/.github/workflows/e2e-staging-external.yml b/.github/workflows/e2e-staging-external.yml index 12ac4577..923423a1 100644 --- a/.github/workflows/e2e-staging-external.yml +++ b/.github/workflows/e2e-staging-external.yml @@ -159,12 +159,16 @@ jobs: # leaked. Sweeper catches the rest within ~45 min. leaks=() for slug in $orgs; do - code=$(curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \ + # Tempfile-routed -w + set +e/-e prevents curl-exit-code + # pollution of the captured status (lint-curl-status-capture.yml). + set +e + curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"confirm\":\"$slug\"}" \ - || echo "000") + -d "{\"confirm\":\"$slug\"}" >/tmp/external-cleanup.code 2>/dev/null + set -e + code=$(cat /tmp/external-cleanup.code 2>/dev/null || echo "000") if [ "$code" = "200" ] || [ "$code" = "204" ]; then echo "[teardown] deleted $slug (HTTP $code)" else diff --git a/.github/workflows/e2e-staging-saas.yml b/.github/workflows/e2e-staging-saas.yml index 8cbe468b..aab10b96 100644 --- a/.github/workflows/e2e-staging-saas.yml +++ b/.github/workflows/e2e-staging-saas.yml @@ -224,12 +224,16 @@ jobs: leaks=() for slug in $orgs; do echo "Safety-net teardown: $slug" - code=$(curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \ + # Tempfile-routed -w + set +e/-e prevents curl-exit-code + # pollution of the captured status (lint-curl-status-capture.yml). + set +e + curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"confirm\":\"$slug\"}" \ - || echo "000") + -d "{\"confirm\":\"$slug\"}" >/tmp/saas-cleanup.code 2>/dev/null + set -e + code=$(cat /tmp/saas-cleanup.code 2>/dev/null || echo "000") if [ "$code" = "200" ] || [ "$code" = "204" ]; then echo "[teardown] deleted $slug (HTTP $code)" else diff --git a/.github/workflows/e2e-staging-sanity.yml b/.github/workflows/e2e-staging-sanity.yml index e98b38fe..a89821a7 100644 --- a/.github/workflows/e2e-staging-sanity.yml +++ b/.github/workflows/e2e-staging-sanity.yml @@ -148,12 +148,16 @@ jobs: # safety net within ~45 min. leaks=() for slug in $orgs; do - code=$(curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \ + # Tempfile-routed -w + set +e/-e prevents curl-exit-code + # pollution of the captured status (lint-curl-status-capture.yml). + set +e + curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"confirm\":\"$slug\"}" \ - || echo "000") + -d "{\"confirm\":\"$slug\"}" >/tmp/sanity-cleanup.code 2>/dev/null + set -e + code=$(cat /tmp/sanity-cleanup.code 2>/dev/null || echo "000") if [ "$code" = "200" ] || [ "$code" = "204" ]; then echo "[teardown] deleted $slug (HTTP $code)" else diff --git a/.github/workflows/lint-curl-status-capture.yml b/.github/workflows/lint-curl-status-capture.yml new file mode 100644 index 00000000..487b2eb4 --- /dev/null +++ b/.github/workflows/lint-curl-status-capture.yml @@ -0,0 +1,94 @@ +name: Lint curl status-code capture + +# Pins the workflow-bash anti-pattern that produced "HTTP 000000" on the +# 2026-05-04 redeploy-tenants-on-main run for sha 2b862f6: +# +# HTTP_CODE=$(curl ... -w '%{http_code}' ... || echo "000") +# +# When curl exits non-zero (connection reset → 56, --fail-with-body 4xx/5xx +# → 22), the `-w '%{http_code}'` already wrote a status to stdout — usually +# "000" for connection failures or the actual code for HTTP errors. The +# `|| echo "000"` then fires AND appends ANOTHER "000" to the captured +# stdout, producing values like "000000" or "409000" that fail string +# comparisons against "200" while looking superficially right. +# +# Same class of bug the synth-E2E §7c gate hit twice (PRs #2779/#2783 + +# #2797). Memory: feedback_curl_status_capture_pollution.md. +# +# Fix shape (route -w into a tempfile so curl's exit code can't pollute): +# +# set +e +# curl ... -w '%{http_code}' >code.txt 2>/dev/null +# set -e +# HTTP_CODE=$(cat code.txt 2>/dev/null) +# [ -z "$HTTP_CODE" ] && HTTP_CODE="000" + +on: + pull_request: + paths: ['.github/workflows/**'] + push: + branches: [main, staging] + paths: ['.github/workflows/**'] + merge_group: + types: [checks_requested] + +jobs: + scan: + name: Scan workflows for curl status-capture pollution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Find curl ... -w '%{http_code}' ... || echo "000" subshells + run: | + set -uo pipefail + # Multi-line aware: look for `$(curl ... -w '%{http_code}' ... || echo "000")` + # subshell where the entire command-substitution wraps a curl that + # ends with `|| echo "000"`. Must distinguish from the SAFE shape + # `$(cat tempfile 2>/dev/null || echo "000")` — `cat` with a missing + # tempfile produces empty stdout, no pollution. + python3 <<'PY' + import os, re, sys, glob + + BAD_FILES = [] + + # Match the buggy substitution across newlines: $(curl ... -w '%{http_code}' ... || echo "000") + # The `\\n` is the bash line-continuation that lets curl flags span lines. + # We collapse continuation lines first, then look for the single-line bad pattern. + PATTERN = re.compile( + r'\$\(\s*curl\b[^)]*-w\s*[\'"]%\{http_code\}[\'"][^)]*\|\|\s*echo\s+"000"\s*\)', + re.DOTALL, + ) + + # Self-skip: this lint workflow contains the literal anti-pattern in + # its own docstring — that's intentional, not a bug. + SELF = ".github/workflows/lint-curl-status-capture.yml" + + for f in sorted(glob.glob(".github/workflows/*.yml")): + if f == SELF: + continue + with open(f) as fh: + content = fh.read() + # Collapse bash line-continuations (\\\n + leading whitespace) + # into a single logical line so the regex can see the full + # curl invocation as one chunk. + flat = re.sub(r'\\\s*\n\s*', ' ', content) + for m in PATTERN.finditer(flat): + BAD_FILES.append((f, m.group(0)[:120])) + + if not BAD_FILES: + print("✓ No curl-status-capture pollution patterns detected") + sys.exit(0) + + print(f"::error::Found {len(BAD_FILES)} curl-status-capture pollution site(s):") + for f, snippet in BAD_FILES: + print(f"::error file={f}::Curl status-capture pollution: '|| echo \"000\"' inside a $(curl ... -w '%{{http_code}}' ...) subshell. On non-2xx or connection failure, curl's -w writes a status, then exits non-zero, then the || echo appends another '000' — producing 'HTTP 000000' or '409000' that fails comparisons silently. Fix: route -w into a tempfile so the exit code can't pollute stdout. See memory feedback_curl_status_capture_pollution.md.") + print(f" matched: {snippet}…") + print() + print("Fix template:") + print(' set +e') + print(' curl ... -w \'%{http_code}\' >code.txt 2>/dev/null') + print(' set -e') + print(' HTTP_CODE=$(cat code.txt 2>/dev/null)') + print(' [ -z "$HTTP_CODE" ] && HTTP_CODE="000"') + sys.exit(1) + PY diff --git a/.github/workflows/redeploy-tenants-on-main.yml b/.github/workflows/redeploy-tenants-on-main.yml index 85acda60..7d68a6d9 100644 --- a/.github/workflows/redeploy-tenants-on-main.yml +++ b/.github/workflows/redeploy-tenants-on-main.yml @@ -184,12 +184,26 @@ jobs: echo " body: $BODY" HTTP_RESPONSE=$(mktemp) - HTTP_CODE=$(curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \ + HTTP_CODE_FILE=$(mktemp) + # Route -w into its own tempfile so curl's exit code (e.g. 56 + # on connection-reset, 22 on --fail-with-body 4xx/5xx) can't + # pollute the captured stdout. The previous inline-substitution + # shape produced "000000" on connection reset (curl wrote + # "000" via -w, then the inline echo-fallback appended another + # "000") — caught on the 2026-05-04 redeploy of sha 2b862f6. + # set +e/-e keeps the non-zero curl exit from tripping the + # outer pipeline. See lint-curl-status-capture.yml for the + # CI gate that pins this fix shape. + set +e + curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \ -m 1200 \ -H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \ -H "Content-Type: application/json" \ -X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \ - -d "$BODY" || echo "000") + -d "$BODY" >"$HTTP_CODE_FILE" 2>/dev/null + set -e + HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000") + [ -z "$HTTP_CODE" ] && HTTP_CODE="000" echo "HTTP $HTTP_CODE" cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE" diff --git a/.github/workflows/redeploy-tenants-on-staging.yml b/.github/workflows/redeploy-tenants-on-staging.yml index 97392172..433df238 100644 --- a/.github/workflows/redeploy-tenants-on-staging.yml +++ b/.github/workflows/redeploy-tenants-on-staging.yml @@ -146,12 +146,24 @@ jobs: echo " body: $BODY" HTTP_RESPONSE=$(mktemp) - HTTP_CODE=$(curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \ + HTTP_CODE_FILE=$(mktemp) + # Route -w into its own tempfile so curl's exit code (e.g. 56 + # on connection-reset) can't pollute the captured stdout. The + # previous inline-substitution shape produced "000000" on + # connection reset — caught on main variant 2026-05-04 + # redeploying sha 2b862f6. Same fix shape as the synth-E2E + # §9c gate (PR #2797). See lint-curl-status-capture.yml for + # the CI gate that pins this fix shape. + set +e + curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \ -m 1200 \ -H "Authorization: Bearer $CP_STAGING_ADMIN_API_TOKEN" \ -H "Content-Type: application/json" \ -X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \ - -d "$BODY" || echo "000") + -d "$BODY" >"$HTTP_CODE_FILE" 2>/dev/null + set -e + HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000") + [ -z "$HTTP_CODE" ] && HTTP_CODE="000" echo "HTTP $HTTP_CODE" cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE" diff --git a/.github/workflows/sweep-stale-e2e-orgs.yml b/.github/workflows/sweep-stale-e2e-orgs.yml index d2fcb8be..1531f117 100644 --- a/.github/workflows/sweep-stale-e2e-orgs.yml +++ b/.github/workflows/sweep-stale-e2e-orgs.yml @@ -159,12 +159,17 @@ jobs: # The DELETE handler requires {"confirm": ""} matching # the URL slug — fat-finger guard. Idempotent: re-issuing # picks up via org_purges.last_step. - http_code=$(curl -sS -o /tmp/del_resp -w "%{http_code}" \ + # Tempfile-routed -w + set +e/-e prevents curl-exit-code + # pollution of the captured status (lint-curl-status-capture.yml). + set +e + curl -sS -o /tmp/del_resp -w "%{http_code}" \ --max-time 60 \ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ - -d "{\"confirm\":\"$slug\"}" || echo "000") + -d "{\"confirm\":\"$slug\"}" >/tmp/del_code 2>/dev/null + set -e + http_code=$(cat /tmp/del_code 2>/dev/null || echo "000") if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then deleted=$((deleted+1)) echo " deleted: $slug"