Fix: ElizaOS plugin-form Control-Type Registry Desynchronization Bug
In autonomous agent architectures powered by ElizaOS (formerly ai16z), the Form Plugin (@elizaos/plugin-form) is tasked with collecting structured user data—such as decentralized identity (DID) credentials, whitelist registration addresses, KYC verification details, and scheduling timestamps.
Developers register custom control types to validate specific Web3 inputs (for example, verifying valid checksummed Ethereum addresses or ENS domains).
However, in production deployments, developers discover that custom validation rules, formatting functions, and extraction prompts are silently ignored:
[ElizaOS:FormService] Registered custom control type: "ethereum_address"
[ElizaOS:FormRunner] Processing field: "recipientAddress" (type: "ethereum_address")
[ElizaOS:Validation] Warning: No typeHandler found for "ethereum_address". Falling back to raw string passthrough!
[ElizaOS:Memory] Stored unvalidated recipientAddress: "not_a_valid_hex_string"
Furthermore, whenever users submit birth dates or scheduling deadlines, the saved date shifts by a full calendar day in agent memory.
1. Architectural Flaw: The Dual Registry Split
The defect lies in a architectural disconnect between packages/plugin-form/src/service.ts and packages/plugin-form/src/validation.ts.
Registry A: The Public API (FormService)
The documented public method in service.ts registers types into the service instance:
// packages/plugin-form/src/service.ts
export class FormService extends Service {
private controlTypes: Map<string, ControlType> = new Map();
public registerControlType(type: ControlType): void {
// Populates service.controlTypes
this.controlTypes.set(type.name, type);
}
}
Registry B: The Validation Engine (validation.ts)
However, during runtime execution, the form extraction and parsing pipeline in validation.ts queries an entirely different module-scoped map:
// packages/plugin-form/src/validation.ts
const typeHandlers = new Map<string, TypeHandler>();
export function registerTypeHandler(name: string, handler: TypeHandler) {
typeHandlers.set(name, handler);
}
export function validateField(field: FormField, value: unknown) {
// Reads ONLY from typeHandlers!
const handler = typeHandlers.get(field.controlType);
if (!handler) {
// Silently skips validation!
return { valid: true, value };
}
return handler.validate(value);
}
Because registerTypeHandler is never invoked by FormService.registerControlType(), any custom control types registered by developers or built-in plugins exist solely in service.controlTypes and are never executed by validateField().
(For other serialization and type coercion bugs in ElizaOS, see our guide on ElizaOS JS Runtime Bridge False Cycle Serialization Fix).
2. The UTC Midnight Date Shift Defect
When parsing date fields, the built-in date type handler in plugin-form executes:
// VULNERABLE DATE PARSING
const parsedDate = new Date(userInput); // e.g. "2026-09-12"
According to the ECMAScript specification:
- An ISO date-only string (
YYYY-MM-DD) is parsed as UTC midnight:2026-09-12T00:00:00.000Z. - When an agent hosted on a server in Tokyo (
UTC+9) or Singapore (UTC+8) formats the date or queriesparsedDate.toLocaleDateString(), the timestamp evaluates to2026-09-12 09:00:00, which is fine. - But if the server runs in New York (
UTC-4), UTC midnight corresponds to2026-09-11 20:00:00(the previous day!). - Conversely, when converting back to UTC from local strings, dates in eastern hemispheres shift forward by a day.
3. Production Remediation Strategies
Strategy 1: Unify the Registries in FormService
If compiling from source or maintaining an internal fork of @elizaos/plugin-form, bridge registerControlType to invoke registerTypeHandler:
// packages/plugin-form/src/service.ts
import { registerTypeHandler } from './validation';
export class FormService extends Service {
public registerControlType(type: ControlType): void {
this.controlTypes.set(type.name, type);
// CRITICAL FIX: Synchronize with the validation registry
registerTypeHandler(type.name, {
validate: type.validate,
parse: type.parse,
format: type.format,
extractionPrompt: type.extractionPrompt,
});
}
}
Strategy 2: Timezone-Agnostic UTC Date Normalization
Replace the naive new Date() constructor with a deterministic date parser that preserves calendar dates regardless of server timezone:
// utils/safeDateParser.ts
export function parseCalendarDate(input: string): string {
// Normalize string to match YYYY-MM-DD
const match = input.trim().match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
if (!match) {
throw new Error(`Invalid date format: ${input}. Expected YYYY-MM-DD.`);
}
const year = parseInt(match[1], 10);
const month = parseInt(match[2], 10) - 1; // 0-indexed
const day = parseInt(match[3], 10);
// Construct date using explicit UTC constructor
const utcDate = new Date(Date.UTC(year, month, day, 12, 0, 0)); // Noon UTC avoids midnight roll-over
return utcDate.toISOString().split('T')[0]; // Guarantees YYYY-MM-DD
}
Strategy 3: Direct Registration Workaround (Without Forking)
If your application consumes @elizaos/plugin-form via npm and cannot modify internal files directly, import registerTypeHandler directly from the package’s subpath:
// bootstrap.ts
import { FormService } from '@elizaos/plugin-form';
import { registerTypeHandler } from '@elizaos/plugin-form/dist/validation';
import { isAddress } from 'viem';
// Register to both endpoints manually
const ethAddressControlType = {
name: 'ethereum_address',
validate: (val: string) => isAddress(val),
parse: (val: string) => val.toLowerCase(),
format: (val: string) => val,
};
formService.registerControlType(ethAddressControlType);
registerTypeHandler('ethereum_address', ethAddressControlType);
4. Verification Unit Test
Validate that custom validators and date normalization execute correctly:
import { describe, it, expect } from 'vitest';
import { validateField } from './validation';
import { parseCalendarDate } from './safeDateParser';
describe('Form Validation & Date Shifting Fix', () => {
it('enforces custom ethereum address validation', () => {
const field = { name: 'wallet', controlType: 'ethereum_address' };
const invalidResult = validateField(field, 'invalid_address');
expect(invalidResult.valid).toBe(false);
const validResult = validateField(field, '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045');
expect(validResult.valid).toBe(true);
});
it('prevents calendar day roll-over across all timezones', () => {
const rawDate = '2026-09-12';
const normalized = parseCalendarDate(rawDate);
expect(normalized).toBe('2026-09-12');
});
});