fix(auth): mosaic-managed auth lives under the data root, never ~/.pi (#48)

Owner direction: the stack must never impact default harness usage.
Correction to M19 as shipped (nothing had been created in ~/.pi — the
move breaks nothing).

- Mosaic-managed accounts: <dataRoot>/auth/<account>.json, perms 0600
  enforced (loose perms flagged in listings, refused by --auth — mirrors
  gitea-api.sh credential hygiene).
- ~/.pi is read-only to the stack, permanently; the only interaction
  remains the existing read-only container mount of the default
  credential. Recorded as a ROADMAP standing decision.
- auth.sh is now config-driven (data root from config.json, fail closed,
  consistent with every other tool); status reports both sources labeled.
- agent.sh --auth resolution moved after load_config (needs the data
  root); missing/symlinked/non-0600 accounts refuse.
- test-auth.sh: 15 no-Docker cases (accounts-create-nothing, loose-perms
  refusal, invalid-config refusal added). Test-authoring correction
  recorded in BUILD-LOG (fixture-state mismatch caught before running).

Suites 24/15/90/14/17 + verify green.
This commit is contained in:
2026-09-03 22:53:33 -05:00
parent 073bbfdb6a
commit 975084abe2
9 changed files with 203 additions and 99 deletions
+72 -42
View File
@@ -1,36 +1,42 @@
#!/usr/bin/env bash
# auth.sh — pi credential checkpoint (M19): per-provider auth report and
# named-account listing. Never prints credential material: provider names,
# credential types, permission bits, and env var NAMES only.
# auth.sh — pi credential checkpoint (M19, corrected per owner in #48):
# per-provider auth report and mosaic-managed account listing. Never prints
# credential material: provider names, credential types, permission bits,
# and env var NAMES only.
#
# Ownership rule (#48): ~/.pi is READ-ONLY to the stack — the only
# interaction is the existing read-only container mount of the default
# credential. Mosaic-managed accounts live under the data root:
# <dataRoot>/auth/<account>.json
#
# Usage:
# scripts/auth.sh status per-provider report (auth.json + env-side names)
# scripts/auth.sh accounts list named account files (auth.<account>.json)
#
# The credential file is the compose read-only mount source (PI_AUTH_FILE,
# default ~/.pi/agent/auth.json). Resolution follows pi: auth.json entries
# take priority over environment variables.
# scripts/auth.sh status report default harness credential + accounts
# scripts/auth.sh accounts list mosaic-managed accounts (data root)
#
# Exit codes: 0 report produced · 2 credential file unparseable ·
# 3 file/dir missing for a read · 4 file/environment problem.
# 3 default credential file missing for a read · 4 file/environment problem.
set -uo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=common.sh
source scripts/common.sh
load_config # fail closed: data root comes from the system config
AUTH_FILE="${PI_AUTH_FILE:-$HOME/.pi/agent/auth.json}"
AUTH_DIR="$(dirname "$AUTH_FILE")"
HARNESS_FILE="${PI_AUTH_FILE:-$HOME/.pi/agent/auth.json}"
ACCOUNTS_DIR="$MOSAIC_DEV_DIR/auth"
OP="${1:-}"
case "$OP" in
status)
if [ ! -e "$AUTH_FILE" ]; then
echo "auth: credential file not found: $AUTH_FILE" >&2
exit 3
fi
if [ ! -f "$AUTH_FILE" ] || [ -L "$AUTH_FILE" ]; then
echo "auth: credential file must be a regular, non-symbolic-link file: $AUTH_FILE" >&2
exit 4
fi
if ! node -e '
# report_file FILE — providers + types for a pi auth.json; never values.
report_file() {
local file="$1"
if [ ! -e "$file" ]; then
echo " (not present)"
return 3
fi
if [ ! -f "$file" ] || [ -L "$file" ]; then
echo "auth: credential file must be a regular, non-symbolic-link file: $file" >&2
return 4
fi
if ! node -e '
const fs = require("fs");
const file = process.argv[1];
let doc;
@@ -40,45 +46,69 @@ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
console.error("auth: credential file must be a JSON object keyed by provider"); process.exit(2);
}
const st = fs.statSync(file);
console.log("auth file: " + file + " (perms " + String(st.mode & 0o777).toString(8).padStart(3, "0") + ")");
console.log(" perms " + String(st.mode & 0o777).toString(8).padStart(3, "0"));
const keys = Object.keys(doc).sort();
if (keys.length === 0) console.log("providers: (none in file)");
if (keys.length === 0) console.log(" providers: (none in file)");
for (const k of keys) {
const e = doc[k];
let type = "unknown";
if (typeof e === "object" && e !== null && typeof e.type === "string" && /^[a-z0-9_-]+$/i.test(e.type)) type = e.type;
console.log(" " + k + " type=" + type + " source=auth.json");
console.log(" " + k + " type=" + type + " source=auth.json");
}
console.log("resolution: auth.json entries take priority over environment (pi order: --api-key > auth.json > env > models.json)");
' "$AUTH_FILE"; then
exit 2
fi
' "$file"; then
return 2
fi
return 0
}
case "$OP" in
status)
echo "default harness credential (read-only to the stack; never written): $HARNESS_FILE"
RC=0
report_file "$HARNESS_FILE" || RC=$?
echo "resolution: auth.json entries take priority over environment (pi order: --api-key > auth.json > env > models.json)"
ENV_NAMES="$(env | grep -oE '^[A-Z0-9_]+_API_KEY' | sort -u | paste -sd, -)"
if [ -n "$ENV_NAMES" ]; then
echo "env-side credential-like names set (informational; file entries take priority): $ENV_NAMES"
else
echo "env-side credential-like names set: (none)"
fi
;;
accounts)
if [ ! -d "$AUTH_DIR" ]; then
echo "auth: directory not found: $AUTH_DIR" >&2
exit 3
fi
echo "mosaic-managed accounts ($ACCOUNTS_DIR):"
shopt -s nullglob
FILES=("$AUTH_DIR"/auth.*.json)
FILES=("$ACCOUNTS_DIR"/*.json)
shopt -u nullglob
if [ "${#FILES[@]}" -eq 0 ]; then
echo "accounts: (none) — named accounts are auth.<account>.json beside the credential file"
echo " (none)"
else
for f in "${FILES[@]}"; do
name="$(basename "$f" .json)"
perms="$(stat -c %a "$f" 2>/dev/null || echo '?')"
marker=""
[ "$f" = "$HARNESS_FILE" ] && marker=" <- active (PI_AUTH_FILE)"
warn=""
[ "$perms" != "600" ] && warn=" [not 0600 — agent --auth will refuse]"
echo " $name (perms $perms)$marker$warn"
done
fi
exit "$RC"
;;
accounts)
shopt -s nullglob
FILES=("$ACCOUNTS_DIR"/*.json)
shopt -u nullglob
if [ "${#FILES[@]}" -eq 0 ]; then
echo "accounts: (none) — mosaic-managed accounts are $ACCOUNTS_DIR/<account>.json (perms 0600)"
exit 0
fi
echo "named accounts in $AUTH_DIR:"
echo "mosaic-managed accounts in $ACCOUNTS_DIR:"
for f in "${FILES[@]}"; do
name="$(basename "$f" .json)"
name="${name#auth.}"
perms="$(stat -c %a "$f" 2>/dev/null || echo '?')"
marker=""
[ "$f" = "$AUTH_FILE" ] && marker=" <- active (PI_AUTH_FILE)"
echo " $name$marker"
[ "$f" = "$HARNESS_FILE" ] && marker=" <- active (PI_AUTH_FILE)"
warn=""
[ "$perms" != "600" ] && warn=" [not 0600 — agent --auth will refuse]"
echo " $name (perms $perms)$marker$warn"
done
;;
*)