Compare commits

..
Author SHA1 Message Date
fargo 8eb8e7cfce Merge remote-tracking branch 'origin/next' into fix/1280-identity-first-resolution
ci/woodpecker/pr/ci Pipeline was successful
# Conflicts:
#	packages/mosaic/package.json
2026-08-17 16:24:43 -05:00
jason.woltje 8199261caa Merge pull request 'fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion — unblocks every PR on next' (#1270) from fix/1269-ci-chain-unblock into next
ci/woodpecker/push/publish Pipeline failed
Reviewed-on: #1270
2026-08-17 20:44:59 +00:00
fargo d789a43cae test(git): hermetic fixtures for issue-create harnesses (#1282-#1287)
ci/woodpecker/pr/ci Pipeline was canceled
test-issue-create-body-safety.sh and test-issue-create-interactive-auth.sh
inherited the seat's real HOME and global git config. With the #1280 fix
activating identity mode BEFORE the tea path, a workstation-global
mosaic.gitIdentity resolved inside the fixture repo, and the wrapper's
API fallback posted to the LIVE forge with a real per-slot token — six
real issues (#1282-#1287, authored mos-dt-0, closed with provenance by
fred within the hour).

Neutralize the source the resolver actually reads, and prove it by making
the resolution fail. A control that does not make the thing fail has not
been shown to control it. The earlier attempted neutralization pinned
MOSAIC_CREDENTIALS_FILE to a fake — a real guard aimed at an adjacent
input: the identity arm reads the per-slot token file directly and never
consults credentials.json. Hence env -i with a fake HOME and
GIT_CONFIG_GLOBAL=/dev/null (severing the global identity) rather than
one more targeted variable, plus a curl tripwire stub in the body-safety
harness so ANY provider request is a loud test failure instead of a live
write.
2026-08-17 15:19:04 -05:00
fargo 19ad93999f fix(git): identity-first principal resolution across write wrappers (#1280)
MOSAIC_GIT_IDENTITY=fargo produced objects attributed to mos-dt-0: every
write wrapper resolved its acting principal from tea's login list, which
enumerates whatever logins the host happens to hold and knows nothing
about which seat is calling. The identity-aware code was present and
correct but unreachable on the happy path — it sat on arms that only ran
when tea failed.

One shared resolver, not twenty patches: resolve_gitea_principal() in
detect-platform.sh implements the precedence (explicit --login beats
MOSAIC_GIT_IDENTITY / worktree git config mosaic.gitIdentity; the tea
login list is the LAST resort), fails loud (nonzero, naming the identity
or login and the expected slot path) when the requested principal has no
credential, and never prints a token value. gitea_identity_token_slot()
is the single source of truth for the slot layout, shared with
get_gitea_token, so resolver and token resolution cannot disagree.

Call-site conversions (the proving five): pr-review.sh (principal
resolved once for every action; the comment action now honors --login),
issue-comment.sh, pr-create.sh (identity mode reaches the REST API on the
HAPPY path — tea is never consulted, so the login list cannot shadow the
identity; --login wins even on the tea-failure fallback arm),
issue-create.sh (same), pr-merge.sh (gains --login; --dry-run reports the
principal the merge WOULD act as, resolved exactly as the merge resolves
it; no cross-principal fallback — an identity-bound 401 is a hard stop).

Remaining wrappers are call-site conversions onto the same resolver,
measured: write-path issue-assign, issue-close, issue-edit, issue-reopen,
milestone-close, milestone-create, pr-close; read-path issue-list,
milestone-list, pr-list, pr-view (issue-view mixed). pr-diff, pr-metadata,
pr-ci-wait and ci-queue-wait already inherit identity-first resolution
via get_gitea_token.

Known interaction: on a host with a workstation-GLOBAL mosaic.gitIdentity,
this fix activates identity mode for every seat that has not set a local
one — correct behavior driven by a wrong configuration (measured:
#1282-#1287, six accidental live issues, closed with provenance by fred).
Set mosaic.gitIdentity per-worktree, never --global.

Tests: test-gitea-principal-resolution.sh (resolver matrix — identity
present/absent, --login precedence, env vs git-config, unrecognized-host
containment, slot-path-by-path-never-by-value); test-pr-create-identity-
first.sh (the load-bearing ordering test: identity arm REACHED on the
happy path with tea never invoked, fail-loud BEFORE any write on a
missing slot, --login wins, default preserved); test-pr-merge-principal-
resolution.sh (dry-run truthfulness, merge credential binding, unknown
--login never reaches the provider). All wired into test:framework-shell.

Sabotage control: precedence inverted to tea-list-first inside the
resolver -> exactly the three new suites redden with the #1280
signatures (identity resolves to the tea-list account; missing slot
returns rc=0 with silent fallthrough) while all 11 pre-existing git
suites stay green; restored byte-identical (sha256 verified); all 14
green again.
2026-08-17 15:18:54 -05:00
fred 57a2f2b40e docs(ci): point the exclusion at tracking issue #1271, not the closed first filing
ci/woodpecker/pr/ci Pipeline was successful
The first PR for this change was filed under the retired mos-dt-0 principal
(pr-create.sh has no --login flag and find_tea_login_for_host returns the first
host match) and was closed and refiled as #1270. That left in-tree references
pointing at a closed duplicate PR rather than at the burn-down issue, which is
the wrong target for them anyway: the open design question belongs on #1271.
2026-08-16 18:03:02 -05:00
fred 93c1de51e1 fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion (#1269)
ci/woodpecker/pr/ci Pipeline was canceled
The `test` step has failed on every `next` pipeline since #1017 on exactly one
assertion, and it is the same one on unrelated PRs:

    FAIL: host provides 'pi' in the system path; missing-binary cases are not
    measurable here            (framework/tools/fleet/test-start-agent-session.sh:103)

Measured 2026-08-16 across pipelines 2444 (#1256), 2438 (#1240) and 2441
(#1017-quality): exactly one FAIL line in each full log, identical, this line.
Control `zzz-not-present-zzz` -> 0 on all three.

Cause. #1241 (5c35a250) added the guard: the suite shims fake mosaic/pi/npm into
$FAKE_BIN, but the constructed PANE_PATH always ends in the real system path, so
on a host that installs those binaries the missing-binary cases cannot be
measured and a green run would mean nothing. The guard says so instead of
passing. Its own pipeline 2430 was green only because the suite was CI-excluded
at the time, so the guard had never run in CI. #1017 (c56483eb) then enumerated
it and dropped the exclusion. The CI image installs
@earendil-works/[email protected].1 on purpose, so the precondition is
unsatisfiable there. Both commits are mine.

The guard is correct and is not being softened. A check that cannot measure its
property and reports success is the failure mode this repo has been cataloguing
all week; the error was wiring the suite into an image that violates its
precondition, so the wiring is what gets reverted.

Second effect, which is the reason this cost a day rather than an hour:
test:framework-shell is one && chain and this sat at position 44 of 48, so
glpi/test-list-http-status.sh, orchestrator/test-board-roll.sh,
woodpecker/test-ci-wait-exit-matrix.sh and _scripts/test-fleet-transport-check.sh
have not run at all since the merge. The pipeline reported one failure, never
"one failure plus four unrun". All four are green when run directly on
sb-it-1-dt, so the mask hid nothing broken -- but that is a local result on one
host, not a CI-image result.

Verification, with controls:
- enumeration guard OK (population 52, enumerated 36, signed-excluded 16).
- control A, exclusion line removed while unwired -> FAIL UNENUMERATED.
- control B, exclusion line kept while rewired -> FAIL CONTRADICTORY EXCLUSION.
  The gate discriminates in both directions, so its OK is load-bearing.
- the four formerly-masked suites: rc=0 each, run directly.
- the full chain cannot be run to completion on sb-it-1-dt: it stops earlier, at
  the lease-broker Invariant R test, because this host carries the quarantined
  operator-global pi 0.84.2 against a measured 0.84.1. That is host-specific and
  out of scope here -- CI pins 0.84.1, and the single FAIL line in those three
  pipelines proves positions 1-43 passed there.

Burn-down is to control the tail of PANE_PATH inside the test, not to remove pi
from the image. Recorded in the exclusion reason and in #1269.
2026-08-16 17:58:49 -05:00
18 changed files with 1225 additions and 1145 deletions
+31 -1
View File
@@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation (as of #1280, `pr-create.sh`, `pr-merge.sh` and `issue-create.sh` accept it too, and it wins over `MOSAIC_GIT_IDENTITY` everywhere). The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
@@ -58,6 +58,36 @@ token file present, both tools fall through to the existing shared-account path
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
tokens.
### Identity-first principal resolution in the wrappers (#1280)
`resolve_gitea_principal()` (detect-platform.sh) gives the write wrappers —
`pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `issue-create.sh`, `issue-comment.sh`
ONE precedence for choosing the acting principal:
1. an explicit `--login <name>` (now accepted by all five; operator intent beats
environment), then
2. the per-agent identity above (`MOSAIC_GIT_IDENTITY` env / worktree
`mosaic.gitIdentity`) when a per-slot token exists — the wrapper then writes via the
REST API with that identity's token and never consults `tea`, so the tea login list
cannot shadow the requested principal, then
3. the tea login list — the LAST resort, never the first, because it enumerates
whatever logins the host happens to hold and knows nothing about which seat is
calling.
A requested identity whose per-slot token is absent, or a `--login` whose token cannot
resolve host-bound, **fails loud** (nonzero, naming the identity/login and the expected
slot) instead of silently writing under whatever account `tea` has configured — that
silent fallthrough is defect #1280 (reviews, comments, merges, PRs and issues filed
under the wrong account). `pr-merge.sh --dry-run` reports the principal the merge would
act as, resolved exactly as the real merge resolves it. ⚠ A **workstation-global**
`mosaic.gitIdentity` shadows every seat on that host (a fresh clone with no local value
resolves the global one) — set it per-worktree, not with `--global`.
The resolver is covered by `test-gitea-principal-resolution.sh`; the happy-path
ordering (identity arm REACHED, not sitting behind a tea failure) by
`test-pr-create-identity-first.sh`; merge credential binding by
`test-pr-merge-principal-resolution.sh`.
### Enabling it for a clone
The framework installer syncs `git-credential-mosaic` to
@@ -497,6 +497,32 @@ get_gitea_url_for_host() {
return 1
}
# Map a Gitea host to the per-agent identity-token slot PREFIX ("gitea-usc" /
# "gitea-mosaicstack") used by identity-first principal resolution
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity; #1280). Returns 1 for
# hosts with no per-slot scheme — callers treat that as "identity does not
# bind here" and fall through to existing behavior, never as an error. This is
# the single source of truth for the slot layout: get_gitea_token and
# resolve_gitea_principal both derive their slot paths from here, so the two
# resolutions can never disagree about where an identity's credential lives.
gitea_identity_slot_prefix() {
case "$1" in
git.uscllc.com) echo "gitea-usc" ;;
git.mosaicstack.dev) echo "gitea-mosaicstack" ;;
*) return 1 ;;
esac
}
# Resolve the per-slot token FILE PATH for an identity on a host. Prints the
# absolute path on success; returns 1 (no output) when the host has no per-slot
# scheme. Prints a PATH only — never a token value.
gitea_identity_token_slot() {
local identity="$1" host="$2" prefix
[[ -n "$identity" ]] || return 1
prefix=$(gitea_identity_slot_prefix "$host") || return 1
printf '%s\n' "$HOME/.config/mosaic/secrets/gitea-tokens/${prefix}-${identity}.token"
}
# Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
get_gitea_token() {
@@ -517,13 +543,8 @@ get_gitea_token() {
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
local _idtok=""
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
@@ -1465,6 +1486,81 @@ raise SystemExit(1)
PY
}
# resolve_gitea_principal — identity-first acting-principal resolution shared by
# the git wrappers (#1280). The defect this fixes: wrappers resolved their
# acting principal from tea's login list FIRST, and that list enumerates
# whatever logins happen to be configured on the host — it knows nothing about
# which seat is calling — so a wrapper invoked with MOSAIC_GIT_IDENTITY=fargo
# still wrote under whichever account tea held (mos-dt-0), and the correct
# identity-aware code sat behind arms that only ran when the tea path failed.
# Precedence here is the contract:
# 1. an explicit login override ($1, the wrapper's --login) — operator intent
# beats environment;
# 2. MOSAIC_GIT_IDENTITY env, else per-worktree `git config mosaic.gitIdentity`
# (mirroring get_gitea_token exactly, so resolver and token resolution can
# never disagree) — binds only on hosts with a per-slot token scheme;
# 3. the tea login list — LAST resort, never the first.
#
# Prints exactly one line, three tab-separated fields (machine-readable for
# wrapper dispatch and tests):
# mode "login" | "identity" | "default"
# principal login name (login) | identity name (identity) | tea login or "" (default)
# source "tea-login:<name>" | "identity-slot:<path>" | "tea-default" | "host-credential"
#
# Fails LOUD (nonzero, empty stdout, stderr diagnostic) when an explicit
# override cannot be honored — a refusal is a good day; silently falling
# through to whoever tea has configured is the exact defect this resolves:
# - login mode: no host-bound token for that tea login. The existence check
# runs the same tea-config lookup tea itself uses; the token VALUE is
# discarded (never printed, never used).
# - identity mode: no per-slot token file for that identity on a recognized
# host — the diagnostic names the identity, its source, and the expected
# slot path. An identity requested on a host with NO per-slot scheme does
# not bind (matching get_gitea_token's containment) and falls to default.
#
# NEVER prints a token value — principal names and slot paths only.
# $1 = explicit login override ("" when absent), $2 = host (default: the
# origin remote's host).
resolve_gitea_principal() {
local login_override="${1:-}" host="${2:-}" ident ident_src slot login
[[ -n "$host" ]] || { host=$(get_remote_host) || return 1; }
if [[ -n "$login_override" ]]; then
get_gitea_token_for_login "$login_override" "$host" >/dev/null || {
echo "Error: --login '$login_override' has no host-matched token on host '$host' (tea config lookup); refusing to fall back to any other principal (#1280 identity-first resolution)." >&2
return 1
}
printf 'login\t%s\ttea-login:%s\n' "$login_override" "$login_override"
return 0
fi
ident="${MOSAIC_GIT_IDENTITY:-}"
ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$ident" ]] && slot="$(gitea_identity_token_slot "$ident" "$host" 2>/dev/null)"; then
if [[ -r "$slot" ]]; then
printf 'identity\t%s\tidentity-slot:%s\n' "$ident" "$slot"
return 0
fi
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no per-slot token at $slot (#1280 identity-first resolution)." >&2
echo " Refusing to fall back to the tea login list or shared credentials. Provision the per-slot token, or unset the identity." >&2
return 1
fi
# No override requested: tea's login list is the LAST resort. Absence is
# not an error here — callers fall back to the host credential, exactly as
# they did before this resolver existed (preserved behavior).
if login=$(get_gitea_login_for_host "$host" 2>/dev/null); then
printf 'default\t%s\ttea-default\n' "$login"
else
printf 'default\t\thost-credential\n'
fi
return 0
}
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
get_gitea_basic_auth() {
@@ -76,27 +76,36 @@ fi
detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280):
# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config
# mosaic.gitIdentity binds the per-slot credential, else the tea login list
# (last resort). Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE
# (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default) so that the single credential used for
# the write ALSO drives the /user identity read and the read-back — write token
# and read-back token are the same identity by construction (this is the
# credential-ordering fix: a --login override is no longer written under one
# credential and verified under a different default one). Falls back to the
# host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the write to the host default
# identity — otherwise a caller relying on a dedicated per-role credential would
# be told the write succeeded as requested while it was attributed to the shared
# default. Returns non-zero (clear stderr) on any resolution failure.
# The token is resolved for the EFFECTIVE principal so that the single
# credential used for the write ALSO drives the /user identity read and the
# read-back — write token and read-back token are the same identity by
# construction (this is the credential-ordering fix: a --login override is no
# longer written under one credential and verified under a different default
# one). When $2 is "identity" the principal ($1) is a requested git identity:
# the token MUST resolve from that identity's per-slot token (get_gitea_token's
# identity arm), failing closed rather than borrowing the tea default login —
# the tea login list must never shadow a requested identity (#1280). When $2
# is "explicit" the principal came from a caller-supplied --login: that exact
# login's token MUST resolve, and we FAIL CLOSED rather than silently
# downgrading the write to the host default identity. Otherwise the best-effort
# default path applies (per-login token, else the host-scoped credential).
# Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
if [[ "$override_explicit" == "identity" ]]; then
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (comment write/read-back, #1280)." >&2
return 1
}
elif [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
return 1
@@ -318,23 +327,31 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the login this comment should be attributed to: the --login
# override when given, otherwise the detected default for this repo's host.
# A --login override always wins. Otherwise name this repo host's login only
# as a best effort: the login name merely selects a per-login token, and
# gitea_resolve_api_for_login falls back to the host credential
# (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Resolve the acting principal identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
# selects the principal when a per-slot token exists (fail-loud when it
# does not); the tea login list is the LAST resort — it knows nothing about
# which seat is calling, so resolving from it first wrote under whichever
# account tea had configured (the #1280 family).
principal_host=$(get_remote_host)
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
# Bind the REST endpoint + token to the effective login, then derive the
# Bind the REST endpoint + token to the resolved principal, then derive the
# acting identity from that SAME credential (GET /user). The write below and
# its read-back both use this credential, so the write is verified against
# the identity that actually performed it. Passing "explicit" when --login
# was supplied forbids the host-default fallback: an unresolvable explicit
# override fails closed instead of writing under the default identity.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# the identity that actually performed it.
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
@@ -1,6 +1,15 @@
#!/bin/bash
# issue-create.sh - Create issues on Gitea or GitHub
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"]
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login <name>]
#
# Acting principal is resolved identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
# the wrapper then creates the issue through the REST API with that identity's
# token — tea is never invoked, so the tea login list cannot shadow the
# requested principal); the tea login list is the LAST resort. A requested
# identity with no per-slot token fails LOUD rather than writing under
# whichever account tea happens to hold.
set -e
@@ -16,6 +25,14 @@ INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh
# Acting-principal mode set in the Gitea branch below (from
# resolve_gitea_principal): "login" when --login was given, "identity" when a
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
# arm resolve the --login principal's token too, so an explicit --login keeps
# winning even on the tea-FAILURE fallback arm.
PRINCIPAL_MODE=""
PRINCIPAL_NAME=""
gitea_issue_create_api() {
local host repo token url payload
host=$(get_remote_host) || {
@@ -26,10 +43,19 @@ gitea_issue_create_api() {
echo "Error: could not determine repo owner/name for API fallback" >&2
return 1
}
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
return 1
}
else
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
# is set (per-slot token, fail-loud on absence); shared default otherwise.
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
fi
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
@@ -67,6 +93,7 @@ Options:
-b, --body BODY Issue body/description
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
-m, --milestone NAME Milestone name to assign
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
-i, --interactive Prompt for missing issue fields
-h, --help Show this help message
@@ -97,6 +124,10 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2"
shift 2
;;
--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-i|--interactive)
INTERACTIVE=true
shift
@@ -134,13 +165,37 @@ case "$PLATFORM" in
"${CMD[@]}"
;;
gitea)
# Resolve the acting principal identity-first (#1280). The tea login
# list is the LAST resort: it knows nothing about which seat is calling,
# and a login resolved from it first is what attributed issues to the
# wrong account even when MOSAIC_GIT_IDENTITY was set.
principal_host=$(get_remote_host 2>/dev/null || true)
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
# HAPPY PATH for a requested identity: create through the REST API
# with the per-slot token and never invoke tea — the identity arm
# must be REACHED, not sit behind a tea failure (#1280).
gitea_issue_create_api
exit $?
fi
if command -v tea >/dev/null 2>&1; then
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
else
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
}
fi
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_issue_create_api
@@ -1,6 +1,15 @@
#!/bin/bash
# pr-create.sh - Create pull requests on Gitea or GitHub
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] [--login <name>]
#
# Acting principal is resolved identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
# the wrapper then creates the PR through the REST API with that identity's
# token — tea is never invoked, so the tea login list cannot shadow the
# requested principal); the tea login list is the LAST resort. A requested
# identity with no per-slot token fails LOUD rather than writing under
# whichever account tea happens to hold.
set -e
@@ -19,6 +28,15 @@ ISSUE=""
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
# Acting-principal mode set in the Gitea branch below (from
# resolve_gitea_principal): "login" when --login was given, "identity" when a
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
# arm resolve the --login principal's token too, so an explicit --login keeps
# winning even on the tea-FAILURE fallback arm (otherwise the fallback would
# silently re-resolve to the environment identity or shared credential).
PRINCIPAL_MODE=""
PRINCIPAL_NAME=""
gitea_pr_create_api() {
local host repo token url payload
host=$(get_remote_host) || {
@@ -29,10 +47,19 @@ gitea_pr_create_api() {
echo "Error: could not determine repo owner/name for API fallback" >&2
return 1
}
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
return 1
}
else
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
# is set (per-slot token, fail-loud on absence); shared default otherwise.
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1
}
fi
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
@@ -76,6 +103,7 @@ Options:
-H, --head BRANCH Head branch with changes (default: current branch)
-l, --labels LABELS Comma-separated labels
-m, --milestone NAME Milestone name
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
-d, --draft Create as draft PR
-h, --help Show this help message
@@ -116,6 +144,10 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2"
shift 2
;;
--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-i|--issue)
ISSUE="$2"
shift 2
@@ -174,15 +206,41 @@ case "$PLATFORM" in
"${CMD[@]}"
;;
gitea)
# Resolve the acting principal identity-first (#1280). The tea login
# list is the LAST resort: it knows nothing about which seat is calling,
# and a login resolved from it first is what attributed PRs to the wrong
# account even when MOSAIC_GIT_IDENTITY was set.
principal_host=$(get_remote_host 2>/dev/null || true)
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
# HAPPY PATH for a requested identity: the per-slot token IS the
# credential, so create through the REST API directly and never
# invoke tea — the identity arm must be REACHED, not sit behind a
# tea failure (#1280). Fail-loud on a missing slot already happened
# in resolve_gitea_principal.
gitea_pr_create_api
exit $?
fi
# tea pull create syntax. Always pass --repo because tea repo inference
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
# of eval so markdown backticks/body content are not shell-executed.
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
}
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
else
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
}
fi
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_pr_create_api
@@ -1,6 +1,13 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] [--login <name>]
#
# Acting principal is resolved identity-first (#1280): an explicit --login
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
# mosaic.gitIdentity selects the credential (per-slot token, fail-loud when
# absent); the shared host credential is the last resort. The merge is
# performed with the resolved credential only — never a cross-principal
# fallback (an HTTP 401 from the identity-bound token is a hard stop).
set -euo pipefail
@@ -16,6 +23,7 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
LOGIN_OVERRIDE=""
usage() {
cat <<EOF
@@ -31,6 +39,7 @@ Options:
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
-h, --help Show this help message
Examples:
@@ -39,6 +48,7 @@ Examples:
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
$(basename "$0") -n 42 --login fred-ms # Merge under the fred-ms tea login
EOF
exit "${1:-1}"
}
@@ -82,6 +92,14 @@ while [[ $# -gt 0 ]]; do
ESCALATE_TO="$2"
shift 2
;;
--login|-l)
if [[ $# -lt 2 ]]; then
echo "Error: --login requires one tea login name." >&2
exit 1
fi
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -572,9 +590,22 @@ PY
merge_gitea_with_api() {
local host="$1" token attempt_rc
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
# Identity-first principal resolution (#1280): an explicit --login wins
# over MOSAIC_GIT_IDENTITY (operator intent beats environment); otherwise
# get_gitea_token resolves the identity's per-slot token when an identity
# is requested (fail-loud when absent) and the shared host credential only
# when no identity is set. No cross-principal fallback: whatever resolves
# here is the ONLY credential the merge is attempted with.
if [[ -n "$LOGIN_OVERRIDE" ]]; then
if ! token=$(get_gitea_token_for_login "$LOGIN_OVERRIDE" "$host"); then
echo "Error: --login '$LOGIN_OVERRIDE' has no host-matched token on host '$host'; refusing to merge under any other principal (#1280 identity-first resolution)." >&2
return 1
fi
else
if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1
fi
fi
if [[ -z "$token" ]]; then
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
@@ -602,10 +633,25 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
# Report the acting principal the merge WOULD use, resolved the same
# way the real merge resolves it (#1280) — a dry run that names a
# different principal than the merge would act as is a lie.
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$HOST")"; then
# Fail-loud diagnostic already printed (unresolvable --login or a
# requested identity with no per-slot token).
exit 1
fi
DRY_PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
DRY_PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
case "$DRY_PRINCIPAL_MODE" in
login) DRY_PRINCIPAL_DESC="tea login '$DRY_PRINCIPAL_NAME'" ;;
identity) DRY_PRINCIPAL_DESC="git identity '$DRY_PRINCIPAL_NAME' (per-slot credential)" ;;
*) DRY_PRINCIPAL_DESC="default host credential" ;;
esac
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
else
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
echo "Dry run: would merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
fi
else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
@@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)"
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
echo " -h, --help Show this help"
@@ -346,7 +346,14 @@ gitea_resolve_api_for_login() {
else
host=$(get_remote_host)
fi
if [[ -n "$override_explicit" ]]; then
if [[ "$override_explicit" == "identity" ]]; then
# Requested git identity (#1280): the per-slot token MUST resolve via
# get_gitea_token's identity arm; never borrow the tea default login.
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (review write/read-back, #1280)." >&2
return 1
}
elif [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
return 1
@@ -676,29 +683,32 @@ if [[ "$PLATFORM" == "github" ]]; then
;;
esac
elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the acting principal ONCE for every action, identity-first
# (#1280): an explicit --login wins; otherwise MOSAIC_GIT_IDENTITY /
# per-worktree git config mosaic.gitIdentity selects the principal when a
# per-slot token exists (fail-loud when it does not); the tea login list is
# the LAST resort — it enumerates whatever logins this host happens to hold
# and knows nothing about which seat is calling, so resolving from it first
# wrote under whichever account tea had configured (the #1280 family).
principal_host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
# resolve_gitea_principal already printed the fail-loud diagnostic.
exit 1
fi
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
case $ACTION in
approve)
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
# PRINCIPAL_NAME were resolved once above from --login >
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
# The review body (if any) travels with the review itself in the REST
@@ -715,24 +725,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required for request-changes"
exit 1
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
# PRINCIPAL_NAME were resolved once above from --login >
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
@@ -746,24 +748,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required"
exit 1
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
# PRINCIPAL_NAME were resolved once above from --login >
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
else
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
fi
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
# Regression harness for detect-platform.sh's resolve_gitea_principal() — the
# identity-first acting-principal resolution shared by the git wrappers
# (mosaicstack/stack #1280).
#
# The contract under test (precedence: --login > MOSAIC_GIT_IDENTITY /
# git config mosaic.gitIdentity > tea login list, which is the LAST resort):
# 1. identity env + per-slot token present -> mode=identity, principal=
# identity name, source names the identity's slot PATH (never a token
# value).
# 2. identity env + per-slot token ABSENT -> FAIL LOUD: nonzero, empty
# stdout, stderr naming the identity and the expected slot path.
# 3. identity env + --login -> --login wins (login mode resolves even when
# the identity has no slot — operator intent beats environment).
# 4. identity unset + no --login -> default mode: the tea login list
# resolves the principal exactly as before (preserved behavior).
# 5. no identity + no host-matching tea login -> default/host-credential
# (preserved behavior; absence is not an error on the default path).
# 6. identity on an UNRECOGNIZED host (no per-slot scheme) -> does not bind;
# default mode (containment, mirroring get_gitea_token).
# 7. --login with no host-bound token for that login -> FAIL LOUD, stderr
# naming the login and the host.
# 8. git config mosaic.gitIdentity is honored when the env var is unset.
# 9. The resolver NEVER emits a token value — stdout/stderr of every
# successful resolution must not contain the slot file's contents.
#
# Uses a stubbed tea binary, stubbed tea config.yml, stubbed credentials.json
# and stubbed per-slot token files under a fake HOME. NEVER reads real secrets.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-principal-resolution}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "shared-usc-token"
}
}
}
JSON
# tea's own config store: the source get_gitea_token_for_login reads. Logins
# "alice" (mosaicstack) and "bob-usc" (usc) carry sentinel token values that
# the assertions prove are NEVER emitted by the resolver.
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
logins:
- name: alice
url: https://git.mosaicstack.dev
token: SECRET-alice-tea-token
- name: bob-usc
url: https://git.uscllc.com
token: SECRET-bob-usc-tea-token
YAML
# Stubbed tea: only what login resolution needs (`login list --output json`).
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"alice","url":"https://git.mosaicstack.dev","default":true},
{"name":"bob-usc","url":"https://git.uscllc.com"}
]
JSON
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
# Per-slot identity token with a sentinel value the assertions prove is never
# emitted (proving "token came from the identity's slot BY PATH, not by value").
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" != *"$needle"* ]]; then
echo "FAIL: $desc — missing '$needle' in: $haystack" >&2
fail=1
fi
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "FAIL: $desc — must not contain '$needle', got: $haystack" >&2
fail=1
fi
}
# Runs resolve_gitea_principal for $1=login_override $2=host inside REPO_DIR
# (per-worktree git config resolves there) under a fake HOME, stubbed tea, and
# stubbed credentials. Extra env (e.g. MOSAIC_GIT_IDENTITY) via $@.
call_resolver() {
local login="$1" host="$2"; shift 2
(
cd "$REPO_DIR"
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
bash -c 'source "$DETECT_PLATFORM_SH"; resolve_gitea_principal "$1" "$2"' _ "$login" "$host"
)
}
field() { printf '%s' "$1" | cut -f"$2"; }
# ---------------------------------------------------------------------------
# 1. Identity env + slot present -> identity mode, slot named BY PATH, and no
# token value ever emitted.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "identity mode" "identity" "$(field "$out" 1)"
assert_eq "identity principal" "agentX" "$(field "$out" 2)"
assert_eq "identity slot source" \
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" \
"$(field "$out" 3)"
assert_not_contains "identity stdout leaks token" "$out" "SECRET"
# ---------------------------------------------------------------------------
# 2. Identity env + slot ABSENT -> fail loud: nonzero, empty stdout, stderr
# naming the identity and the expected slot path.
# ---------------------------------------------------------------------------
stderr_file="$WORK_DIR/stderr.tmp"
set +e
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
fail=1
fi
if [[ -n "$out" ]]; then
echo "FAIL: missing slot — expected empty stdout, got '$out'" >&2
fail=1
fi
err=$(cat "$stderr_file")
assert_contains "missing slot names identity" "$err" "agentNoSlot"
assert_contains "missing slot names slot path" "$err" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"
assert_not_contains "missing-slot stderr leaks token" "$err" "SECRET"
# ---------------------------------------------------------------------------
# 3. Identity + --login -> --login wins. Also wins when the identity has NO
# slot (no identity check may veto an explicit login).
# ---------------------------------------------------------------------------
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "login beats identity (mode)" "login" "$(field "$out" 1)"
assert_eq "login beats identity (principal)" "alice" "$(field "$out" 2)"
assert_eq "login source" "tea-login:alice" "$(field "$out" 3)"
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot)
assert_eq "login beats slot-less identity" "login" "$(field "$out" 1)"
# ---------------------------------------------------------------------------
# 4. No identity, no --login -> default mode via the tea login list
# (preserved behavior).
# ---------------------------------------------------------------------------
out=$(call_resolver "" "git.mosaicstack.dev")
assert_eq "default mode" "default" "$(field "$out" 1)"
assert_eq "default principal" "alice" "$(field "$out" 2)"
assert_eq "default source" "tea-default" "$(field "$out" 3)"
# ---------------------------------------------------------------------------
# 5. No identity, no --login, no host-matching tea login -> default with the
# host credential (absence is not an error on the default path).
# ---------------------------------------------------------------------------
out=$(call_resolver "" "git.unknown.test")
assert_eq "no-match default mode" "default" "$(field "$out" 1)"
assert_eq "no-match default principal" "" "$(field "$out" 2)"
assert_eq "no-match default source" "host-credential" "$(field "$out" 3)"
# ---------------------------------------------------------------------------
# 6. Identity on an UNRECOGNIZED host -> does not bind; default mode
# (containment, mirroring get_gitea_token's scope).
# ---------------------------------------------------------------------------
out=$(call_resolver "" "github.com" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "unrecognized host falls to default" "default" "$(field "$out" 1)"
# ---------------------------------------------------------------------------
# 7. --login with no host-bound token for that login -> fail loud, stderr
# naming the login and the host.
# ---------------------------------------------------------------------------
: > "$stderr_file"
set +e
out=$(call_resolver "ghost-login" "git.mosaicstack.dev" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: unknown --login — expected nonzero return, got 0 (stdout='$out')" >&2
fail=1
fi
err=$(cat "$stderr_file")
assert_contains "unknown login names login" "$err" "ghost-login"
assert_contains "unknown login names host" "$err" "git.mosaicstack.dev"
# A cross-host login (exists, but for usc) must ALSO fail loud for mosaicstack.
set +e
out=$(call_resolver "bob-usc" "git.mosaicstack.dev" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: cross-host --login — expected nonzero return, got 0" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 8. git config mosaic.gitIdentity honored when env is unset.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config mosaic.gitIdentity agentX
out=$(call_resolver "" "git.mosaicstack.dev")
assert_eq "git-config identity mode" "identity" "$(field "$out" 1)"
assert_eq "git-config identity principal" "agentX" "$(field "$out" 2)"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 9. Cross-host slot layout: the usc slot path is chosen for the usc host.
# ---------------------------------------------------------------------------
echo -n "SECRET-agentX-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token"
out=$(call_resolver "" "git.uscllc.com" MOSAIC_GIT_IDENTITY=agentX)
assert_eq "usc identity mode" "identity" "$(field "$out" 1)"
assert_eq "usc slot source" \
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token" \
"$(field "$out" 3)"
if [[ "$fail" -eq 0 ]]; then
echo "resolve_gitea_principal identity-first resolution regression passed"
fi
exit "$fail"
@@ -77,12 +77,30 @@ exit 0
SH
chmod +x "$BIN_DIR/tea"
# TRIPWIRE provider stub: this harness tests argv construction, so ANY curl
# call is a failure of that contract (and, before this stub existed, a LIVE
# write — the #1282#1287 incident: the seat's real HOME leaked a global
# mosaic.gitIdentity, flipping the wrapper into identity mode whose real
# per-slot token created real issues on the forge). Fail loudly instead.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
echo "FAIL: body-safety harness reached a provider request — this test must never curl" >&2
exit 99
SH
chmod +x "$BIN_DIR/curl"
# Hermetic invocation: fake HOME (no credentials, no tea config, no token
# slots) and GIT_CONFIG_GLOBAL severed — `git config --get mosaic.gitIdentity`
# otherwise resolves the WORKSTATION's global identity (mos-dt-0 on the seat
# that wrote this) and reroutes the wrapper into identity mode (#1280 family).
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
) >/dev/null
mkdir -p "$WORK_DIR/home"
# 1. No command substitution executed anywhere in the pipeline.
if [[ -e "$SENTINEL" ]]; then
@@ -47,14 +47,31 @@ SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() {
# Hermetic: fake HOME (fixture credentials only, no token slots, no tea
# config) and GIT_CONFIG_GLOBAL severed — `git config --get
# mosaic.gitIdentity` otherwise resolves the WORKSTATION's global identity
# and reroutes the wrapper into identity mode before the tea paths this
# harness exercises (#1280 family; see test-issue-create-body-safety.sh).
# An `env …` prefix (used for MOSAIC_TEA_STALE_USER) is re-wrapped, not
# doubled: arguments beginning with "env" are shifted past.
local env_pairs=()
if [[ "${1:-}" == "env" ]]; then
shift
while [[ "$#" -gt 0 && "$1" == *=* ]]; do
env_pairs+=("$1")
shift
done
fi
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" "${env_pairs[@]}" \
"$@"
)
}
mkdir -p "$WORK_DIR/home"
: > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
# Load-bearing regression harness for pr-create.sh identity-first principal
# resolution (mosaicstack/stack #1280).
#
# The failure this harness is written down to catch: `MOSAIC_GIT_IDENTITY=fargo
# pr-create.sh …` produces a PR attributed to `mos-dt-0` (whichever account the
# tea login list happens to hold). Before #1280 the identity-aware code existed
# but sat on the API arm that only ran when the tea path FAILED — tea succeeded,
# so the identity arm never executed, and every test that did not check ORDERING
# passed. This harness checks ordering directly:
#
# 1. identity set + slot present -> the PR is created via the REST API with
# the identity's per-slot token (asserted by sentinel value AT the fake
# provider), and tea's `pr create` is NEVER invoked.
# 2. identity set + slot ABSENT -> nonzero, stderr naming the identity and
# the expected slot path; neither tea `pr create` nor any API request
# fires. No silent fallback to the tea login list.
# 3. identity set + --login -> --login wins: tea runs WITH the explicit
# --login, no API request.
# 4. nothing set -> preserved behavior: tea path with the tea-list login.
#
# Uses a stubbed tea, a stubbed curl provider, stubbed credentials.json and
# per-slot token under a fake HOME. NEVER reads real secrets or hits a live
# forge — all assertions are against the stubs' logs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-identity-first}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
TOOLS_DIR="$WORK_DIR/tools"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
# Fixture: the real scripts under test, copied so sibling stubs (and the
# ../_lib credential loader) resolve inside the fixture tree.
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS_DIR/git/pr-create.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
chmod +x "$TOOLS_DIR/git/pr-create.sh"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
}
}
}
JSON
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
logins:
- name: alice
url: https://git.mosaicstack.dev
token: SECRET-alice-tea-token
YAML
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
: > "$LOG_FILE"
# Stubbed tea: records every invocation; `login list` feeds login resolution;
# `api --login <n> /user` feeds get_gitea_authenticated_user; `pr create` marks
# the marker file (its presence fails the identity-mode assertions).
cat > "$BIN_DIR/tea" <<SH
#!/usr/bin/env bash
set -euo pipefail
printf 'TEA: %s\n' "\$*" >> "$LOG_FILE"
if [[ "\$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"alice","url":"https://git.mosaicstack.dev","default":true}
]
JSON
exit 0
fi
if [[ "\${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"alice"}'
exit 0
fi
if [[ "\$*" == pr\ create* ]]; then
echo "TEA-PR-CREATE-INVOKED" >> "$LOG_FILE"
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
# Stubbed provider: records the URL and the Authorization header VALUE it
# received, answers 201 with a created-PR object. The sentinel token values are
# synthetic fixtures — asserting them at the provider proves WHICH slot's
# credential carried the write.
cat > "$BIN_DIR/curl" <<SH
#!/usr/bin/env bash
set -euo pipefail
url=""
auth=""
while [[ \$# -gt 0 ]]; do
case "\$1" in
-H)
case "\$2" in
Authorization*) auth="\$2" ;;
esac
shift 2
;;
*) [[ -n "\$1" && "\$1" != -* ]] && url="\$1"
shift
;;
esac
done
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
cat <<'JSON'
{"number": 1299, "html_url": "https://git.mosaicstack.dev/mosaicstack/stack/pulls/1299"}
JSON
exit 0
SH
chmod +x "$BIN_DIR/curl"
fail=0
assert_contains() {
local desc="$1" needle="$2"
if ! grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log does not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
assert_not_contains() {
local desc="$1" needle="$2"
if grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log must not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
EXTRA_ARGS=""
run_pr_create() {
# "$@" carries ONLY environment assignments (VAR=value); EXTRA_ARGS (if
# set) carries wrapper arguments, so `env` never mistakes a wrapper flag
# like --login for one of its own.
(
cd "$REPO_DIR"
# shellcheck disable=SC2086 # EXTRA_ARGS is deliberately word-split wrapper args
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
bash "$TOOLS_DIR/git/pr-create.sh" -t "Test PR" -B next -H fix/test $EXTRA_ARGS
)
}
# ---------------------------------------------------------------------------
# 1. HAPPY PATH (the load-bearing ordering test): identity set + slot present
# -> REST API with the per-slot token; tea `pr create` NEVER invoked.
# ---------------------------------------------------------------------------
set +e
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-1.tmp")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: identity happy path — expected rc=0, got $rc" >&2
cat "$WORK_DIR/stderr-1.tmp" >&2
fail=1
fi
assert_contains "identity happy path reaches the API" "CURL-URL: https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls"
assert_contains "identity happy path carries the slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
assert_not_contains "identity happy path must NOT invoke tea pr create" "TEA-PR-CREATE-INVOKED"
# ---------------------------------------------------------------------------
# 2. Identity set + slot ABSENT -> fail loud BEFORE any write: nonzero, stderr
# naming identity + slot path, no tea pr create, no API request.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
set +e
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$WORK_DIR/stderr-2.tmp")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
fail=1
fi
err=$(cat "$WORK_DIR/stderr-2.tmp")
if [[ "$err" != *"agentNoSlot"* ]]; then
echo "FAIL: missing slot — stderr does not name the identity:" >&2
echo "$err" >&2
fail=1
fi
if [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
echo "FAIL: missing slot — stderr does not name the expected slot path:" >&2
echo "$err" >&2
fail=1
fi
assert_not_contains "missing slot must not reach tea pr create" "TEA-PR-CREATE-INVOKED"
assert_not_contains "missing slot must not reach the API" "CURL-URL"
# ---------------------------------------------------------------------------
# 3. Identity set + --login -> --login wins: tea runs WITH the explicit login.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
EXTRA_ARGS="--login alice"
set +e
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-3.tmp")
rc=$?
set -e
EXTRA_ARGS=""
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: login override — expected rc=0, got $rc" >&2
cat "$WORK_DIR/stderr-3.tmp" >&2
fail=1
fi
assert_contains "login override drives tea with the explicit login" "TEA: pr create --repo mosaicstack/stack --login alice"
assert_not_contains "login override must not hit the API" "CURL-URL"
# ---------------------------------------------------------------------------
# 4. Nothing set -> preserved behavior: tea path with the tea-list login.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
set +e
out=$(run_pr_create 2>"$WORK_DIR/stderr-4.tmp")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: default path — expected rc=0, got $rc" >&2
cat "$WORK_DIR/stderr-4.tmp" >&2
fail=1
fi
assert_contains "default path still uses the tea-list login" "TEA: pr create --repo mosaicstack/stack --login alice"
if [[ "$fail" -eq 0 ]]; then
echo "pr-create identity-first happy-path regression passed"
fi
exit "$fail"
@@ -0,0 +1,247 @@
#!/usr/bin/env bash
# Regression harness for pr-merge.sh identity-first principal resolution
# (mosaicstack/stack #1280).
#
# Covers:
# 1. --dry-run reports the acting principal the merge WOULD use, resolved the
# same way the real merge resolves it: --login > MOSAIC_GIT_IDENTITY /
# git config mosaic.gitIdentity > shared host credential. (The pre-#1280
# deployed copy reported a tea login that the merge would not act as.)
# 2. --dry-run fails closed when the requested principal has no credential:
# unknown --login, or an identity with no per-slot token (stderr names
# the login / the identity and its slot path).
# 3. The real merge POST carries the resolved principal's credential and no
# other: --login merges with that login's tea-config token; an identity
# merges with the per-slot token; an unresolvable --login never reaches
# the provider.
#
# Fixture pattern from test-pr-merge-head-pin.sh: the scripts under test are
# copied into a fixture tree with stubbed pr-metadata.sh / ci-queue-wait.sh
# siblings; the provider is a stubbed curl that records the credential it
# received. NEVER reads real secrets or hits a live forge.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-principal-resolution}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
TOOLS_DIR="$WORK_DIR/tools"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
SHA=0123456789abcdef0123456789abcdef01234567
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$TOOLS_DIR/git/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
chmod +x "$TOOLS_DIR/git/pr-merge.sh"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# Stubbed siblings pr-merge.sh resolves relative to its own SCRIPT_DIR.
cat > "$TOOLS_DIR/git/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"mosaicstack/stack","title":"Test PR","author":{"login":"contributor"}}'
SH
cat > "$TOOLS_DIR/git/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$TOOLS_DIR/git/pr-metadata.sh" "$TOOLS_DIR/git/ci-queue-wait.sh"
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
}
}
}
JSON
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
logins:
- name: fred-ms
url: https://git.mosaicstack.dev
token: SECRET-fred-ms-tea-token
YAML
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
: > "$LOG_FILE"
# Stubbed tea for login-list resolution only.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"fred-ms","url":"https://git.mosaicstack.dev","default":true}
]
JSON
exit 0
fi
exit 0
SH
chmod +x "$BIN_DIR/tea"
# Stubbed provider. pr-merge passes curl config on STDIN with -K -; the stub
# reads stdin, records the Authorization header it received, answers 200.
cat > "$BIN_DIR/curl" <<SH
#!/usr/bin/env bash
set -euo pipefail
url=""
out_file=""
stdin_config=""
if [[ ! -t 0 ]]; then
stdin_config="\$(cat || true)"
fi
while [[ \$# -gt 0 ]]; do
case "\$1" in
-o) out_file="\$2"; shift 2 ;;
-K|-w|--max-filesize|--max-time|--connect-timeout|-sS) shift 2 ;;
*) [[ -n "\$1" && "\$1" != -* && -z "\$url" ]] && url="\$1"
shift
;;
esac
done
auth="\$(printf '%s' "\$stdin_config" | grep -o 'Authorization: token [^"]*' || true)"
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
[[ -n "\$out_file" ]] && printf '{}' > "\$out_file"
printf '200\n'
exit 0
SH
chmod +x "$BIN_DIR/curl"
fail=0
assert_contains_log() {
local desc="$1" needle="$2"
if ! grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log does not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
assert_not_contains_log() {
local desc="$1" needle="$2"
if grep -qF -- "$needle" "$LOG_FILE"; then
echo "FAIL: $desc — log must not contain '$needle':" >&2
cat "$LOG_FILE" >&2
fail=1
fi
}
run_pr_merge() {
local extra_args="$1"; shift
(
cd "$REPO_DIR"
# shellcheck disable=SC2086 # extra_args is deliberately word-split wrapper args
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
bash "$TOOLS_DIR/git/pr-merge.sh" -n 42 $extra_args
)
}
# ---------------------------------------------------------------------------
# 1. --dry-run reports the resolved acting principal truthfully.
# ---------------------------------------------------------------------------
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentX)
if [[ "$out" != *"as git identity 'agentX' (per-slot credential)"* ]]; then
echo "FAIL: dry-run identity — principal not reported: $out" >&2
fail=1
fi
out=$(run_pr_merge "--dry-run --login fred-ms" MOSAIC_GIT_IDENTITY=agentX)
if [[ "$out" != *"as tea login 'fred-ms'"* ]]; then
echo "FAIL: dry-run login override — login not reported (must beat env identity): $out" >&2
fail=1
fi
out=$(run_pr_merge "--dry-run")
if [[ "$out" != *"as default host credential"* ]]; then
echo "FAIL: dry-run default — not reported: $out" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 2. --dry-run fails closed when the requested principal has no credential.
# ---------------------------------------------------------------------------
stderr_file="$WORK_DIR/stderr.tmp"
set +e
out=$(run_pr_merge "--dry-run --login ghost" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]] || [[ "$(cat "$stderr_file")" != *"ghost"* ]]; then
echo "FAIL: dry-run unknown --login — expected fail-loud naming 'ghost', rc=$rc" >&2
cat "$stderr_file" >&2
fail=1
fi
: > "$stderr_file"
set +e
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
rc=$?
set -e
err=$(cat "$stderr_file")
if [[ "$rc" -eq 0 ]] || [[ "$err" != *"agentNoSlot"* ]] \
|| [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
echo "FAIL: dry-run identity without slot — expected fail-loud naming identity + slot path, rc=$rc" >&2
echo "$err" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. The real merge POST carries the resolved principal's credential ONLY.
# ---------------------------------------------------------------------------
: > "$LOG_FILE"
set +e
out=$(run_pr_merge "--login fred-ms" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: merge with --login — expected rc=0, got $rc" >&2
cat "$stderr_file" >&2
fail=1
fi
assert_contains_log "merge --login uses the login token" "CURL-AUTH: Authorization: token SECRET-fred-ms-tea-token"
assert_not_contains_log "merge --login must not use the identity slot token" "SECRET-agentX-slot-token"
assert_not_contains_log "merge --login must not use the shared token" "shared-mosaicstack-token"
: > "$LOG_FILE"
set +e
out=$(run_pr_merge "" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -ne 0 ]]; then
echo "FAIL: merge with identity — expected rc=0, got $rc" >&2
cat "$stderr_file" >&2
fail=1
fi
assert_contains_log "merge identity uses the per-slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
assert_not_contains_log "merge identity must not use the shared token" "shared-mosaicstack-token"
: > "$LOG_FILE"
set +e
out=$(run_pr_merge "--login ghost" 2>"$stderr_file")
rc=$?
set -e
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: merge with unknown --login — expected nonzero, got 0" >&2
fail=1
fi
assert_not_contains_log "merge with unknown --login must not reach the provider" "CURL-URL"
if [[ "$fail" -eq 0 ]]; then
echo "pr-merge identity-first principal resolution regression passed"
fi
exit "$fail"
@@ -39,3 +39,20 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.)
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
# real system path, so on a host that installs those binaries the missing-binary
# cases cannot be measured at all. The suite's own guard (line 103) says so and
# fails rather than reporting a pass it cannot back. That guard is correct.
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
# dropped this exclusion, and the CI image provides `pi` in the system path, so
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
# the four suites after it had not run at all since the merge.
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-gitea-principal-resolution.sh && bash framework/tools/git/test-pr-create-identity-first.sh && bash framework/tools/git/test-pr-merge-principal-resolution.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
-5
View File
@@ -20,7 +20,6 @@ import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
import { registerSkillCommand } from './commands/skill.js';
import { registerStoreCommand } from './commands/store.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
@@ -426,10 +425,6 @@ registerRestoreCommand(program);
registerSkillCommand(program);
// ─── store ───────────────────────────────────────────────────────────────────
registerStoreCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
-458
View File
@@ -1,458 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Command } from 'commander';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
addStoreEntry,
getDefaultStorePaths,
listStoreEntries,
registerStoreCommand,
StoreError,
storeKindDir,
validateStoreKind,
validateStoreName,
validateStoreVersion,
type StorePaths,
} from './store.js';
/** Assert a typed StoreError with exactly the expected code. */
function expectStoreError(run: () => unknown, code: string): void {
try {
run();
} catch (error) {
expect(error).toBeInstanceOf(StoreError);
expect((error as StoreError).code).toBe(code);
return;
}
throw new Error(`expected StoreError ${code}, but nothing threw`);
}
describe('vetted user store (W-F4)', () => {
let root: string;
let paths: StorePaths;
let sourceRoot: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-store-cli-'));
paths = { userRoot: join(root, '.mosaic') };
sourceRoot = join(root, 'sources');
mkdirSync(sourceRoot, { recursive: true });
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function createSource(name: string): string {
const dir = join(sourceRoot, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
return dir;
}
describe('name and version validation (before any filesystem call)', () => {
const invalidNames = [
'../../etc',
'/abs/path',
'a/b',
String.raw`a\b`,
'-rf',
'..',
'safe.',
'space name',
'line\nbreak',
'escape\u001B[31m',
];
for (const name of invalidNames) {
it(`rejects name ${JSON.stringify(name)}`, () => {
expect(() => validateStoreName(name)).toThrow(StoreError);
});
}
const invalidVersions = ['', '-1', '1..0', 'a/b', '..', '1.0 beta', '/x'];
for (const version of invalidVersions) {
it(`rejects version ${JSON.stringify(version)}`, () => {
expect(() => validateStoreVersion(version)).toThrow(StoreError);
});
}
it('accepts semver-shaped versions including prerelease and build metadata', () => {
expect(() => validateStoreVersion('0.1.0-beta.1')).not.toThrow();
expect(() => validateStoreVersion('1.2.3+build.7')).not.toThrow();
});
it('rejects plural and unknown kinds', () => {
expectStoreError(() => validateStoreKind('plugins'), 'STORE_INVALID_KIND');
expectStoreError(() => validateStoreKind('widget'), 'STORE_INVALID_KIND');
});
it('accepts the two spec kinds', () => {
expect(() => validateStoreKind('plugin')).not.toThrow();
expect(() => validateStoreKind('skill')).not.toThrow();
});
});
describe('addStoreEntry', () => {
it('copies content into a versioned directory and writes the marker last', () => {
const result = addStoreEntry(
'skill',
'demo',
'1.0.0',
createSource('demo'),
'op',
undefined,
paths,
);
expect(result.status).toBe('added');
const entryPath = join(paths.userRoot, 'skills', 'demo', '1.0.0');
expect(result.entryPath).toBe(entryPath);
expect(existsSync(join(entryPath, 'SKILL.md'))).toBe(true);
expect(existsSync(join(entryPath, 'store-entry.json'))).toBe(true);
const meta = JSON.parse(readFileSync(join(entryPath, 'store-entry.json'), 'utf-8'));
expect(meta).toMatchObject({
schema: 1,
kind: 'skill',
name: 'demo',
version: '1.0.0',
vettedBy: 'op',
});
expect(typeof meta['vettedAt']).toBe('string');
});
it('writes plugins under plugins/ and skills under skills/', () => {
addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'op', undefined, paths);
addStoreEntry('skill', 'beta', '2.0.0', createSource('beta'), 'op', undefined, paths);
expect(existsSync(join(paths.userRoot, 'plugins', 'alpha', '0.1.0'))).toBe(true);
expect(existsSync(join(paths.userRoot, 'skills', 'beta', '2.0.0'))).toBe(true);
});
it('is append-only: an existing version with a marker is refused, not overwritten', () => {
const sourceA = createSource('demo');
const sourceB = join(sourceRoot, 'demo-other');
mkdirSync(sourceB, { recursive: true });
writeFileSync(join(sourceB, 'SKILL.md'), '# changed\n');
addStoreEntry('skill', 'demo', '1.0.0', sourceA, 'op', undefined, paths);
expectStoreError(
() => addStoreEntry('skill', 'demo', '1.0.0', sourceB, 'op', undefined, paths),
'STORE_ALREADY_PRESENT',
);
expect(
readFileSync(join(paths.userRoot, 'skills', 'demo', '1.0.0', 'SKILL.md'), 'utf-8'),
).toBe('# demo\n');
});
it('allows a second version alongside the first', () => {
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
const result = addStoreEntry(
'skill',
'demo',
'1.1.0',
createSource('demo'),
'op',
undefined,
paths,
);
expect(result.status).toBe('added');
expect(readdirSync(join(paths.userRoot, 'skills', 'demo')).sort()).toEqual([
'1.0.0',
'1.1.0',
]);
});
it('refuses an unmarked target directory by default and preserves its content', () => {
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
mkdirSync(unmarked, { recursive: true });
writeFileSync(join(unmarked, 'SKILL.md'), '# operator content\n');
expectStoreError(
() => addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths),
'STORE_TARGET_UNMARKED',
);
// The operator's hand-placed content survives the refusal.
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# operator content\n');
});
it('reclaims an unmarked target only under explicit reclaim opt-in', () => {
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
mkdirSync(unmarked, { recursive: true });
writeFileSync(join(unmarked, 'SKILL.md'), '# torn write\n');
const result = addStoreEntry(
'skill',
'demo',
'1.0.0',
createSource('demo'),
'op',
undefined,
paths,
{ reclaim: true },
);
expect(result.status).toBe('reclaimed-unmarked');
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# demo\n');
});
it('reclaim can never destroy a marked, vetted entry (append-only holds under --reclaim)', () => {
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
const marked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
const vettedContent = readFileSync(join(marked, 'SKILL.md'), 'utf-8');
expectStoreError(
() =>
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths, {
reclaim: true,
}),
'STORE_ALREADY_PRESENT',
);
// Load-bearing half: the throw alone does not prove nothing was deleted
// before it. Pins the marker-check-before-reclaim-check ordering against
// the guard-clause-migrates-upward refactor (review finding on b2124c6).
expect(readFileSync(join(marked, 'SKILL.md'), 'utf-8')).toBe(vettedContent);
expect(existsSync(join(marked, 'store-entry.json'))).toBe(true);
});
it('refuses a missing source with a typed error', () => {
expectStoreError(
() =>
addStoreEntry('skill', 'demo', '1.0.0', join(sourceRoot, 'nope'), 'op', undefined, paths),
'STORE_SOURCE_MISSING',
);
});
it('refuses a file (non-directory) source with a typed error', () => {
const filePath = join(sourceRoot, 'file.txt');
writeFileSync(filePath, 'x');
expectStoreError(
() => addStoreEntry('skill', 'demo', '1.0.0', filePath, 'op', undefined, paths),
'STORE_SOURCE_NOT_DIR',
);
});
it('refuses a symlinked source with a typed error and writes nothing', () => {
const real = createSource('demo');
const link = join(sourceRoot, 'demo-link');
symlinkSync(real, link);
expectStoreError(
() => addStoreEntry('skill', 'demo', '1.0.0', link, 'op', undefined, paths),
'STORE_SOURCE_SYMLINK',
);
expect(existsSync(join(paths.userRoot, 'skills', 'demo'))).toBe(false);
});
it('refuses a source tree containing nested symlinks and writes nothing', () => {
const src = createSource('demo');
const target = join(sourceRoot, 'elsewhere');
mkdirSync(target, { recursive: true });
symlinkSync(target, join(src, 'escape'));
expectStoreError(
() => addStoreEntry('skill', 'demo', '1.0.0', src, 'op', undefined, paths),
'STORE_SOURCE_SYMLINK',
);
expect(existsSync(join(paths.userRoot, 'skills', 'demo'))).toBe(false);
});
it('refuses adding from inside the store itself', () => {
const first = addStoreEntry(
'skill',
'demo',
'1.0.0',
createSource('demo'),
'op',
undefined,
paths,
);
expectStoreError(
() => addStoreEntry('skill', 'copy', '1.0.0', first.entryPath, 'op', undefined, paths),
'STORE_SOURCE_INSIDE_STORE',
);
});
it('refuses a symlinked user root ancestor', () => {
const linkedRoot = join(sourceRoot, 'linked-mosaic');
symlinkSync(paths.userRoot, linkedRoot);
expectStoreError(
() =>
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, {
userRoot: linkedRoot,
}),
'STORE_SYMLINK_ROOT',
);
});
it('requires a non-empty vetting attribution', () => {
expectStoreError(
() => addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), ' ', undefined, paths),
'STORE_INVALID_VETTER',
);
});
});
describe('listStoreEntries', () => {
it('returns empty for an absent store without creating it', () => {
expect(listStoreEntries(paths)).toEqual([]);
expect(existsSync(paths.userRoot)).toBe(false);
});
it('lists entries deterministically with vetting metadata', () => {
addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'fred', undefined, paths);
addStoreEntry('skill', 'beta', '2.0.0', createSource('beta'), 'fargo', 'looked fine', paths);
addStoreEntry('skill', 'beta', '2.1.0', createSource('beta'), 'fargo', undefined, paths);
const entries = listStoreEntries(paths);
expect(entries.map((e) => `${e.kind}:${e.name}:${e.version}`)).toEqual([
'plugin:alpha:0.1.0',
'skill:beta:2.0.0',
'skill:beta:2.1.0',
]);
expect(entries[0]?.meta?.vettedBy).toBe('fred');
expect(entries[1]?.meta?.notes).toBe('looked fine');
});
it('classifies markerless version directories as incomplete', () => {
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
mkdirSync(join(paths.userRoot, 'skills', 'demo', '2.0.0'), { recursive: true });
const entries = listStoreEntries(paths, { kind: 'skill', name: 'demo' });
expect(entries.find((e) => e.version === '1.0.0')?.status).toBe('vetted');
expect(entries.find((e) => e.version === '2.0.0')?.status).toBe('incomplete');
});
it('classifies malformed marker JSON as invalid-metadata, not vetted', () => {
addStoreEntry('skill', 'demo', '1.0.0', createSource('demo'), 'op', undefined, paths);
writeFileSync(
join(paths.userRoot, 'skills', 'demo', '1.0.0', 'store-entry.json'),
'{not json',
);
const entries = listStoreEntries(paths);
expect(entries[0]?.status).toBe('invalid-metadata');
});
it('surfaces foreign files (never mutates them)', () => {
mkdirSync(join(paths.userRoot, 'skills'), { recursive: true });
writeFileSync(join(paths.userRoot, 'skills', 'stray.txt'), 'x');
const entries = listStoreEntries(paths);
expect(entries[0]?.status).toBe('foreign');
expect(existsSync(join(paths.userRoot, 'skills', 'stray.txt'))).toBe(true);
});
it('filters by kind and name', () => {
addStoreEntry('plugin', 'alpha', '0.1.0', createSource('alpha'), 'op', undefined, paths);
addStoreEntry('skill', 'beta', '1.0.0', createSource('beta'), 'op', undefined, paths);
expect(listStoreEntries(paths, { kind: 'plugin' }).map((e) => e.name)).toEqual(['alpha']);
expect(listStoreEntries(paths, { name: 'beta' }).map((e) => e.name)).toEqual(['beta']);
expect(() => listStoreEntries(paths, { name: '../escape' })).toThrow(StoreError);
});
});
describe('default paths seam', () => {
it('honors MOSAIC_USER_HOME', () => {
const previous = process.env['MOSAIC_USER_HOME'];
try {
process.env['MOSAIC_USER_HOME'] = join(root, 'custom-user-home');
expect(getDefaultStorePaths().userRoot).toBe(join(root, 'custom-user-home'));
expect(storeKindDir('plugin')).toBe(join(root, 'custom-user-home', 'plugins'));
} finally {
if (previous === undefined) delete process.env['MOSAIC_USER_HOME'];
else process.env['MOSAIC_USER_HOME'] = previous;
}
});
});
describe('CLI', () => {
let previousExitCode: string | number | null | undefined;
beforeEach(() => {
previousExitCode = process.exitCode;
process.exitCode = undefined;
});
afterEach(() => {
process.exitCode = previousExitCode;
});
const parse = (args: string[]) => {
const program = new Command().exitOverride();
registerStoreCommand(program, paths);
return program.parseAsync(['node', 'mosaic', 'store', ...args]);
};
it('registers on the parent program and renders help', () => {
const program = new Command().exitOverride();
registerStoreCommand(program, paths);
const cmd = program.commands.find((c) => c.name() === 'store');
expect(cmd).toBeDefined();
expect(() => cmd?.helpInformation()).not.toThrow();
});
it('add exits nonzero with a typed code for an invalid name', async () => {
await parse([
'add',
'skill',
'../../etc',
'1.0.0',
'--from',
createSource('x'),
'--by',
'op',
]);
expect(process.exitCode).toBe(1);
});
it('add exits nonzero when the kind is plural', async () => {
await parse(['add', 'skills', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']);
expect(process.exitCode).toBe(1);
});
it('add succeeds and creates the entry directory', async () => {
await parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']);
expect(process.exitCode).toBeUndefined();
expect(lstatSync(join(paths.userRoot, 'skills', 'demo', '1.0.0')).isDirectory()).toBe(true);
});
it('add requires --by (commander requiredOption)', async () => {
await expect(
parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo')]),
).rejects.toThrow(/--by/);
});
it('add exits nonzero on an unmarked target without --reclaim, preserving content', async () => {
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
mkdirSync(unmarked, { recursive: true });
writeFileSync(join(unmarked, 'SKILL.md'), '# operator\n');
await parse(['add', 'skill', 'demo', '1.0.0', '--from', createSource('demo'), '--by', 'op']);
expect(process.exitCode).toBe(1);
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# operator\n');
});
it('add --reclaim replaces the unmarked target and succeeds', async () => {
const unmarked = join(paths.userRoot, 'skills', 'demo', '1.0.0');
mkdirSync(unmarked, { recursive: true });
writeFileSync(join(unmarked, 'SKILL.md'), '# torn\n');
await parse([
'add',
'skill',
'demo',
'1.0.0',
'--from',
createSource('demo'),
'--by',
'op',
'--reclaim',
]);
expect(process.exitCode).toBeUndefined();
expect(readFileSync(join(unmarked, 'SKILL.md'), 'utf-8')).toBe('# demo\n');
});
it('list exits 0 on an empty store', async () => {
await parse(['list']);
expect(process.exitCode).toBeUndefined();
});
});
});
-544
View File
@@ -1,544 +0,0 @@
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
type Dirent,
type Stats,
} from 'node:fs';
import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import { DEFAULT_MOSAIC_USER_HOME } from '../constants.js';
/**
* `mosaic store` — the vetted user store under `~/.mosaic/{plugins,skills}` (W-F4).
*
* Two roots with distinct ownership (HARNESS-HOMES design, frozen REV3):
* - `~/.config/mosaic/` is the SYSTEM root: update-owned, replaceable wholesale.
* - `~/.mosaic/` is the USER root: never touched by installs or updates.
*
* This module only ever writes under the USER root. The store is the vetting
* boundary: content lands here only through an explicit `store add` carrying a
* named vetting attribution, and every entry is versioned
* (`store/<kind>s/<name>/<version>/`) with a `store-entry.json` marker written
* LAST — a version directory without its marker is never a usable entry, and
* an unmarked target is REFUSED by default: it may be this tool's own debris
* from an interrupted add, or content the operator placed by hand, and the
* code cannot tell those apart — so deletion happens only under an explicit
* `--reclaim` opt-in, and the result status names what was done.
*
* Deferred by design (W-F6 and later): activation/symlink-install into agent
* homes, `current`-pointer pinning, network acquisition. `add` accepts a local
* source path only — no network, no credentials, ever.
*/
export type StoreKind = 'plugin' | 'skill';
export const STORE_KINDS: readonly StoreKind[] = ['plugin', 'skill'];
/** On-disk metadata marker; written last so its presence commits an entry. */
export const STORE_ENTRY_MARKER = 'store-entry.json';
export interface StorePaths {
/** User data root, e.g. `~/.mosaic`. */
userRoot: string;
}
export interface StoreEntryMeta {
schema: 1;
kind: StoreKind;
name: string;
version: string;
/** Absolute source path the content was vetted from, as resolved at add time. */
sourcePath: string;
/** Operator who vouched for the content — required, non-empty. */
vettedBy: string;
/** ISO timestamp of the add. */
vettedAt: string;
/** Free-form vetting notes, if any. */
notes?: string;
}
export type StoreAddStatus = 'added' | 'reclaimed-unmarked';
export interface StoreAddResult {
kind: StoreKind;
name: string;
version: string;
status: StoreAddStatus;
entryPath: string;
sourcePath: string;
}
export type StoreEntryStatus = 'vetted' | 'incomplete' | 'invalid-metadata' | 'foreign';
export interface StoreListEntry {
kind: StoreKind;
name: string;
/** Undefined for name-level foreign files (not a directory at all). */
version?: string;
status: StoreEntryStatus;
entryPath: string;
meta?: StoreEntryMeta;
}
const SAFE_STORE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const SAFE_STORE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
export class StoreError extends Error {
public readonly code: string;
public constructor(code: string, message: string) {
super(message);
this.name = 'StoreError';
this.code = code;
}
}
/** Resolve the user store root while keeping tests injectable. */
export function getDefaultStorePaths(): StorePaths {
const userRoot = process.env['MOSAIC_USER_HOME'] ?? DEFAULT_MOSAIC_USER_HOME;
return { userRoot };
}
/**
* Reject a user-supplied name before any filesystem operation.
* A store name identifies one directory under `store/<kind>s/`.
*/
export function validateStoreName(name: string): void {
if (
name.length === 0 ||
name.startsWith('-') ||
name.endsWith('.') ||
name.includes('..') ||
name.includes('/') ||
name.includes('\\') ||
isAbsolute(name) ||
!SAFE_STORE_NAME.test(name)
) {
throw new StoreError(
'STORE_INVALID_NAME',
`Invalid store name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
);
}
}
/** Versions share the name discipline plus `+` (semver build metadata). */
export function validateStoreVersion(version: string): void {
if (
version.length === 0 ||
version.startsWith('-') ||
version.endsWith('.') ||
version.includes('..') ||
version.includes('/') ||
version.includes('\\') ||
isAbsolute(version) ||
!SAFE_STORE_VERSION.test(version)
) {
throw new StoreError(
'STORE_INVALID_VERSION',
`Invalid version ${JSON.stringify(version)}: use letters, numbers, dots, underscores, hyphens, or plus; start with a letter or number; and do not use paths, "..", or a leading "-".`,
);
}
}
export function validateStoreKind(kind: string): asserts kind is StoreKind {
if (!(STORE_KINDS as readonly string[]).includes(kind)) {
throw new StoreError(
'STORE_INVALID_KIND',
`Invalid store kind ${JSON.stringify(kind)}: expected one of ${STORE_KINDS.map((k) => `"${k}"`).join(', ')}.`,
);
}
}
function validateVettedBy(vettedBy: string): void {
if (vettedBy.trim().length === 0 || vettedBy.includes('\n') || vettedBy.length > 80) {
throw new StoreError(
'STORE_INVALID_VETTER',
'Invalid --by value: name the operator vouching for this content (single line, at most 80 characters).',
);
}
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
throw error;
}
}
function assertNoSymlinkAncestors(path: string): void {
const absolute = resolve(path);
const pathRoot = parse(absolute).root;
let current = pathRoot;
for (const segment of relative(pathRoot, absolute).split(sep)) {
if (segment.length === 0) continue;
current = join(current, segment);
const entry = lstatIfPresent(current);
if (!entry) break;
if (entry.isSymbolicLink()) {
throw new StoreError(
'STORE_SYMLINK_ROOT',
`Refusing symlink ancestor at ${current}; the user store root must resolve without symlink traversal.`,
);
}
}
}
/** `plugins` for plugin, `skills` for skill — plural on disk per the layout. */
function kindDirName(kind: StoreKind): string {
return kind === 'plugin' ? 'plugins' : 'skills';
}
export function storeKindDir(kind: StoreKind, paths: StorePaths = getDefaultStorePaths()): string {
return join(paths.userRoot, kindDirName(kind));
}
function entryDir(kind: StoreKind, name: string, version: string, paths: StorePaths): string {
return join(storeKindDir(kind, paths), name, version);
}
function isInsideRoot(candidate: string, root: string): boolean {
const rel = relative(resolve(root), resolve(candidate));
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
/**
* Refuse any symlink in the source tree — the vetting boundary copies real
* content only, so a vetted entry can never carry a link that escapes it.
*
* NOTE: known check-then-use window between this walk and the `cpSync` below:
* a symlink created concurrently with the add could slip through. Accepted
* for a local, operator-run CLI; revisit before any unattended or networked
* acquisition path exists.
*/
function assertSourceTreeHasNoSymlinks(sourcePath: string): void {
const stack: string[] = [sourcePath];
while (stack.length > 0) {
const current = stack.pop()!;
for (const dirent of readdirSync(current, { withFileTypes: true })) {
const child = join(current, dirent.name);
if (dirent.isSymbolicLink()) {
throw new StoreError(
'STORE_SOURCE_SYMLINK',
`Refusing to vet content containing a symlink: ${child}. Resolve or remove symlinks before adding to the store.`,
);
}
if (dirent.isDirectory()) stack.push(child);
}
}
}
/**
* Vet and add one versioned entry to the user store.
*
* Copies the source directory (real content, no symlinks) to
* `<userRoot>/<kind>s/<name>/<version>/` and writes the `store-entry.json`
* marker LAST: a crash mid-copy leaves at most a recoverable partial, never a
* half-vetted entry that lists as present.
*/
export function addStoreEntry(
kind: StoreKind,
name: string,
version: string,
sourcePath: string,
vettedBy: string,
notes: string | undefined,
paths: StorePaths = getDefaultStorePaths(),
options: { reclaim?: boolean } = {},
): StoreAddResult {
validateStoreKind(kind);
validateStoreName(name);
validateStoreVersion(version);
validateVettedBy(vettedBy);
assertNoSymlinkAncestors(paths.userRoot);
const resolvedSource = resolve(sourcePath);
const source = lstatIfPresent(resolvedSource);
if (!source) {
throw new StoreError('STORE_SOURCE_MISSING', `Source path does not exist: ${resolvedSource}`);
}
if (source.isSymbolicLink()) {
throw new StoreError(
'STORE_SOURCE_SYMLINK',
`Refusing to vet a symlink as store content: ${resolvedSource} (points at ${resolve(sourcePath)}). Add the real directory.`,
);
}
if (!source.isDirectory()) {
throw new StoreError(
'STORE_SOURCE_NOT_DIR',
`Source path is not a directory: ${resolvedSource}`,
);
}
if (isInsideRoot(resolvedSource, paths.userRoot)) {
throw new StoreError(
'STORE_SOURCE_INSIDE_STORE',
`Refusing to add store content from inside the store itself: ${resolvedSource}`,
);
}
assertSourceTreeHasNoSymlinks(resolvedSource);
const target = entryDir(kind, name, version, paths);
const existing = lstatIfPresent(target);
let status: StoreAddStatus = 'added';
if (existing) {
if (existsSync(join(target, STORE_ENTRY_MARKER))) {
throw new StoreError(
'STORE_ALREADY_PRESENT',
`${kind} "${name}" version "${version}" is already present at ${target}; stores are append-only — add a new version instead.`,
);
}
// Unmarked target: either this tool's own debris from an interrupted add,
// or content the operator placed by hand — indistinguishable on disk. The
// USER root's contract is that tooling never destroys operator content,
// so deletion requires the explicit --reclaim opt-in (review finding on
// c23a71d7: silent rmSync under a benign-sounding status).
if (!options.reclaim) {
throw new StoreError(
'STORE_TARGET_UNMARKED',
`Target exists without ${STORE_ENTRY_MARKER}: ${target}. Refusing to delete unmarked content — if this is debris from an interrupted add, re-run with --reclaim to replace it.`,
);
}
rmSync(target, { recursive: true, force: true });
status = 'reclaimed-unmarked';
}
mkdirSync(target, { recursive: true });
cpSync(resolvedSource, target, { recursive: true });
const meta: StoreEntryMeta = {
schema: 1,
kind,
name,
version,
sourcePath: resolvedSource,
vettedBy: vettedBy.trim(),
vettedAt: new Date().toISOString(),
...(notes === undefined ? {} : { notes }),
};
writeFileSync(join(target, STORE_ENTRY_MARKER), `${JSON.stringify(meta, null, 2)}\n`);
return { kind, name, version, status, entryPath: target, sourcePath: resolvedSource };
}
function readEntryMeta(markerPath: string): { meta?: StoreEntryMeta; status: StoreEntryStatus } {
let raw: string;
try {
raw = readFileSync(markerPath, 'utf-8');
} catch {
return { status: 'invalid-metadata' };
}
try {
const parsed = JSON.parse(raw) as StoreEntryMeta;
if (
parsed?.schema === 1 &&
(STORE_KINDS as readonly string[]).includes(parsed.kind) &&
typeof parsed.name === 'string' &&
typeof parsed.version === 'string' &&
typeof parsed.vettedBy === 'string' &&
typeof parsed.vettedAt === 'string'
) {
return { meta: parsed, status: 'vetted' };
}
} catch {
// fall through
}
return { status: 'invalid-metadata' };
}
/**
* Enumerate every store entry deterministically (kind, then name, then
* version). Version directories without a marker list as `incomplete`; files
* where directories were expected list as `foreign` — surfaced, never mutated.
*/
export function listStoreEntries(
paths: StorePaths = getDefaultStorePaths(),
filter: { kind?: StoreKind; name?: string } = {},
): StoreListEntry[] {
if (filter.name !== undefined) validateStoreName(filter.name);
assertNoSymlinkAncestors(paths.userRoot);
const kinds = filter.kind ? [filter.kind] : [...STORE_KINDS];
const entries: StoreListEntry[] = [];
for (const kind of kinds) {
const kindRoot = lstatIfPresent(storeKindDir(kind, paths));
if (!kindRoot) continue;
if (!kindRoot.isDirectory()) {
entries.push({
kind,
name: kindDirName(kind),
status: 'foreign',
entryPath: storeKindDir(kind, paths),
});
continue;
}
for (const nameDirent of readdirSync(storeKindDir(kind, paths), {
withFileTypes: true,
}).sort(byName) as Dirent[]) {
if (filter.name !== undefined && nameDirent.name !== filter.name) continue;
const namePath = join(storeKindDir(kind, paths), nameDirent.name);
if (!nameDirent.isDirectory()) {
entries.push({ kind, name: nameDirent.name, status: 'foreign', entryPath: namePath });
continue;
}
const versionDirents = readdirSync(namePath, { withFileTypes: true }).sort(byName);
if (versionDirents.length === 0) {
entries.push({ kind, name: nameDirent.name, status: 'incomplete', entryPath: namePath });
continue;
}
for (const versionDirent of versionDirents) {
const versionPath = join(namePath, versionDirent.name);
if (!versionDirent.isDirectory()) {
entries.push({
kind,
name: nameDirent.name,
version: versionDirent.name,
status: 'foreign',
entryPath: versionPath,
});
continue;
}
const markerPath = join(versionPath, STORE_ENTRY_MARKER);
if (!existsSync(markerPath)) {
entries.push({
kind,
name: nameDirent.name,
version: versionDirent.name,
status: 'incomplete',
entryPath: versionPath,
});
continue;
}
const { meta, status } = readEntryMeta(markerPath);
entries.push({
kind,
name: nameDirent.name,
version: versionDirent.name,
status,
entryPath: versionPath,
...(meta === undefined ? {} : { meta }),
});
}
}
}
return entries;
}
function byName(a: Dirent, b: Dirent): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
}
function reportCommandError(error: unknown): void {
if (error instanceof StoreError) {
console.error(`store: ${error.code}: ${error.message}`);
} else {
console.error(error instanceof Error ? error.message : String(error));
}
process.exitCode = 1;
}
function displayStoreName(name: string): string {
return SAFE_STORE_NAME.test(name) ? name : JSON.stringify(name);
}
/** Register the `mosaic store` command group (W-F4). */
export function registerStoreCommand(
program: Command,
paths: StorePaths = getDefaultStorePaths(),
): void {
const store = program
.command('store')
.description('Manage the vetted user store under ~/.mosaic (plugins, skills)')
.configureHelp({ sortSubcommands: true });
store
.command('add <kind> <name> <version>')
.description(
'Vet and add a local plugin/skill directory to the user store (versioned, append-only)',
)
.requiredOption('--from <path>', 'Local source directory to vet (no network acquisition)')
.requiredOption('--by <operator>', 'Name of the operator vouching for this content')
.option('--notes <notes>', 'Vetting notes recorded in the entry metadata')
.option(
'--reclaim',
'Replace an existing UNMARKED target directory (e.g. debris from an interrupted add); refuses without this flag',
)
.action(
async (
kind: string,
name: string,
version: string,
opts: {
from: string;
by: string;
notes?: string;
reclaim: boolean;
},
) => {
try {
const result = addStoreEntry(
kind as StoreKind,
name,
version,
opts.from,
opts.by,
opts.notes,
paths,
{ reclaim: opts.reclaim },
);
const suffix =
result.status === 'reclaimed-unmarked' ? ' (replaced unmarked directory)' : '';
console.log(
`${result.kind} ${displayStoreName(result.name)} ${result.version}: added${suffix}`,
);
console.log(` entry: ${result.entryPath}`);
console.log(` vetted by ${opts.by.trim()}`);
} catch (error: unknown) {
reportCommandError(error);
}
},
);
store
.command('list')
.description('List store entries with vetting status')
.option('--kind <kind>', 'Filter by kind (plugin | skill)')
.option('--name <name>', 'Filter by entry name')
.action((opts: { kind?: string; name?: string }) => {
try {
let kind: StoreKind | undefined;
if (opts.kind !== undefined) {
validateStoreKind(opts.kind);
kind = opts.kind;
}
const entries = listStoreEntries(paths, {
...(kind === undefined ? {} : { kind }),
...(opts.name === undefined ? {} : { name: opts.name }),
});
if (entries.length === 0) {
console.log('No store entries found.');
return;
}
for (const entry of entries) {
const version = entry.version ?? '-';
const vetter = entry.meta?.vettedBy ?? '-';
console.log(
`${entry.status.padEnd(17)}${entry.kind.padEnd(8)}${displayStoreName(entry.name).padEnd(24)}${version.padEnd(16)}${vetter}`,
);
}
} catch (error: unknown) {
reportCommandError(error);
}
});
}
-7
View File
@@ -5,13 +5,6 @@ export const VERSION = '0.0.2';
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
/**
* USER data root (HARNESS-HOMES two-root split): everything under here is user
* content — never replaced or removed by installs, updates, or uninstallers.
* Distinct from the SYSTEM root above, which is update-owned.
*/
export const DEFAULT_MOSAIC_USER_HOME = join(homedir(), '.mosaic');
export const DEFAULTS = {
agentName: 'Assistant',
roleDescription: 'execution partner and visibility engine',