diff --git a/packages/mosaic/__tests__/platform/detect.test.ts b/packages/mosaic/__tests__/platform/detect.test.ts new file mode 100644 index 00000000..540f0e8a --- /dev/null +++ b/packages/mosaic/__tests__/platform/detect.test.ts @@ -0,0 +1,74 @@ +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(); + 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'); + }); +}); diff --git a/packages/mosaic/framework/install.sh b/packages/mosaic/framework/install.sh index 7d262a24..1578c33b 100755 --- a/packages/mosaic/framework/install.sh +++ b/packages/mosaic/framework/install.sh @@ -35,6 +35,18 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" 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) — # never an environment variable — so this opt-out can never sit silently # inherited in a shell profile. See #869 Point-1 C2. @@ -696,6 +708,52 @@ sync_framework mkdir -p "$TARGET_DIR/memory" 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 # 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. diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index f4d4a985..81f76b54 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack Requires=mosaic-tmux-holder.service After=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] Type=oneshot diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 6973a9ce..ceb4ff70 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -128,6 +128,14 @@ EOF sleep 30 EOF 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_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" \ diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 60e73d78..6faffa38 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -286,6 +286,36 @@ _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 +# #1241. The pane runs `mosaic yolo ` 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 ` 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() { local workdir="$1" local resolved @@ -384,6 +414,19 @@ if [ -n "$PANE_PID" ]; then _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ 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 - echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2 + # #1241. This branch used to print a WARNING about the heartbeat sidecar and + # 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 diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 5d1c27d6..e7d8e94f 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -23,8 +23,26 @@ index=0 if [ "${args[0]:-}" = -L ]; then index=2; fi case "${args[$index]:-}" in 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 [ "$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 exit 1 ;; @@ -62,6 +80,30 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment" SHIM 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() { local home="$1" local agent="$2" @@ -81,6 +123,19 @@ MOSAIC_TMUX_SOCKET=mosaic-test EOF chmod 600 "$home/fleet/agents/$agent.env.generated" 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() { @@ -88,6 +143,7 @@ run_start() { local agent="$2" HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \ MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \ + MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \ MOSAIC_TEST_HOME="$home" \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_HOME="$home" "$START" "$agent" @@ -98,7 +154,10 @@ run_start() { HOME_VALID="$ROOT/valid" AGENT_VALID="coder0" write_generated "$HOME_VALID" "$AGENT_VALID" -run_start "$HOME_VALID" "$AGENT_VALID" +# A live pane PID is part of what "valid launch" means. Until #1241 this case +# 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") 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" @@ -245,6 +304,13 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env" printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ "$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' \ BASH_ENV="$PANE_BASH_ENV" \ MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ @@ -258,6 +324,7 @@ PATH="$PANE_STALE_PATH" \ "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ MOSAIC_TEST_EXECUTE_PANE=1 \ + "MOSAIC_TEST_PANE_PID=$$" \ "$START" coder-pane-boundary pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ @@ -392,6 +459,75 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \ echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \ fail "interaction pinned-policy check did not follow strict parsing" +# #1241. The pane runs `mosaic yolo ` 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 # projection and ignores an ambient socket supplied by the caller. : > "$TMUX_CALLS" diff --git a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt index 49186d6f..54f6ffe5 100644 --- a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt +++ b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt @@ -32,10 +32,6 @@ 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) # --- 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 --- # (#1017: three independent censuses handled the microtest file three different diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 066aa6b8..98cf494f 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/_scripts/test-fleet-transport-check.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" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts new file mode 100644 index 00000000..12d039a0 --- /dev/null +++ b/packages/mosaic/src/commands/fleet-roster-v2-dispatch.spec.ts @@ -0,0 +1,323 @@ +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 => { + 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 { + 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 => { + 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 { + 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 => { + 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 => { + 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', + 'mosaic-agent@.service', + 'mosaic-interaction-agent@.service', + ]) { + 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 => { + 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 => { + await v2Home(); + const lines = capture(); + + await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']); + + expect(lines.join('\n')).toContain('mosaic fleet apply'); + }); +}); + +describe('mosaic-agent@.service', (): void => { + const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service'); + + /** The single `ConditionPathExists=` value declared by the unit template. */ + async function conditionPath(): Promise { + 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 => { + // 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@` 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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); + }); +}); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index c344660a..b77c6e3a 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -34,6 +34,7 @@ export { resolveInstalledFleetRosterPath, } from '../fleet/fleet-roster-v1.js'; export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js'; +import { parseRosterV2 } from '../fleet/roster-v2.js'; import { registerFleetAgentCrudCommands, type FleetAgentCrudCommandDeps, @@ -820,7 +821,7 @@ export function buildEnableLingerCommand(user: string): string[] { */ export async function enableFleetUnits( runner: CommandRunner, - roster: FleetRoster, + roster: { readonly agents: readonly { readonly name: string }[] }, opts: { enable?: boolean }, ): Promise { if (opts.enable === false) { @@ -1527,7 +1528,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--no-enable', 'Skip enabling units for boot-survival') .action(async (opts: { enable?: boolean }) => { await installFleet(cmd, frameworkRoot); - const roster = await loadRosterForCommand(cmd); + // Unit enablement needs agent names only, so it reads either version. + const roster = await loadRosterReadModel(cmd); await enableFleetUnits(runner, roster, opts); }); @@ -1537,7 +1539,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .option('--no-enable', 'Skip enabling units for boot-survival') .action(async (opts: { enable?: boolean }) => { await installFleet(cmd, frameworkRoot); - const roster = await loadRosterForCommand(cmd); + // Unit enablement needs agent names only, so it reads either version. + const roster = await loadRosterReadModel(cmd); await enableFleetUnits(runner, roster, opts); }); @@ -1688,7 +1691,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .action(async (opts: { json?: boolean }) => { const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>(); const activePaths = resolveFleetPaths(commandOpts.mosaicHome); - const roster = await loadRosterForCommand(cmd); + // ps only reads, so it takes the version-agnostic read model rather than + // the v1 parser, which rejects a v2 roster outright. + const roster = await loadRosterReadModel(cmd); const { tenant_id, host } = getDefaultTenantAndHost(); const nowMs = Date.now(); @@ -1908,6 +1913,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = 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)) { throw new Error( `Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`, @@ -1973,6 +1988,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = .description('Remove an agent from the fleet roster') .option('--keep-files', 'Skip deleting env and heartbeat files') .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 activePaths = resolveFleetPaths(commandOpts.mosaicHome); const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster); @@ -2331,7 +2352,9 @@ export function registerFleetAgentCommands( async function installFleet(cmd: Command, frameworkRoot: string): Promise { const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome); assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome); - const roster = await loadRosterForCommand(cmd); + // Read model first: every file this function places is roster-independent, and + // the v1 parser would reject a v2 roster before any of them were written. + const roster = await loadRosterReadModel(cmd); await ensureFleetHolderIdentity(activePaths.mosaicHome); await mkdir(activePaths.fleetToolsDir, { recursive: true }); await mkdir(activePaths.tmuxToolsDir, { recursive: true }); @@ -2391,16 +2414,30 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'), ); - for (const agent of roster.agents) { + // On roster v2 the reconciler owns the generated env: `apply` writes it and + // `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 `, + ); + return; + } + + const v1Roster = await loadRosterForCommand(cmd); + for (const agent of v1Roster.agents) { await writeAgentEnvironmentProjection({ mosaicHome: activePaths.mosaicHome, agentEnvDir: activePaths.agentEnvDir, agentName: agent.name, - generated: generateAgentEnvValues(roster, agent), + generated: generateAgentEnvValues(v1Roster, agent), }); } - console.log(`Installed fleet files for ${roster.agents.length} agent(s).`); + console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`); } async function loadRosterForCommand(cmd: Command): Promise { @@ -2427,6 +2464,77 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise { ); } +/** + * `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 ` + + `${v2Command === 'create' ? "--agent '' " : ''}` + + `(edits the roster only)\n` + + ` 2. mosaic fleet apply --expected-generation (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/.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 { + 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( command: Command, mosaicHomeOverride?: string, diff --git a/packages/mosaic/src/platform/detect.ts b/packages/mosaic/src/platform/detect.ts index 59f48173..4abb3129 100644 --- a/packages/mosaic/src/platform/detect.ts +++ b/packages/mosaic/src/platform/detect.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, platform } from 'node:os'; @@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null { const shell = detectShell(); 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': { const zdotdir = process.env['ZDOTDIR'] ?? home; - return join(zdotdir, '.zshrc'); + return join(zdotdir, '.zshenv'); } - case 'bash': { - const bashrc = join(home, '.bashrc'); - if (existsSync(bashrc)) return bashrc; + case 'bash': return join(home, '.profile'); - } case 'fish': return join(home, '.config', 'fish', 'config.fish'); default: diff --git a/tools/install.sh b/tools/install.sh index 59334b71..5d28f773 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -309,6 +309,87 @@ require_cmd() { fi } +# True if any shell rc file already puts $1 on PATH. +# +# Each file is tested for existence first and grepped one at a time, rather than +# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file +# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q +# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless. +# On the 2 path the caller reads "not present yet" and appends a duplicate PATH +# line on every single install. +path_entry_exists() { + local dir="$1" rc_file + for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do + if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then + return 0 + fi + done + return 1 +} + +# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this +# process. An `export` here reaches only the installer; every directory the +# 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 + profile="$HOME/.profile" + fi + + # Probe writability in a subshell. A redirection failure on a special built-in + # aborts the shell it runs in, so it has to be a child; and the redirection on + # the subshell is what silences the "Permission denied" the shell would + # otherwise print ahead of our own message. + 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 + + { + echo "" + echo "# $label" + echo "export PATH=\"$dir:\$PATH\"" + } >>"$profile" + 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 + + if path_entry_exists "$PREFIX/bin"; then + warn "$PREFIX/bin is in your shell profile but not in this shell" + 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). # # `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every @@ -553,8 +634,175 @@ install_next_cli_from_registry() { 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 ──────────────────────────────────────────────────────────────── +ensure_node require_cmd node require_cmd npm @@ -719,11 +967,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ensure_monorepo install_cli_from_source - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path elif is_next_registry_lane; then info "Next mode — trying fast npm @next install from ${REGISTRY}…" if install_next_cli_from_registry; then @@ -736,11 +980,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path else if [[ -z "$LATEST" ]]; then warn "Could not reach registry at $REGISTRY — skipping npm CLI." @@ -758,11 +998,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then ok "CLI is at or ahead of registry ($CURRENT ≥ $LATEST)." fi - # PATH check for npm prefix - if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then - warn "$PREFIX/bin is not on your PATH" - dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\"" - fi + ensure_prefix_on_path fi fi