` to the record's animated GLB, so the
+ // stage needs no special case for record-backed characters.
+ it('keys the canvas guard on the rigged variant url for an animated record', async () => {
+ render();
+ await waitFor(() => expect(screen.getByTestId('canvas-guard')).toHaveAttribute(
+ 'data-reset-key',
+ '/api/avatar/model.glb?variant=rigged-image3d-1',
+ ));
+ });
});
diff --git a/client/src/components/cos/tabs/ConfigTab.jsx b/client/src/components/cos/tabs/ConfigTab.jsx
index aab5c6ce58..8200c38338 100644
--- a/client/src/components/cos/tabs/ConfigTab.jsx
+++ b/client/src/components/cos/tabs/ConfigTab.jsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import {
Activity,
@@ -32,6 +32,7 @@ import {
} from '../constants';
import ProviderModelSelector from '../../ProviderModelSelector';
import useProviderModels from '../../../hooks/useProviderModels';
+import { coverageSummary, isRiggedAvatarStyle, riggedRecordForStyle } from '../../../hooks/useAvatarCapabilities';
import { timeAgo } from '../../../utils/formatters';
const DOMAIN_MODE_COLORS = {
@@ -222,7 +223,7 @@ function PersistentMindStatus({ mind, loaded, error }) {
);
}
-export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle }) {
+export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle, riggedAvatars = [] }) {
const {
providers,
availableModels,
@@ -273,6 +274,37 @@ export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle })
useEffect(() => { void refreshBudgetUsage(); }, [refreshBudgetUsage]);
useAutoRefetch(refreshMindStatus, 15_000, { pollOnly: true });
+ // Built-in styles plus the install's verified animated records (#5894). A
+ // record entry carries its state coverage in the label, so what the
+ // character can and cannot do is visible BEFORE it is picked.
+ const avatarOptions = useMemo(() => ([
+ ...Object.entries(AVATAR_STYLE_LABELS).map(([value, label]) => ({ value, label })),
+ ...(Array.isArray(riggedAvatars) ? riggedAvatars : [])
+ .filter((record) => record?.variant)
+ .map((record) => ({
+ value: record.variant,
+ label: `${record.name} (rigged 3D) — ${coverageSummary(record.coverage)}`,
+ })),
+ ]), [riggedAvatars]);
+
+ const avatarLabel = (style) => {
+ if (AVATAR_STYLE_LABELS[style]) return AVATAR_STYLE_LABELS[style];
+ const record = riggedRecordForStyle(riggedAvatars, style);
+ return record ? `${record.name} (rigged 3D)` : style;
+ };
+
+ // Honest coverage note for the staged value: which states the character
+ // covers, what the rest fall back to — or a warning when the record the
+ // saved style points at is gone.
+ const stagedRiggedNote = useMemo(() => {
+ if (!isRiggedAvatarStyle(formData.avatarStyle)) return null;
+ const record = riggedRecordForStyle(riggedAvatars, formData.avatarStyle);
+ if (!record) return 'That animated record is no longer available — pick another avatar.';
+ const covered = record.coverage?.coveredStates || [];
+ const fallback = record.clip ? `Other states play ${record.clip}.` : '';
+ return `${coverageSummary(record.coverage)}. Covered: ${covered.join(', ') || 'none'}. ${fallback}`.trim();
+ }, [formData.avatarStyle, riggedAvatars]);
+
const handleCancel = () => {
setFormData(getDefaultFormData(config, avatarStyle));
setEditing(false);
@@ -434,9 +466,12 @@ export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle })
- ({ value, label }))} onChange={(value) => setFormData((current) => ({ ...current, avatarStyle: value }))} />
+ setFormData((current) => ({ ...current, avatarStyle: value }))} />
setFormData((current) => ({ ...current, dynamicAvatar: value }))} />
+ {stagedRiggedNote && (
+ {stagedRiggedNote}
+ )}
diff --git a/client/src/components/cos/tabs/ConfigTab.test.jsx b/client/src/components/cos/tabs/ConfigTab.test.jsx
index 2bb0ccdcd2..82db9c10a5 100644
--- a/client/src/components/cos/tabs/ConfigTab.test.jsx
+++ b/client/src/components/cos/tabs/ConfigTab.test.jsx
@@ -267,6 +267,65 @@ describe('Default Avatar Style dropdown', () => {
});
});
+describe('Rigged avatar records in the Default Avatar dropdown', () => {
+ const riggedAvatars = [{
+ id: 'image3d-1',
+ name: 'Example Dancer',
+ variant: 'rigged-image3d-1',
+ assetUrl: '/api/avatar/model.glb?variant=rigged-image3d-1',
+ clip: 'Dance',
+ coverage: {
+ availableClips: ['Dance'],
+ coverageByState: {
+ thinking: { covered: false, clip: null },
+ ideating: { covered: true, clip: 'Dance' },
+ },
+ coveredStates: ['ideating'],
+ missingStates: ['thinking'],
+ complete: false,
+ },
+ }];
+
+ it('offers verified animated records alongside the built-in styles', async () => {
+ renderConfig({ config: { ...config, avatarStyle: 'svg' }, riggedAvatars });
+ await screen.findByText('Waiting for the next wake');
+
+ fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
+
+ const select = screen.getByRole('combobox', { name: 'Default avatar' });
+ const labels = [...select.options].map((option) => option.text);
+ expect(labels).toContain('Digital (SVG)');
+ expect(labels.some((label) => label.includes('Example Dancer') && label.includes('rigged 3D'))).toBe(true);
+ });
+
+ it('shows the coverage note when a rigged record is staged', async () => {
+ renderConfig({ config: { ...config, avatarStyle: 'svg' }, riggedAvatars });
+ await screen.findByText('Waiting for the next wake');
+
+ fireEvent.click(screen.getByRole('button', { name: /Edit/i }));
+
+ const select = screen.getByRole('combobox', { name: 'Default avatar' });
+ fireEvent.change(select, { target: { value: 'rigged-image3d-1' } });
+
+ expect(await screen.findByText(/Covered: ideating/)).toBeInTheDocument();
+ expect(screen.getByText(/Other states play Dance/)).toBeInTheDocument();
+
+ api.updateCosConfig.mockResolvedValue({ success: true });
+ fireEvent.click(screen.getByRole('button', { name: /Save/i }));
+ await waitFor(() => expect(api.updateCosConfig).toHaveBeenCalledWith(
+ expect.objectContaining({ avatarStyle: 'rigged-image3d-1' }),
+ { silent: true },
+ ));
+ });
+
+ it('warns when the saved rigged record is no longer offered', async () => {
+ renderConfig({ config: { ...config, avatarStyle: 'rigged-image3d-gone' }, riggedAvatars });
+ await screen.findByText('Waiting for the next wake');
+
+ expect(screen.getByText(/no longer available/)).toBeInTheDocument();
+ });
+});
+
describe('persistent mind status', () => {
it('shows the live supervisor state and links to the full mind workspace', async () => {
renderConfig();
diff --git a/client/src/components/songbook/PracticeLogger.jsx b/client/src/components/songbook/PracticeLogger.jsx
index 38b143b07c..efd4145545 100644
--- a/client/src/components/songbook/PracticeLogger.jsx
+++ b/client/src/components/songbook/PracticeLogger.jsx
@@ -66,6 +66,7 @@ export default function PracticeLogger({ song, onLogged, className = '' }) {
type="button"
onClick={() => logPractice(rating.quality)}
disabled={logging}
+ aria-label={rating.label}
className="min-h-[48px] px-2.5 py-2 text-left sm:text-center rounded-lg border border-port-border text-gray-300 hover:text-white hover:border-port-accent/50 hover:bg-port-border/50 disabled:opacity-50 flex flex-col justify-center"
>
{rating.label}
diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md
index 284fe9240d..441c59f6a7 100644
--- a/client/src/hooks/README.md
+++ b/client/src/hooks/README.md
@@ -62,6 +62,8 @@ grep -i "what you want to do" client/src/hooks/README.md
| Hook | Purpose | Use when |
|---|---|---|
+| `useAvatarCapabilities` | Verified animated records for the avatar selectors (`GET /avatar/rigged`) plus the honest state→clip resolvers (`resolveStateClip`, `resolvePlaybackClip`, `coverageSummary`): an uncovered CoS state falls back to a clip the character actually has, never a pretended one. | Any surface offering or playing a rigged-record avatar (CoS avatar selector, CoS playback). |
+|---|---|---|
| `useMediaAnnotations` | Per-entry `own`/`others` annotations with back-compat aliases. | Showing media annotations + ownership. |
| `useMediaCompletionRefresh` | Refetch on image/video completion socket events. | A list view that needs to refresh when new media lands. |
| `useOpenClawAttachments` | File attachment handling (base64, size-capped). | OpenClaw attachment UI. |
diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js
index 1130f9b503..d2af021ce0 100644
--- a/client/src/hooks/index.js
+++ b/client/src/hooks/index.js
@@ -129,6 +129,7 @@ export * from './useStoryStepRuns.jsx';
export * from './useModelDownloadStatus.js';
// === Media (annotations, completion, attachments) ===
+export * from './useAvatarCapabilities.js';
export * from './useMediaAnnotations.js';
export * from './useSpritePendingRenders.js';
export { default as useSpriteRecordCrud } from './useSpriteRecordCrud.js';
diff --git a/client/src/hooks/useAvatarCapabilities.js b/client/src/hooks/useAvatarCapabilities.js
new file mode 100644
index 0000000000..9cd36ce9e5
--- /dev/null
+++ b/client/src/hooks/useAvatarCapabilities.js
@@ -0,0 +1,111 @@
+import { useCallback, useEffect, useState } from 'react';
+import { getRiggedAvatars } from '../services/api';
+
+// Avatar capabilities for rigged + animated records (#5894).
+//
+// A retargeted character carries whatever clips its ONE retarget produced —
+// usually a single clip — while every CoS state wants its own motion. This
+// module is the honesty layer between the two: the server reports per-state
+// coverage (`GET /avatar/rigged`, computed by
+// `server/services/rigging/clipCapabilities.js`), and these resolvers turn
+// that report into a playable clip WITHOUT ever pretending an uncovered state
+// is covered. A missing state deterministically falls back to a clip the
+// character actually has.
+
+/** `?variant=` namespace prefix for record-backed avatar styles. */
+export const RIGGED_AVATAR_PREFIX = 'rigged-';
+
+/** Whether an avatar-style value selects a rigged record vs a built-in style. */
+export const isRiggedAvatarStyle = (style) => typeof style === 'string' && style.startsWith(RIGGED_AVATAR_PREFIX);
+
+/** The selector entry for a style value, or null when it is not offered. */
+export const riggedRecordForStyle = (records, style) => (
+ isRiggedAvatarStyle(style) && Array.isArray(records)
+ ? records.find((record) => record?.variant === style) || null
+ : null
+);
+
+/**
+ * The clip a CoS state maps to under a server coverage report: the covered
+ * clip when the state is covered, else the first available clip
+ * (deterministic — same record, same answer), else null when the character
+ * carries no clip at all. Never invents coverage.
+ * @param {object|null} coverage The `coverage` half of a `/avatar/rigged` entry.
+ * @param {string} state A CoS agent state.
+ * @returns {string|null}
+ */
+export function resolveStateClip(coverage, state) {
+ const stateClip = coverage?.coverageByState?.[state]?.clip;
+ if (typeof stateClip === 'string' && stateClip) return stateClip;
+ const available = Array.isArray(coverage?.availableClips) ? coverage.availableClips : [];
+ return available.find((clip) => typeof clip === 'string' && clip) || null;
+}
+
+/**
+ * The clip to actually PLAY from a loaded GLB's roster. The coverage answer
+ * wins when the GLB still carries that clip (the record may have been
+ * re-retargeted since the selector read it, so presence is re-checked —
+ * never trusted blindly); then the caller's ordered fallbacks; then the
+ * roster's first clip, so an uncovered state degrades to real motion instead
+ * of a frozen frame. Null when the GLB carries nothing playable.
+ * @param {string[]} names Clip names on the loaded GLB.
+ * @param {{state?: string|null, coverage?: object|null, fallbacks?: string[]}} opts
+ * @returns {string|null}
+ */
+export function resolvePlaybackClip(names, { state = null, coverage = null, fallbacks = [] } = {}) {
+ const roster = Array.isArray(names) ? names.filter((name) => typeof name === 'string' && name) : [];
+ const candidates = [
+ ...(state && coverage ? [resolveStateClip(coverage, state)] : []),
+ ...(Array.isArray(fallbacks) ? fallbacks : []),
+ ];
+ return candidates.find((clip) => clip && roster.includes(clip)) || roster[0] || null;
+}
+
+/**
+ * One-line honest summary of a coverage report for selector copy.
+ * @param {object|null} coverage
+ * @returns {string}
+ */
+export function coverageSummary(coverage) {
+ const states = coverage?.coverageByState ? Object.keys(coverage.coverageByState) : [];
+ const covered = Array.isArray(coverage?.coveredStates) ? coverage.coveredStates.length : 0;
+ if (states.length === 0) return 'No animation clips';
+ if (covered >= states.length) return `Covers all ${states.length} CoS states`;
+ if (covered > 0) return `Covers ${covered} of ${states.length} CoS states`;
+ const fallback = resolveStateClip(coverage);
+ return fallback ? `No covered CoS state — plays ${fallback} throughout` : 'No animation clips';
+}
+
+/**
+ * The install's verified animated records for the avatar selectors. Fetches
+ * once on mount; `refresh` re-reads (e.g. after a retarget completes
+ * elsewhere). Failures resolve to an empty list with `error` set — the
+ * selectors render their built-in styles regardless, so a rigging-lane outage
+ * must never take down the CoS config screen.
+ * @returns {{records: object[], loading: boolean, error: Error|null, refresh: Function}}
+ */
+export function useAvatarCapabilities() {
+ const [records, setRecords] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await getRiggedAvatars({ silent: true });
+ setRecords(Array.isArray(data?.records) ? data.records : []);
+ } catch (err) {
+ setRecords([]);
+ setError(err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return { records, loading, error, refresh };
+}
diff --git a/client/src/hooks/useAvatarCapabilities.test.jsx b/client/src/hooks/useAvatarCapabilities.test.jsx
new file mode 100644
index 0000000000..fea1b2122a
--- /dev/null
+++ b/client/src/hooks/useAvatarCapabilities.test.jsx
@@ -0,0 +1,118 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const api = vi.hoisted(() => ({ getRiggedAvatars: vi.fn() }));
+vi.mock('../services/api', () => api);
+
+import {
+ coverageSummary,
+ isRiggedAvatarStyle,
+ resolvePlaybackClip,
+ resolveStateClip,
+ riggedRecordForStyle,
+ useAvatarCapabilities,
+} from './useAvatarCapabilities';
+
+// A single-clip retargeted character: the server maps its one clip against
+// the CoS vocabulary, so exactly the matching states read as covered.
+const danceCoverage = {
+ availableClips: ['Dance'],
+ coverageByState: {
+ thinking: { covered: false, clip: null },
+ coding: { covered: false, clip: null },
+ ideating: { covered: true, clip: 'Dance' },
+ },
+ coveredStates: ['ideating'],
+ missingStates: ['thinking', 'coding'],
+ complete: false,
+};
+
+describe('resolveStateClip', () => {
+ it('returns the covered clip for a covered state', () => {
+ expect(resolveStateClip(danceCoverage, 'ideating')).toBe('Dance');
+ });
+
+ it('falls back to the available clip for a missing state', () => {
+ expect(resolveStateClip(danceCoverage, 'coding')).toBe('Dance');
+ });
+
+ it('returns null when the character carries no clip', () => {
+ expect(resolveStateClip({ availableClips: [], coverageByState: {} }, 'coding')).toBe(null);
+ expect(resolveStateClip(null, 'coding')).toBe(null);
+ });
+});
+
+describe('resolvePlaybackClip', () => {
+ it('plays the covered clip when the GLB carries it', () => {
+ expect(resolvePlaybackClip(['Dance', 'Idle'], {
+ state: 'ideating', coverage: danceCoverage, fallbacks: ['idle'],
+ })).toBe('Dance');
+ });
+
+ it('falls back to a present clip when the covered one is absent from the GLB', () => {
+ // The record was re-retargeted after the selector read it: coverage names
+ // a clip the file no longer has, so playback degrades to real motion
+ // rather than a frozen frame.
+ expect(resolvePlaybackClip(['Idle'], {
+ state: 'ideating', coverage: danceCoverage, fallbacks: ['idle'],
+ })).toBe('Idle');
+ });
+
+ it('keeps the legacy fallback chain when there is no coverage', () => {
+ expect(resolvePlaybackClip(['walk', 'idle'], { fallbacks: ['walk', 'idle'] })).toBe('walk');
+ expect(resolvePlaybackClip(['idle'], { fallbacks: ['walk', 'idle'] })).toBe('idle');
+ expect(resolvePlaybackClip(['sprint'], { fallbacks: ['walk', 'idle'] })).toBe('sprint');
+ expect(resolvePlaybackClip([], { fallbacks: ['walk', 'idle'] })).toBe(null);
+ });
+});
+
+describe('coverageSummary', () => {
+ it('names the covered fraction honestly', () => {
+ expect(coverageSummary(danceCoverage)).toBe('Covers 1 of 3 CoS states');
+ });
+
+ it('celebrates full coverage and admits none', () => {
+ expect(coverageSummary({ coverageByState: { a: {}, b: {} }, coveredStates: ['a', 'b'] }))
+ .toBe('Covers all 2 CoS states');
+ expect(coverageSummary({
+ coverageByState: { a: { covered: false, clip: null } },
+ coveredStates: [],
+ availableClips: ['Dance'],
+ })).toBe('No covered CoS state — plays Dance throughout');
+ expect(coverageSummary(null)).toBe('No animation clips');
+ });
+});
+
+describe('style helpers', () => {
+ it('recognizes rigged styles and finds their record', () => {
+ const records = [{ variant: 'rigged-image3d-1', name: 'Example Dancer' }];
+ expect(isRiggedAvatarStyle('rigged-image3d-1')).toBe(true);
+ expect(isRiggedAvatarStyle('muse')).toBe(false);
+ expect(riggedRecordForStyle(records, 'rigged-image3d-1')).toEqual(records[0]);
+ expect(riggedRecordForStyle(records, 'rigged-image3d-gone')).toBe(null);
+ expect(riggedRecordForStyle(records, 'muse')).toBe(null);
+ });
+});
+
+describe('useAvatarCapabilities', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('resolves with the served records', async () => {
+ api.getRiggedAvatars.mockResolvedValue({ records: [{ variant: 'rigged-image3d-1' }] });
+ const { result } = renderHook(() => useAvatarCapabilities());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.records).toHaveLength(1);
+ expect(result.current.error).toBe(null);
+ expect(api.getRiggedAvatars).toHaveBeenCalledWith({ silent: true });
+ });
+
+ it('fails open to an empty list so the built-in styles keep working', async () => {
+ api.getRiggedAvatars.mockRejectedValue(new Error('rigging lane down'));
+ const { result } = renderHook(() => useAvatarCapabilities());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.records).toEqual([]);
+ expect(result.current.error).not.toBe(null);
+ });
+});
diff --git a/client/src/pages/ChiefOfStaff.jsx b/client/src/pages/ChiefOfStaff.jsx
index aa0d48150a..00475e572f 100644
--- a/client/src/pages/ChiefOfStaff.jsx
+++ b/client/src/pages/ChiefOfStaff.jsx
@@ -5,6 +5,7 @@ import { useLocalStorageBool } from '../hooks/useLocalStorageBool';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import { useValidTab } from '../hooks/useValidTab';
import * as api from '../services/api';
+import { isRiggedAvatarStyle, riggedRecordForStyle, useAvatarCapabilities } from '../hooks/useAvatarCapabilities';
import { coalesce } from '../utils/coalesce';
import { sameJsonShape } from '../lib/sameJsonShape';
import { Play, Pause, Square, Clock, CheckCircle, AlertCircle, Cpu, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Brain, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
@@ -70,6 +71,12 @@ const LAZY_AVATARS = {
miniFemaleD: lazy(() => import('../components/cos/MiniCharFemaleD')),
};
+// A verified animated record renders through the same mini-character stage —
+// the variant URL resolves to the record's GLB via /api/avatar, and playback
+// falls back to a present clip per its coverage. Lazy like every other 3D
+// avatar so three.js stays out of the main chunk until it is picked.
+const LazyRiggedAvatar = lazy(() => import('../components/cos/MiniCharacterCoSAvatar'));
+
const CANVAS_AVATAR_STYLES = new Set([
'cyber', 'sigil', 'esoteric', 'nexus', 'muse',
'miniMaleC', 'miniFemaleD',
@@ -134,6 +141,9 @@ export default function ChiefOfStaff() {
const queueSeqRef = useRef(0);
const socket = useSocket();
+ // Verified animated records for the avatar selector and rigged playback (#5894).
+ const { records: riggedAvatars } = useAvatarCapabilities();
+
// Derive avatar style from server config, with optional dynamic override
const configAvatarStyle = status?.config?.avatarStyle || 'svg';
const dynamicAvatarEnabled = status?.config?.dynamicAvatar || false;
@@ -710,7 +720,7 @@ export default function ChiefOfStaff() {
el.scrollBy({ left: direction === 'left' ? -scrollAmount : scrollAmount, behavior: 'smooth' });
}, []);
- const hasCanvasAvatar = CANVAS_AVATAR_STYLES.has(avatarStyle);
+ const hasCanvasAvatar = CANVAS_AVATAR_STYLES.has(avatarStyle) || isRiggedAvatarStyle(avatarStyle);
// Learning tile behaviour shared by the compact (sidebar/mobile) and mini
// (ascii stats bar) renderings — only the icon scale and the empty-state
@@ -834,6 +844,18 @@ export default function ChiefOfStaff() {
);
const renderAvatar = (background = false) => {
+ // A rigged record plays on the mini-character stage: the variant URL
+ // resolves to its animated GLB, and its coverage drives the fallback to
+ // a present clip. A record deleted after being picked 404s its HEAD
+ // probe, so the stage shows the missing-model hint instead of a canvas.
+ if (isRiggedAvatarStyle(avatarStyle)) {
+ const record = riggedRecordForStyle(riggedAvatars, avatarStyle);
+ return (
+ }>
+
+
+ );
+ }
const LazyAvatar = LAZY_AVATARS[avatarStyle];
if (LazyAvatar) {
return (
@@ -1259,7 +1281,7 @@ export default function ChiefOfStaff() {
{activeTab === 'config' && (
}>
-
+
)}
diff --git a/client/src/pages/ChiefOfStaff.test.jsx b/client/src/pages/ChiefOfStaff.test.jsx
index 885ec0c36e..479ecf8087 100644
--- a/client/src/pages/ChiefOfStaff.test.jsx
+++ b/client/src/pages/ChiefOfStaff.test.jsx
@@ -30,6 +30,7 @@ const api = vi.hoisted(() => ({
getCosLearningDurations: vi.fn(),
getCosPopularTemplates: vi.fn(),
getCodeReviewDefaults: vi.fn(),
+ getRiggedAvatars: vi.fn(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
const socketStub = vi.hoisted(() => ({ connected: false, on: vi.fn(), off: vi.fn(), emit: vi.fn() }));
@@ -60,6 +61,18 @@ vi.mock('../hooks/useProviderModels', () => ({
selectedModel: '',
}),
}));
+// The rigged avatar stage pulls three.js through a lazy chunk — stub the
+// module so this suite asserts the wiring (variant + coverage reach the
+// stage), not the 3D render.
+vi.mock('../components/cos/MiniCharacterCoSAvatar', () => ({
+ default: ({ variant, coverage }) => (
+
+ ),
+}));
const { default: ChiefOfStaff, SPEAKING_MS } = await import('./ChiefOfStaff');
@@ -100,6 +113,7 @@ beforeEach(() => {
api.getCosLearningDurations.mockResolvedValue(null);
api.getCosPopularTemplates.mockResolvedValue([]);
api.getCodeReviewDefaults.mockResolvedValue({});
+ api.getRiggedAvatars.mockResolvedValue({ records: [] });
localLlm.getLocalLlmStatus.mockResolvedValue({ ollama: { models: [] }, lmstudio: { models: [] } });
localLlm.getToolUseModels.mockResolvedValue({ models: [] });
});
@@ -863,3 +877,43 @@ describe('ChiefOfStaff Issues card', () => {
}
});
});
+
+describe('Rigged avatar style', () => {
+ it('renders a rigged record on the mini-character stage with its coverage', async () => {
+ api.getCosStatus.mockResolvedValue({
+ running: true,
+ config: { ...config, avatarStyle: 'rigged-image3d-1' },
+ stats: {},
+ });
+ api.getRiggedAvatars.mockResolvedValue({
+ records: [{
+ id: 'image3d-1',
+ name: 'Example Dancer',
+ variant: 'rigged-image3d-1',
+ clip: 'Dance',
+ coverage: { coveredStates: ['ideating'], missingStates: ['coding'], complete: false },
+ }],
+ });
+ await renderSettledAt('tasks');
+
+ const avatar = await screen.findByTestId('rigged-avatar');
+ expect(avatar).toHaveAttribute('data-variant', 'rigged-image3d-1');
+ expect(avatar).toHaveAttribute('data-covered', 'ideating');
+ });
+
+ it('still renders the stage when the rigged record is gone', async () => {
+ api.getCosStatus.mockResolvedValue({
+ running: true,
+ config: { ...config, avatarStyle: 'rigged-image3d-gone' },
+ stats: {},
+ });
+ api.getRiggedAvatars.mockResolvedValue({ records: [] });
+ await renderSettledAt('tasks');
+
+ // No coverage to hand over (null), but the variant URL still probes —
+ // a deleted record shows the stage's missing-model hint, not a crash.
+ const avatar = await screen.findByTestId('rigged-avatar');
+ expect(avatar).toHaveAttribute('data-variant', 'rigged-image3d-gone');
+ expect(avatar).toHaveAttribute('data-covered', '');
+ });
+});
diff --git a/client/src/services/README.md b/client/src/services/README.md
index c8a3c87075..41494b1cca 100644
--- a/client/src/services/README.md
+++ b/client/src/services/README.md
@@ -151,3 +151,4 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `domIndex.js` | DOM indexer for voice accessibility mode. |
| `staleBuildToast.jsx` | Sticky toast shown when server's build id differs from client's. |
| `apiRigging.js` | Character rigging. `getRiggingReadiness()` (`GET /rigging/readiness`): whether this install's Blender runtime is provisioned, the resolved interpreter, the module version, the install command when it is not, and the auto-skin threshold defaults. `rigImageTo3dModel(id, body)` (`POST /rigging/models/:id`): auto-skin a rendered mesh behind the measured weight-coverage gate, resolving with the updated model record. |
+| `apiAvatar.js` | Avatar surfaces. `getRiggedAvatars()` (`GET /avatar/rigged`): the install's verified animated records, each with its `?variant=` spelling, serving URL, retargeted clip name, and server-computed CoS-state coverage. |
diff --git a/client/src/services/api.js b/client/src/services/api.js
index aa8c274e52..d99caf089e 100644
--- a/client/src/services/api.js
+++ b/client/src/services/api.js
@@ -62,6 +62,7 @@ export * from './apiUniverseBuilder.js';
export * from './apiAuthors.js';
export * from './apiArtists.js';
export * from './apiAlbums.js';
+export * from './apiAvatar.js';
export * from './apiTracks.js';
export * from './apiVideoDownload.js';
export * from './apiMusic.js';
diff --git a/client/src/services/apiAvatar.js b/client/src/services/apiAvatar.js
new file mode 100644
index 0000000000..53935a3926
--- /dev/null
+++ b/client/src/services/apiAvatar.js
@@ -0,0 +1,9 @@
+import { request } from './apiCore.js';
+
+// Selectable rigged + animated records for the avatar surfaces (#5894).
+// `GET /avatar/rigged` answers the install's verified animated records, each
+// with the `?variant=` spelling that selects it, the serving URL, the
+// retargeted clip name, and the server-computed CoS-state coverage — so a
+// selector shows what a character covers without re-deriving the vocabulary.
+export const getRiggedAvatars = (options) =>
+ request('/avatar/rigged', options);
diff --git a/server/lib/README.md b/server/lib/README.md
index f5fd005817..1ea10ade91 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -396,6 +396,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `investigationTasks.js` | Investigation-task identity, approval, auto-retry, and PR-backed delivery policy — shared by the producers (`services/investigationTaskProducer.js`), the reaper (`cosTaskStore.js`, which used to hand-copy the predicate to dodge an import cycle) and the retry (`services/investigationRetry.js`). `isInvestigationTask(task)` (durable `isInvestigation` marker, falling back to the `INVESTIGATION_HEADLINE_PREFIX` headline for pre-#2615 / peer-synced tasks); `INVESTIGATION_TASK_DELIVERY` keeps unattended investigations in an isolated worktree and routes them through a PR merged on green; `buildInvestigationFingerprint(task, analysis)` / `investigationFingerprint({category, kind, scope})` → the `category:kind:scope` dedup key; `resolveInvestigationApproval({fingerprint, tasks, recentCreations})` → unattended by default (#3714), held only on a `repeat-fingerprint` or `failure-storm` loop, with `approvalReason` + `loopProse` for the queue UI; `couldReleaseBlockedTasks(investigation)` is the pure pre-read gate for `resolveInvestigationRetryTargets({investigation, tasksById})`, which returns the failure-blocked tasks a just-completed investigation releases plus every skip and its `RETRY_SKIP_REASONS` reason (an `auto-expired` completion, a non-`blocked` task, a `NON_AUTO_RETRY_BLOCK_CATEGORIES` block, or a task past `MAX_AUTO_RETRIES_PER_TASK`); `autoRetryMetadata(task, investigationId, now)` stamps the budget that survives the revive's own `failureCount` reset. Pure. |
| `mediaItemKey.js` | `:[` key vocabulary for media items. |
| `assetProvenance.js` | Stamp-time model/LoRA license provenance (`buildProvenance` / `provenanceForRender` / `rollupProvenance`). Unknown stays `null` (displayed as "unknown") — never a permissive default. Mirrored byte-for-byte to `client/src/lib/assetProvenance.js`. |
+| `avatarVariants.js` | Rigged-record avatar variant spelling (`RIGGED_VARIANT_PREFIX`, `AVATAR_VARIANT_PATTERN`, `parseRiggedVariant`, `riggedVariantForId`, `isAnimatedRecordReady`) — the `rigged-` namespace over `?variant=`, sharing the route's strict traversal guard. |
| `migrationMarker.js` | Shared marker-file helpers for one-time migration/repair/reconcile scripts — `markerExists(filename)` (boolean gate), `readMarker(filename)` (parsed payload or null), `writeMarker(filename, payload)` (atomic write). All anchor `filename` under `PATHS.data` and use `tryReadFile`/`atomicWrite` so a crash can't leave a truncated marker. |
| `goalFeatureMap.js` | Deterministic goal `category` → PortOS feature-area map (deep-links sourced from `NAV_COMMANDS`). `getGoalFeatureAreas(goal)` honors the per-goal `featureAreas` override, else the category default. Mirrored byte-for-byte to `client/src/lib/`. |
| `navManifest.js` | Single source of truth for nav (`⌘K` palette + voice). Add an entry when you add a page. |
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index 02ca6e6af8..0150c5b07f 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -1502,6 +1502,14 @@
"server/routes/avatar.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/avatar/rigged",
+ "mountPath": "/api/avatar",
+ "sources": [
+ "server/routes/avatar.js"
+ ]
+ },
{
"method": "POST",
"path": "/api/backup/restore",
@@ -17505,8 +17513,8 @@
],
"stats": {
"mounts": 147,
- "operations": 2168,
- "declarations": 2176,
+ "operations": 2169,
+ "declarations": 2177,
"sourceFiles": 230
}
}
diff --git a/server/lib/avatarVariants.js b/server/lib/avatarVariants.js
new file mode 100644
index 0000000000..001c32500c
--- /dev/null
+++ b/server/lib/avatarVariants.js
@@ -0,0 +1,63 @@
+/**
+ * Rigged-record avatar variants (issue #5894).
+ *
+ * A completed rigged + animated image-to-3D record is selectable as an avatar
+ * through the SAME `?variant=` namespace as the file-backed variants in
+ * `server/routes/avatar.js`, under the `rigged-` spelling. The record
+ * id charset (`image3d-`) already satisfies the route's strict variant
+ * guard, so the prefix is a namespace — not a second guard — and traversal
+ * attempts are still rejected by the one shared pattern.
+ *
+ * Pure string/shape helpers only: record lookup and file serving stay in the
+ * route, and clip coverage stays in `services/rigging/clipCapabilities.js`.
+ */
+
+/** Namespace prefix separating record-backed variants from file-backed ones. */
+export const RIGGED_VARIANT_PREFIX = 'rigged-';
+
+/**
+ * The one charset the avatar variant namespace accepts. Mirrors the guard in
+ * `server/routes/avatar.js` — both the full file-variant spelling and the
+ * record id inside a `rigged-` spelling must match it, so a malicious
+ * `?variant` can never carry a slash, a dot, or an extension into a path join.
+ */
+export const AVATAR_VARIANT_PATTERN = /^[a-z0-9-]+$/;
+
+/**
+ * Extract the record id from a rigged variant spelling, or `null` when the
+ * value is not a rigged spelling or its id fails the shared charset guard.
+ * @param {unknown} variant
+ * @returns {string|null}
+ */
+export function parseRiggedVariant(variant) {
+ if (typeof variant !== 'string' || !variant.startsWith(RIGGED_VARIANT_PREFIX)) return null;
+ const id = variant.slice(RIGGED_VARIANT_PREFIX.length);
+ return id && AVATAR_VARIANT_PATTERN.test(id) ? id : null;
+}
+
+/**
+ * The `?variant=` spelling that selects a record, or `null` when the id is
+ * not variant-safe (never emit an unresolvable spelling into a selector).
+ * @param {unknown} modelId
+ * @returns {string|null}
+ */
+export function riggedVariantForId(modelId) {
+ if (typeof modelId !== 'string' || !AVATAR_VARIANT_PATTERN.test(modelId)) return null;
+ return `${RIGGED_VARIANT_PREFIX}${modelId}`;
+}
+
+/**
+ * Whether an image-to-3D record has a published, verified animated GLB worth
+ * offering as an avatar. Mirrors the retarget lane's own readiness read
+ * (`retargetImageTo3dModel` persists `status: 'ready'` plus the run id only
+ * after the published pair verifies) — absent/`null` (pre-rigging records)
+ * and non-ready states are all "not selectable", never errors.
+ * @param {object|null} record
+ * @returns {boolean}
+ */
+export function isAnimatedRecordReady(record) {
+ const retarget = record?.retarget;
+ return retarget?.status === 'ready'
+ && typeof retarget?.retargetId === 'string'
+ && retarget.retargetId.length > 0;
+}
diff --git a/server/lib/avatarVariants.test.js b/server/lib/avatarVariants.test.js
new file mode 100644
index 0000000000..85e56b03d3
--- /dev/null
+++ b/server/lib/avatarVariants.test.js
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import {
+ AVATAR_VARIANT_PATTERN,
+ RIGGED_VARIANT_PREFIX,
+ isAnimatedRecordReady,
+ parseRiggedVariant,
+ riggedVariantForId,
+} from './avatarVariants.js';
+
+describe('avatarVariants', () => {
+ it('parses rigged spellings and rejects traversal ids', () => {
+ expect(parseRiggedVariant('rigged-image3d-abc-123')).toBe('image3d-abc-123');
+ expect(parseRiggedVariant('mini-male-c')).toBe(null);
+ expect(parseRiggedVariant('rigged-../secret')).toBe(null);
+ expect(parseRiggedVariant('rigged-')).toBe(null);
+ expect(parseRiggedVariant(null)).toBe(null);
+ });
+
+ it('emits only variant-safe spellings', () => {
+ expect(riggedVariantForId('image3d-abc-123')).toBe('rigged-image3d-abc-123');
+ expect(riggedVariantForId('../secret')).toBe(null);
+ expect(riggedVariantForId(null)).toBe(null);
+ });
+
+ it('keeps the rigged spelling inside the file-variant guard', () => {
+ // The route checks the rigged prefix FIRST, so a rigged spelling must also
+ // satisfy the file charset — otherwise one spelling could pass its own
+ // parse and fail (or bypass) the shared traversal guard.
+ expect(AVATAR_VARIANT_PATTERN.test(`${RIGGED_VARIANT_PREFIX}image3d-abc-123`)).toBe(true);
+ });
+
+ it('treats only verified retargets as selectable', () => {
+ expect(isAnimatedRecordReady({ retarget: { status: 'ready', retargetId: 'retarget-1' } })).toBe(true);
+ // Absent key (pre-rigging record), null, in-flight, and failed are all "not yet".
+ expect(isAnimatedRecordReady({ rig: null })).toBe(false);
+ expect(isAnimatedRecordReady({ retarget: null })).toBe(false);
+ expect(isAnimatedRecordReady({ retarget: { status: 'retargeting' } })).toBe(false);
+ expect(isAnimatedRecordReady({ retarget: { status: 'failed' } })).toBe(false);
+ expect(isAnimatedRecordReady({ retarget: { status: 'ready' } })).toBe(false);
+ expect(isAnimatedRecordReady(null)).toBe(false);
+ });
+});
diff --git a/server/lib/index.js b/server/lib/index.js
index caf7813bc8..b9c6e7320b 100644
--- a/server/lib/index.js
+++ b/server/lib/index.js
@@ -19,6 +19,7 @@
export * from './appDeployFlags.js';
export * from './apiContractSchemas.js';
export * from './asyncApiSpec.js';
+export * from './avatarVariants.js';
export * as agentValidation from './agentValidation.js';
export * as agentContextValidation from './agentContextValidation.js';
export * as appleHealthValidation from './appleHealthValidation.js';
diff --git a/server/routes/avatar.js b/server/routes/avatar.js
index f5402260a4..ba3dc39751 100644
--- a/server/routes/avatar.js
+++ b/server/routes/avatar.js
@@ -3,7 +3,17 @@ import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import { join } from 'path';
import { PATHS, pathExists } from '../lib/fileUtils.js';
+import {
+ AVATAR_VARIANT_PATTERN,
+ RIGGED_VARIANT_PREFIX,
+ isAnimatedRecordReady,
+ parseRiggedVariant,
+ riggedVariantForId,
+} from '../lib/avatarVariants.js';
import { ServerError, getErrorCode } from '../lib/errorHandler.js';
+import { buildClipCoverage } from '../services/rigging/clipCapabilities.js';
+import { retargetRunPaths } from '../services/rigging/retarget.js';
+import { getModel, listModels } from '../services/imageTo3d/db.js';
const router = Router();
const AVATAR_DIR = join(PATHS.data, 'avatar');
@@ -15,12 +25,41 @@ const AVATAR_PATH = join(AVATAR_DIR, 'model.glb');
// can never escape the avatar directory.
function resolveVariant(variant) {
if (!variant || typeof variant !== 'string') return AVATAR_PATH;
- if (!/^[a-z0-9-]+$/.test(variant)) return null;
+ if (!AVATAR_VARIANT_PATTERN.test(variant)) return null;
return join(AVATAR_DIR, `${variant}.glb`);
}
+// Resolve a `rigged-` spelling to the record's published animated GLB
+// (#5894). The id passed the shared charset guard in `parseRiggedVariant`, so
+// the joins below cannot escape the record dir; the retarget id comes from the
+// stored record (not the request) and is re-checked for the same reason. Only
+// a record whose retarget verified at publish time resolves — anything else
+// (unknown id, rig-only, failed, in-flight) is a 404, never a half-animated file.
+async function resolveRiggedVariant(variant) {
+ const modelId = parseRiggedVariant(variant);
+ if (!modelId) return null;
+ const record = await getModel(modelId);
+ if (!isAnimatedRecordReady(record)) return null;
+ const { retargetId } = record.retarget;
+ if (!AVATAR_VARIANT_PATTERN.test(retargetId)) return null;
+ return retargetRunPaths({ recordDir: join(PATHS.imageTo3d, modelId), retargetId }).publishedGlb;
+}
+
+// Resolve any `?variant=` to an absolute GLB path, or null when unresolvable.
+// Rigged spellings are checked FIRST: `rigged-` also satisfies the file
+// charset, so falling through to the file branch would 404 a selectable
+// record against a filename that was never meant to exist.
+async function resolveAvatarPath(variant) {
+ if (typeof variant === 'string' && variant.startsWith(RIGGED_VARIANT_PREFIX)) {
+ return resolveRiggedVariant(variant);
+ }
+ return resolveVariant(variant);
+}
+
+const isRiggedSpelling = (variant) => typeof variant === 'string' && variant.startsWith(RIGGED_VARIANT_PREFIX);
+
router.head('/model.glb', async (req, res) => {
- const path = resolveVariant(req.query.variant);
+ const path = await resolveAvatarPath(req.query.variant);
if (!path) return res.status(404).end();
// Single async stat off the event loop, doubling as the existence check —
// a missing/removed file (TOCTOU) just resolves null → 404.
@@ -33,8 +72,11 @@ router.head('/model.glb', async (req, res) => {
});
router.get('/model.glb', async (req, res) => {
- const path = resolveVariant(req.query.variant);
+ const path = await resolveAvatarPath(req.query.variant);
if (!path || !(await pathExists(path))) {
+ if (isRiggedSpelling(req.query.variant)) {
+ throw new ServerError('That animated character is not available. Rig and animate the record first.', { status: 404 });
+ }
throw new ServerError('No avatar model configured. Drop a GLB at data/avatar/model.glb', { status: 404 });
}
res.set('Content-Type', 'model/gltf-binary');
@@ -62,4 +104,31 @@ router.get('/model.glb', async (req, res) => {
stream.pipe(res);
});
+// One selector entry for a ready animated record: the `?variant=` spelling
+// that selects it, the avatar-route URL that serves it (variant-guarded and
+// HEAD-probeable, unlike the raw `/data` mount), the retargeted clip name, and
+// the server-computed CoS-state coverage so selectors can show what the
+// character covers without re-deriving the vocabulary client-side.
+const riggedAvatarEntry = (record) => {
+ const variant = riggedVariantForId(record.id);
+ const clip = typeof record?.retarget?.clip === 'string' && record.retarget.clip ? record.retarget.clip : null;
+ return {
+ id: record.id,
+ name: record.name || record.id,
+ variant,
+ assetUrl: variant ? `/api/avatar/model.glb?variant=${encodeURIComponent(variant)}` : null,
+ clip,
+ coverage: buildClipCoverage(clip ? [clip] : []),
+ };
+};
+
+// The animated records the avatar selectors may offer. Read-only: a DB list
+// filtered to verified retargets, each carrying its coverage — an empty list
+// (no records, none animated yet) is the normal fresh-install answer, not an error.
+router.get('/rigged', async (_req, res) => {
+ const records = await listModels();
+ const ready = records.filter(isAnimatedRecordReady).map(riggedAvatarEntry).filter((entry) => entry.variant);
+ res.json({ records: ready });
+});
+
export default router;
diff --git a/server/routes/avatar.test.js b/server/routes/avatar.test.js
index 09ab777579..9ffdd2299e 100644
--- a/server/routes/avatar.test.js
+++ b/server/routes/avatar.test.js
@@ -13,14 +13,21 @@ vi.mock('fs/promises', () => ({
stat: vi.fn()
}));
-vi.mock('../lib/fileUtils.js', () => ({
- PATHS: { data: '/mock/data' },
+vi.mock('../lib/fileUtils.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ PATHS: { data: '/mock/data', imageTo3d: '/mock/image-to-3d' },
pathExists: vi.fn()
}));
+vi.mock('../services/imageTo3d/db.js', () => ({
+ getModel: vi.fn(),
+ listModels: vi.fn(),
+}));
+
import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import { pathExists } from '../lib/fileUtils.js';
+import { getModel, listModels } from '../services/imageTo3d/db.js';
import avatarRoutes from './avatar.js';
const buildApp = () => {
@@ -143,4 +150,83 @@ describe('avatar routes', () => {
expect(bad.status).toBe(404);
});
});
+
+ describe('rigged record variants', () => {
+ const readyRecord = {
+ id: 'image3d-record-1',
+ name: 'Example Character',
+ retarget: { status: 'ready', retargetId: 'retarget-run-1', clip: 'Idle' },
+ };
+
+ it('serves a ready record animated GLB through the rigged spelling', async () => {
+ getModel.mockResolvedValue(readyRecord);
+ pathExists.mockResolvedValue(true);
+ createReadStream.mockReturnValue(Readable.from([Buffer.from('ANIMATED-GLB')]));
+ const res = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-image3d-record-1');
+ expect(res.status).toBe(200);
+ expect(res.text).toBe('ANIMATED-GLB');
+ // The resolved path stays inside the record's retarget publish dir.
+ expect(createReadStream).toHaveBeenCalledWith(joinPath(
+ '/mock/image-to-3d', 'image3d-record-1', 'retarget', 'retarget-run-1', 'character.animated.glb',
+ ));
+ });
+
+ it('HEAD honors a ready rigged spelling', async () => {
+ getModel.mockResolvedValue(readyRecord);
+ stat.mockResolvedValue({ size: 7 });
+ const res = await request(buildApp()).head('/api/avatar/model.glb?variant=rigged-image3d-record-1');
+ expect(res.status).toBe(200);
+ expect(res.headers['content-type']).toBe('model/gltf-binary');
+ });
+
+ it('404s a rigged spelling for unknown, unready, and traversal ids alike', async () => {
+ pathExists.mockResolvedValue(false);
+ // Unknown record.
+ getModel.mockResolvedValue(null);
+ const missing = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-image3d-nope');
+ expect(missing.status).toBe(404);
+ expect(missing.body.error).toMatch(/animated character/i);
+ // Rig-only record (no verified retarget yet) — never a half-animated file.
+ getModel.mockResolvedValue({ id: 'image3d-record-2', retarget: { status: 'retargeting' } });
+ const unready = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-image3d-record-2');
+ expect(unready.status).toBe(404);
+ // Traversal inside the rigged namespace never reaches the record lookup.
+ getModel.mockClear();
+ const traversal = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-../secret');
+ expect(traversal.status).toBe(404);
+ expect(getModel).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /rigged', () => {
+ it('lists only ready records with their clip coverage attached', async () => {
+ listModels.mockResolvedValue([
+ { id: 'image3d-animated-1', name: 'Example Dancer', retarget: { status: 'ready', retargetId: 'retarget-1', clip: 'Dance' } },
+ { id: 'image3d-rig-only-1', name: 'Not Yet', rig: { status: 'ready' }, retarget: null },
+ { id: 'image3d-failed-1', name: 'Failed', retarget: { status: 'failed' } },
+ ]);
+ const res = await request(buildApp()).get('/api/avatar/rigged');
+ expect(res.status).toBe(200);
+ expect(res.body.records).toHaveLength(1);
+ expect(res.body.records[0]).toMatchObject({
+ id: 'image3d-animated-1',
+ name: 'Example Dancer',
+ variant: 'rigged-image3d-animated-1',
+ assetUrl: '/api/avatar/model.glb?variant=rigged-image3d-animated-1',
+ clip: 'Dance',
+ });
+ // The single retargeted clip drives the coverage answer: Dance covers
+ // ideating, everything else honestly reports missing.
+ expect(res.body.records[0].coverage.coveredStates).toContain('ideating');
+ expect(res.body.records[0].coverage.missingStates.length).toBeGreaterThan(0);
+ expect(res.body.records[0].coverage.complete).toBe(false);
+ });
+
+ it('answers an empty list when no record is animated yet', async () => {
+ listModels.mockResolvedValue([]);
+ const res = await request(buildApp()).get('/api/avatar/rigged');
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ records: [] });
+ });
+ });
});
diff --git a/server/routes/cosStatusRoutes.js b/server/routes/cosStatusRoutes.js
index e7bdd670b1..dd5243035b 100644
--- a/server/routes/cosStatusRoutes.js
+++ b/server/routes/cosStatusRoutes.js
@@ -11,6 +11,7 @@ import { asyncHandler } from '../lib/errorHandler.js';
import { validateRequest } from '../lib/validation.js';
import { z } from 'zod';
import { DOMAIN_IDS, DOMAIN_MODES } from '../lib/domainAutonomy.js';
+import { AVATAR_VARIANT_PATTERN, RIGGED_VARIANT_PREFIX } from '../lib/avatarVariants.js';
import { BUDGET_LIMIT_FIELDS } from '../lib/domainBudgets.js';
import { persistentMindCapabilitiesSchema } from '../lib/persistentMindCapabilities.js';
import { persistentMindProfileSchema } from '../lib/persistentMindProfile.js';
@@ -40,7 +41,16 @@ export const cosConfigSchema = z.object({
selfImprovementEnabled: z.boolean().optional(),
appImprovementEnabled: z.boolean().optional(),
improvementEnabled: z.boolean().optional(),
- avatarStyle: z.enum(['svg', 'ascii', 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'miniMaleC', 'miniFemaleD']).optional(),
+ // Built-in styles plus selectable rigged records (`rigged-`, #5894).
+ // The record half reuses the avatar variant charset (no slashes, no dots —
+ // the same traversal guard `server/routes/avatar.js` enforces), so unknown
+ // spellings still 400 here instead of persisting a style nothing can render.
+ avatarStyle: z.union([
+ z.enum(['svg', 'ascii', 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'miniMaleC', 'miniFemaleD']),
+ z.string().startsWith(RIGGED_VARIANT_PREFIX).refine(
+ (value) => AVATAR_VARIANT_PATTERN.test(value.slice(RIGGED_VARIANT_PREFIX.length)),
+ ),
+ ]).optional(),
dynamicAvatar: z.boolean().optional(),
alwaysOn: z.boolean().optional(),
appReviewCooldownMs: z.number().int().min(0).optional(),
diff --git a/server/routes/cosStatusRoutesAvatar.test.js b/server/routes/cosStatusRoutesAvatar.test.js
new file mode 100644
index 0000000000..a658888987
--- /dev/null
+++ b/server/routes/cosStatusRoutesAvatar.test.js
@@ -0,0 +1,25 @@
+import { describe, it, expect, vi } from 'vitest';
+
+// The schema is the save-path gate for avatar styles: pin that a rigged
+// record spelling persists while traversal-shaped values still 400. The
+// service graph behind the route is stubbed — only the exported schema is
+// under test here.
+vi.mock('../services/cos.js', () => ({}));
+vi.mock('../services/domainUsage.js', () => ({ getAllDomainUsageToday: vi.fn() }));
+vi.mock('../services/taskWatcher.js', () => ({}));
+vi.mock('../services/memoryEmbeddings.js', () => ({ reinitialize: vi.fn() }));
+
+import { cosConfigSchema } from './cosStatusRoutes.js';
+
+describe('cosConfigSchema avatarStyle', () => {
+ it('accepts built-in styles and rigged record spellings', () => {
+ expect(cosConfigSchema.safeParse({ avatarStyle: 'muse' }).success).toBe(true);
+ expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-image3d-abc-123' }).success).toBe(true);
+ });
+
+ it('rejects unknown and traversal-shaped styles', () => {
+ expect(cosConfigSchema.safeParse({ avatarStyle: 'not-a-style' }).success).toBe(false);
+ expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-../secret' }).success).toBe(false);
+ expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-' }).success).toBe(false);
+ });
+});
]