fix(fleet): preflight pane runtimes before install (#1256)
This commit is contained in:
@@ -51,8 +51,12 @@ See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
||||
## Manual canary sequence
|
||||
|
||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||
helpers, and writes private roster-derived projections before any service is started.
|
||||
edit a generated projection. Before it writes any holder identity, tool, projection, or unit file,
|
||||
`mosaic fleet install` validates the fleet CLI and every distinct roster runtime through the exact
|
||||
pane PATH. The shared helper also unwraps `/usr/bin/env` shebangs, so a resolved Node script with no
|
||||
pane-visible Node fails before effects. `mosaic fleet doctor` reports the same named executable
|
||||
checks without mutation. After that preflight, install places the units and helpers and writes private
|
||||
roster-derived projections before any service starts.
|
||||
|
||||
```bash
|
||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical fleet-pane PATH construction and executable reachability checks.
|
||||
#
|
||||
# This file is both sourceable by start-agent-session.sh and executable by the
|
||||
# fleet CLI preflight. Keep the pane PATH in one implementation: provisioning
|
||||
# checks and the eventual pane must answer the same question.
|
||||
|
||||
mosaic_fleet_pane_home() {
|
||||
local mosaic_home="$1"
|
||||
local fallback_home="$2"
|
||||
case "$mosaic_home" in
|
||||
*/.config/mosaic) printf '%s' "${mosaic_home%/.config/mosaic}" ;;
|
||||
*) printf '%s' "$fallback_home" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mosaic_fleet_build_runtime_bin_prefix() {
|
||||
local pane_home="$1"
|
||||
local runtime_bin="${2:-}"
|
||||
local candidates=()
|
||||
if [ -n "$runtime_bin" ]; then candidates+=("$runtime_bin"); fi
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$pane_home/.npm-global/bin" "$pane_home/.local/bin")
|
||||
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
done
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
mosaic_fleet_build_pane_path() {
|
||||
local pane_home="$1"
|
||||
local runtime_bin="${2:-}"
|
||||
local system_path="${3:-/usr/local/bin:/usr/bin:/bin}"
|
||||
local prefix
|
||||
prefix=$(mosaic_fleet_build_runtime_bin_prefix "$pane_home" "$runtime_bin")
|
||||
printf '%s' "${prefix:+${prefix}:}${system_path}"
|
||||
}
|
||||
|
||||
mosaic_fleet_resolve_in_pane_path() {
|
||||
local pane_path="$1"
|
||||
local binary="$2"
|
||||
PATH="$pane_path" command -v -- "$binary" 2>/dev/null
|
||||
}
|
||||
|
||||
# Sets executable evidence in MOSAIC_FLEET_EXECUTABLE_* and returns nonzero when
|
||||
# a resolved script's shebang interpreter cannot run in the pane. Native/ELF
|
||||
# binaries have no PATH-resolved interpreter dependency and pass the executable
|
||||
# bit check. Node receives an additional side-effect-free `node --version`
|
||||
# execution check; invoking `mosaic --version` itself is intentionally avoided
|
||||
# because Mosaic performs a cache-writing/network update check at CLI startup.
|
||||
mosaic_fleet_check_resolved_executable() {
|
||||
local pane_path="$1"
|
||||
local resolved="$2"
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY=""
|
||||
MOSAIC_FLEET_EXECUTABLE_PROBE=""
|
||||
MOSAIC_FLEET_EXECUTABLE_EXIT=""
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT=""
|
||||
|
||||
[ -x "$resolved" ] || {
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="resolved path is not executable"
|
||||
return 70
|
||||
}
|
||||
|
||||
local magic=""
|
||||
IFS= read -r -n 2 magic < "$resolved" || true
|
||||
[ "$magic" = '#!' ] || return 0
|
||||
|
||||
local shebang
|
||||
IFS= read -r shebang < "$resolved" || true
|
||||
shebang=${shebang%$'\r'}
|
||||
shebang=${shebang#\#!}
|
||||
local parts=()
|
||||
read -r -a parts <<< "$shebang"
|
||||
local interpreter="${parts[0]:-}"
|
||||
[[ "$interpreter" = /* ]] && [ -x "$interpreter" ] || {
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$interpreter"
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang interpreter is absent or not executable"
|
||||
return 70
|
||||
}
|
||||
|
||||
local dependency="$interpreter"
|
||||
local dependency_path="$interpreter"
|
||||
if [ "${interpreter##*/}" = env ]; then
|
||||
local index=1
|
||||
if [ "${parts[$index]:-}" = -S ]; then index=$((index + 1)); fi
|
||||
dependency="${parts[$index]:-}"
|
||||
if [ -z "$dependency" ] || [[ "$dependency" = -* ]]; then
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="unsupported env shebang"
|
||||
return 70
|
||||
fi
|
||||
fi
|
||||
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
||||
if [ "${dependency##*/}" = node ]; then
|
||||
MOSAIC_FLEET_EXECUTABLE_PROBE="node --version"
|
||||
fi
|
||||
if [ "${interpreter##*/}" = env ]; then
|
||||
if ! dependency_path=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$dependency"); then
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang command is not on the pane PATH"
|
||||
return 70
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${dependency##*/}" = node ]; then
|
||||
if MOSAIC_FLEET_EXECUTABLE_OUTPUT=$(PATH="$pane_path" "$dependency_path" --version 2>&1); then
|
||||
MOSAIC_FLEET_EXECUTABLE_EXIT=0
|
||||
else
|
||||
MOSAIC_FLEET_EXECUTABLE_EXIT=$?
|
||||
return 70
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
mosaic_fleet_runtime_path_main() {
|
||||
local mosaic_home=""
|
||||
local runtime_bin=""
|
||||
local system_path="/usr/local/bin:/usr/bin:/bin"
|
||||
local binary=""
|
||||
local check_executable=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mosaic-home)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
mosaic_home="$2"
|
||||
shift 2
|
||||
;;
|
||||
--runtime-bin)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
runtime_bin="$2"
|
||||
shift 2
|
||||
;;
|
||||
--binary)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
binary="$2"
|
||||
shift 2
|
||||
;;
|
||||
--check-executable)
|
||||
check_executable=1
|
||||
shift
|
||||
;;
|
||||
# Test seam for measuring a greenfield host with no system Node. The
|
||||
# launcher and production CLI omit it and retain the fixed system suffix.
|
||||
--system-path)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
system_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) return 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$mosaic_home" ] && [ -n "$binary" ] || return 64
|
||||
local pane_home pane_path resolved
|
||||
pane_home=$(mosaic_fleet_pane_home "$mosaic_home" "${HOME:-}")
|
||||
# npm config is HOME-sensitive. Pin it to the derived pane home before asking
|
||||
# for its prefix so an operator's unrelated npmrc cannot influence preflight.
|
||||
HOME=$pane_home
|
||||
export HOME
|
||||
pane_path=$(mosaic_fleet_build_pane_path "$pane_home" "$runtime_bin" "$system_path")
|
||||
if ! resolved=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$binary"); then
|
||||
printf 'pane_path\0%s\0status\0missing\0binary_path\0\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
||||
"$pane_path"
|
||||
return 69
|
||||
fi
|
||||
|
||||
if [ "$check_executable" -eq 1 ]; then
|
||||
if mosaic_fleet_check_resolved_executable "$pane_path" "$resolved"; then
|
||||
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
||||
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
||||
return 0
|
||||
fi
|
||||
printf 'pane_path\0%s\0status\0unexecutable\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
||||
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
||||
return 70
|
||||
fi
|
||||
|
||||
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
||||
"$pane_path" "$resolved"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
set -euo pipefail
|
||||
mosaic_fleet_runtime_path_main "$@"
|
||||
fi
|
||||
@@ -258,46 +258,22 @@ if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
fi
|
||||
|
||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||
# that home. Derive the pane home from the canonical path when available so an
|
||||
# inherited pane/session HOME cannot become runtime authority.
|
||||
PANE_HOME=$HOME
|
||||
case "$MOSAIC_HOME" in
|
||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||
esac
|
||||
# that home. The provisioning preflight executes this same helper under the
|
||||
# unit's clean launcher environment, so operator PATH cannot produce a false
|
||||
# green result for a binary the pane will never see.
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
# shellcheck source=pane-runtime-path.sh
|
||||
. "$SCRIPT_DIR/pane-runtime-path.sh"
|
||||
PANE_HOME=$(mosaic_fleet_pane_home "$MOSAIC_HOME" "$HOME")
|
||||
PANE_PATH=$(mosaic_fleet_build_pane_path "$PANE_HOME" "$MOSAIC_RUNTIME_BIN")
|
||||
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
done
|
||||
printf '%s' "$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 <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.
|
||||
# #1241/#1256. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a
|
||||
# cleared environment. Resolve both names and validate any shebang interpreter
|
||||
# here, before an effect, where the failure remains attributable. Name
|
||||
# resolution alone is insufficient: an `#!/usr/bin/env node` script resolves
|
||||
# even when the pane cannot execute it because Node is absent.
|
||||
_resolve_in_pane_path() {
|
||||
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
||||
mosaic_fleet_resolve_in_pane_path "$PANE_PATH" "$1"
|
||||
}
|
||||
|
||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||
@@ -312,8 +288,15 @@ fail_launch() {
|
||||
}
|
||||
|
||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
||||
resolved_binary=$(_resolve_in_pane_path "$required_binary") ||
|
||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
||||
if mosaic_fleet_check_resolved_executable "$PANE_PATH" "$resolved_binary"; then
|
||||
continue
|
||||
else
|
||||
executable_exit=$?
|
||||
fi
|
||||
fail_launch unexecutable-binary \
|
||||
"'${required_binary}' resolves to '${resolved_binary}' but dependency '${MOSAIC_FLEET_EXECUTABLE_DEPENDENCY:-unknown}' is not executable on the pane PATH (${PANE_PATH}); check_exit=${executable_exit} detail=${MOSAIC_FLEET_EXECUTABLE_OUTPUT:-unavailable}"
|
||||
done
|
||||
|
||||
_ensure_claude_workdir_trusted() {
|
||||
|
||||
@@ -484,6 +484,27 @@ assert_missing_pane_binary_rejected() {
|
||||
assert_missing_pane_binary_rejected mosaic
|
||||
assert_missing_pane_binary_rejected pi
|
||||
|
||||
# #1256. Name resolution is not executable reachability. A script can resolve
|
||||
# while its /usr/bin/env shebang command is absent from PANE_PATH; reject that
|
||||
# before tmux creates the doomed session.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_UNEXECUTABLE="$ROOT/unexecutable-shebang"
|
||||
write_generated "$HOME_UNEXECUTABLE" "coder-unexecutable"
|
||||
rm -f "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||
printf '#!/usr/bin/env mosaic-test-absent-interpreter\n' > \
|
||||
"$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||
chmod +x "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_UNEXECUTABLE" coder-unexecutable 2>&1); then
|
||||
fail "launcher accepted a resolved mosaic script with an absent shebang command"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=unexecutable-binary' || \
|
||||
fail "unexecutable shebang diagnostic missing: $output"
|
||||
echo "$output" | grep -qF 'mosaic-test-absent-interpreter' || \
|
||||
fail "unexecutable shebang diagnostic did not name the missing dependency"
|
||||
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
||||
fail "launcher created a session after its shebang dependency check failed"
|
||||
fi
|
||||
|
||||
# #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
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||
import {
|
||||
type FleetRuntimeProbeResult,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from '../fleet/fleet-runtime-preflight.js';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
@@ -65,12 +69,15 @@ function program(
|
||||
mosaicHome: string,
|
||||
runner: FleetCommandDeps['runner'],
|
||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||
runtimeProbeRunner: FleetRuntimeProbeRunner = runtimeProbe('present'),
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
mosaicHome,
|
||||
runner,
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
runtimeProbeRunner,
|
||||
reconcileDeps: {
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
@@ -83,6 +90,24 @@ function program(
|
||||
return result;
|
||||
}
|
||||
|
||||
function runtimeProbe(status: 'present' | 'missing'): FleetRuntimeProbeRunner {
|
||||
return async (_command, args): Promise<FleetRuntimeProbeResult> => {
|
||||
const binaryFlag = args.indexOf('--binary');
|
||||
const binary = binaryFlag >= 0 ? args[binaryFlag + 1] : undefined;
|
||||
const effectiveStatus = binary === 'mosaic' ? 'present' : status;
|
||||
return {
|
||||
stdout:
|
||||
`pane_path\u0000/fixture/runtime-bin:/usr/bin:/bin\u0000status\u0000${effectiveStatus}\u0000` +
|
||||
`binary_path\u0000${effectiveStatus === 'present' ? `/fixture/runtime-bin/${binary ?? 'unknown'}` : ''}\u0000` +
|
||||
`dependency\u0000${effectiveStatus === 'present' ? 'node' : ''}\u0000` +
|
||||
`probe_command\u0000${effectiveStatus === 'present' ? 'node --version' : ''}\u0000` +
|
||||
`probe_exit\u0000${effectiveStatus === 'present' ? '0' : ''}\u0000probe_output\u0000\u0000`,
|
||||
stderr: '',
|
||||
exitCode: effectiveStatus === 'present' ? 0 : 69,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
@@ -152,13 +177,64 @@ describe('mosaic fleet reconciler commands', (): void => {
|
||||
|
||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{
|
||||
applied: false,
|
||||
lifecycle: 'not-applied',
|
||||
checks: {
|
||||
fleetCliExecutable: [
|
||||
{
|
||||
check: 'fleet-cli-executable',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0'],
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
},
|
||||
],
|
||||
fleetRuntimeAvailability: [
|
||||
{
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a missing roster runtime as a named non-green doctor check', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner([]), {}, runtimeProbe('missing')).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'doctor',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
checks: {
|
||||
fleetRuntimeAvailability: [
|
||||
{
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'missing',
|
||||
requestedBy: ['coder0'],
|
||||
panePath: '/fixture/runtime-bin:/usr/bin:/bin',
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart'] as const)(
|
||||
'uses exact roster-owned systemd targeting for %s',
|
||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||
|
||||
@@ -8,10 +8,18 @@ import {
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import {
|
||||
inspectFleetRuntimeAvailability,
|
||||
type FleetRuntimeInspection,
|
||||
type FleetRuntimePreflightCheck,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from '../fleet/fleet-runtime-preflight.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetReconcilerCommandDeps {
|
||||
readonly runner: CommandRunner;
|
||||
readonly runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||
readonly frameworkRoot?: string;
|
||||
readonly mosaicHome?: string;
|
||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||
}
|
||||
@@ -71,6 +79,10 @@ export async function executeReconcilerCommand(
|
||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const runtimeInspection =
|
||||
operation === 'doctor'
|
||||
? await inspectRuntimeAvailability(roster.agents, mosaicHome, deps)
|
||||
: undefined;
|
||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||
const expectedGeneration = mutating
|
||||
? parseExpectedGeneration(opts.expectedGeneration)
|
||||
@@ -90,8 +102,31 @@ export async function executeReconcilerCommand(
|
||||
...(deps.reconcileDeps ?? {}),
|
||||
},
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||
printJson(operation === 'doctor' ? { ...result, checks: runtimeInspection } : result);
|
||||
const executableFailure =
|
||||
runtimeInspection !== undefined &&
|
||||
[...runtimeInspection.fleetCliExecutable, ...runtimeInspection.fleetRuntimeAvailability].some(
|
||||
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
||||
);
|
||||
process.exitCode =
|
||||
result.recovery === undefined && result.cleanup === undefined && !executableFailure ? 0 : 1;
|
||||
}
|
||||
|
||||
async function inspectRuntimeAvailability(
|
||||
agents: readonly { readonly name: string; readonly runtime: string }[],
|
||||
mosaicHome: string,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
): Promise<FleetRuntimeInspection> {
|
||||
if (deps.frameworkRoot === undefined || deps.runtimeProbeRunner === undefined) {
|
||||
throw new Error('Fleet doctor runtime preflight dependencies are unavailable.');
|
||||
}
|
||||
return inspectFleetRuntimeAvailability({
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
helperPath: join(deps.frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
||||
agents,
|
||||
runner: deps.runtimeProbeRunner,
|
||||
});
|
||||
}
|
||||
|
||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FleetRuntimeProbeRunner } from '../fleet/fleet-runtime-preflight.js';
|
||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||
|
||||
/**
|
||||
@@ -69,6 +70,7 @@ agents:
|
||||
let tempHome: string | undefined;
|
||||
const savedHome = process.env.HOME;
|
||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||
const savedPath = process.env.PATH;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -77,6 +79,8 @@ afterEach(async (): Promise<void> => {
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
||||
if (savedPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = savedPath;
|
||||
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
});
|
||||
@@ -85,7 +89,7 @@ afterEach(async (): Promise<void> => {
|
||||
* 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> {
|
||||
async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<string> {
|
||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
@@ -97,6 +101,12 @@ async function v2Home(): Promise<string> {
|
||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
const runtimeDir = join(tempHome, '.npm-global', 'bin');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
if (options.withPaneRuntime !== false) {
|
||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
}
|
||||
return mosaicHome;
|
||||
}
|
||||
|
||||
@@ -113,10 +123,17 @@ const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult>
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
};
|
||||
|
||||
function program(runner: CommandRunner = greenfieldRunner): Command {
|
||||
function program(
|
||||
runner: CommandRunner = greenfieldRunner,
|
||||
runtimeProbeRunner?: FleetRuntimeProbeRunner,
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
registerFleetCommand(result, {
|
||||
runner,
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
...(runtimeProbeRunner === undefined ? {} : { runtimeProbeRunner }),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -166,6 +183,93 @@ describe('mosaic fleet ps — roster v2', (): void => {
|
||||
});
|
||||
|
||||
describe('mosaic fleet install — roster v2', (): void => {
|
||||
it('rejects a roster runtime missing from the pane PATH before installing any files', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home({ withPaneRuntime: false });
|
||||
const operatorBin = join(tempHome!, 'operator-bin');
|
||||
await mkdir(operatorBin, { recursive: true });
|
||||
await writeFile(join(operatorBin, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
process.env.PATH = `${operatorBin}:${savedPath ?? '/usr/bin:/bin'}`;
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await program().parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--mosaic-home',
|
||||
mosaicHome,
|
||||
'install',
|
||||
'--no-enable',
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain('runtime=pi');
|
||||
expect(message).toContain('requested_by=coder0,coder1');
|
||||
expect(message).toContain('pane_path=');
|
||||
expect(message).toContain('npm install -g @earendil-works/pi-coding-agent');
|
||||
expect(message).not.toContain(operatorBin);
|
||||
expect(
|
||||
await exists(join(tempHome!, '.config', 'systemd', 'user', '[email protected]')),
|
||||
).toBe(false);
|
||||
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
||||
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects resolved Node-shebang commands when Node is absent from the pane PATH', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const runtimeDir = join(tempHome!, '.npm-global', 'bin');
|
||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||
await writeFile(join(runtimeDir, 'mosaic'), nodeScript, { mode: 0o755 });
|
||||
await writeFile(join(runtimeDir, 'pi'), nodeScript, { mode: 0o755 });
|
||||
await writeFile(join(tempHome!, '.npmrc'), `prefix=${join(tempHome!, 'absent-prefix')}\n`);
|
||||
const isolatedSystemPath = join(tempHome!, 'system-bin');
|
||||
await mkdir(isolatedSystemPath, { recursive: true });
|
||||
const isolatedProbeRunner: FleetRuntimeProbeRunner = async (
|
||||
command,
|
||||
args,
|
||||
): Promise<CommandResult> =>
|
||||
new Promise((settle) => {
|
||||
const child = execFile(
|
||||
command,
|
||||
[...args, '--system-path', isolatedSystemPath],
|
||||
{ encoding: 'utf8' },
|
||||
(error, stdout, stderr) => {
|
||||
settle({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: child.exitCode ?? (error === null ? 0 : 1),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await program(greenfieldRunner, isolatedProbeRunner).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--mosaic-home',
|
||||
mosaicHome,
|
||||
'install',
|
||||
'--no-enable',
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain('check=fleet-cli-executable');
|
||||
expect(message).toContain('binary=mosaic');
|
||||
expect(message).toContain('dependency=node');
|
||||
expect(message).toContain('check=fleet-runtime-available');
|
||||
expect(message).toContain('runtime=pi');
|
||||
expect(message).not.toContain('/usr/bin');
|
||||
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
||||
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
||||
});
|
||||
|
||||
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
capture();
|
||||
@@ -183,9 +287,11 @@ describe('mosaic fleet install — roster v2', (): void => {
|
||||
]) {
|
||||
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);
|
||||
for (const tool of ['start-agent-session.sh', 'pane-runtime-path.sh']) {
|
||||
const toolPath = join(mosaicHome, 'tools', 'fleet', tool);
|
||||
expect(await exists(toolPath)).toBe(true);
|
||||
expect((await stat(toolPath)).mode & 0o777).toBe(0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||
|
||||
@@ -1277,6 +1277,10 @@ describe('fleet command construction', () => {
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
const runtimeDir = join(home, '.npm-global', 'bin');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
@@ -1315,6 +1319,10 @@ describe('fleet command construction', () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
const runtimeDir = join(home, '.npm-global', 'bin');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
|
||||
@@ -60,6 +60,12 @@ import {
|
||||
writeAgentEnvironmentProjection,
|
||||
writeManagedFleetRoster,
|
||||
} from '../fleet/generated-env-boundary.js';
|
||||
import {
|
||||
assertFleetRuntimeAvailability,
|
||||
FleetRuntimePreflightError,
|
||||
inspectFleetRuntimeAvailability,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from '../fleet/fleet-runtime-preflight.js';
|
||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||
@@ -89,6 +95,8 @@ export type SleepFn = (ms: number) => Promise<void>;
|
||||
|
||||
export interface FleetCommandDeps {
|
||||
runner?: CommandRunner;
|
||||
/** Executes the pane-PATH helper under a clean launcher environment. */
|
||||
runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
||||
interactiveRunner?: InteractiveRunner;
|
||||
/**
|
||||
@@ -1429,6 +1437,10 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
||||
|
||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
const runtimeProbeRunner: FleetRuntimeProbeRunner =
|
||||
deps.runtimeProbeRunner ??
|
||||
(async (command: string, args: readonly string[]): Promise<CommandResult> =>
|
||||
runCommand(command, [...args]));
|
||||
const sleepFn = deps.sleepFn ?? defaultSleep;
|
||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||
@@ -1527,7 +1539,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||
.action(async (opts: { enable?: boolean }) => {
|
||||
await installFleet(cmd, frameworkRoot);
|
||||
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||
// Unit enablement needs agent names only, so it reads either version.
|
||||
const roster = await loadRosterReadModel(cmd);
|
||||
await enableFleetUnits(runner, roster, opts);
|
||||
@@ -1538,7 +1550,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||
.action(async (opts: { enable?: boolean }) => {
|
||||
await installFleet(cmd, frameworkRoot);
|
||||
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||
// Unit enablement needs agent names only, so it reads either version.
|
||||
const roster = await loadRosterReadModel(cmd);
|
||||
await enableFleetUnits(runner, roster, opts);
|
||||
@@ -2084,6 +2096,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
});
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
runtimeProbeRunner,
|
||||
frameworkRoot,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
@@ -2349,18 +2363,68 @@ export function registerFleetAgentCommands(
|
||||
});
|
||||
}
|
||||
|
||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||
async function installFleet(
|
||||
cmd: Command,
|
||||
frameworkRoot: string,
|
||||
runtimeProbeRunner: FleetRuntimeProbeRunner,
|
||||
): Promise<void> {
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
// 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.
|
||||
// Read and preflight before the first mkdir/copy/chmod/write. A successful
|
||||
// install must mean every roster runtime is executable in the eventual pane,
|
||||
// not merely visible to the operator who invoked this command.
|
||||
const roster = await loadRosterReadModel(cmd);
|
||||
const v1Roster = roster.version === 1 ? await loadRosterForCommand(cmd) : undefined;
|
||||
const preflightV1Projections =
|
||||
v1Roster === undefined
|
||||
? []
|
||||
: await Promise.all(
|
||||
v1Roster.agents.map((agent: FleetAgent) =>
|
||||
prepareAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: generateAgentEnvValues(v1Roster, agent),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const preflightAgents =
|
||||
v1Roster === undefined
|
||||
? roster.agents
|
||||
: v1Roster.agents.map((agent: FleetAgent, index: number) => {
|
||||
const prepared = preflightV1Projections[index];
|
||||
if (prepared === undefined) {
|
||||
throw new Error(`Missing prepared environment projection for ${agent.name}.`);
|
||||
}
|
||||
const local = parseAgentEnvironment(prepared.local, 'local');
|
||||
return {
|
||||
name: agent.name,
|
||||
runtime: agent.runtime,
|
||||
runtimeBin: local['MOSAIC_RUNTIME_BIN'] ?? '',
|
||||
};
|
||||
});
|
||||
const runtimeInspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
helperPath: join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
||||
agents: preflightAgents,
|
||||
runner: runtimeProbeRunner,
|
||||
});
|
||||
try {
|
||||
assertFleetRuntimeAvailability(runtimeInspection);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetRuntimePreflightError) {
|
||||
cmd.error(error.message, { code: 'fleet.runtime-preflight', exitCode: 1 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||
|
||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||
const paneRuntimePath = join(activePaths.fleetToolsDir, 'pane-runtime-path.sh');
|
||||
const startInteractionServicePath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'start-interaction-service.sh',
|
||||
@@ -2374,6 +2438,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||
const executableToolPaths = [
|
||||
startAgentSessionPath,
|
||||
paneRuntimePath,
|
||||
startInteractionServicePath,
|
||||
startTmuxHolderPath,
|
||||
printInteractionPolicyPath,
|
||||
@@ -2384,6 +2449,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||
startAgentSessionPath,
|
||||
);
|
||||
await copyFile(join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'), paneRuntimePath);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||
startInteractionServicePath,
|
||||
@@ -2427,7 +2493,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
const v1Roster = await loadRosterForCommand(cmd);
|
||||
if (v1Roster === undefined) {
|
||||
throw new Error('Roster version changed while installing fleet files.');
|
||||
}
|
||||
for (const agent of v1Roster.agents) {
|
||||
await writeAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
inspectFleetRuntimeAvailability,
|
||||
type FleetRuntimeProbeResult,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from './fleet-runtime-preflight.js';
|
||||
|
||||
const helperPath = resolve(process.cwd(), 'framework', 'tools', 'fleet', 'pane-runtime-path.sh');
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
interface FleetFixture {
|
||||
readonly root: string;
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly runtimeDir: string;
|
||||
}
|
||||
|
||||
async function fleetHome(): Promise<FleetFixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-runtime-preflight-'));
|
||||
cleanup = root;
|
||||
const mosaicHome = join(root, '.config', 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const runtimeDir = join(root, '.npm-global', 'bin');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
||||
await chmod(directory, 0o700);
|
||||
}
|
||||
await writeExecutable(runtimeDir, 'mosaic', '#!/bin/sh\nexit 0\n');
|
||||
return { root, mosaicHome, agentEnvDir, runtimeDir };
|
||||
}
|
||||
|
||||
async function writeExecutable(directory: string, name: string, content: string): Promise<void> {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, name), content, { mode: 0o755 });
|
||||
}
|
||||
|
||||
const processRunner: FleetRuntimeProbeRunner = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetRuntimeProbeResult> =>
|
||||
new Promise((settle) => {
|
||||
const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk: string): void => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk: string): void => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', (error: Error): void => {
|
||||
settle({ stdout, stderr: `${stderr}${error.message}`, exitCode: 127 });
|
||||
});
|
||||
child.on('close', (code: number | null): void => {
|
||||
settle({ stdout, stderr, exitCode: code ?? 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet runtime preflight', (): void => {
|
||||
it('executes one distinct pane runtime and aggregates every requesting roster row', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
await writeExecutable(fixture.runtimeDir, 'pi', '#!/bin/sh\nexit 0\n');
|
||||
let probes = 0;
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [
|
||||
{ name: 'coder1', runtime: 'pi' },
|
||||
{ name: 'coder0', runtime: 'pi' },
|
||||
],
|
||||
runner: async (command, args): Promise<FleetRuntimeProbeResult> => {
|
||||
probes += 1;
|
||||
return processRunner(command, args);
|
||||
},
|
||||
});
|
||||
|
||||
expect(probes).toBe(2);
|
||||
expect(inspection.fleetCliExecutable).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-cli-executable',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0', 'coder1'],
|
||||
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
||||
dependency: '/bin/sh',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0', 'coder1'],
|
||||
binaryPath: join(fixture.runtimeDir, 'pi'),
|
||||
dependency: '/bin/sh',
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability[0]?.panePath).toContain(fixture.runtimeDir);
|
||||
});
|
||||
|
||||
it('returns an actionable non-green check when the pane PATH lacks the runtime', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: processRunner,
|
||||
});
|
||||
|
||||
expect(inspection.fleetCliExecutable[0]?.status).toBe('ok');
|
||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'missing',
|
||||
requestedBy: ['coder0'],
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability[0]?.panePath).not.toContain(
|
||||
process.env['PATH'] ?? 'operator-path-absent',
|
||||
);
|
||||
});
|
||||
|
||||
it('executes the side-effect-free Node version probe for Node-shebang commands', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
||||
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
||||
await writeExecutable(
|
||||
fixture.runtimeDir,
|
||||
'node',
|
||||
'#!/bin/sh\n[ "$1" = --version ] || exit 9\nprintf "v-fixture-node\\n"\n',
|
||||
);
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: processRunner,
|
||||
});
|
||||
|
||||
for (const check of [
|
||||
...inspection.fleetCliExecutable,
|
||||
...inspection.fleetRuntimeAvailability,
|
||||
]) {
|
||||
expect(check).toMatchObject({
|
||||
status: 'ok',
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
probeExit: 0,
|
||||
probeOutput: 'v-fixture-node',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('reddens when resolved Node-shebang commands cannot execute without pane Node', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
const isolatedSystemPath = join(fixture.root, 'system-bin');
|
||||
await mkdir(isolatedSystemPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(fixture.root, '.npmrc'),
|
||||
`prefix=${join(fixture.root, 'absent-prefix')}\n`,
|
||||
);
|
||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
||||
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: processRunner,
|
||||
systemPath: isolatedSystemPath,
|
||||
});
|
||||
|
||||
expect(inspection.fleetCliExecutable).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-cli-executable',
|
||||
status: 'unexecutable',
|
||||
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'unexecutable',
|
||||
binaryPath: join(fixture.runtimeDir, 'pi'),
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetCliExecutable[0]?.probeOutput).toBe(
|
||||
'shebang command is not on the pane PATH',
|
||||
);
|
||||
expect(inspection.fleetCliExecutable[0]?.panePath).not.toContain('/usr/bin');
|
||||
});
|
||||
|
||||
it('keeps distinct effective local runtime-bin paths as distinct checks', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
const firstBin = join(fixture.root, 'first-bin');
|
||||
const secondBin = join(fixture.root, 'second-bin');
|
||||
for (const override of [
|
||||
{ agent: 'coder0', runtimeBin: firstBin },
|
||||
{ agent: 'coder1', runtimeBin: secondBin },
|
||||
]) {
|
||||
await writeExecutable(override.runtimeBin, 'pi', '#!/bin/sh\nexit 0\n');
|
||||
await writeFile(
|
||||
join(fixture.agentEnvDir, `${override.agent}.env.local`),
|
||||
`MOSAIC_RUNTIME_BIN=${override.runtimeBin}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [
|
||||
{ name: 'coder0', runtime: 'pi' },
|
||||
{ name: 'coder1', runtime: 'pi' },
|
||||
],
|
||||
runner: processRunner,
|
||||
});
|
||||
|
||||
expect(inspection.fleetCliExecutable).toHaveLength(2);
|
||||
expect(inspection.fleetRuntimeAvailability).toHaveLength(2);
|
||||
expect(inspection.fleetRuntimeAvailability.map((check) => check.requestedBy)).toEqual([
|
||||
['coder0'],
|
||||
['coder1'],
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability.map((check) => check.binaryPath)).toEqual([
|
||||
join(firstBin, 'pi'),
|
||||
join(secondBin, 'pi'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports each distinct roster runtime with its exact install command', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
let probes = 0;
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [
|
||||
{ name: 'pi-seat', runtime: 'pi' },
|
||||
{ name: 'claude-seat', runtime: 'claude' },
|
||||
{ name: 'codex-seat', runtime: 'codex' },
|
||||
{ name: 'opencode-seat', runtime: 'opencode' },
|
||||
],
|
||||
runner: async (): Promise<FleetRuntimeProbeResult> => {
|
||||
probes += 1;
|
||||
return {
|
||||
stdout:
|
||||
'pane_path\u0000/fixture/bin:/usr/bin:/bin\u0000status\u0000missing\u0000' +
|
||||
'binary_path\u0000\u0000probe_exit\u0000\u0000probe_output\u0000\u0000',
|
||||
stderr: '',
|
||||
exitCode: 69,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(probes).toBe(5);
|
||||
expect(
|
||||
inspection.fleetRuntimeAvailability.map((check) => ({
|
||||
runtime: check.runtime,
|
||||
installCommand: check.installCommand,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
runtime: 'claude',
|
||||
installCommand: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||
},
|
||||
{
|
||||
runtime: 'codex',
|
||||
installCommand: 'npm install -g @openai/codex',
|
||||
},
|
||||
{
|
||||
runtime: 'opencode',
|
||||
installCommand: 'npm install -g opencode-ai',
|
||||
},
|
||||
{
|
||||
runtime: 'pi',
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed when the shared helper returns malformed evidence', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
|
||||
await expect(
|
||||
inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: async (): Promise<FleetRuntimeProbeResult> => ({
|
||||
stdout: 'not-a-field-protocol',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow('malformed field output');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { getInstallInstructions } from '../runtime/detector.js';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
import { compareCodePoints } from './deterministic-order.js';
|
||||
import {
|
||||
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
||||
readAgentLocalEnvironment,
|
||||
} from './generated-env-boundary.js';
|
||||
|
||||
const RUNTIME_SET = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
||||
|
||||
export interface FleetRuntimeRequestedAgent {
|
||||
readonly name: string;
|
||||
readonly runtime: string;
|
||||
/** Planned effective local override, when provisioning has already prepared it. */
|
||||
readonly runtimeBin?: string;
|
||||
}
|
||||
|
||||
export interface FleetRuntimeProbeResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly exitCode: number;
|
||||
}
|
||||
|
||||
export type FleetRuntimeProbeRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
) => Promise<FleetRuntimeProbeResult>;
|
||||
|
||||
export interface FleetRuntimePreflightOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly helperPath: string;
|
||||
readonly agents: readonly FleetRuntimeRequestedAgent[];
|
||||
readonly runner: FleetRuntimeProbeRunner;
|
||||
/** Test-only system suffix; production and the launcher use the helper default. */
|
||||
readonly systemPath?: string;
|
||||
}
|
||||
|
||||
export type FleetExecutableStatus = 'ok' | 'missing' | 'unexecutable';
|
||||
|
||||
interface FleetExecutableEvidence {
|
||||
readonly status: FleetExecutableStatus;
|
||||
readonly panePath: string;
|
||||
readonly binaryPath?: string;
|
||||
readonly dependency?: string;
|
||||
readonly probeCommand?: string;
|
||||
readonly probeExit?: number;
|
||||
readonly probeOutput?: string;
|
||||
}
|
||||
|
||||
export interface FleetCliExecutableCheck extends FleetExecutableEvidence {
|
||||
readonly check: 'fleet-cli-executable';
|
||||
readonly binary: 'mosaic';
|
||||
readonly requestedBy: readonly string[];
|
||||
}
|
||||
|
||||
export interface FleetRuntimeCheck extends FleetExecutableEvidence {
|
||||
readonly check: 'fleet-runtime-available';
|
||||
readonly runtime: RuntimeName;
|
||||
readonly requestedBy: readonly string[];
|
||||
readonly installCommand: string;
|
||||
}
|
||||
|
||||
export type FleetRuntimePreflightCheck = FleetCliExecutableCheck | FleetRuntimeCheck;
|
||||
|
||||
export interface FleetRuntimeInspection {
|
||||
readonly fleetCliExecutable: readonly FleetCliExecutableCheck[];
|
||||
readonly fleetRuntimeAvailability: readonly FleetRuntimeCheck[];
|
||||
}
|
||||
|
||||
interface EffectiveAgent {
|
||||
readonly name: string;
|
||||
readonly runtime: RuntimeName;
|
||||
readonly runtimeBin: string;
|
||||
}
|
||||
|
||||
interface PaneProbeGroup {
|
||||
readonly runtimeBin: string;
|
||||
readonly requestedBy: string[];
|
||||
}
|
||||
|
||||
interface RuntimeProbeGroup extends PaneProbeGroup {
|
||||
readonly runtime: RuntimeName;
|
||||
}
|
||||
|
||||
interface BinaryProbeRequest {
|
||||
readonly binary: string;
|
||||
readonly runtimeBin: string;
|
||||
}
|
||||
|
||||
export class FleetRuntimePreflightError extends Error {
|
||||
readonly checks: readonly FleetRuntimePreflightCheck[];
|
||||
|
||||
constructor(checks: readonly FleetRuntimePreflightCheck[]) {
|
||||
super(formatFleetRuntimePreflightError(checks));
|
||||
this.name = FleetRuntimePreflightError.name;
|
||||
this.checks = checks;
|
||||
}
|
||||
}
|
||||
|
||||
export class FleetRuntimeProbeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = FleetRuntimeProbeError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the fleet CLI and every distinct runtime/effective-bin pair resolve
|
||||
* with an executable shebang interpreter through the eventual pane PATH. The
|
||||
* helper runs under the unit's clean launcher environment, so operator PATH can
|
||||
* neither create a false green nor provide a hidden interpreter.
|
||||
*/
|
||||
export async function inspectFleetRuntimeAvailability(
|
||||
options: FleetRuntimePreflightOptions,
|
||||
): Promise<FleetRuntimeInspection> {
|
||||
const agents = await resolveEffectiveAgents(options);
|
||||
const paneGroups = groupPaneRequests(agents);
|
||||
const runtimeGroups = groupRuntimeRequests(agents);
|
||||
|
||||
const fleetCliExecutable: FleetCliExecutableCheck[] = [];
|
||||
for (const group of paneGroups) {
|
||||
const evidence = await probeBinary(options, {
|
||||
binary: 'mosaic',
|
||||
runtimeBin: group.runtimeBin,
|
||||
});
|
||||
fleetCliExecutable.push({
|
||||
check: 'fleet-cli-executable',
|
||||
binary: 'mosaic',
|
||||
requestedBy: sortedRequestedBy(group.requestedBy),
|
||||
...evidence,
|
||||
});
|
||||
}
|
||||
|
||||
const fleetRuntimeAvailability: FleetRuntimeCheck[] = [];
|
||||
for (const group of runtimeGroups) {
|
||||
const evidence = await probeBinary(options, {
|
||||
binary: group.runtime,
|
||||
runtimeBin: group.runtimeBin,
|
||||
});
|
||||
fleetRuntimeAvailability.push({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: group.runtime,
|
||||
requestedBy: sortedRequestedBy(group.requestedBy),
|
||||
installCommand: getInstallInstructions(group.runtime),
|
||||
...evidence,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
fleetCliExecutable: Object.freeze(fleetCliExecutable),
|
||||
fleetRuntimeAvailability: Object.freeze(fleetRuntimeAvailability),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertFleetRuntimeAvailability(inspection: FleetRuntimeInspection): void {
|
||||
const checks: FleetRuntimePreflightCheck[] = [
|
||||
...inspection.fleetCliExecutable,
|
||||
...inspection.fleetRuntimeAvailability,
|
||||
];
|
||||
const failures = checks.filter(
|
||||
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
||||
);
|
||||
if (failures.length > 0) throw new FleetRuntimePreflightError(failures);
|
||||
}
|
||||
|
||||
export function formatFleetRuntimePreflightError(
|
||||
checks: readonly FleetRuntimePreflightCheck[],
|
||||
): string {
|
||||
const lines = ['Fleet runtime preflight failed:'];
|
||||
for (const check of checks) {
|
||||
const dependency = check.dependency === undefined ? '' : ` dependency=${check.dependency}`;
|
||||
const probe = check.probeCommand === undefined ? '' : ` dependency_probe=${check.probeCommand}`;
|
||||
const execution =
|
||||
check.status === 'unexecutable'
|
||||
? ` probe_exit=${check.probeExit?.toString() ?? 'not-run'} ` +
|
||||
`probe_output=${JSON.stringify(check.probeOutput ?? '')}`
|
||||
: '';
|
||||
if (check.check === 'fleet-cli-executable') {
|
||||
lines.push(
|
||||
`check=${check.check} binary=${check.binary} ` +
|
||||
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
||||
`${dependency}${probe}${execution} ` +
|
||||
'action=repair the Mosaic installation until its pane dependencies resolve',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
lines.push(
|
||||
`check=${check.check} runtime=${check.runtime} ` +
|
||||
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
||||
`${dependency}${probe}${execution} install_command=${check.installCommand}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function resolveEffectiveAgents(
|
||||
options: FleetRuntimePreflightOptions,
|
||||
): Promise<readonly EffectiveAgent[]> {
|
||||
const agents: EffectiveAgent[] = [];
|
||||
for (const agent of options.agents) {
|
||||
if (!isRuntimeName(agent.runtime)) {
|
||||
throw new FleetRuntimeProbeError(`Unsupported fleet runtime: ${agent.runtime}`);
|
||||
}
|
||||
const runtimeBin =
|
||||
agent.runtimeBin ??
|
||||
(
|
||||
await readAgentLocalEnvironment({
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
agentName: agent.name,
|
||||
})
|
||||
)['MOSAIC_RUNTIME_BIN'] ??
|
||||
'';
|
||||
agents.push({ name: agent.name, runtime: agent.runtime, runtimeBin });
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function groupPaneRequests(agents: readonly EffectiveAgent[]): readonly PaneProbeGroup[] {
|
||||
const groups = new Map<string, PaneProbeGroup>();
|
||||
for (const agent of agents) {
|
||||
const current = groups.get(agent.runtimeBin);
|
||||
if (current === undefined) {
|
||||
groups.set(agent.runtimeBin, { runtimeBin: agent.runtimeBin, requestedBy: [agent.name] });
|
||||
} else {
|
||||
current.requestedBy.push(agent.name);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((left, right): number =>
|
||||
compareCodePoints(left.runtimeBin, right.runtimeBin),
|
||||
);
|
||||
}
|
||||
|
||||
function groupRuntimeRequests(agents: readonly EffectiveAgent[]): readonly RuntimeProbeGroup[] {
|
||||
const groups = new Map<string, RuntimeProbeGroup>();
|
||||
for (const agent of agents) {
|
||||
const key = JSON.stringify([agent.runtime, agent.runtimeBin]);
|
||||
const current = groups.get(key);
|
||||
if (current === undefined) {
|
||||
groups.set(key, {
|
||||
runtime: agent.runtime,
|
||||
runtimeBin: agent.runtimeBin,
|
||||
requestedBy: [agent.name],
|
||||
});
|
||||
} else {
|
||||
current.requestedBy.push(agent.name);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((left, right): number =>
|
||||
compareCodePoints(
|
||||
`${left.runtime}\u0000${left.runtimeBin}`,
|
||||
`${right.runtime}\u0000${right.runtimeBin}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function probeBinary(
|
||||
options: FleetRuntimePreflightOptions,
|
||||
probe: BinaryProbeRequest,
|
||||
): Promise<FleetExecutableEvidence> {
|
||||
const args = [
|
||||
'-i',
|
||||
`HOME=${process.env['HOME'] ?? homedir()}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
`MOSAIC_HOME=${options.mosaicHome}`,
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
options.helperPath,
|
||||
'--mosaic-home',
|
||||
options.mosaicHome,
|
||||
'--binary',
|
||||
probe.binary,
|
||||
'--check-executable',
|
||||
];
|
||||
if (probe.runtimeBin !== '') args.push('--runtime-bin', probe.runtimeBin);
|
||||
if (options.systemPath !== undefined) args.push('--system-path', options.systemPath);
|
||||
|
||||
const result = await options.runner('/usr/bin/env', args);
|
||||
const fields = parseNulFields(result.stdout);
|
||||
const panePath = requiredField(fields, 'pane_path');
|
||||
const status = requiredField(fields, 'status');
|
||||
if (result.exitCode === 0 && status === 'present') {
|
||||
return executableEvidence('ok', panePath, fields);
|
||||
}
|
||||
if (result.exitCode === 69 && status === 'missing') {
|
||||
return { status: 'missing', panePath };
|
||||
}
|
||||
if (result.exitCode === 70 && status === 'unexecutable') {
|
||||
return executableEvidence('unexecutable', panePath, fields);
|
||||
}
|
||||
throw new FleetRuntimeProbeError(
|
||||
`Fleet executable probe failed: binary=${probe.binary} exit=${result.exitCode.toString()} ` +
|
||||
`stderr=${JSON.stringify(result.stderr.trim())}`,
|
||||
);
|
||||
}
|
||||
|
||||
function executableEvidence(
|
||||
status: 'ok' | 'unexecutable',
|
||||
panePath: string,
|
||||
fields: ReadonlyMap<string, string>,
|
||||
): FleetExecutableEvidence {
|
||||
const dependency = requiredField(fields, 'dependency');
|
||||
const probeCommand = requiredField(fields, 'probe_command');
|
||||
const probeExit = requiredField(fields, 'probe_exit');
|
||||
const probeOutput = requiredField(fields, 'probe_output');
|
||||
return {
|
||||
status,
|
||||
panePath,
|
||||
binaryPath: requiredField(fields, 'binary_path'),
|
||||
...(dependency === '' ? {} : { dependency }),
|
||||
...(probeCommand === '' ? {} : { probeCommand }),
|
||||
...(probeExit === '' ? {} : { probeExit: parseProbeExit(probeExit) }),
|
||||
...(probeOutput === '' ? {} : { probeOutput }),
|
||||
};
|
||||
}
|
||||
|
||||
function sortedRequestedBy(requestedBy: readonly string[]): readonly string[] {
|
||||
return Object.freeze(
|
||||
[...requestedBy].sort((left: string, right: string): number => compareCodePoints(left, right)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseNulFields(source: string): ReadonlyMap<string, string> {
|
||||
const parts = source.split('\u0000');
|
||||
if (parts.at(-1) === '') parts.pop();
|
||||
if (parts.length % 2 !== 0) {
|
||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
||||
}
|
||||
const fields = new Map<string, string>();
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const key = parts[index];
|
||||
const value = parts[index + 1];
|
||||
if (key === undefined || value === undefined || key === '' || fields.has(key)) {
|
||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
||||
}
|
||||
fields.set(key, value);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function requiredField(fields: ReadonlyMap<string, string>, key: string): string {
|
||||
const value = fields.get(key);
|
||||
if (value === undefined) {
|
||||
throw new FleetRuntimeProbeError(`Fleet runtime probe omitted ${key}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseProbeExit(value: string): number {
|
||||
const exitCode = Number(value);
|
||||
if (!Number.isSafeInteger(exitCode) || exitCode < 0) {
|
||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned an invalid execution status.');
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
function isRuntimeName(value: string): value is RuntimeName {
|
||||
return RUNTIME_SET.has(value);
|
||||
}
|
||||
@@ -25,6 +25,12 @@ export interface AgentGeneratedProjectionDeletionOptions {
|
||||
readonly agentName: string;
|
||||
}
|
||||
|
||||
export interface AgentLocalEnvironmentReadOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionResult {
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
@@ -145,6 +151,23 @@ export function parseAgentEnvironment(
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one agent's optional local overrides through the same path, file-type,
|
||||
* permission, key, and value boundary used by projection/launch handling.
|
||||
*/
|
||||
export async function readAgentLocalEnvironment(
|
||||
options: AgentLocalEnvironmentReadOptions,
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const source = await readOptionalPrivateFile(
|
||||
join(options.agentEnvDir, `${options.agentName}.env.local`),
|
||||
);
|
||||
return source === undefined ? Object.freeze({}) : parseAgentEnvironment(source, 'local');
|
||||
}
|
||||
|
||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
const normalized = normalizeGeneratedValues(values);
|
||||
|
||||
@@ -19,7 +19,7 @@ const RUNTIME_DEFS: Record<
|
||||
label: 'Claude Code',
|
||||
command: 'claude',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||
installHint: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
@@ -31,13 +31,13 @@ const RUNTIME_DEFS: Record<
|
||||
label: 'OpenCode',
|
||||
command: 'opencode',
|
||||
versionFlag: 'version',
|
||||
installHint: 'See https://opencode.ai for install instructions',
|
||||
installHint: 'npm install -g opencode-ai',
|
||||
},
|
||||
pi: {
|
||||
label: 'Pi',
|
||||
command: 'pi',
|
||||
versionFlag: '--version',
|
||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||
installHint: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user