# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)

**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
**Date:** 2026-06-20
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only

## What the issues asked for vs. what was already landed

Both issues were largely satisfied by prior merged work; this fold-in closes the
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
rather than re-implementing finished functionality.

### #559 — remove `eval` from issue-create.sh (and siblings)

- `eval`-based command construction was already removed across the wrapper surface
  (landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
  their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
  bodies pass through verbatim.
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
  `$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
  quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
  and it failed silently when no login resolved). Converted to an argv array with an
  explicit, loud login-resolution error.
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
  Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
  pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
  executes (sentinel file never created) and (2) the `--description` `tea` receives is
  byte-for-byte the original body.

### #560 — auto-detect Gitea `--login` from repo origin host

- Centralized host→login resolution already exists in `detect-platform.sh`
  (`get_gitea_login_for_host` → `find_tea_login_for_host`, matching `urlparse(url).hostname`).
  Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
  **no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
  when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
  `get_gitea_login_for_host` returned non-zero with no message. Added
  `print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
  unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
  override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
  resolved login name) or the log-grep assertions in the existing harnesses. Callers with
  an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
  own "using API fallback" line, giving a clear "no login → fallback" trail.
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
  diagnostic fires and lists available logins for an unresolved host, (b) login is derived
  from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
  mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
  existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.

## Files changed (wrapper surface only)

- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
  `get_gitea_login_for_host` failure path.
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
  `$(get_gitea_repo_args)`).
- `test-issue-create-body-safety.sh` — **new** (#559 regression).
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).

## Verification

All wrapper harnesses pass locally:

- `test-issue-create-body-safety.sh` — PASS
- `test-gitea-login-resolution.sh` — PASS
- `test-pr-merge-gitea-empty-uid.sh` — PASS
- `test-pr-metadata-gitea.sh` — PASS
- `test-lane-brief-pr-linkage.sh` — PASS

## Open items flagged to mos-claude (orchestrator decisions)

1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
   #550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
   recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
   description (`Closes #559 Closes #560`) follows the repo's actual convention
   (`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
   modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
   issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
   `package.json`, so these shell harnesses run **locally/manually only** — they do not gate
   the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
   this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
   the fleet wants these gated.
