diff --git a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking index ef1a6d0d..3f116ad6 100755 --- a/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking +++ b/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking @@ -5,6 +5,7 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MODE="apply" RUNTIME="all" STRICT_CHECK=0 +CLAUDE_CONFIG_DIR="" PKG="@modelcontextprotocol/server-sequential-thinking" @@ -29,6 +30,14 @@ while [[ $# -gt 0 ]]; do STRICT_CHECK=1 shift ;; + --claude-config-dir) + if [[ $# -lt 2 ]]; then + err "--claude-config-dir requires an absolute seat config directory" + exit 2 + fi + CLAUDE_CONFIG_DIR="$2" + shift 2 + ;; *) err "Unknown argument: $1" exit 2 @@ -67,10 +76,16 @@ warm_package() { } check_claude_config() { - python3 - <<'PY' + CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".claude" / "settings.json" +# Claude reads MCP definitions from .claude.json, not settings.json. The +# settings.json fallback preserves legacy operator flows until their config is migrated. +config_dir = os.environ.get("CLAUDE_CONFIG_DIR") +p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" +if not p.exists() and not config_dir: + p = Path.home() / ".claude" / "settings.json" if not p.exists(): raise SystemExit(1) try: @@ -92,10 +107,15 @@ PY } apply_claude_config() { - python3 - <<'PY' + CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY' import json +import os from pathlib import Path -p = Path.home() / ".claude" / "settings.json" +# Claude reads MCP definitions from .claude.json for both operator and +# explicitly isolated fleet config dirs. The checker retains a settings.json +# fallback only to avoid breaking legacy operator configurations. +config_dir = os.environ.get("CLAUDE_CONFIG_DIR") +p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json" p.parent.mkdir(parents=True, exist_ok=True) if p.exists(): try: diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts index 97da8aa3..ad403184 100644 --- a/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts @@ -1,3 +1,4 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -23,7 +24,23 @@ function program(dataHome: string): Command { const result = new Command(); result.exitOverride(); const fleet = result.command('fleet'); - registerFleetAgentScaffoldCommand(fleet, { fleetDataHome: dataHome }); + const mosaicHome = join(root!, 'installed-mosaic'); + mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true }); + writeFileSync( + join(mosaicHome, 'runtime', 'claude', 'settings.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + registerFleetAgentScaffoldCommand(fleet, { + fleetDataHome: dataHome, + mosaicHomeFor: () => mosaicHome, + }); return result; } @@ -59,9 +76,16 @@ describe('mosaic fleet agent new', (): void => { env: { MOSAIC_AGENT_NAME: 'mira' }, }); expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity'); - expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual( - `${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`, - ); + expect(JSON.parse(await readFile(join(agent, '.claude', '.claude.json'), 'utf8'))).toEqual({ + hasCompletedOnboarding: true, + theme: 'dark', + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }); expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe( join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'), ); diff --git a/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts index 64743237..4b36abf3 100644 --- a/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts +++ b/packages/mosaic/src/commands/fleet-agent-scaffold-command.ts @@ -4,6 +4,8 @@ import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agen export interface FleetAgentScaffoldCommandDeps { /** Test seam for the user-owned ~/.mosaic root. */ readonly fleetDataHome?: string; + /** Resolves the active installed Mosaic root that owns the canonical runtime base. */ + readonly mosaicHomeFor?: () => string; } interface NewAgentOptions { @@ -35,6 +37,7 @@ export function registerFleetAgentScaffoldCommand( bundle: options.bundle, model: options.model, ...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }), + ...(deps.mosaicHomeFor === undefined ? {} : { mosaicHome: deps.mosaicHomeFor() }), }); console.log( result.idempotent diff --git a/packages/mosaic/src/commands/fleet-launch-command.spec.ts b/packages/mosaic/src/commands/fleet-launch-command.spec.ts index 8a403faa..595f2c63 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.spec.ts @@ -604,7 +604,7 @@ describe('fleet launch command outcomes', () => { MOSAIC_AGENT_NAME: 'fred', SEAT_FLAG: 'yes', }, - { agentDir: fx.agentDir }, + { agentDir: fx.agentDir, mosaicHome: fx.systemHome }, ); expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe( true, diff --git a/packages/mosaic/src/commands/fleet-launch-command.ts b/packages/mosaic/src/commands/fleet-launch-command.ts index 422767c5..72aeb4bf 100644 --- a/packages/mosaic/src/commands/fleet-launch-command.ts +++ b/packages/mosaic/src/commands/fleet-launch-command.ts @@ -110,6 +110,7 @@ export interface FleetLaunchComposition { readonly profilePath: string; readonly profile: FleetAgentLaunchProfile; readonly agentDir: string; + readonly systemHome: string; readonly seatHome: string; readonly settings: { readonly layers: readonly SettingsLayer[]; @@ -749,6 +750,7 @@ export function resolveFleetLaunchComposition( return { name, profilePath, + systemHome: roots.systemHome, profile, agentDir, seatHome, @@ -939,7 +941,10 @@ export function registerFleetLaunchCommand( applyFleetLaunchComposition(plan); console.log(`[mosaic] bundle: ${plan.bundle.display}`); const launcher = deps.launcher ?? launchFleetRuntime; - launcher(plan.profile.harness, plan.argv.slice(1), plan.env, { agentDir: plan.agentDir }); + launcher(plan.profile.harness, plan.argv.slice(1), plan.env, { + agentDir: plan.agentDir, + mosaicHome: plan.systemHome, + }); } catch (error: unknown) { process.exitCode = 1; const code = error instanceof FleetLaunchError ? `${error.code}: ` : ''; diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 03b840f9..56f3171a 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -2076,7 +2076,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = // `fleet agent new` owns user-data harness homes under ~/.mosaic. The // existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there // is one `agent` namespace but deliberately separate state authorities. - registerFleetAgentScaffoldCommand(cmd, { fleetDataHome: deps.fleetDataHome }); + registerFleetAgentScaffoldCommand(cmd, { + ...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }), + mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome, + }); // Roster-v2 desired-state mutations belong directly to the fleet control // plane; they do not share the root `mosaic agent` gateway-backed surface. registerFleetAgentCrudCommands(cmd, deps); diff --git a/packages/mosaic/src/commands/launch.spec.ts b/packages/mosaic/src/commands/launch.spec.ts index d468c9bc..9768b612 100644 --- a/packages/mosaic/src/commands/launch.spec.ts +++ b/packages/mosaic/src/commands/launch.spec.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { Command } from 'commander'; -import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, + symlinkSync, + rmSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -8,6 +19,7 @@ import { enumerateSkillDirs, piForceSkillNames, registerRuntimeLaunchers, + checkSequentialThinking, type RuntimeLaunchHandler, type ClaudexLaunchHandler, } from './launch.js'; @@ -86,6 +98,157 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => { }); }); +describe('checkSequentialThinking', () => { + it('passes with a seeded seat even when operator HOME has no MCP configuration', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + mkdirSync(join(agentDir, '.claude'), { recursive: true }); + writeFileSync( + join(agentDir, '.claude', '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + vi.stubEnv('MOSAIC_HOME', installed); + vi.stubEnv('HOME', home); + expect(() => + checkSequentialThinking('claude', { agentDir, mosaicHome: installed }), + ).not.toThrow(); + } finally { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('repairs a legacy seat config in place without using operator HOME', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const bin = join(installed, 'bin'); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + mkdirSync(join(agentDir, '.claude'), { recursive: true }); + mkdirSync(bin, { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + for (const name of ['node', 'npx']) { + writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n'); + chmodSync(join(bin, name), 0o755); + } + writeFileSync( + join(agentDir, '.claude', '.claude.json'), + JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }), + ); + const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }; + expect( + spawnSync( + checker, + ['--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')], + { + env, + }, + ).status, + ).toBe(0); + expect( + spawnSync( + checker, + ['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')], + { env }, + ).status, + ).toBe(0); + expect( + JSON.parse(readFileSync(join(agentDir, '.claude', '.claude.json'), 'utf8')), + ).toMatchObject({ + hasCompletedOnboarding: true, + theme: 'dark', + mcpServers: { 'sequential-thinking': { command: 'npx' } }, + }); + expect(existsSync(join(home, '.claude.json'))).toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('rejects a group-writable installed helper root', () => { + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + chmodSync(installed, 0o770); + expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow( + /not a trusted installed file/, + ); + } finally { + chmodSync(installed, 0o700); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); + + it('refuses an empty seat even when operator HOME is configured', () => { + const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-')); + const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-')); + const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-')); + const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows); + try { + mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true }); + copyFileSync( + join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'), + checker, + ); + writeFileSync( + join(home, '.claude.json'), + JSON.stringify({ + mcpServers: { + 'sequential-thinking': { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-sequential-thinking'], + }, + }, + }), + ); + vi.stubEnv('MOSAIC_HOME', installed); + vi.stubEnv('HOME', home); + expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow( + 'process.exit called', + ); + expect(exit).toHaveBeenCalledWith(1); + } finally { + exit.mockRestore(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + rmSync(installed, { recursive: true, force: true }); + } + }); +}); + describe('buildPiSkillArgs', () => { it('disables auto-discovery but force-loads fleet-critical skills by default', () => { expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([ diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 0466a0f3..e59b195a 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -8,6 +8,7 @@ import { execFileSync, execSync, spawnSync } from 'node:child_process'; import { existsSync, + lstatSync, mkdirSync, readFileSync, writeFileSync, @@ -19,14 +20,14 @@ import { import { createHash, randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { homedir, hostname } from 'node:os'; -import { join, dirname } from 'node:path'; +import { join, dirname, relative, resolve, sep } from 'node:path'; import type { Command } from 'commander'; import { buildResolvedFleetCommsBlock, renderToolsContractStatus, resolveFleetIdentity, } from '../fleet/comms-onboarding.js'; -import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { canonicalizeRoleClass } from './fleet-personas.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; @@ -40,6 +41,8 @@ export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi'; /** Fleet context for the single harness-home resolution seam. */ export interface FleetHarnessContext { readonly agentDir: string; + /** Active installed Mosaic root for fleet-specific helper resolution. */ + readonly mosaicHome?: string; } const RUNTIME_LABELS: Record = { @@ -343,13 +346,67 @@ function printSettingsWarnings(audit: SettingsAudit): void { ); } -function checkSequentialThinking(runtime: string): void { - const checker = fwScript('mosaic-ensure-sequential-thinking'); +function trustedFleetHelper(mosaicHome: string): string { + const root = resolve(mosaicHome); + const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'); + try { + assertNoSymlinkAncestors(checker); + const owner = typeof process.getuid === 'function' ? process.getuid() : undefined; + let cursor = root; + for (const component of relative(root, checker).split(sep).filter(Boolean)) { + const info = lstatSync(cursor); + if ( + !info.isDirectory() || + info.isSymbolicLink() || + (info.mode & 0o022) !== 0 || + (owner !== undefined && info.uid !== owner && info.uid !== 0) + ) { + throw new Error('helper directory has unsafe type, owner, or permissions'); + } + cursor = join(cursor, component); + } + const helperInfo = lstatSync(checker); + if ( + !helperInfo.isFile() || + helperInfo.isSymbolicLink() || + (helperInfo.mode & 0o022) !== 0 || + (helperInfo.mode & 0o111) === 0 || + (owner !== undefined && helperInfo.uid !== owner && helperInfo.uid !== 0) + ) { + throw new Error('helper has unsafe type, owner, or permissions'); + } + } catch (error: unknown) { + throw new Error( + `fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return checker; +} + +export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void { + // Fleet launch must use the active --mosaic-home installation. Non-fleet + // launches retain the package/deployed helper resolver. + const checker = fleet?.mosaicHome + ? trustedFleetHelper(fleet.mosaicHome) + : fwScript('mosaic-ensure-sequential-thinking'); if (!existsSync(checker)) return; // Skip if checker doesn't exist - const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' }); + const fleetClaudeConfig = + runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined; + const result = spawnSync( + checker, + [ + '--check', + '--runtime', + runtime, + ...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]), + ], + { stdio: 'ignore' }, + ); if (result.status !== 0) { console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.'); - console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`); + const repairArgs = + fleetClaudeConfig === undefined ? '' : ` --claude-config-dir ${fleetClaudeConfig}`; + console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`); process.exit(1); } } @@ -947,7 +1004,7 @@ function launchRuntime( // Pi doesn't need sequential-thinking (has native thinking levels) if (runtime !== 'pi') { - checkSequentialThinking(runtime); + checkSequentialThinking(runtime, context.fleet); } checkResumableSession(); diff --git a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts index fbb4dbaa..e929f3cb 100644 --- a/packages/mosaic/src/fleet/fleet-agent-scaffold.ts +++ b/packages/mosaic/src/fleet/fleet-agent-scaffold.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { isAbsolute, join, relative, resolve } from 'node:path'; @@ -6,6 +7,8 @@ export type FleetAgentHarness = 'claude' | 'pi'; export interface FleetAgentScaffoldOptions { readonly dataHome?: string; + /** Active installed Mosaic root; supplies the canonical authored runtime base. */ + readonly mosaicHome?: string; readonly name: string; readonly harness?: string; readonly bundle?: string; @@ -48,6 +51,7 @@ export async function scaffoldFleetAgent( const bundle = requireBundle(options.bundle ?? 'primary'); const model = optionalNonEmpty(options.model, '--model'); const dataHome = resolve(options.dataHome ?? defaultFleetDataHome()); + const mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic')); const agentDir = join(dataHome, 'fleet', 'agents', name); const homeName = harness === 'claude' ? '.claude' : '.pi'; const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json'; @@ -73,7 +77,7 @@ export async function scaffoldFleetAgent( if (harness === 'claude') { entries.push([ join(homeName, '.claude.json'), - { type: 'file', content: json(onboardingState()) }, + { type: 'file', content: json(onboardingState(mosaicHome)) }, ]); } const files = new Map(entries); @@ -232,8 +236,33 @@ function optionalNonEmpty(value: string | undefined, option: string): string | u return value; } -function onboardingState(): Record { - return { hasCompletedOnboarding: true, theme: 'dark' }; +function onboardingState(mosaicHome: string): Record { + const settingsPath = join(mosaicHome, 'runtime', 'claude', 'settings.json'); + let authored: unknown; + try { + authored = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new FleetAgentScaffoldError( + 'invalid-request', + `canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`, + ); + } + if ( + typeof authored !== 'object' || + authored === null || + Array.isArray(authored) || + !('mcpServers' in authored) || + typeof authored.mcpServers !== 'object' || + authored.mcpServers === null || + Array.isArray(authored.mcpServers) + ) { + throw new FleetAgentScaffoldError( + 'invalid-request', + `canonical Claude settings lack an mcpServers object: ${settingsPath}`, + ); + } + return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers }; } function soul(name: string): string {