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
+28
View File
@@ -553,3 +553,31 @@ Auth is checkpointable without exposing credentials, and multi-account has
a per-launch path. Suites 24/90/14/17/13 + verify green. The agreed a per-launch path. Suites 24/90/14/17/13 + verify green. The agreed
ROADMAP sequence M16M19 is complete; M20 (packages/* restructure + ROADMAP sequence M16M19 is complete; M20 (packages/* restructure +
unified CLI) is owner-gated. unified CLI) is owner-gated.
## Phase 24 follow-up: auth ownership corrected — data root, never ~/.pi (#48)
- Owner correction after M19 review: mosaic-managed named accounts must
not live inside ~/.pi — the stack must never impact default harness
usage. Recorded as a ROADMAP standing decision: the stack never writes
to default harness config locations; ~/.pi is read-only to the stack.
- Correction noted honestly: M19 as shipped placed accounts beside
~/.pi/agent/auth.json. Nothing had been created there (accounts reported
none), so the move breaks nothing.
- auth.sh is now config-driven (data root from config.json, fail closed —
consistent with every other tool); status reports both sources labeled:
default harness credential (read-only to the stack) + mosaic-managed
accounts.
- Accounts live at <dataRoot>/auth/<account>.json, perms 0600 enforced:
loose perms flagged in listings and refused by agent.sh --auth
(mirrors scripts/gitea-api.sh credential hygiene).
- Test-authoring correction: the first suite rewrite asserted "(none)" in
cases whose fixtures had already created accounts; caught on review
before any run, assertions rewritten to match fixture state.
- auth.sh suite: 13 → 15 cases (accounts-create-nothing, loose-perms
refusal, invalid-config refusal added).
## Result
Suites 24/15/90/14/17 + verify green. Default harness usage is untouched
by design; all mosaic-managed credentials live inside the governed data
root.
+2
View File
@@ -210,6 +210,8 @@ Pi's documented container authentication (see the package's
passes them through. Pi's documented precedence applies. passes them through. Pi's documented precedence applies.
Credentials are never committed, never copied into the image, and never printed. Credentials are never committed, never copied into the image, and never printed.
Mosaic-managed named accounts (`agent.sh --auth`) live under the data root
(`auth/<account>.json`, 0600) — the stack never writes into `~/.pi`.
`.env.example` contains non-secret settings only. `.env.example` contains non-secret settings only.
## Boundaries honored ## Boundaries honored
+1
View File
@@ -14,3 +14,4 @@ are never rewritten or removed; corrections are new entries.
| 2026-09-03 | assistant (conductor) | M18 seat-role progressive capability restriction (#45): roles/<role>.json contracts (strict schema, name-filename binding, network declared), mosaic-task.mjs resolve-role, agent.sh ceiling intersection with fail-closed refusals, roles/researcher.json shipped, 14 suite cases (task 74 → 88) | scripts/mosaic-task.mjs, scripts/agent.sh, scripts/test-task.sh, roles/researcher.json, docs; suites 24/88/14/17 + verify green | | 2026-09-03 | assistant (conductor) | M18 seat-role progressive capability restriction (#45): roles/<role>.json contracts (strict schema, name-filename binding, network declared), mosaic-task.mjs resolve-role, agent.sh ceiling intersection with fail-closed refusals, roles/researcher.json shipped, 14 suite cases (task 74 → 88) | scripts/mosaic-task.mjs, scripts/agent.sh, scripts/test-task.sh, roles/researcher.json, docs; suites 24/88/14/17 + verify green |
| 2026-09-03 | owner (decision + live verification) + assistant (conductor) | M18 live verification + follow-up (#46): owner confirmed narrowing/refusal/tool-free live; seatless launch under AGENTS_DIR override discovered and made fail-closed (exit 4); task suite 88 → 90 | scripts/agent.sh, scripts/test-task.sh, docs/TOOLS.md; suites 24/90/14/17 + verify green | | 2026-09-03 | owner (decision + live verification) + assistant (conductor) | M18 live verification + follow-up (#46): owner confirmed narrowing/refusal/tool-free live; seatless launch under AGENTS_DIR override discovered and made fail-closed (exit 4); task suite 88 → 90 | scripts/agent.sh, scripts/test-task.sh, docs/TOOLS.md; suites 24/90/14/17 + verify green |
| 2026-09-03 | assistant (conductor) | M19 harness auth tooling (#47): pi auth investigation (native provider stacking, no native multi-account), scripts/auth.sh status/accounts (never prints credential material), agent.sh --auth per-launch injection via PI_AUTH_FILE, test-auth.sh suite (13 cases incl. secret-never-printed assertions) | scripts/auth.sh, scripts/agent.sh, scripts/test-auth.sh, docs/TOOLS.md, AGENTS.md; suites 24/90/14/17/13 + verify green | | 2026-09-03 | assistant (conductor) | M19 harness auth tooling (#47): pi auth investigation (native provider stacking, no native multi-account), scripts/auth.sh status/accounts (never prints credential material), agent.sh --auth per-launch injection via PI_AUTH_FILE, test-auth.sh suite (13 cases incl. secret-never-printed assertions) | scripts/auth.sh, scripts/agent.sh, scripts/test-auth.sh, docs/TOOLS.md, AGENTS.md; suites 24/90/14/17/13 + verify green |
| 2026-09-03 | owner (direction) + assistant (conductor) | M19 correction (#48): mosaic-managed auth moved from ~/.pi to the data root (auth/<account>.json, 0600 enforced); ~/.pi read-only to the stack as a ROADMAP standing decision; auth.sh config-driven; test-auth 15 cases | scripts/auth.sh, scripts/agent.sh, scripts/test-auth.sh, docs/TOOLS.md, docs/plans/ROADMAP.md, README.md; suites 24/15/90/14/17 + verify green |
+12 -5
View File
@@ -65,14 +65,21 @@ provider; resolution order `--api-key` → `auth.json` → env → models.json).
No credential material is ever printed — provider names, credential types, No credential material is ever printed — provider names, credential types,
and env var NAMES only. and env var NAMES only.
Ownership rule (#48): `~/.pi` is read-only to the stack, permanently. The
only interaction is the existing read-only container mount of the default
credential (`PI_AUTH_FILE`, default `~/.pi/agent/auth.json`). Mosaic-managed
accounts live under the data root: `<dataRoot>/auth/<account>.json`, perms
0600 (mirroring `scripts/gitea-api.sh` hygiene — loose perms are flagged in
listings and refused by `--auth`).
| Command | Purpose | Notes | | Command | Purpose | Notes |
|---|---|---| |---|---|---|
| `scripts/auth.sh status` | Per-provider credential report | Types (`oauth`/`api_key`), perms, env-side names (informational); exit 3 when the file is missing | | `scripts/auth.sh status` | Report both credential sources | Default harness credential (read-only) + mosaic-managed accounts; never prints material |
| `scripts/auth.sh accounts` | List named account files | `auth.<account>.json` beside the credential file; marks the active one | | `scripts/auth.sh accounts` | List mosaic-managed accounts | Under the data root; marks the active one; flags non-0600 |
The file is the compose read-only mount source (`PI_AUTH_FILE`, default `agent.sh --auth <account>` injects `<dataRoot>/auth/<account>.json` as the
`~/.pi/agent/auth.json`); named accounts ride the same mount per launch via launch's `PI_AUTH_FILE`; missing/symlinked/non-0600 accounts refuse.
`agent.sh --auth`. Headless task runs keep the default credential. Headless task runs keep the default credential.
## Release ## Release
+1
View File
@@ -53,3 +53,4 @@ git history + Gitea issues.
- 2026-09-03 — M18 seat-role progressive capability restriction (#45; roles resolve to contracts, ceiling ∩ seat grant, fail-closed refusals, roles/researcher.json); task suite 74 → 88 - 2026-09-03 — M18 seat-role progressive capability restriction (#45; roles resolve to contracts, ceiling ∩ seat grant, fail-closed refusals, roles/researcher.json); task suite 74 → 88
- 2026-09-03 — M18 follow-up: fail-closed seat resolution under MOSAIC_AGENTS_DIR override (#46, owner decision after live verification); task suite 88 → 90; next action M19 - 2026-09-03 — M18 follow-up: fail-closed seat resolution under MOSAIC_AGENTS_DIR override (#46, owner decision after live verification); task suite 88 → 90; next action M19
- 2026-09-03 — M19 harness auth tooling (#47; auth.sh status/accounts, agent.sh --auth per-launch injection via PI_AUTH_FILE, test-auth suite 13 cases with secret-never-printed assertions); agreed sequence M16M19 complete, M20 owner-gated - 2026-09-03 — M19 harness auth tooling (#47; auth.sh status/accounts, agent.sh --auth per-launch injection via PI_AUTH_FILE, test-auth suite 13 cases with secret-never-printed assertions); agreed sequence M16M19 complete, M20 owner-gated
- 2026-09-03 — M19 correction: auth ownership moved to the data root (#48, owner direction — the stack never writes to default harness config locations; ROADMAP standing decision); auth.sh config-driven, accounts at <dataRoot>/auth, 0600 enforced; test-auth 13 → 15
+4
View File
@@ -17,6 +17,10 @@ and BUILD-LOG phase.
referenced from AGENTS.md. referenced from AGENTS.md.
- **Capability restriction becomes progressive and role-based** (M18), - **Capability restriction becomes progressive and role-based** (M18),
porting prior Mosaic Stack concepts where they fit the file-based runtime. porting prior Mosaic Stack concepts where they fit the file-based runtime.
- **The stack never writes to default harness config locations** (owner,
2026-09-03, #48): `~/.pi` — and any future harness home — is read-only to
Mosaic tooling; the only interaction is the existing read-only credential
mount. Mosaic-managed auth lives under the data root (`<dataRoot>/auth/`).
## M16 — Release self-determination ## M16 — Release self-determination
+17 -14
View File
@@ -41,25 +41,28 @@ done
[ -n "$NAME" ] || { echo "agent: usage: scripts/agent.sh <name> [--mission f] [--workspace ws] [--session s] [--tools list] [--auth account]" >&2; exit 4; } [ -n "$NAME" ] || { echo "agent: usage: scripts/agent.sh <name> [--mission f] [--workspace ws] [--session s] [--tools list] [--auth account]" >&2; exit 4; }
case "$NAME" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid agent name" >&2; exit 4;; esac case "$NAME" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid agent name" >&2; exit 4;; esac
# Named auth account (M19): per-launch credential injection. Resolves
# auth.<account>.json beside the active credential file and exports
# PI_AUTH_FILE (the compose read-only mount source). A missing or invalid
# account refuses - silently falling back to the default credential would
# put one account's work on another's identity.
if [ -n "$AUTH_ACCOUNT" ]; then
case "$AUTH_ACCOUNT" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid auth account name" >&2; exit 4;; esac
AUTH_DIR="$(dirname "${PI_AUTH_FILE:-$HOME/.pi/agent/auth.json}")"
AUTH_RESOLVED="$AUTH_DIR/auth.$AUTH_ACCOUNT.json"
[ -r "$AUTH_RESOLVED" ] && [ ! -L "$AUTH_RESOLVED" ] || { echo "agent: no credential file for auth account '$AUTH_ACCOUNT': $AUTH_RESOLVED" >&2; exit 4; }
export PI_AUTH_FILE="$AUTH_RESOLVED"
echo "agent: auth: account '$AUTH_ACCOUNT' -> $AUTH_RESOLVED" >&2
fi
load_config load_config
load_release load_release
bootstrap_runtime_dir bootstrap_runtime_dir
ensure_release_aligned ensure_release_aligned
# Named auth account (M19, corrected per owner in #48): per-launch
# credential injection from MOSAIC-MANAGED storage under the data root —
# never from inside ~/.pi, which is read-only to the stack. Resolves
# <dataRoot>/auth/<account>.json and exports PI_AUTH_FILE (the compose
# read-only mount source). Missing, symlinked, or non-0600 files refuse:
# silently falling back to another credential would put one account's work
# on another's identity.
if [ -n "$AUTH_ACCOUNT" ]; then
case "$AUTH_ACCOUNT" in *[!A-Za-z0-9._-]*|'') echo "agent: invalid auth account name" >&2; exit 4;; esac
AUTH_RESOLVED="$MOSAIC_DEV_DIR/auth/$AUTH_ACCOUNT.json"
[ -r "$AUTH_RESOLVED" ] && [ ! -L "$AUTH_RESOLVED" ] || { echo "agent: no mosaic-managed credential file for auth account '$AUTH_ACCOUNT': $AUTH_RESOLVED (see scripts/auth.sh accounts)" >&2; exit 4; }
AUTH_PERMS="$(stat -c %a "$AUTH_RESOLVED")"
[ "$AUTH_PERMS" = "600" ] || { echo "agent: auth account '$AUTH_ACCOUNT' file must be 0600 (got $AUTH_PERMS): $AUTH_RESOLVED" >&2; exit 4; }
export PI_AUTH_FILE="$AUTH_RESOLVED"
echo "agent: auth: account '$AUTH_ACCOUNT' -> $AUTH_RESOLVED" >&2
fi
# Onboarding gate (M16): a TUI agent cannot launch without a user profile. # Onboarding gate (M16): a TUI agent cannot launch without a user profile.
# The onboarding wizard runs automatically here - the TTY is already yours. # The onboarding wizard runs automatically here - the TTY is already yours.
if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then if [ ! -f "$MOSAIC_DEV_DIR/user/USER.md" ]; then
+68 -38
View File
@@ -1,34 +1,40 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# auth.sh — pi credential checkpoint (M19): per-provider auth report and # auth.sh — pi credential checkpoint (M19, corrected per owner in #48):
# named-account listing. Never prints credential material: provider names, # per-provider auth report and mosaic-managed account listing. Never prints
# credential types, permission bits, and env var NAMES only. # 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: # Usage:
# scripts/auth.sh status per-provider report (auth.json + env-side names) # scripts/auth.sh status report default harness credential + accounts
# scripts/auth.sh accounts list named account files (auth.<account>.json) # scripts/auth.sh accounts list mosaic-managed accounts (data root)
#
# 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.
# #
# Exit codes: 0 report produced · 2 credential file unparseable · # 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 set -uo pipefail
cd "$(dirname "$0")/.." 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}" HARNESS_FILE="${PI_AUTH_FILE:-$HOME/.pi/agent/auth.json}"
AUTH_DIR="$(dirname "$AUTH_FILE")" ACCOUNTS_DIR="$MOSAIC_DEV_DIR/auth"
OP="${1:-}" OP="${1:-}"
case "$OP" in # report_file FILE — providers + types for a pi auth.json; never values.
status) report_file() {
if [ ! -e "$AUTH_FILE" ]; then local file="$1"
echo "auth: credential file not found: $AUTH_FILE" >&2 if [ ! -e "$file" ]; then
exit 3 echo " (not present)"
return 3
fi fi
if [ ! -f "$AUTH_FILE" ] || [ -L "$AUTH_FILE" ]; then if [ ! -f "$file" ] || [ -L "$file" ]; then
echo "auth: credential file must be a regular, non-symbolic-link file: $AUTH_FILE" >&2 echo "auth: credential file must be a regular, non-symbolic-link file: $file" >&2
exit 4 return 4
fi fi
if ! node -e ' if ! node -e '
const fs = require("fs"); const fs = require("fs");
@@ -40,7 +46,7 @@ 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); console.error("auth: credential file must be a JSON object keyed by provider"); process.exit(2);
} }
const st = fs.statSync(file); 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(); 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) { for (const k of keys) {
@@ -49,36 +55,60 @@ for (const k of keys) {
if (typeof e === "object" && e !== null && typeof e.type === "string" && /^[a-z0-9_-]+$/i.test(e.type)) type = e.type; 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)"); ' "$file"; then
' "$AUTH_FILE"; then return 2
exit 2
fi 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, -)" ENV_NAMES="$(env | grep -oE '^[A-Z0-9_]+_API_KEY' | sort -u | paste -sd, -)"
if [ -n "$ENV_NAMES" ]; then if [ -n "$ENV_NAMES" ]; then
echo "env-side credential-like names set (informational; file entries take priority): $ENV_NAMES" echo "env-side credential-like names set (informational; file entries take priority): $ENV_NAMES"
else else
echo "env-side credential-like names set: (none)" echo "env-side credential-like names set: (none)"
fi fi
;; echo "mosaic-managed accounts ($ACCOUNTS_DIR):"
accounts)
if [ ! -d "$AUTH_DIR" ]; then
echo "auth: directory not found: $AUTH_DIR" >&2
exit 3
fi
shopt -s nullglob shopt -s nullglob
FILES=("$AUTH_DIR"/auth.*.json) FILES=("$ACCOUNTS_DIR"/*.json)
shopt -u nullglob shopt -u nullglob
if [ "${#FILES[@]}" -eq 0 ]; then if [ "${#FILES[@]}" -eq 0 ]; then
echo "accounts: (none) — named accounts are auth.<account>.json beside the credential file" echo " (none)"
exit 0 else
fi
echo "named accounts in $AUTH_DIR:"
for f in "${FILES[@]}"; do for f in "${FILES[@]}"; do
name="$(basename "$f" .json)" name="$(basename "$f" .json)"
name="${name#auth.}" perms="$(stat -c %a "$f" 2>/dev/null || echo '?')"
marker="" marker=""
[ "$f" = "$AUTH_FILE" ] && marker=" <- active (PI_AUTH_FILE)" [ "$f" = "$HARNESS_FILE" ] && marker=" <- active (PI_AUTH_FILE)"
echo " $name$marker" 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 "mosaic-managed accounts in $ACCOUNTS_DIR:"
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 done
;; ;;
*) *)
+66 -38
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fast, sandboxed selftests for the auth checkpoint (M19). # Fast, sandboxed selftests for the auth checkpoint (M19, corrected in #48).
# #
# No Docker, no network, no real credentials: every case runs against # No Docker, no network, no real credentials. Mosaic-managed accounts live
# fixture files via PI_AUTH_FILE. The suite asserts the core safety # under the data root (config-driven, like every other tool); the default
# property: credential MATERIAL from fixtures never reaches auth.sh or # harness credential appears only as an explicit PI_AUTH_FILE fixture.
# agent.sh output. # The suite asserts the core safety property: credential MATERIAL from
# fixtures never reaches auth.sh or agent.sh output.
set -uo pipefail set -uo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -27,22 +28,36 @@ check() {
SECRET="SK-TEST-DO-NOT-PRINT-9f2b" SECRET="SK-TEST-DO-NOT-PRINT-9f2b"
TOKEN="ACCESS-TOKEN-SHOULD-NOT-PRINT" TOKEN="ACCESS-TOKEN-SHOULD-NOT-PRINT"
ENVVAL="ENVVAL-SHOULD-NOT-PRINT" ENVVAL="ENVVAL-SHOULD-NOT-PRINT"
mkdir -p "$SANDBOX/creds" "$SANDBOX/empty" mkdir -p "$SANDBOX/pi" "$SANDBOX/data/auth" "$SANDBOX/data/state" "$SANDBOX/empty"
printf '{"anthropic":{"type":"oauth","access":"%s"},"zai":{"type":"api_key","key":"%s"}}' "$TOKEN" "$SECRET" > "$SANDBOX/creds/auth.json"
printf '{}' > "$SANDBOX/creds/auth.work.json"
printf '{}' > "$SANDBOX/creds/auth.personal.json"
# status: missing credential file -> exit 3 (config missing for a read) # mock system config: data root inside the sandbox (never the real one)
OUT="$(PI_AUTH_FILE="$SANDBOX/creds/absent.json" scripts/auth.sh status 2>"$SANDBOX/err.txt")"; RC=$? printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}' "$SANDBOX/data" > "$SANDBOX/mock-config.json"
[ "$RC" -eq 3 ] && grep -q "credential file not found" "$SANDBOX/err.txt" \ CFG="MOSAIC_CONFIG=$SANDBOX/mock-config.json"
&& check "status on missing credential file exits 3" 0 || check "status on missing credential file exits 3" 1
# status: happy path — providers sorted with credential types # release pointer so agent.sh's ensure stays on the no-op fast path
OUT="$(PI_AUTH_FILE="$SANDBOX/creds/auth.json" scripts/auth.sh status 2>&1)"; RC=$? REL="$(tr -d '[:space:]' < RELEASE)"
printf '{"pointerVersion":1,"release":"%s","imageTag":"mosaic-poc-agent:0.84.4-r%s","activatedAt":"2026-01-01T00:00:00Z"}\n' "$REL" "$REL" > "$SANDBOX/data/state/active.json"
# fixtures: a default-harness credential and two mosaic-managed accounts
printf '{"anthropic":{"type":"oauth","access":"%s"},"zai":{"type":"api_key","key":"%s"}}' "$TOKEN" "$SECRET" > "$SANDBOX/pi/auth.json"
printf '{}' > "$SANDBOX/data/auth/work.json"
printf '{}' > "$SANDBOX/data/auth/personal.json"
chmod 600 "$SANDBOX/pi/auth.json" "$SANDBOX/data/auth/work.json" "$SANDBOX/data/auth/personal.json"
# status: missing default harness credential -> exit 3, accounts still listed
OUT="$(env PI_AUTH_FILE="$SANDBOX/pi/absent.json" $CFG scripts/auth.sh status 2>"$SANDBOX/err.txt")"; RC=$?
[ "$RC" -eq 3 ] \
&& printf '%s\n' "$OUT" | grep -q ' work (perms 600)' \
&& check "status with missing harness credential exits 3 and still lists accounts" 0 || check "status with missing harness credential exits 3 and still lists accounts" 1
# status: happy path — harness providers + mosaic accounts, both labeled
OUT="$(env PI_AUTH_FILE="$SANDBOX/pi/auth.json" $CFG scripts/auth.sh status 2>&1)"; RC=$?
[ "$RC" -eq 0 ] \ [ "$RC" -eq 0 ] \
&& printf '%s\n' "$OUT" | grep -q 'read-only to the stack' \
&& printf '%s\n' "$OUT" | grep -q ' anthropic type=oauth source=auth\.json' \ && printf '%s\n' "$OUT" | grep -q ' anthropic type=oauth source=auth\.json' \
&& printf '%s\n' "$OUT" | grep -q ' zai type=api_key source=auth\.json' \ && printf '%s\n' "$OUT" | grep -q ' zai type=api_key source=auth\.json' \
&& check "status lists providers with credential types" 0 || check "status lists providers with credential types" 1 && printf '%s\n' "$OUT" | grep -q 'mosaic-managed accounts' \
&& check "status reports harness credential (read-only) + mosaic accounts" 0 || check "status reports harness credential (read-only) + mosaic accounts" 1
# the core safety property: fixture secret material never reaches output # the core safety property: fixture secret material never reaches output
printf '%s\n' "$OUT" | grep -q "$SECRET" \ printf '%s\n' "$OUT" | grep -q "$SECRET" \
@@ -50,48 +65,61 @@ printf '%s\n' "$OUT" | grep -q "$SECRET" \
printf '%s\n' "$OUT" | grep -q "$TOKEN" \ printf '%s\n' "$OUT" | grep -q "$TOKEN" \
&& check "oauth token material never reaches output" 1 || check "oauth token material never reaches output" 0 && check "oauth token material never reaches output" 1 || check "oauth token material never reaches output" 0
# status: unparseable file -> exit 2 # status: unparseable harness file -> exit 2
printf 'not json' > "$SANDBOX/creds/broken.json" printf 'not json' > "$SANDBOX/pi/broken.json"
PI_AUTH_FILE="$SANDBOX/creds/broken.json" scripts/auth.sh status >/dev/null 2>&1; RC=$? env PI_AUTH_FILE="$SANDBOX/pi/broken.json" $CFG scripts/auth.sh status >/dev/null 2>&1; RC=$?
[ "$RC" -eq 2 ] && check "unparseable credential file exits 2" 0 || check "unparseable credential file exits 2" 1 [ "$RC" -eq 2 ] && check "unparseable credential file exits 2" 0 || check "unparseable credential file exits 2" 1
# status: symlinked credential file -> exit 4 (must be a regular file) # status: symlinked harness file -> exit 4
ln -s "$SANDBOX/creds/auth.json" "$SANDBOX/creds/link.json" ln -s "$SANDBOX/pi/auth.json" "$SANDBOX/pi/link.json"
PI_AUTH_FILE="$SANDBOX/creds/link.json" scripts/auth.sh status >/dev/null 2>&1; RC=$? env PI_AUTH_FILE="$SANDBOX/pi/link.json" $CFG scripts/auth.sh status >/dev/null 2>&1; RC=$?
[ "$RC" -eq 4 ] && check "symlinked credential file exits 4" 0 || check "symlinked credential file exits 4" 1 [ "$RC" -eq 4 ] && check "symlinked credential file exits 4" 0 || check "symlinked credential file exits 4" 1
# status: env-side names are informational — name shown, value never # status: env-side names informational — name shown, value never
OUT="$(FAKE_TEST_API_KEY=$ENVVAL PI_AUTH_FILE="$SANDBOX/creds/auth.json" scripts/auth.sh status 2>&1)" OUT="$(env FAKE_TEST_API_KEY=$ENVVAL PI_AUTH_FILE="$SANDBOX/pi/auth.json" $CFG scripts/auth.sh status 2>&1)"
printf '%s\n' "$OUT" | grep -q "FAKE_TEST_API_KEY" \ printf '%s\n' "$OUT" | grep -q "FAKE_TEST_API_KEY" \
&& check "env-side credential names reported" 0 || check "env-side credential names reported" 1 && check "env-side credential names reported" 0 || check "env-side credential names reported" 1
printf '%s\n' "$OUT" | grep -q "$ENVVAL" \ printf '%s\n' "$OUT" | grep -q "$ENVVAL" \
&& check "env var values never reach output" 1 || check "env var values never reach output" 0 && check "env var values never reach output" 1 || check "env var values never reach output" 0
# accounts: empty directory -> (none), exit 0 # accounts: mosaic-managed, under the data root — a data root with no accounts dir is the default state: (none), exit 0, creates nothing
OUT="$(PI_AUTH_FILE="$SANDBOX/empty/auth.json" scripts/auth.sh accounts 2>&1)"; RC=$? printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m","adapter":"mock"}}' "$SANDBOX/empty" > "$SANDBOX/mock-config-empty.json"
[ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q "(none)" \ OUT="$(env PI_AUTH_FILE="$SANDBOX/pi/auth.json" MOSAIC_CONFIG="$SANDBOX/mock-config-empty.json" scripts/auth.sh accounts 2>&1)"; RC=$?
&& check "accounts with no named files reports none" 0 || check "accounts with no named files reports none" 1 [ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q '(none)' && [ ! -d "$SANDBOX/empty/auth" ] \
&& check "accounts without an accounts dir reports none and creates nothing" 0 || check "accounts without an accounts dir reports none and creates nothing" 1
# accounts: listing with active marker # accounts: listing with active marker
OUT="$(PI_AUTH_FILE="$SANDBOX/creds/auth.work.json" scripts/auth.sh accounts 2>&1)"; RC=$? OUT="$(env PI_AUTH_FILE="$SANDBOX/data/auth/work.json" $CFG scripts/auth.sh accounts 2>&1)"; RC=$?
[ "$RC" -eq 0 ] && printf '%s\n' "$OUT" | grep -q '^ work <- active (PI_AUTH_FILE)$' \ printf '%s\n' "$OUT" | grep -q '^ work (perms 600) <- active (PI_AUTH_FILE)$' \
&& printf '%s\n' "$OUT" | grep -q '^ personal$' \ && printf '%s\n' "$OUT" | grep -q '^ personal (perms 600)$' \
&& check "accounts lists files and marks the active one" 0 || check "accounts lists files and marks the active one" 1 && check "accounts lists files and marks the active one" 0 || check "accounts lists files and marks the active one" 1
# accounts: missing directory -> exit 3 # accounts: loose perms flagged loudly
PI_AUTH_FILE="$SANDBOX/nodir/auth.json" scripts/auth.sh accounts >/dev/null 2>&1; RC=$? printf '{}' > "$SANDBOX/data/auth/loose.json"
[ "$RC" -eq 3 ] && check "accounts on missing directory exits 3" 0 || check "accounts on missing directory exits 3" 1 chmod 644 "$SANDBOX/data/auth/loose.json"
OUT="$(env $CFG scripts/auth.sh accounts 2>&1)"
printf '%s\n' "$OUT" | grep -q 'loose (perms 644) \[not 0600 — agent --auth will refuse\]' \
&& check "loose account perms flagged in listing" 0 || check "loose account perms flagged in listing" 1
# agent.sh --auth: missing account file refuses before any container work # agent.sh --auth: missing mosaic-managed account file refuses (pre-container)
scripts/agent.sh researcher --auth nope </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$? env $CFG scripts/agent.sh researcher --auth nope </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "no credential file for auth account 'nope'" "$SANDBOX/err.txt" \ [ "$RC" -eq 4 ] && grep -q "no mosaic-managed credential file for auth account 'nope'" "$SANDBOX/err.txt" \
&& check "agent --auth with missing account file refuses (exit 4)" 0 || check "agent --auth with missing account file refuses (exit 4)" 1 && check "agent --auth with missing account file refuses (exit 4)" 0 || check "agent --auth with missing account file refuses (exit 4)" 1
# agent.sh --auth: loose perms refuse (0600 required, mirroring gitea-api.sh)
env $CFG scripts/agent.sh researcher --auth loose </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "must be 0600 (got 644)" "$SANDBOX/err.txt" \
&& check "agent --auth with non-0600 account file refuses" 0 || check "agent --auth with non-0600 account file refuses" 1
# agent.sh --auth: invalid account name refuses # agent.sh --auth: invalid account name refuses
scripts/agent.sh researcher --auth "bad~name" </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$? env $CFG scripts/agent.sh researcher --auth "bad~name" </dev/null >/dev/null 2>"$SANDBOX/err.txt"; RC=$?
[ "$RC" -eq 4 ] && grep -q "invalid auth account name" "$SANDBOX/err.txt" \ [ "$RC" -eq 4 ] && grep -q "invalid auth account name" "$SANDBOX/err.txt" \
&& check "agent --auth with invalid account name refuses" 0 || check "agent --auth with invalid account name refuses" 1 && check "agent --auth with invalid account name refuses" 0 || check "agent --auth with invalid account name refuses" 1
# auth.sh: invalid config refuses (fail closed, like every tool)
MOSAIC_CONFIG="$SANDBOX/no-such-config.json" scripts/auth.sh status >/dev/null 2>&1; RC=$?
[ "$RC" -ne 0 ] && check "auth.sh without valid config refuses" 0 || check "auth.sh without valid config refuses" 1
echo echo
echo "selftest: $PASS passed, $FAIL failed" echo "selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]