Compare commits

..
Author SHA1 Message Date
fred b7a6179a58 installer: harden the Node provisioning path against its own inputs
ci/woodpecker/pr/ci Pipeline is pending approval
Answers the review on #1228. Each item below was measured against the pre-change
code, and where the review's stated consequence did not reproduce, that is recorded
rather than repeated.

BLOCKER -- `mapfile` is a Bash 4 builtin and macOS ships Bash 3.2, which this
installer supports (node_platform names Darwin). newest_matching_file was therefore
unavailable on macOS, and an empty answer is exactly what sends the uninstaller down
its delete-the-destination branch. The lookup no longer renders candidates as text at
all: the glob output is compared in-shell by mtime, via a stat helper that probes for
GNU -c vs BSD -f once. That removes the Bash 4 dependency, the `ls | head` SIGPIPE
failure, and the newline-splitting bug together, because all three came from turning
filenames into lines.

The function now distinguishes three outcomes instead of two: found, nothing matched,
and could-not-tell. Callers act destructively on the answer, so the third case had to
stop being indistinguishable from the second. The uninstaller leaves the file in place
on an unanswerable lookup, and the manifest builder refuses to record a null backup it
cannot vouch for.

HIGH -- writing ~/.profile does not reach the shells that matter. A bash login shell
reads the first of .bash_profile / .bash_login / .profile that exists and never looks
at the rest, so on a host with either of the first two the entry was a silent no-op; a
non-interactive remote zsh reads .zshenv and neither .zprofile nor .zshrc, which is
what the previous version wrote; and a systemd --user unit reads no shell file at all,
which is how a Mosaic agent seat starts. All four are now covered, with .bash_profile
and .bash_login appended to only when they already exist -- creating one would itself
start shadowing .profile. The systemd case is an environment.d drop-in.

MEDIUM -- the checksum lookup interpolated the filename into a grep pattern. A Node
tarball name is mostly dots, and a dot matches any character, so a manifest line for a
different-but-regex-equivalent name was accepted as this file's checksum. Confirmed
against the old function: it accepted the decoy. Filenames are now compared exactly,
every line is read so a duplicate entry is refused rather than silently resolved, and
the digest must look like a SHA-256.

MEDIUM -- the PATH line is executed by every future shell that reads the file, and the
directory was interpolated unescaped. A path containing shell syntax is now refused
with a message instead of written.

MEDIUM -- the idempotence check was an unanchored substring match, so a commented-out
example of the same export made the installer skip the real entry. Reproduced against
the old function, and now anchored with grep -Fqx.

MEDIUM -- MOSAIC_NODE_DIST accepted any scheme. https:// and file:// only. The
narrower point in the review stands and is not fixed by this: when the dist is
overridden, the tarball and the checksum that vouches for it come from the same place,
so the gate is integrity and not authenticity.

HIGH, with a correction -- MOSAIC_NODE_VERSION is now validated before it becomes a
path, but the review's specific consequence does not reproduce. `rm -rf` on a path
ending in `..` is refused by rm itself, and a traversal version mangles the download
URL so the run dies at curl long before the removal. Both were measured. The check is
defence in depth and a clearer error, not a demonstrated hole being closed.

Also removed a second `| head -1` in node_resolve_version, the same SIGPIPE shape as
the one this PR already fixed, and the index result is validated before it becomes a
path.

Tests. The review was right that several existing cases passed on the unpatched code.
The version-selection case now lists a higher major first and an older release of the
right major after the right answer, so "first entry" and "last match" both fail it.
The PATH case starts a real login shell and asks it to resolve node, rather than
grepping for text the installer just wrote. The checksum-failure case asserts nothing
survives, including the staging directory. New cases cover the empty manifest, the
regex-equivalent decoy, the duplicate entry, the invalid version, the non-https dist,
the shell-syntax path, the commented-out profile line, the .bash_profile shadow, and
the environment.d drop-in. Each new case was run against the pre-change installer:
the decoy, the commented-out line, the .bash_profile shadow and environment.d all go
red there, which is the evidence that they test something.

Bash 3.2 cannot be executed here, so the portability guard is a lint over install.sh
for Bash 4 syntax. It is a weaker instrument than a run and is not claimed otherwise
-- but every Bash 4 construct that has broken macOS in this file was added by someone
who was not running it there either.

test:installer passes.
2026-08-15 13:46:48 -05:00
Jason WoltjeandClaude Opus 5 06c714ddf3 installer: stop newest_matching_file from dying on SIGPIPE
ci/woodpecker/pr/ci Pipeline is pending approval
newest_matching_file() piped `ls -1t` into `head -1`. Under `set -o pipefail`
head closes the pipe after the first line, ls dies on SIGPIPE, and the function
returns 141 having printed nothing. Its callers assign it at top level under
`set -e`, so that 141 aborts the install.

It takes roughly 1600 matching names to fill the pipe buffer, which is why this
has sat unnoticed: with two or three files the old code is correct. Measured on
origin/next with 5001 matches, the function returns 141 and prints nothing; with
this change it returns rc=0 and the right filename.

Two of the four callers are the "find the newest .mosaic-bak-* backup" lookup,
which is the path a restore leans on.

Reading the listing into an array through process substitution has no pipeline,
so there is nothing for pipefail to catch. This also clears the one remaining
violation `scripts/pipefail-early-exit.test.mjs` reports against tools/install.sh
-- that test lives on main, not on next, so it starts failing the moment main is
merged into next for the 0.0.50 integration.

tools/install-newest-matching-file.test.sh pins it, including the large-population
case that is the whole point. Red on origin/next (rc=141), green here.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-15 12:51:59 -05:00
Jason WoltjeandClaude Opus 5 cb2bf4e4a4 installer: provision Node instead of refusing to run without it
ci/woodpecker/pr/ci Pipeline is pending approval
The installer's promise is that one command turns a bare host into a working
one, but Node was carved out of that: it was checked as a prerequisite and the
run died on a greenfield host. That made the documented one-command install a
two-command install whose first command always failed.

It now installs a user-local Node under ~/.mosaic/node when the system Node is
missing or too old, from the official nodejs.org tarballs, verified against
SHASUMS256.txt. User-local rather than apt/dnf/brew: no root, one code path on
every distro, and it works on an immutable host. A system Node that is already
new enough is preferred and left untouched. --no-node-install (or
MOSAIC_NO_NODE_INSTALL=1) keeps the old refuse-and-explain behaviour, and
neither --check nor --uninstall provisions anything.

PATH now lands in the login profile as well as the interactive rc. Writing only
~/.bashrc looked right interactively and was invisible to every way an agent
seat actually starts -- bash -lc, ssh host cmd, a systemd unit -- because
Debian's .bashrc returns early when non-interactive.

Verified end to end on mosaic-sbx-dev rolled back to its greenfield snapshot:
red on origin/next (rc=1, "Required command not found: node"), green with this
change (Node v22.23.2 fetched and verified, CLI 0.0.50-next.2413 installed), and
a fresh `bash -lc` finds both. tools/install-node-provisioning.test.sh pins the
behaviour offline against a file:// dist fixture, including the refusals and the
checksum gate.

The next-lane test's Node 20 case moves to --no-node-install: the >= 22 gate must
still fire before anything is installed, but refusing is no longer the outcome
when provisioning is allowed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WYgWocp36goy8hj2ui6ps1
2026-08-15 12:44:39 -05:00
30 changed files with 1200 additions and 2553 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"typecheck": "pnpm preflight && turbo run typecheck", "typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs", "test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer", "test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh", "test:installer": "bash tools/install-next-lane.test.sh && bash tools/install-node-provisioning.test.sh && bash tools/install-newest-matching-file.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "node scripts/install-hooks.mjs" "prepare": "node scripts/install-hooks.mjs"
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// homedir/platform are read at call time, so they can be stubbed per case.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
homedir: () => '/home/tester',
platform: () => mockPlatform,
};
});
let mockPlatform: NodeJS.Platform = 'linux';
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
describe('getShellProfilePath', () => {
const originalShell = process.env['SHELL'];
const originalZdotdir = process.env['ZDOTDIR'];
beforeEach(() => {
mockPlatform = 'linux';
delete process.env['ZDOTDIR'];
});
afterEach(() => {
if (originalShell === undefined) delete process.env['SHELL'];
else process.env['SHELL'] = originalShell;
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
else process.env['ZDOTDIR'] = originalZdotdir;
});
// The regression this guards: setupPath() in stages/finalize.ts appends the
// PATH export to whatever this returns. A line written to ~/.bashrc is
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
// default .bashrc returns early for non-interactive shells — so an install
// reported success and left `mosaic: command not found`. Same for .zshrc,
// which zsh only reads for interactive shells.
it('never targets an interactive-only rc file', () => {
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
process.env['SHELL'] = shell;
const profile = getShellProfilePath();
expect(profile).not.toMatch(/\.bashrc$/);
expect(profile).not.toMatch(/\.zshrc$/);
}
});
it('uses ~/.profile for bash', () => {
process.env['SHELL'] = '/bin/bash';
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('uses ~/.zshenv for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
});
it('honours ZDOTDIR for zsh', () => {
process.env['SHELL'] = '/usr/bin/zsh';
process.env['ZDOTDIR'] = '/custom/zdot';
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
});
it('falls back to ~/.profile for an unknown shell', () => {
process.env['SHELL'] = '/bin/somethingelse';
expect(detectShell()).toBe('unknown');
expect(getShellProfilePath()).toBe('/home/tester/.profile');
});
it('still routes fish to its own config', () => {
process.env['SHELL'] = '/usr/bin/fish';
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
});
});
-58
View File
@@ -35,18 +35,6 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Normalize the ambient umask so directory modes are a property of the installer
# and not of whatever shell invoked it (#1236). Debian/Ubuntu ship umask 002, so
# every `mkdir -p` below yielded 0775 — and the fleet env boundary rejects any
# managed directory with `mode & 0o022`, which made `mosaic fleet init --write`
# impossible on a stock install of those distros. Fedora/RHEL ship 022 and did
# not trip it, so the product worked or did not depending on the operator's
# login shell. 022 is what this script already assumes it produces: see the
# umask note in make_durable_snapshot, which restores to the ambient value
# precisely so "every later sync copy and new framework dir" gets 0644/0755.
# Now that value is 022 rather than whatever was inherited.
umask 022
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) — # Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
# never an environment variable — so this opt-out can never sit silently # never an environment variable — so this opt-out can never sit silently
# inherited in a shell profile. See #869 Point-1 C2. # inherited in a shell profile. See #869 Point-1 C2.
@@ -708,52 +696,6 @@ sync_framework
mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials" mkdir -p "$TARGET_DIR/credentials"
# Three directories must be 0700, not merely not-group-writable (#1236).
# The fleet code guards them with two different masks in two different
# languages, and the strict one wins:
#
# assertPrivateManagedDirectory (fleet-reconciler.js, `mode & 0o077`)
# -> MOSAIC_HOME and MOSAIC_HOME/fleet, checked before the roster lock is
# taken, so every mutating `mosaic fleet` command dies at 0755.
# assert_private_directory (tools/fleet/start-agent-session.sh, `mode & 077`)
# -> MOSAIC_HOME/fleet/agents, checked before a pane is ever spawned.
#
# Their laxer siblings (`mode & 0o022`) accept 0755, which is why normalizing
# the umask above is necessary and not sufficient — a correct umask-022 install
# still produces 0755 and still cannot run `mosaic fleet init --write`. Say the
# strict modes outright rather than inferring them from a umask.
#
# Only these. The rest of the tree is content, stays 0755, and is only ever
# reached by the 0o022 checks, which 0755 satisfies.
chmod 700 "$TARGET_DIR" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR — 'mosaic fleet' mutations will fail as unsafe-permissions."
if [[ -d "$TARGET_DIR/fleet" ]]; then
chmod 700 "$TARGET_DIR/fleet" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/fleet — 'mosaic fleet' mutations will fail as unsafe-permissions."
fi
# fleet/agents does not exist on a first install — the CLI creates it 0700 on
# demand. It is chmod'd here for the UPGRADE case: a tree built under umask 002
# has it at 0775, and the repair sweep below cannot rescue it, because stripping
# group/other write from 0755 leaves 0750 and `mode & 077` is still non-zero.
if [[ -d "$TARGET_DIR/fleet/agents" ]]; then
chmod 700 "$TARGET_DIR/fleet/agents" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions."
fi
# credentials/ holds secrets and was never meant to be group-readable either.
# It is not on the fleet boundary, so a failure here breaks nothing — but it is
# the one directory where a silently-failed chmod leaves secrets group-readable,
# which is precisely the failure worth a line in the output.
chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || \
warn "Could not set 0700 on $TARGET_DIR/credentials — stored secrets may be readable by other users on this host."
# Repair an existing tree. The umask above only governs directories this run
# creates, so a host installed under umask 002 before this fix keeps its 0775
# dirs through every upgrade and stays broken. Strips group/other WRITE only —
# never read or execute — so it can repair the boundary violation without
# changing who can traverse or read anything. Scoped to directories: file modes
# are the manifest's business, not this fix's.
find "$TARGET_DIR" -type d -perm /022 -exec chmod go-w {} + 2>/dev/null || true
# Reconcile contract files from defaults/ into the framework root: framework-owned # Reconcile contract files from defaults/ into the framework root: framework-owned
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent # files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
# copy is backed up once); user-seeded files (TOOLS) are written on first install only. # copy is backed up once); user-seeded files (TOOLS) are written on first install only.
@@ -4,14 +4,6 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
Requires=mosaic-tmux-holder.service Requires=mosaic-tmux-holder.service
After=mosaic-tmux-holder.service After=mosaic-tmux-holder.service
PartOf=mosaic-tmux-holder.service PartOf=mosaic-tmux-holder.service
# Do not attempt a seat before its generated env exists. `install` enables this
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
# generated env, so between `install` and the first `apply`/`regen --write` there
# is a boot window where ExecStart would run against an absent env file and the
# launcher would fail the unit. A skipped unit is the honest state for "enabled
# but not yet configured"; systemd re-evaluates the condition on every start, so
# the seat comes up on the next start once the reconciler has written env.
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
[Service] [Service]
Type=oneshot Type=oneshot
@@ -128,14 +128,6 @@ EOF
sleep 30 sleep 30
EOF EOF
chmod 700 "$AGENT_BIN/mosaic" chmod 700 "$AGENT_BIN/mosaic"
# The launcher resolves the roster's runtime against PANE_PATH before it
# spawns anything (#1241), so the runtime this projection names has to be
# present here even though the fake `mosaic` above never execs it.
cat > "$AGENT_BIN/pi" <<'EOF'
#!/bin/sh
sleep 30
EOF
chmod 700 "$AGENT_BIN/pi"
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort) server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort) server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
@@ -225,54 +225,6 @@ else
warn "mosaic-ensure-sequential-thinking helper missing" warn "mosaic-ensure-sequential-thinking helper missing"
fi fi
# Fleet transport binary (#1240).
#
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
# path provides tmux and, until now, nothing here noticed it was absent. On a
# greenfield host that produced a fleet which installed clean, started clean,
# and had no live seat; `mosaic fleet ps` was the operator's first and only
# signal that anything was wrong.
#
# The roster's own `transport:` is read rather than assumed, so a host that
# declares something other than tmux is told about the binary it actually
# needs. Absent a roster the check still runs — `mosaic fleet init` will
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
#
# `tools/install.sh` carries a deliberately parallel check at the end of its
# summary. The two are separate because the installer must be able to say this
# before the framework's own scripts are guaranteed to be on disk; keep their
# wording in step.
fleet_declared_transport() {
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
fi
printf '%s\n' "${declared:-tmux}"
}
check_fleet_transport() {
local transport
transport="$(fleet_declared_transport)"
if command -v "$transport" >/dev/null 2>&1; then
pass "Fleet transport available: $transport"
return
fi
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
else
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
fi
}
check_fleet_transport
# Legacy migration surfaces should no longer contain symlink trees. # Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=( legacy_paths=(
"$HOME/.claude/agent-guides" "$HOME/.claude/agent-guides"
@@ -1,215 +0,0 @@
#!/usr/bin/env bash
# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in
# `tools/install.sh`.
#
# Both checks answer the same question — "can a seat actually launch on this
# host?" — from two different places, because the installer has to be able to
# answer it before the framework's own scripts are guaranteed to be on disk.
# Two implementations of one rule is exactly the shape that drifts, so this
# harness drives BOTH, in one file, from the same table of cases.
#
# The functions are extracted from the shipped scripts rather than copied here.
# A test that carries its own copy of the logic is a test that keeps passing
# after the shipped copy changes — the failure mode this whole change is about.
# Extraction is by exact function header and a closing brace in column one; if
# either script is reshaped so that stops matching, the extraction yields
# nothing and this fails loudly instead of silently measuring an empty string.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo
INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
# The cases below run with PATH set to a directory that deliberately does not
# contain a shell, and a PATH assignment on a command also governs how that
# command is looked up — so bash has to be named absolutely or it becomes the
# thing that is missing.
BASH_BIN=$(command -v bash) || fail "host is missing 'bash'"
# A PATH containing exactly the utilities these functions use and nothing else.
# The absent-transport cases are only meaningful on a PATH where the transport
# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin —
# so the system path cannot be part of the path under test.
FAKE_BIN="$ROOT/bin"
mkdir -p "$FAKE_BIN"
for utility in sed head tr awk; do
utility_path=$(command -v "$utility") || fail "host is missing '$utility'"
ln -s "$utility_path" "$FAKE_BIN/$utility"
done
if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then
fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable"
fi
# Extract a function by its exact header, up to a closing brace in column one.
extract_function() {
local source_file="$1"
local function_name="$2"
local destination="$3"
awk -v name="$function_name" '
$0 == name "() {" { collecting = 1 }
collecting { print }
collecting && $0 == "}" { exit }
' "$source_file" > "$destination"
grep -qF "$function_name() {" "$destination" ||
fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?"
# An unterminated extraction would be a syntax error the moment it is sourced,
# but saying so here names the cause instead of leaving a bash parse error.
bash -n "$destination" ||
fail "extracted '$function_name' does not parse; the closing brace was probably not found"
}
extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh"
extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh"
extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh"
# Build a MOSAIC_HOME, optionally with a roster declaring a transport.
make_home() {
local home="$ROOT/$1"
local declared="${2-}"
rm -rf "$home"
mkdir -p "$home"
if [ -n "$declared" ]; then
mkdir -p "$home/fleet"
cat > "$home/fleet/roster.yaml" <<EOF
version: 2
generation: 1
transport: $declared
agents: []
EOF
fi
printf '%s\n' "$home"
}
# Run the doctor's check against a given home and path, capturing which
# reporter the check chose. The real `pass` prints only under `--verbose` and
# the real `warn` always prints; these stubs make both unconditional on
# purpose, because what is under test is the severity the check selects, not
# whether the default verbosity happens to show it. A check that warned where
# it should pass would otherwise be invisible here.
run_doctor_check() {
local home="$1"
local path="$2"
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
set -euo pipefail
warn() { echo "[WARN] $*"; }
pass() { echo "[OK] $*"; }
MOSAIC_HOME="$1"
source "$2"
source "$3"
check_fleet_transport
' _ "$home" "$ROOT/doctor-declared.sh" "$ROOT/doctor-check.sh" 2>&1
}
run_installer_check() {
local home="$1"
local path="$2"
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
set -euo pipefail
warn() { echo "[WARN] $*"; }
C="" RESET=""
MOSAIC_HOME="$1"
source "$2"
check_fleet_transport
' _ "$home" "$ROOT/installer-check.sh" 2>&1
}
# A transport that exists. Named tmux because that is what the default roster
# declares; the binary never runs, it only has to resolve.
PRESENT_BIN="$ROOT/present-bin"
mkdir -p "$PRESENT_BIN"
printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux"
chmod +x "$PRESENT_BIN/tmux"
PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN"
# ── absent, no roster ────────────────────────────────────────────────────────
# Nothing has been configured yet, so the honest thing to point at is `init`.
home=$(make_home no-roster)
output=$(run_doctor_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent"
echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport"
echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init"
output=$(run_installer_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent"
echo "$output" | grep -qF 'reports success and no seat comes up' ||
fail "installer warning did not say what the missing transport actually breaks"
# ── absent, roster present ───────────────────────────────────────────────────
# A configured fleet that cannot launch is a stronger statement than a
# hypothetical one, and the message says so.
home=$(make_home with-roster tmux)
output=$(run_doctor_check "$home" "$FAKE_BIN")
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent"
echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found"
echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start"
# ── present ──────────────────────────────────────────────────────────────────
# Silence from the installer, and a pass (not a warning) from the audit.
for home_name in no-roster with-roster; do
home="$ROOT/$home_name"
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
if echo "$output" | grep -qF '[WARN]'; then
fail "doctor warned about the transport while tmux was present ($home_name)"
fi
echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)"
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
if [ -n "$output" ]; then
fail "installer was not silent with tmux present ($home_name): $output"
fi
done
# ── the roster declares something other than tmux ────────────────────────────
# The roster is read, not assumed. A host that declares a different transport
# is told about the binary it actually needs, and never about tmux — being sent
# to install the wrong package is worse than no advice at all.
home=$(make_home other-transport zellij)
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport"
if echo "$output" | grep -qF 'tmux'; then
fail "doctor named tmux for a host whose roster declares zellij"
fi
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport"
if echo "$output" | grep -qF 'tmux'; then
fail "installer named tmux for a host whose roster declares zellij"
fi
# ── a quoted or trailing-comment transport value ─────────────────────────────
# YAML permits both and neither is exotic; a check that installs `tmux"` or
# reads `tmux # default` as a binary name would send the operator nowhere.
home=$(make_home quoted-transport '"tmux" # the only transport today')
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
echo "$output" | grep -qF '[OK] Fleet transport available: tmux' ||
fail "doctor did not parse a quoted/commented transport value: $output"
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
if [ -n "$output" ]; then
fail "installer did not parse a quoted/commented transport value: $output"
fi
echo "ok - fleet transport checks (mosaic-doctor + install.sh)"
@@ -286,36 +286,6 @@ _build_runtime_bin_prefix() {
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
# environment. A binary missing from *that* path is a pane that dies in under a
# second, inside a session nobody is attached to, with its diagnostic scrolled
# into a pane tmux then destroys. Resolve both here, before any effect, where
# the failure is still attributable to the thing that caused it.
#
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
# is named exactly like the runtime, so resolving the runtime name is the same
# question the pane will ask a moment later — asked while an operator can still
# see the answer.
_resolve_in_pane_path() {
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
}
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
# here the data is fine and the host is not ready. Callers tell the individual
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
fail_launch() {
local code="$1"
shift
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
exit 69
}
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
_resolve_in_pane_path "$required_binary" >/dev/null ||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
done
_ensure_claude_workdir_trusted() { _ensure_claude_workdir_trusted() {
local workdir="$1" local workdir="$1"
local resolved local resolved
@@ -414,19 +384,6 @@ if [ -n "$PANE_PID" ]; then
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
# #1241. Session present, no pane PID after a second of retries. Whatever this
# is, it is not a seat an operator can use, so it is not a success either.
fail_launch pane-pid-unresolved \
"tmux reports the session but no pane PID after 5 attempts"
else else
# #1241. This branch used to print a WARNING about the heartbeat sidecar and echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
# command exits, so an absent session one second after new-session means the
# runtime died on startup. Reporting it as success is what let `fleet start`
# return 0 over three dead panes — the launcher knew, and said the wrong thing
# at the wrong severity to the wrong layer.
fail_launch pane-did-not-survive \
"the pane exited immediately and tmux destroyed the session;" \
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
fi fi
@@ -23,26 +23,8 @@ index=0
if [ "${args[0]:-}" = -L ]; then index=2; fi if [ "${args[0]:-}" = -L ]; then index=2; fi
case "${args[$index]:-}" in case "${args[$index]:-}" in
has-session) has-session)
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
# other targets that should answer too — without it there is no way to
# model "tmux still reports the session" for a non-holder agent, and the
# launcher's pane-pid-unresolved branch is unreachable from this harness.
#
# A listed target answers only AFTER new-session, because the launcher asks
# this question twice about the same name: once before launching, where a
# yes means "already running, nothing to do, exit 0", and once after, where
# a yes means "the session survived". A shim that answered yes to both
# would short-circuit at the first and never reach the branch under test —
# it would look like coverage and measure the idempotency path instead.
for argument in "${args[@]}"; do for argument in "${args[@]}"; do
[ "$argument" = '=_holder:0.0' ] && exit 0 [ "$argument" = '=_holder:0.0' ] && exit 0
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
*" $argument "*)
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
exit 0
fi
;;
esac
done done
exit 1 exit 1
;; ;;
@@ -80,30 +62,6 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
SHIM SHIM
chmod +x "$FAKE_BIN/mosaic" chmod +x "$FAKE_BIN/mosaic"
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
# before spawning (#1241), so it has to exist somewhere the pane would find it —
# not merely on the launcher's own PATH.
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
chmod +x "$FAKE_BIN/pi"
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
# it would splice whatever the host has installed into the path under test, and
# the missing-binary cases below would pass or fail by accident of the machine.
cat > "$FAKE_BIN/npm" <<'SHIM'
#!/usr/bin/env bash
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
SHIM
chmod +x "$FAKE_BIN/npm"
# PANE_PATH always ends in the system path. A host that installs these there can
# not measure the missing-binary cases at all, and a green run would mean
# nothing — so say so instead of passing.
for host_binary in mosaic pi; do
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
fi
done
write_generated() { write_generated() {
local home="$1" local home="$1"
local agent="$2" local agent="$2"
@@ -123,19 +81,6 @@ MOSAIC_TMUX_SOCKET=mosaic-test
EOF EOF
chmod 600 "$home/fleet/agents/$agent.env.generated" chmod 600 "$home/fleet/agents/$agent.env.generated"
mkdir -p "$home/work" mkdir -p "$home/work"
install_pane_binaries "$home"
}
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
# launcher's PATH. Tests that need a binary *absent* remove it from here.
install_pane_binaries() {
local pane_home="$1"
mkdir -p "$pane_home/.npm-global/bin"
local binary
for binary in mosaic pi; do
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
done
} }
run_start() { run_start() {
@@ -143,7 +88,6 @@ run_start() {
local agent="$2" local agent="$2"
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_HOME="$home" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$home" "$START" "$agent" MOSAIC_HOME="$home" "$START" "$agent"
@@ -154,10 +98,7 @@ run_start() {
HOME_VALID="$ROOT/valid" HOME_VALID="$ROOT/valid"
AGENT_VALID="coder0" AGENT_VALID="coder0"
write_generated "$HOME_VALID" "$AGENT_VALID" write_generated "$HOME_VALID" "$AGENT_VALID"
# A live pane PID is part of what "valid launch" means. Until #1241 this case run_start "$HOME_VALID" "$AGENT_VALID"
# ran with none, so the suite's one success path was itself a dead pane the
# launcher reported as fine.
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS") valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux" echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing" echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
@@ -304,13 +245,6 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
# This case does not go through run_start, so its pane binaries come from
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
# symlinks install_pane_binaries planted under the generated home, which this
# launcher never consults because HOME here is the trusted parent. That is a
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
# this case on the belief that the symlinks cover it would break the #1241
# binary check rather than exercise it.
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \ LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
BASH_ENV="$PANE_BASH_ENV" \ BASH_ENV="$PANE_BASH_ENV" \
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
@@ -324,7 +258,6 @@ PATH="$PANE_STALE_PATH" \
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_TEST_EXECUTE_PANE=1 \ MOSAIC_TEST_EXECUTE_PANE=1 \
"MOSAIC_TEST_PANE_PID=$$" \
"$START" coder-pane-boundary "$START" coder-pane-boundary
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
@@ -459,75 +392,6 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \ echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
fail "interaction pinned-policy check did not follow strict parsing" fail "interaction pinned-policy check did not follow strict parsing"
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
# missing from that path is a launch failure, and it has to be named before the
# session is created — after it, the diagnostic dies with the pane.
assert_missing_pane_binary_rejected() {
local binary="$1"
local home="$ROOT/missing-$binary"
local agent="coder-missing-$binary"
write_generated "$home" "$agent"
rm -f "$home/.npm-global/bin/$binary"
: > "$TMUX_CALLS"
local output
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
fail "launch succeeded with '$binary' absent from the pane PATH"
fi
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
fail "launcher created a session it knew would die ($binary absent)"
fi
}
assert_missing_pane_binary_rejected mosaic
assert_missing_pane_binary_rejected pi
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
# second after new-session means the runtime died on startup. This used to be a
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
# then reported a fleet that was not running.
: > "$TMUX_CALLS"
HOME_DEAD_PANE="$ROOT/dead-pane"
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
fail "launcher reported success over a pane that did not survive"
fi
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
if echo "$output" | grep -qiF 'heartbeat'; then
fail "dead pane is still being reported as a heartbeat-sidecar problem"
fi
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
fail "dead-pane case did not reach the launch it is measuring"
# #1241, the other way a pane fails. Above, tmux destroyed the session and
# has-session said so. Here the session is still there and no PID comes back
# after the retries — a different fault (the pane is alive but unusable, or
# tmux is answering inconsistently) that an operator has to be told apart from
# a runtime that died on startup.
#
# This case exists because the branch that handles it shipped with nothing able
# to reach it: the shim answered has-session only for the holder, so every
# non-holder agent landed in the session-is-gone branch no matter what. A
# defensive branch nothing exercises is the same shape as the bug this whole
# change is about, one layer down.
: > "$TMUX_CALLS"
HOME_NO_PID="$ROOT/pane-no-pid"
write_generated "$HOME_NO_PID" "coder-no-pid"
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
fail "launcher reported success over a session with no resolvable pane PID"
fi
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
fail "a session tmux still reports was diagnosed as a destroyed session"
fi
if echo "$output" | grep -qiF 'heartbeat'; then
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
fi
# Exact stop derives the socket exclusively from the validated generated # Exact stop derives the socket exclusively from the validated generated
# projection and ignores an ambient socket supplied by the caller. # projection and ignores an ambient socket supplied by the caller.
: > "$TMUX_CALLS" : > "$TMUX_CALLS"
+1 -31
View File
@@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
### `--login` override ### `--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 (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>`. 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>`.
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. 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,36 +58,6 @@ 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 unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
tokens. 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 ### Enabling it for a clone
The framework installer syncs `git-credential-mosaic` to The framework installer syncs `git-credential-mosaic` to
@@ -497,32 +497,6 @@ get_gitea_url_for_host() {
return 1 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. # Resolve a Gitea API token for the given host.
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials # Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
get_gitea_token() { get_gitea_token() {
@@ -543,8 +517,13 @@ get_gitea_token() {
_ident_src="git config mosaic.gitIdentity" _ident_src="git config mosaic.gitIdentity"
fi fi
if [[ -n "$_ident" ]]; then if [[ -n "$_ident" ]]; then
local _idtok="" local _idpfx=""
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then 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"
if [[ -r "$_idtok" ]]; then if [[ -r "$_idtok" ]]; then
cat "$_idtok" cat "$_idtok"
return 0 return 0
@@ -1486,81 +1465,6 @@ raise SystemExit(1)
PY 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. # 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. # Prints "username:password" for direct curl -u consumption. Callers must not log it.
get_gitea_basic_auth() { get_gitea_basic_auth() {
@@ -76,36 +76,27 @@ fi
detect_platform >/dev/null detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote, # Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280): # bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config # GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
# 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 principal so that the single # The token is resolved for the EFFECTIVE login (the --login override when
# credential used for the write ALSO drives the /user identity read and the # given, otherwise the detected default) so that the single credential used for
# read-back — write token and read-back token are the same identity by # the write ALSO drives the /user identity read and the read-back — write token
# construction (this is the credential-ordering fix: a --login override is no # and read-back token are the same identity by construction (this is the
# longer written under one credential and verified under a different default # credential-ordering fix: a --login override is no longer written under one
# one). When $2 is "identity" the principal ($1) is a requested git identity: # credential and verified under a different default one). Falls back to the
# the token MUST resolve from that identity's per-slot token (get_gitea_token's # host-scoped credential ONLY when NO --login override was supplied (the
# identity arm), failing closed rather than borrowing the tea default login — # best-effort default path). When $2 is "explicit" the login came from a
# the tea login list must never shadow a requested identity (#1280). When $2 # caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# is "explicit" the principal came from a caller-supplied --login: that exact # CLOSED rather than silently downgrading the write to the host default
# login's token MUST resolve, and we FAIL CLOSED rather than silently # identity — otherwise a caller relying on a dedicated per-role credential would
# downgrading the write to the host default identity. Otherwise the best-effort # be told the write succeeded as requested while it was attributed to the shared
# default path applies (per-login token, else the host-scoped credential). # default. Returns non-zero (clear stderr) on any resolution failure.
# Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() { gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host) host=$(get_remote_host)
if [[ "$override_explicit" == "identity" ]]; then if [[ -n "$override_explicit" ]]; 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") || { 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 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 return 1
@@ -327,31 +318,23 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER" echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the acting principal identity-first (#1280): an explicit --login # Resolve the login this comment should be attributed to: the --login
# wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity # override when given, otherwise the detected default for this repo's host.
# selects the principal when a per-slot token exists (fail-loud when it # A --login override always wins. Otherwise name this repo host's login only
# does not); the tea login list is the LAST resort — it knows nothing about # as a best effort: the login name merely selects a per-login token, and
# which seat is calling, so resolving from it first wrote under whichever # gitea_resolve_api_for_login falls back to the host credential
# account tea had configured (the #1280 family). # (get_gitea_token) when no tea login is named, so the default credential
principal_host=$(get_remote_host) # still resolves even when the host tea has no matching login entry.
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
# resolve_gitea_principal already printed the fail-loud diagnostic. [[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
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 resolved principal, then derive the # Bind the REST endpoint + token to the effective login, then derive the
# acting identity from that SAME credential (GET /user). The write below and # 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 # its read-back both use this credential, so the write is verified against
# the identity that actually performed it. # the identity that actually performed it. Passing "explicit" when --login
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then # was supplied forbids the host-default fallback: an unresolvable explicit
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 # override fails closed instead of writing under the default identity.
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
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 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || { comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
@@ -1,15 +1,6 @@
#!/bin/bash #!/bin/bash
# issue-create.sh - Create issues on Gitea or GitHub # issue-create.sh - Create issues on Gitea or GitHub
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login <name>] # Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"]
#
# 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 set -e
@@ -25,14 +16,6 @@ INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh # 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() { gitea_issue_create_api() {
local host repo token url payload local host repo token url payload
host=$(get_remote_host) || { host=$(get_remote_host) || {
@@ -43,19 +26,10 @@ gitea_issue_create_api() {
echo "Error: could not determine repo owner/name for API fallback" >&2 echo "Error: could not determine repo owner/name for API fallback" >&2
return 1 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") || { token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1 return 1
} }
fi
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2 echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
@@ -93,7 +67,6 @@ Options:
-b, --body BODY Issue body/description -b, --body BODY Issue body/description
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature") -l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
-m, --milestone NAME Milestone name to assign -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 -i, --interactive Prompt for missing issue fields
-h, --help Show this help message -h, --help Show this help message
@@ -124,10 +97,6 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2" MILESTONE="$2"
shift 2 shift 2
;; ;;
--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-i|--interactive) -i|--interactive)
INTERACTIVE=true INTERACTIVE=true
shift shift
@@ -165,37 +134,13 @@ case "$PLATFORM" in
"${CMD[@]}" "${CMD[@]}"
;; ;;
gitea) 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 if command -v tea >/dev/null 2>&1; then
REPO_SLUG=$(get_repo_slug) REPO_SLUG=$(get_repo_slug)
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
else
GITEA_LOGIN_NAME=$(get_gitea_login) || { GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_issue_create_api gitea_issue_create_api
exit $? exit $?
} }
fi
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then 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 echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_issue_create_api gitea_issue_create_api
@@ -1,15 +1,6 @@
#!/bin/bash #!/bin/bash
# pr-create.sh - Create pull requests on Gitea or GitHub # 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"] [--login <name>] # Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
#
# 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 set -e
@@ -28,15 +19,6 @@ ISSUE=""
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh # 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() { gitea_pr_create_api() {
local host repo token url payload local host repo token url payload
host=$(get_remote_host) || { host=$(get_remote_host) || {
@@ -47,19 +29,10 @@ gitea_pr_create_api() {
echo "Error: could not determine repo owner/name for API fallback" >&2 echo "Error: could not determine repo owner/name for API fallback" >&2
return 1 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") || { token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
return 1 return 1
} }
fi
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then 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 echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
@@ -103,7 +76,6 @@ Options:
-H, --head BRANCH Head branch with changes (default: current branch) -H, --head BRANCH Head branch with changes (default: current branch)
-l, --labels LABELS Comma-separated labels -l, --labels LABELS Comma-separated labels
-m, --milestone NAME Milestone name -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) -i, --issue NUMBER Link to issue (auto-generates title if not provided)
-d, --draft Create as draft PR -d, --draft Create as draft PR
-h, --help Show this help message -h, --help Show this help message
@@ -144,10 +116,6 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2" MILESTONE="$2"
shift 2 shift 2
;; ;;
--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-i|--issue) -i|--issue)
ISSUE="$2" ISSUE="$2"
shift 2 shift 2
@@ -206,41 +174,15 @@ case "$PLATFORM" in
"${CMD[@]}" "${CMD[@]}"
;; ;;
gitea) 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 # tea pull create syntax. Always pass --repo because tea repo inference
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead # is unreliable in Mosaic worktrees/profile shells. Use arrays instead
# of eval so markdown backticks/body content are not shell-executed. # of eval so markdown backticks/body content are not shell-executed.
REPO_SLUG=$(get_repo_slug) REPO_SLUG=$(get_repo_slug)
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
else
GITEA_LOGIN_NAME=$(get_gitea_login) || { GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
gitea_pr_create_api gitea_pr_create_api
exit $? exit $?
} }
fi
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then 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 echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_pr_create_api gitea_pr_create_api
@@ -1,13 +1,6 @@
#!/bin/bash #!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub # 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] [--login <name>] # Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
#
# 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 set -euo pipefail
@@ -23,7 +16,6 @@ DRY_RUN=false
EXPECT_HEAD="" EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false CO_AUTHOR_TRAILERS=false
ESCALATE_TO="" ESCALATE_TO=""
LOGIN_OVERRIDE=""
usage() { usage() {
cat <<EOF cat <<EOF
@@ -39,7 +31,6 @@ Options:
--expect-head SHA Refuse unless the PR head matches this full commit SHA --expect-head SHA Refuse unless the PR head matches this full commit SHA
--co-author-trailers Build verified trailers from linked PR commit authors --co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK --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 -h, --help Show this help message
Examples: Examples:
@@ -48,7 +39,6 @@ Examples:
$(basename "$0") -n 42 -d # Squash merge and delete branch $(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567 $(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic $(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 EOF
exit "${1:-1}" exit "${1:-1}"
} }
@@ -92,14 +82,6 @@ while [[ $# -gt 0 ]]; do
ESCALATE_TO="$2" ESCALATE_TO="$2"
shift 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) -h|--help)
usage 0 usage 0
;; ;;
@@ -590,23 +572,10 @@ PY
merge_gitea_with_api() { merge_gitea_with_api() {
local host="$1" token attempt_rc local host="$1" token attempt_rc
# 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 if ! token=$(get_gitea_token "$host"); then
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2 echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
return 1 return 1
fi fi
fi
if [[ -z "$token" ]]; then if [[ -z "$token" ]]; then
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2 echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
return 1 return 1
@@ -633,25 +602,10 @@ if [[ "$DRY_RUN" == true ]]; then
echo "Error: Cannot determine host from origin remote URL" >&2 echo "Error: Cannot determine host from origin remote URL" >&2
exit 1 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 if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
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)." 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)."
else else
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)." echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
fi fi
else else
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)." 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 " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)" echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)" echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)" echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)" echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
echo " -H, --host Explicit Gitea host (skips remote-host inference)" echo " -H, --host Explicit Gitea host (skips remote-host inference)"
echo " -h, --help Show this help" echo " -h, --help Show this help"
@@ -346,14 +346,7 @@ gitea_resolve_api_for_login() {
else else
host=$(get_remote_host) host=$(get_remote_host)
fi fi
if [[ "$override_explicit" == "identity" ]]; then if [[ -n "$override_explicit" ]]; 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") || { 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 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 return 1
@@ -683,32 +676,29 @@ if [[ "$PLATFORM" == "github" ]]; then
;; ;;
esac esac
elif [[ "$PLATFORM" == "gitea" ]]; then 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 case $ACTION in
approve) approve)
# Identity-first principal resolution (#1280): PRINCIPAL_MODE / # Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# PRINCIPAL_NAME were resolved once above from --login > # below re-derives the real host from HOST_OVERRIDE/remote independently and
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort). # is authoritative). Prefer an explicit -H/--host; otherwise best-effort
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then # git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 # under `set -e`, with no origin and no -H, previously killed the script
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then # SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 # that support running with no usable origin at all).
else host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 # A --login override always wins. Otherwise name this host's login
fi # 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
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || 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 # The review body (if any) travels with the review itself in the REST
@@ -725,16 +715,24 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required for request-changes" echo "Error: Comment required for request-changes"
exit 1 exit 1
fi fi
# Identity-first principal resolution (#1280): PRINCIPAL_MODE / # Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# PRINCIPAL_NAME were resolved once above from --login > # below re-derives the real host from HOST_OVERRIDE/remote independently and
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort). # is authoritative). Prefer an explicit -H/--host; otherwise best-effort
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then # git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 # under `set -e`, with no origin and no -H, previously killed the script
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then # SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 # that support running with no usable origin at all).
else host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 # A --login override always wins. Otherwise name this host's login
fi # 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
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || 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") || { review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
@@ -748,16 +746,24 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required" echo "Error: Comment required"
exit 1 exit 1
fi fi
# Identity-first principal resolution (#1280): PRINCIPAL_MODE / # Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# PRINCIPAL_NAME were resolved once above from --login > # below re-derives the real host from HOST_OVERRIDE/remote independently and
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort). # is authoritative). Prefer an explicit -H/--host; otherwise best-effort
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then # git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1 # under `set -e`, with no origin and no -H, previously killed the script
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then # SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1 # that support running with no usable origin at all).
else host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1 # A --login override always wins. Otherwise name this host's login
fi # 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
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || { 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 echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
@@ -1,255 +0,0 @@
#!/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,30 +77,12 @@ exit 0
SH SH
chmod +x "$BIN_DIR/tea" 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" cd "$REPO_DIR"
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \ PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \ MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY" "$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
) >/dev/null ) >/dev/null
mkdir -p "$WORK_DIR/home"
# 1. No command substitution executed anywhere in the pipeline. # 1. No command substitution executed anywhere in the pipeline.
if [[ -e "$SENTINEL" ]]; then if [[ -e "$SENTINEL" ]]; then
@@ -47,31 +47,14 @@ SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() { 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" cd "$REPO_DIR"
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \ PATH="$BIN_DIR:$PATH" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" "${env_pairs[@]}" \ MOSAIC_TEST_LOG="$LOG_FILE" \
"$@" "$@"
) )
} }
mkdir -p "$WORK_DIR/home"
: > "$LOG_FILE" : > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
@@ -1,244 +0,0 @@
#!/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"
@@ -1,247 +0,0 @@
#!/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"
@@ -32,6 +32,10 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling) packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
# --- single-suite directories: unmeasured in CI --- # --- single-suite directories: unmeasured in CI ---
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
# --- naming-boundary files the strict test-*.sh prefix cannot even name --- # --- naming-boundary files the strict test-*.sh prefix cannot even name ---
# (#1017: three independent censuses handled the microtest file three different # (#1017: three independent censuses handled the microtest file three different
@@ -39,20 +43,3 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.) # 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/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 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", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "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/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" "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"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
@@ -1,323 +0,0 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
/**
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
* never get its units placed. These tests pin the three behaviours that fix
* gives it, and the two it deliberately does NOT give it.
*
* The load-bearing negative is that `install` on v2 writes no generated env:
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
* second writer here — necessarily through the v1 mapping — is exactly the
* drift the #791 single-SSOT invariant exists to prevent.
*/
const rosterV2 = `
version: 2
generation: 4
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
- name: coder1
alias: Coder 1
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: medium
tool_policy: code
working_directory: /srv/other
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let tempHome: string | undefined;
const savedHome = process.env.HOME;
const savedMosaicHome = process.env.MOSAIC_HOME;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
else process.env.MOSAIC_HOME = savedMosaicHome;
if (tempHome) await rm(tempHome, { recursive: true, force: true });
tempHome = undefined;
});
/**
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
* anything has been installed, applied or started.
*/
async function v2Home(): Promise<string> {
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
process.env.HOME = tempHome;
delete process.env.MOSAIC_HOME;
const mosaicHome = join(tempHome, '.config', 'mosaic');
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
}
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
mode: 0o600,
});
return mosaicHome;
}
/**
* Stands in for a box where nothing is running: every systemctl and tmux probe
* fails the way it does before the holder has ever started. `ps` must survive
* this — it is the command an operator reaches for to find out *why* there is
* no seat, so it has to report the emptiness rather than fail on it.
*/
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
if (command === 'tmux') {
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 1 };
};
function program(runner: CommandRunner = greenfieldRunner): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
return result;
}
function capture(): string[] {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return lines;
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
describe('mosaic fleet ps — roster v2', (): void => {
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
await v2Home();
const lines = capture();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
).resolves.toBeDefined();
const rows = JSON.parse(lines.join('\n')) as {
name: string;
runtime: string;
alias?: string;
paneAlive: boolean;
source: string;
}[];
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
// The v2 roster's per-agent fields must survive the read model, not be
// flattened into defaults.
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
// Nothing is running, and that is a report, not an error.
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
expect(rows.every((row) => row.source === 'roster')).toBe(true);
expect(process.exitCode ?? 0).toBe(0);
});
});
describe('mosaic fleet install — roster v2', (): void => {
it('places the tool files and unit templates', async (): Promise<void> => {
const mosaicHome = await v2Home();
capture();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
).resolves.toBeDefined();
// Units live in the systemd user dir, not under the Mosaic home.
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
for (const unit of [
'mosaic-tmux-holder.service',
'[email protected]',
'[email protected]',
]) {
expect(await exists(join(systemdUserDir, unit))).toBe(true);
}
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
expect(await exists(launcher)).toBe(true);
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
});
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
const mosaicHome = await v2Home();
capture();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
const agentDir = join(mosaicHome, 'fleet', 'agents');
expect(await readdir(agentDir)).toEqual([]);
});
it('tells the operator which command does own the env', async (): Promise<void> => {
await v2Home();
const lines = capture();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
expect(lines.join('\n')).toContain('mosaic fleet apply');
});
});
describe('[email protected]', (): void => {
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
/** The single `ConditionPathExists=` value declared by the unit template. */
async function conditionPath(): Promise<string> {
const unit = await readFile(unitPath, 'utf8');
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
expect(matches).toHaveLength(1);
return matches[0]!.slice('ConditionPathExists='.length).trim();
}
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
// The pairing that makes "install writes no env" safe: install enables the
// unit (WantedBy=default.target) but does not start it, so without this
// condition a reboot between `install` and the first `apply` would run
// ExecStart against an absent env file and fail every seat unit.
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
});
/**
* The two halves of the guard's *effect*, which no assertion on the literal
* string can cover on its own.
*
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
* and journals "skipped, unmet condition check". With the condition removed by
* drop-in and nothing else changed, the same start returns rc=1,
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
*
* systemd is not available in this suite, so these two tests pin the parts
* that can drift in code: the condition naming a *different* file than the one
* the fleet actually writes, and the launcher quietly becoming tolerant of an
* absent env — either of which turns the condition into decoration while the
* literal-string assertion above still passes.
*/
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
// The path an installed fleet actually places for this agent.
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
});
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
await v2Home();
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
// Exactly what ExecStart runs, against the state the condition exists to
// catch: unit enabled, reconciler has not written env yet.
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
const child = execFile(
'/bin/bash',
[
'--noprofile',
'--norc',
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
'coder0',
],
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
(_error, _stdout, stderr) => {
settle({ code: child.exitCode, stderr });
},
);
});
expect(launched.code).not.toBe(0);
expect(launched.stderr).toContain('missing-file');
});
});
describe('mosaic fleet add / remove — roster v2', (): void => {
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
await v2Home();
await expect(
program().parseAsync([
'node',
'mosaic',
'fleet',
'add',
'coder2',
'--runtime',
'pi',
'--class',
'code',
]),
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
});
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
await v2Home();
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
});
// Note: this one passes on the unmodified tree too — there `remove` throws in
// the v1 parser, before it can touch anything. It is a regression guard on the
// ordering of the new guard clause, not evidence that the fix works.
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const before = await readFile(rosterPath, 'utf8');
await expect(
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
).rejects.toThrow();
expect(await readFile(rosterPath, 'utf8')).toBe(before);
});
});
+8 -116
View File
@@ -34,7 +34,6 @@ export {
resolveInstalledFleetRosterPath, resolveInstalledFleetRosterPath,
} from '../fleet/fleet-roster-v1.js'; } from '../fleet/fleet-roster-v1.js';
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js'; export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
import { import {
registerFleetAgentCrudCommands, registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps, type FleetAgentCrudCommandDeps,
@@ -821,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
*/ */
export async function enableFleetUnits( export async function enableFleetUnits(
runner: CommandRunner, runner: CommandRunner,
roster: { readonly agents: readonly { readonly name: string }[] }, roster: FleetRoster,
opts: { enable?: boolean }, opts: { enable?: boolean },
): Promise<void> { ): Promise<void> {
if (opts.enable === false) { if (opts.enable === false) {
@@ -1528,8 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival') .option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => { .action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot); await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version. const roster = await loadRosterForCommand(cmd);
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts); await enableFleetUnits(runner, roster, opts);
}); });
@@ -1539,8 +1537,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival') .option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => { .action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot); await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version. const roster = await loadRosterForCommand(cmd);
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts); await enableFleetUnits(runner, roster, opts);
}); });
@@ -1691,9 +1688,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.action(async (opts: { json?: boolean }) => { .action(async (opts: { json?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
// ps only reads, so it takes the version-agnostic read model rather than const roster = await loadRosterForCommand(cmd);
// the v1 parser, which rejects a v2 roster outright.
const roster = await loadRosterReadModel(cmd);
const { tenant_id, host } = getDefaultTenantAndHost(); const { tenant_id, host } = getDefaultTenantAndHost();
const nowMs = Date.now(); const nowMs = Date.now();
@@ -1913,16 +1908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
start: boolean; start: boolean;
}, },
) => { ) => {
if (await usesRosterV2ControlPlane(cmd)) {
// command.error, not a bare throw: this is operator guidance, and a
// bare throw reaches the top level uncaught and prints it under a Node
// stack trace. Measured on canary — the message is the whole point of
// the refusal, so it has to arrive readable.
cmd.error(rosterV2MutationGuidance('add', 'create', name), {
code: 'fleet.roster-v2',
exitCode: 1,
});
}
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) { if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
throw new Error( throw new Error(
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`, `Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
@@ -1988,12 +1973,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Remove an agent from the fleet roster') .description('Remove an agent from the fleet roster')
.option('--keep-files', 'Skip deleting env and heartbeat files') .option('--keep-files', 'Skip deleting env and heartbeat files')
.action(async (name: string, opts: { keepFiles?: boolean }) => { .action(async (name: string, opts: { keepFiles?: boolean }) => {
if (await usesRosterV2ControlPlane(cmd)) {
cmd.error(rosterV2MutationGuidance('remove', 'delete', name), {
code: 'fleet.roster-v2',
exitCode: 1,
});
}
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome); const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
@@ -2352,9 +2331,7 @@ export function registerFleetAgentCommands(
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> { async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
// Read model first: every file this function places is roster-independent, and const roster = await loadRosterForCommand(cmd);
// the v1 parser would reject a v2 roster before any of them were written.
const roster = await loadRosterReadModel(cmd);
await ensureFleetHolderIdentity(activePaths.mosaicHome); await ensureFleetHolderIdentity(activePaths.mosaicHome);
await mkdir(activePaths.fleetToolsDir, { recursive: true }); await mkdir(activePaths.fleetToolsDir, { recursive: true });
await mkdir(activePaths.tmuxToolsDir, { recursive: true }); await mkdir(activePaths.tmuxToolsDir, { recursive: true });
@@ -2414,30 +2391,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
join(activePaths.systemdUserDir, '[email protected]'), join(activePaths.systemdUserDir, '[email protected]'),
); );
// On roster v2 the reconciler owns the generated env: `apply` writes it and for (const agent of roster.agents) {
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
// here too — necessarily through the v1 mapping — would be the third writer of
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
// files and the units, and nothing else.
if (roster.version === 2) {
console.log(
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
);
return;
}
const v1Roster = await loadRosterForCommand(cmd);
for (const agent of v1Roster.agents) {
await writeAgentEnvironmentProjection({ await writeAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome, mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir, agentEnvDir: activePaths.agentEnvDir,
agentName: agent.name, agentName: agent.name,
generated: generateAgentEnvValues(v1Roster, agent), generated: generateAgentEnvValues(roster, agent),
}); });
} }
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`); console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
} }
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> { async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
@@ -2464,77 +2427,6 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
); );
} }
/**
* `add`/`remove` and `create`/`delete` are not two spellings of one operation.
* The v1 pair edits the roster *and* drives systemd; the v2 pair is documented
* as changing desired state "without runtime actions", leaving convergence to
* `apply`. `add` also collects four fields where a v2 agent requires eleven, so
* routing it to `create` would mean inventing provider, alias, reasoning and
* tool-policy defaults on the operator's behalf. Refusing with the real command
* is honest; silently guessing an agent's provider is not.
*/
function rosterV2MutationGuidance(
v1Command: 'add' | 'remove',
v2Command: 'create' | 'delete',
name: string,
): string {
const target = v2Command === 'delete' ? ` ${name}` : '';
return (
`mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` +
`Roster v2 separates desired state from convergence:\n` +
` 1. mosaic fleet ${v2Command}${target} --expected-generation <current> ` +
`${v2Command === 'create' ? "--agent '<json>' " : ''}` +
`(edits the roster only)\n` +
` 2. mosaic fleet apply --expected-generation <new> (converges systemd and tmux)\n` +
`Read the current generation with: mosaic fleet status`
);
}
/**
* The read-only fields shared by roster v1 and v2, for the commands that only
* ever *read* the roster (`ps`, and unit enablement inside `install`).
*
* This is deliberately NOT a v2→v1 downshift. A downshifted `FleetRoster` would
* be accepted by `generateAgentEnvValues`, and that would make a third writer of
* `fleet/agents/<name>.env.generated` — through the v1 mapping — breaking the
* #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is
* documented to hold. Keeping the read model this small makes that misuse
* impossible: there is nothing here to write a roster or an env file back from.
*/
interface FleetRosterReadModel {
readonly version: 1 | 2;
readonly tmux: { readonly socketName: string; readonly holderSession: string };
readonly agents: readonly {
readonly name: string;
readonly alias?: string;
readonly runtime: string;
}[];
}
/** Reads either roster version into the shared read-only view. */
async function loadRosterReadModel(cmd: Command): Promise<FleetRosterReadModel> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
if (!(await usesRosterV2ControlPlane(cmd))) {
const v1 = await loadRosterAtPath(cmd, path);
return {
version: 1,
tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession },
agents: v1.agents,
};
}
try {
const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml');
return {
version: 2,
tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession },
agents: v2.agents,
};
} catch (error) {
reportFleetRosterConfigurationError(cmd, error);
}
}
async function loadRosterFromAgentCommand( async function loadRosterFromAgentCommand(
command: Command, command: Command,
mosaicHomeOverride?: string, mosaicHomeOverride?: string,
+6 -8
View File
@@ -1,3 +1,4 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir, platform } from 'node:os'; import { homedir, platform } from 'node:os';
@@ -21,18 +22,15 @@ export function getShellProfilePath(): string | null {
const shell = detectShell(); const shell = detectShell();
switch (shell) { switch (shell) {
// Both of these deliberately avoid the interactive-only rc files.
// Debian's default .bashrc returns early for non-interactive shells, so a
// PATH line appended to it never runs for `bash -lc`, systemd units, or
// agent seats — an install could report success and still leave `mosaic`
// unreachable. .profile is read by login shells and sources .bashrc for
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
case 'zsh': { case 'zsh': {
const zdotdir = process.env['ZDOTDIR'] ?? home; const zdotdir = process.env['ZDOTDIR'] ?? home;
return join(zdotdir, '.zshenv'); return join(zdotdir, '.zshrc');
} }
case 'bash': case 'bash': {
const bashrc = join(home, '.bashrc');
if (existsSync(bashrc)) return bashrc;
return join(home, '.profile'); return join(home, '.profile');
}
case 'fish': case 'fish':
return join(home, '.config', 'fish', 'config.fish'); return join(home, '.config', 'fish', 'config.fish');
default: default:
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Tests for newest_matching_file() in tools/install.sh.
#
# The function answers one question -- "which is the most recent backup / tarball
# here?" -- and its callers act destructively on the answer. Three ways of getting it
# wrong have already been found, and each has a case below:
#
# * `ls -1t | head -1` returns 141 under `set -o pipefail` once the listing fills a
# pipe buffer (~1600 names), because head closes the pipe and ls takes SIGPIPE.
# Callers assign it at top level under `set -e`, so a 141 aborts the run.
# * `mapfile` is a Bash 4 builtin. macOS ships Bash 3.2 and the installer supports
# Darwin, so the whole lookup was unavailable there -- and an empty answer is what
# sends the uninstaller down its delete-the-destination branch.
# * Any line-based parse of `ls` splits a filename containing a newline into two
# wrong answers.
#
# The large-population and newline cases are the point: with two or three ordinary
# names every version of this function passes, which is why the first two went
# unnoticed.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-newest-match-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
# Load the function under test and the mtime helper it depends on, with the same
# shell options install.sh runs under.
eval "$(sed -n '/^_MTIME_STYLE=/,/^}/p' "$ROOT/tools/install.sh")"
eval "$(sed -n '/^newest_matching_file()/,/^}/p' "$ROOT/tools/install.sh")"
POPULATED="$TMP/many"
mkdir -p "$POPULATED"
# Enough names to overflow a 64 KiB pipe buffer several times over.
for i in $(seq 1 5000); do
: > "$POPULATED/mosaicstack-mosaic-0.0.${i}.tgz"
done
sleep 1
: > "$POPULATED/mosaicstack-mosaic-9.9.9.tgz"
echo "[test] the newest match is returned from a directory large enough to fill a pipe"
GOT="$(newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz')"
[[ "$(basename "$GOT")" == "mosaicstack-mosaic-9.9.9.tgz" ]] || {
echo "expected the newest tarball, got '${GOT}'" >&2
exit 1
}
echo "[test] a large population does not make the lookup fail"
set +e
newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz' >/dev/null
RC=$?
set -e
[[ "$RC" -eq 0 ]] || { echo "expected rc=0, got ${RC} (141 means the SIGPIPE regression is back)" >&2; exit 1; }
echo "[test] a small population still works"
SMALL="$TMP/few"
mkdir -p "$SMALL"
: > "$SMALL/mosaicstack-gateway-0.0.1.tgz"
sleep 1
: > "$SMALL/mosaicstack-gateway-0.0.2.tgz"
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
[[ "$(basename "$GOT")" == "mosaicstack-gateway-0.0.2.tgz" ]] || {
echo "expected the newer gateway tarball, got '${GOT}'" >&2
exit 1
}
echo "[test] a name containing a space is returned whole"
SPACED="$TMP/spaced"
mkdir -p "$SPACED"
: > "$SPACED/agents.md.mosaic-bak-one two"
GOT="$(newest_matching_file "$SPACED" 'agents.md.mosaic-bak-*')"
[[ "$GOT" == "$SPACED/agents.md.mosaic-bak-one two" ]] || {
echo "expected the spaced name intact, got '${GOT}'" >&2
exit 1
}
echo "[test] a name containing a newline is returned whole, not split"
# The old `ls -1t` parse reported this file as two separate shorter names, neither of
# which exists -- so the caller saw a backup path that could not be restored.
NEWLINE="$TMP/newline"
mkdir -p "$NEWLINE"
WEIRD="$NEWLINE/agents.md.mosaic-bak-$(printf 'a\nb')"
: > "$WEIRD"
GOT="$(newest_matching_file "$NEWLINE" 'agents.md.mosaic-bak-*')"
[[ "$GOT" == "$WEIRD" ]] || {
echo "expected the newline-containing name intact, got '${GOT}'" >&2
exit 1
}
[[ -f "$GOT" ]] || { echo "the returned path does not name a real file" >&2; exit 1; }
echo "[test] no match is an empty answer, not an error"
EMPTY="$TMP/none"
mkdir -p "$EMPTY"
set +e
GOT="$(newest_matching_file "$EMPTY" 'nothing-*.tgz')"
RC=$?
set -e
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
echo "[test] a directory that does not exist is an empty answer, not an error"
set +e
GOT="$(newest_matching_file "$TMP/absent" 'nothing-*.tgz')"
RC=$?
set -e
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
echo "[test] an unanswerable lookup fails loudly instead of reporting no match"
# This is the distinction the uninstaller depends on. "No backup exists" is licence to
# delete the destination; "I could not tell" must never reach that branch.
_MTIME_STYLE=none
set +e
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
RC=$?
set -e
_MTIME_STYLE=""
[[ "$RC" -ne 0 ]] || {
echo "expected a non-zero rc when no mtime source is usable, got rc=0 output '${GOT}'" >&2
exit 1
}
echo "[test] the installer uses no Bash 4 syntax"
# A lint, not an execution test: this host has no Bash 3.2 to run under. It is still
# the thing that stops the regression, because every Bash 4 construct that has broken
# macOS here was introduced by someone who never ran the script there either.
# Comments are stripped first -- the ones above name these constructs on purpose.
BASH4_HITS="$(
sed 's/#.*$//' "$ROOT/tools/install.sh" \
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)|declare[[:space:]]+-[a-zA-Z]*A|local[[:space:]]+-[a-zA-Z]*A|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)' \
|| true
)"
[[ -z "$BASH4_HITS" ]] || {
echo "tools/install.sh uses Bash 4+ syntax, which macOS's Bash 3.2 cannot run:" >&2
echo "$BASH4_HITS" >&2
exit 1
}
echo "[test] newest_matching_file tests passed"
+6 -2
View File
@@ -153,17 +153,21 @@ reset_state() {
} }
reset_state reset_state
# The installer now provisions Node itself, so Node 20 no longer stops a --next
# install -- it gets replaced. What still has to hold is that the >= 22 gate fires
# before anything is installed, so this asserts it on the one lane where refusing is
# still the outcome. The replacement path is covered by install-node-provisioning.test.sh.
echo "[test] --next rejects Node 20 before any install action" echo "[test] --next rejects Node 20 before any install action"
if OUTPUT="$( if OUTPUT="$(
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \ HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \ MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \ MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1 bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch --no-node-install 2>&1
)"; then )"; then
echo "expected Node 20 next-lane install to fail" >&2 echo "expected Node 20 next-lane install to fail" >&2
exit 1 exit 1
fi fi
grep -qF 'Node.js >= 22 required for the --next lane' <<<"$OUTPUT" grep -qF 'Node >= 22 required and --no-node-install was given.' <<<"$OUTPUT"
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; } [[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
reset_state reset_state
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env bash
# Tests for the installer's Node provisioning.
#
# The installer's whole promise is that one command turns a bare host into a working
# one. Node was the exception: it was a hard prerequisite the installer checked and
# refused, so on a greenfield host the documented one-command install failed first.
# These tests pin the fixed behaviour, including the refusals.
#
# Everything runs offline. MOSAIC_NODE_DIST points at a local directory laid out like
# nodejs.org/dist, served over file:// -- so the download, the checksum gate, and the
# unpack are the real code paths, with no network and no real Node download.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-provision-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
DIST="$TMP/dist"
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME_DIR="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
NODE_HOME="$TMP/nodehome"
mkdir -p "$DIST" "$FAKE_BIN" "$HOME_DIR" "$STATE"
REAL_NODE="$(command -v node)"
# The platform triple, derived the same way the installer derives it.
case "$(uname -s)" in
Linux) TEST_OS=linux ;;
Darwin) TEST_OS=darwin ;;
*) echo "[skip] no Node build for $(uname -s)"; exit 0 ;;
esac
case "$(uname -m)" in
x86_64|amd64) TEST_ARCH=x64 ;;
aarch64|arm64) TEST_ARCH=arm64 ;;
armv7l) TEST_ARCH=armv7l ;;
*) echo "[skip] no Node build for $(uname -m)"; exit 0 ;;
esac
PLATFORM="${TEST_OS}-${TEST_ARCH}"
VERSION=v22.99.0 # the one that must be chosen
MID_VERSION=v22.50.0 # same major, older -- catches "take the last match"
OLD_VERSION=v20.99.0 # wrong major
NEWER_MAJOR=v24.99.0 # listed first -- catches "take the first entry"
# ─── fixtures ─────────────────────────────────────────────────────────────────
# A node stub that answers the installer's version probe and defers everything else
# to the real interpreter, so the rest of the install still runs.
#
# The major is baked in per stub rather than read from the environment. A shared env
# var would be read by the downloaded Node too, so the "system Node is too old" case
# would install a replacement that also claimed to be too old.
write_node_stub() {
local path="$1" major="${2:-22}"
cat > "$path" <<STUB
#!/usr/bin/env bash
set -euo pipefail
if [[ "\$*" == *'process.versions.node.split'* ]]; then
printf '%s' "${major}"
exit 0
fi
if [[ "\${1:-}" == "--version" ]]; then
printf 'v%s.99.0\n' "${major}"
exit 0
fi
exec "\${MOSAIC_TEST_REAL_NODE:?}" "\$@"
STUB
chmod +x "$path"
}
write_npm_stub() {
cat > "$1" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
echo "$*" >> "${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
if [[ "${1:-}" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.50-next.999" ;;
"@mosaicstack/gateway@next version") echo "0.0.7-next.999" ;;
"@mosaicstack/mosaic version") echo "0.0.49" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "${1:-}" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@"*) echo "0.0.50-next.999" > "$STATE/mosaic" ;;
*"@mosaicstack/gateway@"*) echo "0.0.7-next.999" > "$STATE/gateway" ;;
esac
exit 0
fi
if [[ "${1:-}" == "ls" ]]; then
printf '{"dependencies":{"@mosaicstack/mosaic":{"version":"%s"},"@mosaicstack/gateway":{"version":"%s"}}}\n' \
"$(cat "$STATE/mosaic" 2>/dev/null || echo '')" \
"$(cat "$STATE/gateway" 2>/dev/null || echo '')"
exit 0
fi
exit 0
STUB
chmod +x "$1"
}
# Build a nodejs.org-shaped release: the tarball, and a SHASUMS256.txt over it.
publish_release() {
local version="$1" corrupt_checksum="${2:-false}"
local base="node-${version}-${PLATFORM}"
local stage="$TMP/stage-${version}"
rm -rf "$stage"
mkdir -p "$stage/${base}/bin"
write_node_stub "$stage/${base}/bin/node" "$(sed 's/^v//; s/\..*//' <<<"$version")"
write_npm_stub "$stage/${base}/bin/npm"
mkdir -p "${DIST}/${version}"
tar -czf "${DIST}/${version}/${base}.tar.gz" -C "$stage" "$base"
local sum
if command -v sha256sum &>/dev/null; then
sum="$(sha256sum "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
else
sum="$(shasum -a 256 "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
fi
if [[ "$corrupt_checksum" == "true" ]]; then
sum="0000000000000000000000000000000000000000000000000000000000000000"
fi
printf '%s %s.tar.gz\n' "$sum" "$base" > "${DIST}/${version}/SHASUMS256.txt"
}
publish_release "$VERSION"
publish_release "$MID_VERSION"
publish_release "$OLD_VERSION"
publish_release "$NEWER_MAJOR"
# Newest-first, as nodejs.org publishes it. Every wrong entry is genuinely installable,
# so a resolver that picks one fails on the assertion rather than on a 404 -- the
# assertion is then about version selection and not about the fixture.
printf '[{"version":"%s"},{"version":"%s"},{"version":"%s"},{"version":"%s"}]\n' \
"$NEWER_MAJOR" "$VERSION" "$MID_VERSION" "$OLD_VERSION" > "$DIST/index.json"
# A PATH with the usual tools but no Node toolchain, so "a host with no Node" is
# actually true on a developer machine and in CI, both of which have one installed.
NONODE_BIN="$TMP/nonode-bin"
mkdir -p "$NONODE_BIN"
for candidate in /usr/bin/* /bin/*; do
[[ -e "$candidate" ]] || continue
case "$(basename "$candidate")" in
node|npm|npx|corepack|nodejs) continue ;;
esac
ln -sf "$candidate" "$NONODE_BIN/$(basename "$candidate")" 2>/dev/null || true
done
if PATH="$NONODE_BIN" command -v node &>/dev/null; then
echo "[skip] could not build a Node-free PATH on this host" >&2
exit 0
fi
reset_home() {
rm -rf "$HOME_DIR" "$PREFIX" "$MOSAIC_HOME_DIR" "$NODE_HOME" "$LOG" "$STATE"
mkdir -p "$HOME_DIR" "$STATE"
: > "$LOG"
}
# Run the installer with no Node anywhere on PATH.
run_bare() {
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" "$@"
}
# ─── tests ────────────────────────────────────────────────────────────────────
reset_home
echo "[test] a host with no Node gets one, and the CLI install proceeds"
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
grep -qF -- "Node is not installed" <<<"$OUTPUT"
grep -qF -- "Installed Node ${VERSION}" <<<"$OUTPUT"
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
grep -qF -- "install -g @mosaicstack/[email protected]" "$LOG"
echo "[test] the newest release of the required major is chosen"
# The index lists a higher major first and an older release of the right major after
# the right answer, so "first entry" and "last match" both produce a wrong directory.
[[ -d "${NODE_HOME}/${VERSION}" ]]
[[ ! -d "${NODE_HOME}/${NEWER_MAJOR}" ]]
[[ ! -d "${NODE_HOME}/${MID_VERSION}" ]]
[[ ! -d "${NODE_HOME}/${OLD_VERSION}" ]]
echo "[test] future shells can find both Node and the CLI"
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.profile"
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.profile"
# Debian's .bashrc returns early when non-interactive, so the login profile is the
# one that matters -- but an interactive non-login shell only reads .bashrc.
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
echo "[test] a real login shell resolves node, not just the text of a profile line"
# Grepping the file only proves the installer wrote something. This starts an actual
# login shell against that HOME and asks it to find the binary.
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
echo "a login shell resolved node to '${RESOLVED}'" >&2
exit 1
}
echo "[test] a systemd --user unit gets the same PATH, via environment.d"
# Units read no shell file at all, which is how a Mosaic agent seat starts.
ENVD="$HOME_DIR/.config/environment.d/50-mosaic-path.conf"
[[ -f "$ENVD" ]] || { echo "no environment.d drop-in was written" >&2; exit 1; }
grep -qF -- "PATH=${NODE_HOME}/${VERSION}/bin:\${PATH}" "$ENVD"
grep -qF -- "PATH=${PREFIX}/bin:\${PATH}" "$ENVD"
echo "[test] re-running reuses the Node it installed and does not duplicate PATH lines"
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
grep -qF -- "from ${NODE_HOME}" <<<"$OUTPUT"
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.profile")" -eq 2 ]]
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.bashrc")" -eq 2 ]]
[[ "$(grep -c '^PATH=' "$ENVD")" -eq 2 ]]
reset_home
echo "[test] a ~/.bash_profile does not silently swallow the PATH entry"
# A bash login shell reads the first of .bash_profile / .bash_login / .profile that
# exists and never looks at the rest. Writing only .profile is a no-op on such a host,
# and the failure is invisible until something cannot find node.
: > "$HOME_DIR/.bash_profile"
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
echo "with a .bash_profile present, a login shell resolved node to '${RESOLVED}'" >&2
exit 1
}
reset_home
echo "[test] a commented-out example does not count as the PATH entry already existing"
# The idempotence check used to be an unanchored substring match, so a line like this
# in a user's profile made the installer skip the real entry.
mkdir -p "$HOME_DIR"
printf '# export PATH="%s/%s/bin:$PATH"\n' "$NODE_HOME" "$VERSION" > "$HOME_DIR/.profile"
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
[[ "$(grep -c '^export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] || {
echo "expected two real export lines, found:" >&2
cat "$HOME_DIR/.profile" >&2
exit 1
}
reset_home
echo "[test] --no-node-install refuses instead of installing"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-node-install 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "--no-node-install was given" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] --check never provisions Node"
set +e
OUTPUT="$(run_bare --check --cli --next 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "Required command not found: node" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a tampered download is rejected and nothing is installed"
publish_release "$VERSION" true
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "failed checksum verification" <<<"$OUTPUT"
# Not just "no usable node": nothing at all may survive. An unpack that ran before
# verification, or a staging directory left behind, would still satisfy the weaker
# check while leaving unverified bytes on disk for the next run to adopt.
[[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]]
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
[[ ! -e "${NODE_HOME}/${VERSION}.partial" ]]
[[ ! -d "$NODE_HOME" ]] || [[ -z "$(ls -A "$NODE_HOME")" ]]
publish_release "$VERSION"
reset_home
echo "[test] a system Node that is new enough is used as-is and left alone"
write_node_stub "$FAKE_BIN/node" 22
write_npm_stub "$FAKE_BIN/npm"
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
grep -qF -- "satisfies the >= 22 requirement" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a system Node that is too old is replaced rather than accepted"
write_node_stub "$FAKE_BIN/node" 18
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
grep -qF -- "older than the required >= 22" <<<"$OUTPUT"
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
# ─── refusals: untrusted input that reaches a path or an exec ─────────────────
reset_home
echo "[test] an empty checksum manifest is refused, not read as an empty digest"
: > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "No checksum published" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
reset_home
echo "[test] a manifest naming a regex-equivalent file does not vouch for this one"
# The lookup used to interpolate the filename into a grep pattern. A Node tarball name
# is mostly dots, and a dot matches any character, so this line -- which names a
# different file -- was accepted as this file's checksum.
DECOY="node-${VERSION}-${PLATFORM}Xtar.gz"
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$DECOY" > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "No checksum published" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
reset_home
echo "[test] a manifest listing the same file twice is refused rather than guessed at"
BASE="node-${VERSION}-${PLATFORM}.tar.gz"
GOOD="$(awk '{print $1}' "${DIST}/${VERSION}/SHASUMS256.txt")"
{
printf '%s %s\n' "$GOOD" "$BASE"
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$BASE"
} > "${DIST}/${VERSION}/SHASUMS256.txt"
set +e
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "refusing to guess" <<<"$OUTPUT"
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
publish_release "$VERSION"
echo "[test] a version string is checked before it becomes a path"
# MOSAIC_NODE_VERSION becomes a directory name under NODE_HOME, and that directory is
# later handed to `rm -rf`. This is defence in depth, and the honest scope should be
# recorded: the plain 'v..' case is separately refused by rm itself, and a traversal
# value breaks the download URL before the removal is reached. Measured, not assumed.
# What the check buys is that neither of those accidents is what is protecting us, and
# that a typo is refused with its own name on it rather than a curl error.
eval "$(sed -n '/^node_valid_version()/,/^}/p' "$ROOT/tools/install.sh")"
for good in v22.99.0 v0.0.0 v22.11.0 v100.0.1; do
node_valid_version "$good" || { echo "rejected a real version: ${good}" >&2; exit 1; }
done
for bad in 'v..' '..' 'v9.9.9/../../elsewhere' '/etc' 'v22' 'v22.1' '22.1.0' 'v22.1.0-rc1' '' 'v1.0.0 ' '$(id)'; do
! node_valid_version "$bad" || { echo "accepted a bad version: '${bad}'" >&2; exit 1; }
done
reset_home
echo "[test] a bad MOSAIC_NODE_VERSION is refused by name, before any download"
set +e
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" MOSAIC_NODE_VERSION="v9.9.9/../../elsewhere" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "MOSAIC_NODE_VERSION" <<<"$OUTPUT"
grep -qF -- "Downloading Node" <<<"$OUTPUT" && {
echo "the download started despite an invalid version" >&2
exit 1
}
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a download location with no transport integrity is refused"
set +e
OUTPUT="$(
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="http://example.invalid/dist" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
)"
RC=$?
set -e
[[ "$RC" -ne 0 ]]
grep -qF -- "MOSAIC_NODE_DIST must be" <<<"$OUTPUT"
[[ ! -d "$NODE_HOME" ]]
reset_home
echo "[test] a path containing shell syntax is not written into a profile"
# The PATH line is executed by every future shell that reads the file, so a directory
# holding $() or a quote would run there as code.
EVIL="$TMP/ev\$(touch $TMP/pwned)il"
set +e
env -u npm_config_prefix \
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$EVIL" \
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
MOSAIC_NODE_DIST="file://${DIST}" \
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch >/dev/null 2>&1
set -e
if [[ -f "$HOME_DIR/.profile" ]]; then
grep -qF -- 'touch' "$HOME_DIR/.profile" && {
echo "a command substitution was written into .profile" >&2
exit 1
}
fi
[[ ! -e "$TMP/pwned" ]] || { echo "the embedded command ran" >&2; exit 1; }
echo "[test] installer node provisioning tests passed"
+447 -288
View File
@@ -25,6 +25,9 @@
# tarballs and installs them globally. Use to test a branch # tarballs and installs them globally. Use to test a branch
# end-to-end before cutting a release. # end-to-end before cutting a release.
# --yes Accept all defaults; headless/non-interactive install # --yes Accept all defaults; headless/non-interactive install
# --no-node-install Do not provision Node; fail if Node >= 20 (>= 22 with
# --next) is not already present. Default is to install a
# user-local Node under ~/.mosaic/node when it is missing.
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install # --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line # --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
# #
@@ -38,6 +41,11 @@
# MOSAIC_NEXT — equivalent to --next (set to 1) # MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# MOSAIC_NODE_HOME — user-local Node install dir (default: ~/.mosaic/node)
# MOSAIC_NODE_VERSION — pin the Node release (default: latest of the
# required major, e.g. v22.23.2)
# MOSAIC_NODE_DIST — Node download mirror (default: nodejs.org/dist)
# MOSAIC_NO_NODE_INSTALL — equivalent to --no-node-install (set to 1)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# #
# Wrapped in main() for safe curl-pipe usage. # Wrapped in main() for safe curl-pipe usage.
@@ -82,7 +90,7 @@ if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
fi fi
installer_usage() { installer_usage() {
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2 printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--no-node-install] [--uninstall]\n' >&2
} }
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -109,6 +117,7 @@ while [[ $# -gt 0 ]]; do
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;; --next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;; --yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--no-node-install) MOSAIC_NO_NODE_INSTALL=1; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;;
*) *)
printf 'Error: Unknown argument: %s\n' "$1" >&2 printf 'Error: Unknown argument: %s\n' "$1" >&2
@@ -150,6 +159,43 @@ fi
WORK_DIR="" WORK_DIR=""
EXTRACTED_DIR="" EXTRACTED_DIR=""
# Modification time of one file, as an integer. GNU/BusyBox stat takes -c, BSD/macOS
# stat takes -f, and there is no flag both accept -- so probe once and remember.
_MTIME_STYLE=""
file_mtime() {
if [[ -z "$_MTIME_STYLE" ]]; then
if stat -c %Y . >/dev/null 2>&1; then
_MTIME_STYLE=gnu
elif stat -f %m . >/dev/null 2>&1; then
_MTIME_STYLE=bsd
else
_MTIME_STYLE=none
fi
fi
case "$_MTIME_STYLE" in
gnu) stat -c %Y -- "$1" 2>/dev/null ;;
bsd) stat -f %m -- "$1" 2>/dev/null ;;
*) return 1 ;;
esac
}
# The most recently modified file in "$dir" matching "$pattern".
#
# Three separate contracts, and callers must tell them apart:
# rc=0 with output — this is the newest match
# rc=0, no output — the directory or the pattern matched nothing
# rc=1 — the answer could not be determined
#
# The third one exists because the uninstall path treats "no backup" as licence to
# delete the destination. A lookup that fails must never be mistaken for a lookup
# that succeeded and found nothing.
#
# The candidates come from a glob and are compared in-shell, never rendered as text.
# That is deliberate, and it closes three bugs at once: `mapfile` is a Bash 4 builtin
# and macOS ships Bash 3.2, which this installer supports (see node_platform); piping
# `ls` into `head` dies on SIGPIPE under `set -o pipefail` once the listing fills a
# pipe buffer, returning 141 with no output; and any line-based parse of `ls` splits a
# filename that contains a newline into two wrong answers.
newest_matching_file() { newest_matching_file() {
local dir="$1" local dir="$1"
local pattern="$2" local pattern="$2"
@@ -160,8 +206,17 @@ newest_matching_file() {
matches=("$dir"/$pattern) matches=("$dir"/$pattern)
shopt -u nullglob shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0 [[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1 local newest="" newest_t="" candidate t
for candidate in "${matches[@]}"; do
t="$(file_mtime "$candidate")" || return 1
[[ -n "$t" ]] || return 1
if [[ -z "$newest_t" ]] || [[ "$t" -gt "$newest_t" ]]; then
newest="$candidate"
newest_t="$t"
fi
done
printf '%s\n' "$newest"
} }
# ─── uninstall path ─────────────────────────────────────────────────────────── # ─── uninstall path ───────────────────────────────────────────────────────────
@@ -224,12 +279,17 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
for dest in "${RUNTIME_DESTS[@]}"; do for dest in "${RUNTIME_DESTS[@]}"; do
base="$(basename "$dest")" base="$(basename "$dest")"
dir="$(dirname "$dest")" dir="$(dirname "$dest")"
# Find most recent backup # Find most recent backup. A lookup that could not answer is not the same as
# "there is no backup": removing the destination on a failed lookup would destroy
# the file the backup exists to restore.
backup="" backup=""
backup_lookup_ok=true
if [[ -d "$dir" ]]; then if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" || backup_lookup_ok=false
fi fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then if [[ "$backup_lookup_ok" != "true" ]]; then
echo " Skipped: $dest (could not check for a backup; left in place)"
elif [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest" cp "$backup" "$dest"
rm -f "$backup" rm -f "$backup"
echo " Restored: $dest" echo " Restored: $dest"
@@ -309,122 +369,376 @@ require_cmd() {
fi fi
} }
# True if any shell rc file already puts $1 on PATH. # ─── node provisioning ────────────────────────────────────────────────────────
# #
# Each file is tested for existence first and grepped one at a time, rather than # Node is a hard prerequisite for everything below, and a greenfield host does not
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file # have it. Treating that as the operator's problem made the documented one-command
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q # install a two-command install that fails first — so the installer provisions Node
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless. # itself.
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH #
# line on every single install. # It installs into the user's own tree rather than through apt/dnf/brew on purpose:
path_entry_exists() { # no root, one code path on every distro, and it works on an immutable host where
local dir="$1" rc_file # there is no system package manager to reach for. A system Node that is already
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do # new enough is always preferred and left untouched.
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
NODE_HOME="${MOSAIC_NODE_HOME:-$HOME/.mosaic/node}"
NODE_DIST="${MOSAIC_NODE_DIST:-https://nodejs.org/dist}"
FLAG_NO_NODE_INSTALL=false
if [[ "${MOSAIC_NO_NODE_INSTALL:-0}" == "1" ]]; then
FLAG_NO_NODE_INSTALL=true
fi
# A Node version string is about to become a directory name under NODE_HOME, and that
# directory is passed to `rm -rf`. Nothing reaches a filesystem operation until it has
# matched this. `v..` is the case that matters: it resolves to NODE_HOME's parent.
node_valid_version() {
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# The download location is executable code. Refuse a scheme that carries no transport
# integrity at all, and say plainly what an override does and does not buy, since the
# tarball and the checksum that vouches for it then come from the same place.
case "$NODE_DIST" in
https://*) ;;
file://*) ;;
*)
if [[ -n "${MOSAIC_NODE_DIST:-}" ]]; then
fail "MOSAIC_NODE_DIST must be an https:// or file:// URL; got '${NODE_DIST}'"
exit 1
fi
;;
esac
node_major_of() {
# Read the major from the binary rather than parsing `node --version` text, so a
# build with a suffix (v22.1.0-nightly…) does not read as a different major.
"$1" -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0
}
# The platform triple in a nodejs.org tarball name, or empty where nodejs.org
# publishes no build we can use.
node_platform() {
local os arch
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) return 1 ;;
esac
# Official Linux builds are glibc-linked; on musl they install and then fail to run.
if [[ "$os" == "linux" ]] && ldd --version 2>&1 | grep -qi musl; then
return 1
fi
case "$(uname -m)" in
x86_64|amd64) arch=x64 ;;
aarch64|arm64) arch=arm64 ;;
armv7l) arch=armv7l ;;
*) return 1 ;;
esac
printf '%s-%s' "$os" "$arch"
}
# Newest release of the wanted major. Resolved rather than pinned so a fresh install
# picks up security releases; MOSAIC_NODE_VERSION pins it when reproducibility matters.
node_resolve_version() {
local want="$1" index resolved
if [[ -n "${MOSAIC_NODE_VERSION:-}" ]]; then
if ! node_valid_version "$MOSAIC_NODE_VERSION"; then
fail "MOSAIC_NODE_VERSION must look like v22.11.0; got '${MOSAIC_NODE_VERSION}'"
return 1
fi
printf '%s' "$MOSAIC_NODE_VERSION"
return 0
fi
index="$(curl -fsSL --retry 3 "${NODE_DIST}/index.json" 2>/dev/null)" || return 1
# index.json is newest-first, so the first match is the latest of that major.
# grep/sed rather than a JSON parser because node is the thing we do not have yet.
# No `| head -1` here: head closes the pipe, grep takes SIGPIPE, and under
# `set -o pipefail` the whole substitution returns 141 -- the bug already fixed in
# newest_matching_file. Take the first line in the shell instead.
local found
found="$(printf '%s' "$index" | grep -o "\"version\":\"v${want}\.[0-9]\+\.[0-9]\+\"")" || return 1
found="${found%%$'\n'*}"
resolved="${found#\"version\":\"}"
resolved="${resolved%\"}"
# The index is remote input, and what comes out of it becomes a path.
[[ -n "$resolved" ]] || return 1
node_valid_version "$resolved" || return 1
printf '%s' "$resolved"
}
node_verify_checksum() {
local dir="$1" file="$2" expected="" line name matched=0
local manifest="${dir}/SHASUMS256.txt"
if [[ ! -f "$manifest" ]]; then
fail "No checksum manifest was downloaded for ${file}"
return 1
fi
# Compare filenames exactly rather than `grep " ${file}$"`. A Node tarball name is
# mostly dots, and in a regex a dot matches any character -- so a manifest line for
# a name that merely looks like this one would be accepted as this one's checksum.
#
# Every line is read, not just the first match: two entries for the same file mean
# the manifest is not trustworthy, and picking either one is a decision this code
# has no basis to make.
while IFS= read -r line || [[ -n "$line" ]]; do
name="${line#* }"
[[ "$name" == "$file" ]] || continue
expected="${line%% *}"
matched=$(( matched + 1 ))
done < "$manifest"
if [[ "$matched" -eq 0 ]]; then
fail "No checksum published for ${file}"
return 1
fi
if [[ "$matched" -gt 1 ]]; then
fail "Checksum manifest lists ${file} ${matched} times; refusing to guess."
return 1
fi
if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then
fail "Checksum for ${file} is not a SHA-256 digest: '${expected}'"
return 1
fi
local actual
if command -v sha256sum &>/dev/null; then
actual="$(sha256sum "${dir}/${file}" | awk '{print $1}')"
elif command -v shasum &>/dev/null; then
actual="$(shasum -a 256 "${dir}/${file}" | awk '{print $1}')"
else
fail "Cannot verify the Node download: neither sha256sum nor shasum is present."
return 1
fi
if [[ "$actual" != "$expected" ]]; then
fail "Node download failed checksum verification (${file})"
dim " expected ${expected}"
dim " got ${actual}"
return 1
fi
}
# Download, verify and unpack one Node release into a scratch dir, then move it into
# place. Staging first means a failed or interrupted download never leaves a half-tree
# that the next run would mistake for an installed Node.
node_fetch_and_unpack() {
local version="$1" platform="$2" work="$3"
local base="node-${version}-${platform}"
local tarball="${base}.tar.gz"
local dest="${NODE_HOME}/${version}"
info "Downloading Node ${version} (${platform})…"
curl -fsSL --retry 3 -o "${work}/${tarball}" "${NODE_DIST}/${version}/${tarball}" || {
fail "Could not download ${NODE_DIST}/${version}/${tarball}"
return 1
}
curl -fsSL --retry 3 -o "${work}/SHASUMS256.txt" "${NODE_DIST}/${version}/SHASUMS256.txt" || {
fail "Could not download the Node checksum file"
return 1
}
node_verify_checksum "$work" "$tarball" || return 1
mkdir -p "$NODE_HOME"
tar -xzf "${work}/${tarball}" -C "$work" || { fail "Could not unpack ${tarball}"; return 1; }
rm -rf "${dest}.partial"
mv "${work}/${base}" "${dest}.partial" || { fail "Could not stage Node into ${NODE_HOME}"; return 1; }
rm -rf "$dest"
mv "${dest}.partial" "$dest" || { fail "Could not install Node into ${dest}"; return 1; }
ok "Installed Node ${version}${dest}"
}
# Install one Node release, reusing it if this installer already put it there.
#
# The scratch dir is removed here rather than by a RETURN trap inside the worker: a
# RETURN trap set inside a function stays installed after that function returns, so it
# fires again on the next unrelated function return, where its variables are gone.
node_install() {
local version="$1" platform="$2"
local dest="${NODE_HOME}/${version}"
# Re-checked here, not only where the version was resolved: `dest` is about to be
# handed to `rm -rf`, and this is the last place before that happens. A version of
# `..` would point the removal at NODE_HOME's parent.
if ! node_valid_version "$version"; then
fail "Refusing to install Node from an unexpected version string: '${version}'"
return 1
fi
if [[ -x "${dest}/bin/node" ]]; then
info "Reusing Node ${version} already at ${dest}"
return 0
fi
local work rc=0
work="$(mktemp -d)" || return 1
node_fetch_and_unpack "$version" "$platform" "$work" || rc=$?
rm -rf "$work"
return "$rc"
}
# Put a directory on PATH for future processes, once. A user-local Node and a
# user-local npm prefix are only useful if the next process can still find them, and
# the installer used to do no more than warn about it.
#
# There is no one file that covers this. Each target below is the only thing that
# works for some way a user -- or an agent seat -- actually starts a process:
#
# ~/.profile POSIX login shells, and `bash -lc` when no bash-specific
# profile exists.
# ~/.bash_profile A bash login shell reads the first of these that exists and
# ~/.bash_login then never reads ~/.profile. On a host with one of them,
# writing only ~/.profile is a silent no-op. Appended to when
# present, never created -- creating one would itself start
# shadowing ~/.profile for everything else the user has there.
# ~/.bashrc Interactive non-login shells. Debian's returns early when the
# shell is not interactive, so it cannot stand in for a profile.
# ~/.zshenv Every zsh invocation, including `ssh host cmd`. A remote
# non-interactive zsh reads neither ~/.zprofile nor ~/.zshrc,
# which is what the previous version of this function wrote.
# environment.d systemd --user units, which read no shell file at all. A
# Mosaic agent seat starts as a unit, so this one is the point.
persist_path_line() {
local dir="$1" line rc wrote=""
# This text is written into files that a future shell will execute, so a directory
# containing shell syntax would run there as code. Refuse rather than escape: such
# a path can only arrive through MOSAIC_NODE_HOME or MOSAIC_PREFIX, and a real
# install directory never needs these characters.
if [[ "$dir" =~ [\"\$\`\\] ]] || [[ "$dir" == *"'"* ]] || [[ "$dir" == *$'\n'* ]]; then
warn "Not adding ${dir} to PATH automatically: the path contains shell syntax."
dim " Put it on PATH by hand, or reinstall to a path without those characters."
return 0
fi
line="export PATH=\"${dir}:\$PATH\""
local files=("$HOME/.profile")
case "$(basename "${SHELL:-/bin/bash}")" in
zsh)
files+=("$HOME/.zshenv")
;;
*)
files+=("$HOME/.bashrc")
if [[ -f "$HOME/.bash_profile" ]]; then files+=("$HOME/.bash_profile"); fi
if [[ -f "$HOME/.bash_login" ]]; then files+=("$HOME/.bash_login"); fi
;;
esac
for rc in "${files[@]}"; do
# -x anchors the match to a whole line. Without it, a commented-out example of
# this same export counts as already present and the real entry never gets
# written -- the failure then looks like the installer simply did nothing.
if [[ -f "$rc" ]] && grep -Fqx "$line" "$rc"; then
continue
fi
{
printf '\n# Added by the Mosaic Stack installer\n'
printf '%s\n' "$line"
} >> "$rc"
wrote+="${wrote:+, }${rc}"
done
# systemd --user units inherit from the user manager, not from any shell.
local envd="$HOME/.config/environment.d"
local envd_file="$envd/50-mosaic-path.conf"
local envd_line="PATH=${dir}:\${PATH}"
if mkdir -p "$envd" 2>/dev/null; then
if [[ ! -f "$envd_file" ]] || ! grep -Fqx "$envd_line" "$envd_file"; then
printf '%s\n' "$envd_line" >> "$envd_file"
wrote+="${wrote:+, }${envd_file}"
fi
fi
if [[ -n "$wrote" ]]; then
ok "Added ${dir} to PATH in ${wrote}"
dim " This shell: export PATH=\"${dir}:\$PATH\""
dim " systemd --user: systemctl --user daemon-reload (or log in again)"
fi
}
# Make the installed `mosaic` reachable, now and in the next shell. Warning about
# this and moving on left a completed install whose CLI could not be found, which
# reads to an operator as a failed install.
ensure_prefix_on_path() {
persist_path_line "$PREFIX/bin"
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
PATH="$PREFIX/bin:$PATH"
export PATH
fi
}
# Guarantee a Node of at least $1 on PATH for the rest of this run.
ensure_node() {
local want="$1" current=0
if command -v node &>/dev/null; then
current="$(node_major_of node)"
if [[ "$current" -ge "$want" ]]; then
ok "Node $(node --version) satisfies the >= ${want} requirement"
return 0
fi
fi
# A Node this installer put there previously, from an earlier run or another lane.
local candidate
for candidate in "$NODE_HOME"/*/bin/node; do
[[ -x "$candidate" ]] || continue
if [[ "$(node_major_of "$candidate")" -ge "$want" ]]; then
PATH="$(dirname "$candidate"):$PATH"
export PATH
ok "Using Node $(node --version) from ${NODE_HOME}"
persist_path_line "$(dirname "$candidate")"
return 0 return 0
fi fi
done done
return 1
}
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this if [[ "$current" == "0" ]]; then
# process. An `export` here reaches only the installer; every directory the info "Node is not installed; the Mosaic CLI needs Node >= ${want}."
# install leaves behind has to be written down somewhere a later shell reads.
#
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
# non-interactive shells, so a PATH line appended to the bottom of it is
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
# exact consumers that need these binaries. ~/.profile is read by login shells
# and Debian's .profile sources .bashrc for interactive ones, so a single line
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
#
# $1 = directory to add, $2 = label for the comment line.
# Returns 1 (having warned) if the profile could not be written.
persist_on_path() {
local dir="$1" label="$2" profile
if path_entry_exists "$dir"; then
return 0
fi
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
profile="$HOME/.zshenv"
else else
profile="$HOME/.profile" info "Node v${current} is older than the required >= ${want}."
fi fi
# Probe writability in a subshell. A redirection failure on a special built-in if [[ "$FLAG_NO_NODE_INSTALL" == "true" ]]; then
# aborts the shell it runs in, so it has to be a child; and the redirection on fail "Node >= ${want} required and --no-node-install was given."
# the subshell is what silences the "Permission denied" the shell would echo " Install Node >= ${want} and re-run, or drop --no-node-install."
# otherwise print ahead of our own message. exit 1
if ! ( : >>"$profile" ) 2>/dev/null; then
warn "$dir is not on your PATH and $profile could not be written"
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
return 1
fi fi
{ local platform
echo "" if ! platform="$(node_platform)"; then
echo "# $label" fail "No official Node build for $(uname -s)/$(uname -m)."
echo "export PATH=\"$dir:\$PATH\"" echo " Install Node >= ${want} with your system package manager and re-run."
} >>"$profile" exit 1
ok "Added $dir to PATH in $profile"
return 0
}
# Persist $PREFIX/bin on PATH instead of only warning about it.
#
# The warning it replaces was the last step of an otherwise successful install,
# so the installer reported success and left `mosaic: command not found` — an
# unattended install had no operator to read the advice and act on it.
ensure_prefix_on_path() {
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
return
fi fi
if path_entry_exists "$PREFIX/bin"; then require_cmd curl
warn "$PREFIX/bin is in your shell profile but not in this shell" require_cmd tar
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
return
fi
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
}
# Fleet transport binary (#1240). local version
# version="$(node_resolve_version "$want")" || true
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every if [[ -z "$version" ]]; then
# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script fail "Could not resolve a Node ${want}.x release from ${NODE_DIST}."
# provides tmux and, until now, nothing in it mentioned tmux at all. A echo " Check network access, or pin one: MOSAIC_NODE_VERSION=v${want}.0.0"
# greenfield host came out of this installer able to install a fleet, start a exit 1
# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`.
#
# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of
# hosts install this to run `mosaic claude` and will never scaffold a roster,
# and failing their install over a binary they do not need would be wrong. It
# is a warning that names precisely what it blocks.
#
# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the
# same host state gets the same answer from an audit as from an install. They
# are separate implementations because this one has to work before the
# framework's scripts are guaranteed to be on disk; keep their wording in step.
check_fleet_transport() {
local transport=tmux
local roster="$MOSAIC_HOME/fleet/roster.yaml"
local declared=""
if [[ -f "$roster" ]]; then
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
[[ -n "$declared" ]] && transport="$declared"
fi fi
command -v "$transport" &>/dev/null && return 0 info "Installing Node ${version} into ${NODE_HOME} (no root required)…"
if ! node_install "$version" "$platform"; then
fail "Node installation failed."
echo " Install Node >= ${want} manually and re-run, or re-run with --no-node-install"
echo " once it is present."
exit 1
fi
warn "Fleet transport '$transport' is not installed." PATH="${NODE_HOME}/${version}/bin:$PATH"
echo " The Mosaic fleet runs its agent seats inside $transport. Without it," export PATH
echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up." persist_path_line "${NODE_HOME}/${version}/bin"
echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}"
echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)." # Prove it, rather than assuming the unpack produced a working binary.
if ! command -v node &>/dev/null || [[ "$(node_major_of node)" -lt "$want" ]]; then
fail "Node ${version} was installed but is not usable on PATH."
exit 1
fi
ok "Node $(node --version) ready"
} }
installed_cli_version() { installed_cli_version() {
@@ -568,8 +882,10 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" # An unanswerable lookup becomes an empty path, which the -f guards below report
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" # properly. Nothing destructive happens on this path, so failing soft is safe here.
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" || cli_tgz=""
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" || gw_tgz=""
if [[ ! -f "$cli_tgz" ]]; then if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack." fail "CLI tarball was not produced by pnpm pack."
@@ -634,186 +950,28 @@ install_next_cli_from_registry() {
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}" ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
} }
# ─── node bootstrap ───────────────────────────────────────────────────────────
#
# Nothing on a greenfield host installs Node.js, yet this installer and the CLI
# it installs both hard-require it. Measured on a clean Debian 13 image: the
# installer stopped at `require_cmd node` with "Required command not found" and
# nothing was installed, with no hint of how to proceed.
#
# Inlined rather than factored into a sibling file on purpose: this script is
# fetched standalone by curl and has nothing to source.
#
# No-op when a suitable node is already on PATH, so it never fights an
# operator's nvm/fnm/distro node.
NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
# Major version of the node at $1, or empty if it will not run.
node_major_of() {
local candidate="$1" version
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
printf '%s' "${version%%.*}"
}
node_is_suitable() {
local major
major="$(node_major_of "$1")"
[[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
}
install_node() {
local node_os node_arch tarball release_url work_dir extracted target node_bin
case "$(uname -s)" in
Linux) node_os="linux" ;;
Darwin) node_os="darwin" ;;
*) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# Linux here means glibc. Node's official linux-x64 build is dynamically
# linked against glibc, so on musl (Alpine) the binary will not exec — but it
# fails visibly: node_is_suitable rejects it and ensure_node exits with
# "install Node.js manually". No silent breakage, just a wasted download.
# A musl host needs the unofficial build, which is out of scope here.
case "$(uname -m)" in
x86_64|amd64) node_arch="x64" ;;
aarch64|arm64) node_arch="arm64" ;;
armv7l) node_arch="armv7l" ;;
*) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
esac
# .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is
# not, and a minimal image is exactly the case this exists to handle.
tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz"
release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")"
info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT"
if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then
fail "Download failed: ${release_url}/${tarball}"
rm -rf "$work_dir"; return 1
fi
# Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
# (the tarball matches the manifest), not AUTHENTICITY (the manifest is
# genuinely Node's). The only thing establishing that is TLS to
# $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
# keys and we do not check it, which is on par with nvm but means pointing
# MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
# Tracked as a hardening follow-up (raised by scooby in the #1229 review).
if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then
fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime."
rm -rf "$work_dir"; return 1
fi
# Keep only our artifact's line, so a missing entry is an error not a pass.
if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then
fail "$tarball has no entry in SHASUMS256.txt; refusing to install."
rm -rf "$work_dir"; return 1
fi
if ! (cd "$work_dir" && verify_sha256 expected.sha256); then
fail "Checksum mismatch for $tarball; refusing to install."
rm -rf "$work_dir"; return 1
fi
ok "Checksum verified"
tar xzf "$work_dir/$tarball" -C "$work_dir"
extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}"
if [[ ! -x "$extracted/bin/node" ]]; then
fail "Extracted archive has no bin/node"
rm -rf "$work_dir"; return 1
fi
mkdir -p "$NODE_ROOT"
target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION"
rm -rf "$target.incoming"
mv "$extracted" "$target.incoming"
rm -rf "$target"
mv "$target.incoming" "$target"
ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current"
rm -rf "$work_dir"
node_bin="$NODE_ROOT/current/bin"
if ! node_is_suitable "$node_bin/node"; then
fail "Installed node at $node_bin/node did not run"
return 1
fi
export PATH="$node_bin:$PATH"
ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')"
return 0
}
# Make the Mosaic-managed Node reachable from the next shell as well as this
# one. Measured on a greenfield canary run: without this the install finished
# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic`
# and then died on `env: 'node': No such file or directory` — the CLI is a Node
# script, so a CLI on PATH without its runtime is a successful install that
# produces a broken command.
persist_node_on_path() {
persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
}
ensure_node() {
if command -v node &>/dev/null && node_is_suitable node; then
return 0
fi
# A previous run may have installed one that is not on this shell's PATH.
if node_is_suitable "$NODE_ROOT/current/bin/node"; then
export PATH="$NODE_ROOT/current/bin:$PATH"
persist_node_on_path
return 0
fi
if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then
fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download."
echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script."
exit 1
fi
require_cmd curl
require_cmd tar
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
# checksum this would install an unauthenticated runtime.
if command -v sha256sum &>/dev/null; then
verify_sha256() { sha256sum -c --status "$1"; }
elif command -v shasum &>/dev/null; then
verify_sha256() { shasum -a 256 -c --status "$1"; }
else
fail "sha256sum or shasum required to verify the Node.js download"
exit 1
fi
if ! install_node; then
fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run."
exit 1
fi
persist_node_on_path
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
ensure_node NODE_REQUIRED=20
if [[ "$FLAG_NEXT" == "true" ]]; then
NODE_REQUIRED=22
fi
if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then
# Neither lane installs anything, so neither one may install Node.
require_cmd node require_cmd node
require_cmd npm require_cmd npm
NODE_MAJOR="$(node_major_of node)"
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')" if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then
if [[ "$NODE_MAJOR" -lt 20 ]]; then fail "Node.js >= ${NODE_REQUIRED} required (found $(node --version))"
fail "Node.js >= 20 required (found v$(node --version))"
exit 1 exit 1
fi fi
if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then else
fail "Node.js >= 22 required for the --next lane (found v$(node --version))" ensure_node "$NODE_REQUIRED"
exit 1 # npm ships inside the Node tarball, so this only fails on a system Node that
# was packaged without it — which is worth saying out loud rather than dying later.
require_cmd npm
NODE_MAJOR="$(node_major_of node)"
fi fi
echo "" echo ""
@@ -1083,7 +1241,13 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val local base dir backup_path backup_val
base="$(basename "$dest")" base="$(basename "$dest")"
dir="$(dirname "$dest")" dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" # Recording null here would tell a later uninstall that no backup exists, and
# it would then delete the destination instead of restoring it. An unanswerable
# lookup must stop the manifest, not guess at it.
if ! backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"; then
fail "Could not determine the backup state of ${dest}; refusing to write a manifest."
return 1
fi
if [[ -n "$backup_path" ]]; then if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\"" backup_val="\"$backup_path\""
else else
@@ -1143,11 +1307,6 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
ok "Done." ok "Done."
fi fi
# Fleet readiness (#1240). Runs in both normal and --check mode: "what is the
# state of this host" is exactly the question --check is asked, and a host that
# cannot run a seat should not have to discover it from `fleet ps`.
check_fleet_transport
} # end main } # end main
main "$@" main "$@"