Files
stack/packages/mosaic/framework/tools/_scripts/mosaic-sync-skills
T
Jason Woltje 4a7fc07ee2 feat(launch): isolate harness homes and record immutable launch provenance
Mosaic wrote into the operator's harness base installs — ~/.claude,
~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a
102-symlink skill farm per harness. Any experiment with hooks or gating
therefore mutated the operator's own tooling, and a broken framework change
could take out the very harness needed to repair it.

Harness home isolation
----------------------
Each runtime now reads config from a dedicated mosaic-owned home via the
harness's own config-dir variable:

  claude    CLAUDE_CONFIG_DIR     ~/.config/mosaic/.claude
  pi        PI_CODING_AGENT_DIR   ~/.config/mosaic/.pi     (replaces ~/.pi/agent)
  codex     CODEX_HOME            ~/.config/mosaic/.codex
  opencode  XDG_CONFIG_HOME       ~/.config/mosaic/.opencode

These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator
ownership and a keep-mode upgrade can neither overwrite nor prune them.
A bare `claude` / `pi` keeps its own config AND auth, making it a structural
break-glass rather than one depending on restoring a file under pressure.

opencode is blunter than the rest: it has no dedicated variable and follows XDG,
so isolation also relocates XDG lookups for anything it spawns. Documented in
place.

mosaic-sync-skills now links into those homes and cleans the legacy farms it
previously planted in base installs. Ownership is proven by RESOLUTION, not by
name — only symlinks resolving inside the canonical/local skills dirs are
removed, mirroring the refusal already in commands/skill.js. Verified against a
real install: codex's own .system directory survived while its 102 mosaic links
were removed. Both resolution prefixes are length-checked first; an empty prefix
would make "$resolved" == "$prefix/"* match every absolute path and delete
foreign symlinks.

Immutable launch record
-----------------------
Every launch now appends one record to fleet/run/sessions/events.ndjson before
exec. Mandatory, mechanical, no model involvement.

pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the
launch evidence — that has already produced a confident wrong diagnosis ("this
agent bypassed the launcher"), disproved only by the parent's argv and only
because the parent had not yet exited. A record written before exec is the only
place this survives.

The path is the #797 Runtime Session Ledger, already operator-classified and
already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a
populated ledger survives keep-mode upgrades — but nothing shipped ever wrote
it. This implements it in the shape that guard already asserts (0600 files under
a 0700 dir).

`mosaic` writes session.launch; launch-runtime.py appends lease.register with
the broker session id and activation capability. They correlate by an explicit
MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a
child with a different pid.

Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/
TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is
mechanically detectable rather than a matter of judgement.

Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over
256 bytes become a sha256 + length rather than being inlined.

Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in
the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED.
resolveTool() uses that same failing specifier, which is why its documented
preference for bundled tools over the deployed ~/.config/mosaic copy has never
once applied — noted in place, not fixed here.

Verified on sb-it-1-dt: isolated homes written and base installs byte-identical
for all four harnesses; 408 legacy symlinks removed with 1 foreign entry
preserved; launch records paired across the spawn boundary. typecheck shows zero
errors in launch.ts (the @mosaicstack/types failures are pre-existing and
reproduce on a pristine origin/main worktree).
2026-08-06 16:56:35 -05:00

352 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
SKILLS_REPO_URL="${MOSAIC_SKILLS_REPO_URL:-https://git.mosaicstack.dev/mosaic/agent-skills.git}"
SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}"
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
# Colon-separated list of skill names to install. When set, only these skills
# are linked into runtime skill directories. Empty/unset = link all skills
# (the legacy "mosaic sync" full-catalog behavior).
MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}"
fetch=1
link_only=0
usage() {
cat <<USAGE
Usage: $(basename "$0") [options]
Sync canonical skills into ~/.config/mosaic/skills and link all Mosaic skills into runtime skill directories.
Options:
--link-only Skip git clone/pull and only relink from ~/.config/mosaic/{skills,skills-local}
--no-link Sync canonical skills but do not update runtime links
-h, --help Show help
Env:
MOSAIC_HOME Default: ~/.config/mosaic
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--link-only)
fetch=0
shift
;;
--no-link)
link_only=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR"
if [[ $fetch -eq 1 ]]; then
if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then
echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR"
# ── Detect dirty state ──────────────────────────────────────────────
dirty=""
dirty="$(git -C "$SKILLS_REPO_DIR" status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
# ── Auto-migrate customized skills to skills-local/ ─────────────
# Instead of stash/pop (fragile, merge conflicts), we:
# 1. Identify which skill dirs contain user edits
# 2. Copy those full skill dirs into skills-local/ (preserving edits)
# 3. Reset the repo clean so pull always succeeds
# 4. skills-local/ takes precedence during linking, so edits win
SOURCE_SKILLS_SUBDIR="$SKILLS_REPO_DIR/skills"
migrated=()
while IFS= read -r line; do
# porcelain format: XY <path> — extract the file path
file="${line:3}"
# Only migrate files under skills/ subdir in the repo
if [[ "$file" == skills/* ]]; then
# Extract the skill directory name (first path component after skills/)
skill_name="${file#skills/}"
skill_name="${skill_name%%/*}"
# Skip if already migrated this skill in this run
local_skill_dir="$MOSAIC_LOCAL_SKILLS_DIR/$skill_name"
if [[ -d "$local_skill_dir" ]]; then
continue
fi
# Skip if skill_name is empty or hidden
if [[ -z "$skill_name" || "$skill_name" == .* ]]; then
continue
fi
# Copy the skill (with user's edits) from repo working tree to skills-local/
if [[ -d "$SOURCE_SKILLS_SUBDIR/$skill_name" ]]; then
cp -R "$SOURCE_SKILLS_SUBDIR/$skill_name" "$local_skill_dir"
migrated+=("$skill_name")
fi
fi
done <<< "$dirty"
if [[ ${#migrated[@]} -gt 0 ]]; then
echo "[mosaic-skills] Migrated ${#migrated[@]} customized skill(s) to skills-local/:"
for s in "${migrated[@]}"; do
echo " → $MOSAIC_LOCAL_SKILLS_DIR/$s"
done
echo "[mosaic-skills] Your edits are preserved there and take precedence over canonical."
fi
# Reset repo to clean state so pull always works
echo "[mosaic-skills] Resetting source repo to clean state..."
git -C "$SKILLS_REPO_DIR" checkout . 2>/dev/null || true
git -C "$SKILLS_REPO_DIR" clean -fd 2>/dev/null || true
fi
if ! git -C "$SKILLS_REPO_DIR" pull --rebase 2>/dev/null; then
echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2
git -C "$SKILLS_REPO_DIR" rebase --abort 2>/dev/null || true
fi
else
echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR"
mkdir -p "$(dirname "$SKILLS_REPO_DIR")"
git clone "$SKILLS_REPO_URL" "$SKILLS_REPO_DIR"
fi
SOURCE_SKILLS_DIR="$SKILLS_REPO_DIR/skills"
if [[ ! -d "$SOURCE_SKILLS_DIR" ]]; then
echo "[mosaic-skills] Missing source skills dir: $SOURCE_SKILLS_DIR" >&2
exit 1
fi
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "$SOURCE_SKILLS_DIR/" "$MOSAIC_SKILLS_DIR/"
else
rm -rf "$MOSAIC_SKILLS_DIR"/*
cp -R "$SOURCE_SKILLS_DIR"/* "$MOSAIC_SKILLS_DIR"/
fi
fi
if [[ ! -d "$MOSAIC_SKILLS_DIR" ]]; then
echo "[mosaic-skills] Canonical skills dir missing: $MOSAIC_SKILLS_DIR" >&2
exit 1
fi
if [[ $link_only -eq 1 ]]; then
echo "[mosaic-skills] Canonical sync completed (link update skipped)"
exit 0
fi
# Skills are linked into the MOSAIC-OWNED harness homes, never a base install.
# Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in
# commands/launch.js):
# claude CLAUDE_CONFIG_DIR -> <home>/skills
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
# codex CODEX_HOME -> <home>/skills
# opencode XDG_CONFIG_HOME -> <home>/opencode/skills (XDG adds a level)
link_targets=(
"$MOSAIC_HOME/.claude/skills"
"$MOSAIC_HOME/.codex/skills"
"$MOSAIC_HOME/.opencode/opencode/skills"
"$MOSAIC_HOME/.pi/skills"
)
# Pre-isolation installs planted the same symlink farm directly in the operator's
# base installs. Those are now orphaned: the launcher no longer reads them, but
# they persist and make a "clean" base install look mosaic-managed.
legacy_link_targets=(
"$HOME/.claude/skills"
"$HOME/.codex/skills"
"$HOME/.config/opencode/skills"
"$HOME/.pi/agent/skills"
)
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
# Build an associative array from the colon-separated whitelist for O(1) lookup.
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
declare -A _skill_whitelist=()
_whitelist_active=0
if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then
_whitelist_active=1
IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS"
for _item in "${_wl_items[@]}"; do
[[ -n "$_item" ]] && _skill_whitelist["$_item"]=1
done
fi
is_skill_selected() {
local name="$1"
if [[ $_whitelist_active -eq 0 ]]; then
return 0
fi
[[ -n "${_skill_whitelist[$name]:-}" ]] && return 0
return 1
}
link_skill_into_target() {
local skill_path="$1"
local target_dir="$2"
local name link_path
name="$(basename "$skill_path")"
# Do not distribute hidden/system skill directories globally.
if [[ "$name" == .* ]]; then
return
fi
# Respect the install whitelist (set during first-run wizard).
if ! is_skill_selected "$name"; then
return
fi
link_path="$target_dir/$name"
if [[ -L "$link_path" ]]; then
local raw_target resolved_target
raw_target="$(readlink "$link_path")"
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
ln -sfn "$skill_path" "$link_path"
else
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
fi
return
fi
if [[ -e "$link_path" ]]; then
echo "[mosaic-skills] Preserve existing runtime-specific entry: $link_path"
return
fi
ln -s "$skill_path" "$link_path"
}
is_mosaic_skill_name() {
local name="$1"
# -d follows symlinks; -L catches broken symlinks that still indicate ownership
[[ -d "$MOSAIC_SKILLS_DIR/$name" || -L "$MOSAIC_SKILLS_DIR/$name" ]] && return 0
[[ -d "$MOSAIC_LOCAL_SKILLS_DIR/$name" || -L "$MOSAIC_LOCAL_SKILLS_DIR/$name" ]] && return 0
return 1
}
prune_stale_links_in_target() {
local target_dir="$1"
while IFS= read -r -d '' link_path; do
local name resolved
name="$(basename "$link_path")"
if is_mosaic_skill_name "$name"; then
continue
fi
# -m resolves lexical dangling targets too. If resolution fails, ownership
# is unproven and the link must be preserved.
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
fi
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
}
# Remove mosaic-owned symlinks left in a base install by a pre-isolation sync.
#
# Ownership is proven by RESOLUTION, not by name: only links resolving inside the
# canonical or local skills dirs are removed. Anything else — a real directory, a
# link elsewhere, an unresolvable link — is left untouched. This mirrors the
# refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills
# directory are managed") and preserves e.g. codex's own `.system` dir.
#
# The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is
# missing, and an empty dir is the correct end state, not an absent one.
cleanup_legacy_target() {
local target_dir="$1"
local removed=0 kept=0
[[ -d "$target_dir" ]] || return 0
while IFS= read -r -d '' link_path; do
local resolved owned=0
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
# Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"*
# match every absolute path and delete foreign links.
if [[ -n "$resolved" ]]; then
if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then
owned=1
elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then
owned=1
fi
fi
if [[ $owned -eq 1 ]]; then
rm -f "$link_path"
removed=$((removed + 1))
else
kept=$((kept + 1))
fi
done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0)
if [[ $removed -gt 0 ]]; then
echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)"
fi
}
for legacy in "${legacy_link_targets[@]}"; do
# Skip anything that is also a current target, so isolation can never
# self-destruct if the two lists ever overlap.
skip=0
for target in "${link_targets[@]}"; do
[[ "$legacy" == "$target" ]] && skip=1
done
[[ $skip -eq 1 ]] && continue
cleanup_legacy_target "$legacy"
done
for target in "${link_targets[@]}"; do
mkdir -p "$target"
# If target already resolves to canonical dir, skip to avoid self-link recursion/corruption.
target_real="$(readlink -f "$target" 2>/dev/null || true)"
if [[ -n "$target_real" && "$target_real" == "$canonical_real" ]]; then
echo "[mosaic-skills] Skip target (already canonical): $target"
continue
fi
prune_stale_links_in_target "$target"
while IFS= read -r -d '' skill; do
link_skill_into_target "$skill" "$target"
done < <(find "$MOSAIC_SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d -print0)
if [[ -d "$MOSAIC_LOCAL_SKILLS_DIR" ]]; then
while IFS= read -r -d '' skill; do
link_skill_into_target "$skill" "$target"
done < <(find "$MOSAIC_LOCAL_SKILLS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0)
fi
echo "[mosaic-skills] Linked skills into: $target"
done
echo "[mosaic-skills] Complete"