fix: validate timezone to prevent crash on POSIX-style TZ values

POSIX-style TZ strings like IST-2 cause a hard RangeError crash in
formatMessages because Intl.DateTimeFormat only accepts IANA identifiers.

- Add isValidTimezone/resolveTimezone helpers to src/timezone.ts
- Make formatLocalTime fall back to UTC on invalid timezone
- Validate TZ candidates in config.ts before accepting
- Add timezone setup step to detect and prompt when autodetection fails
- Use node:22-slim in Dockerfile (node:24-slim Trixie package renames)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-03-25 01:03:43 +02:00
parent 616c1ae10a
commit 11847a1af0
9 changed files with 326 additions and 191 deletions

View File

@@ -98,6 +98,13 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure - If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record APPLE_CONTAINER and DOCKER values for step 3 - Record APPLE_CONTAINER and DOCKER values for step 3
## 2a. Timezone
Run `npx tsx setup/index.ts --step timezone` and parse the status block.
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz <their-answer>`.
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
## 3. Container Runtime ## 3. Container Runtime
### 3a. Choose runtime ### 3a. Choose runtime

View File

@@ -1,7 +1,7 @@
# NanoClaw Agent Container # NanoClaw Agent Container
# Runs Claude Agent SDK in isolated Linux VM with browser automation # Runs Claude Agent SDK in isolated Linux VM with browser automation
FROM node:24-slim FROM node:22-slim
# Install system dependencies for Chromium # Install system dependencies for Chromium
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \

347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,12 +23,12 @@
"dependencies": { "dependencies": {
"@onecli-sh/sdk": "^0.2.0", "@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
"grammy": "^1.39.3",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
"zod": "^4.3.6" "zod": "^4.3.6",
"grammy": "^1.39.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",

View File

@@ -9,6 +9,7 @@ const STEPS: Record<
string, string,
() => Promise<{ run: (args: string[]) => Promise<void> }> () => Promise<{ run: (args: string[]) => Promise<void> }>
> = { > = {
timezone: () => import('./timezone.js'),
environment: () => import('./environment.js'), environment: () => import('./environment.js'),
container: () => import('./container.js'), container: () => import('./container.js'),
groups: () => import('./groups.js'), groups: () => import('./groups.js'),

67
setup/timezone.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Step: timezone — Detect, validate, and persist the user's timezone.
* Writes TZ to .env if a valid IANA timezone is resolved.
* Emits NEEDS_USER_INPUT=true when autodetection fails.
*/
import fs from 'fs';
import path from 'path';
import { isValidTimezone } from '../src/timezone.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const envFile = path.join(projectRoot, '.env');
// Check what's already in .env
let envFileTz: string | undefined;
if (fs.existsSync(envFile)) {
const content = fs.readFileSync(envFile, 'utf-8');
const match = content.match(/^TZ=(.+)$/m);
if (match) envFileTz = match[1].trim().replace(/^["']|["']$/g, '');
}
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const envTz = process.env.TZ;
// Accept --tz flag from CLI (used when setup skill collects from user)
const tzFlagIdx = args.indexOf('--tz');
const userTz = tzFlagIdx !== -1 ? args[tzFlagIdx + 1] : undefined;
// Resolve: user-provided > .env > process.env > system autodetect
let resolvedTz: string | undefined;
for (const candidate of [userTz, envFileTz, envTz, systemTz]) {
if (candidate && isValidTimezone(candidate)) {
resolvedTz = candidate;
break;
}
}
const needsUserInput = !resolvedTz;
if (resolvedTz && resolvedTz !== envFileTz) {
// Write/update TZ in .env
if (fs.existsSync(envFile)) {
let content = fs.readFileSync(envFile, 'utf-8');
if (/^TZ=/m.test(content)) {
content = content.replace(/^TZ=.*$/m, `TZ=${resolvedTz}`);
} else {
content = content.trimEnd() + `\nTZ=${resolvedTz}\n`;
}
fs.writeFileSync(envFile, content);
} else {
fs.writeFileSync(envFile, `TZ=${resolvedTz}\n`);
}
logger.info({ timezone: resolvedTz }, 'Set TZ in .env');
}
emitStatus('TIMEZONE', {
SYSTEM_TZ: systemTz || 'unknown',
ENV_TZ: envTz || 'unset',
ENV_FILE_TZ: envFileTz || 'unset',
RESOLVED_TZ: resolvedTz || 'none',
NEEDS_USER_INPUT: needsUserInput,
STATUS: needsUserInput ? 'needs_input' : 'success',
});
}

View File

@@ -2,12 +2,14 @@ import os from 'os';
import path from 'path'; import path from 'path';
import { readEnvFile } from './env.js'; import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env). // Read config values from .env (falls back to process.env).
const envConfig = readEnvFile([ const envConfig = readEnvFile([
'ASSISTANT_NAME', 'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER', 'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL', 'ONECLI_URL',
'TZ',
]); ]);
export const ASSISTANT_NAME = export const ASSISTANT_NAME =
@@ -67,7 +69,17 @@ export const TRIGGER_PATTERN = new RegExp(
'i', 'i',
); );
// Timezone for scheduled tasks (cron expressions, etc.) // Timezone for scheduled tasks, message formatting, etc.
// Uses system timezone by default // Validates each candidate is a real IANA identifier before accepting.
export const TIMEZONE = function resolveConfigTimezone(): string {
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; const candidates = [
process.env.TZ,
envConfig.TZ,
Intl.DateTimeFormat().resolvedOptions().timeZone,
];
for (const tz of candidates) {
if (tz && isValidTimezone(tz)) return tz;
}
return 'UTC';
}
export const TIMEZONE = resolveConfigTimezone();

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatLocalTime } from './timezone.js'; import {
formatLocalTime,
isValidTimezone,
resolveTimezone,
} from './timezone.js';
// --- formatLocalTime --- // --- formatLocalTime ---
@@ -26,4 +30,44 @@ describe('formatLocalTime', () => {
expect(ny).toContain('8:00'); expect(ny).toContain('8:00');
expect(tokyo).toContain('9:00'); expect(tokyo).toContain('9:00');
}); });
it('does not throw on invalid timezone, falls back to UTC', () => {
expect(() =>
formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'),
).not.toThrow();
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
// Should format as UTC (noon UTC = 12:00 PM)
expect(result).toContain('12:00');
expect(result).toContain('PM');
});
});
describe('isValidTimezone', () => {
it('accepts valid IANA identifiers', () => {
expect(isValidTimezone('America/New_York')).toBe(true);
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
});
it('rejects invalid timezone strings', () => {
expect(isValidTimezone('IST-2')).toBe(false);
expect(isValidTimezone('XYZ+3')).toBe(false);
});
it('rejects empty and garbage strings', () => {
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone('NotATimezone')).toBe(false);
});
});
describe('resolveTimezone', () => {
it('returns the timezone if valid', () => {
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
});
it('falls back to UTC for invalid timezone', () => {
expect(resolveTimezone('IST-2')).toBe('UTC');
expect(resolveTimezone('')).toBe('UTC');
});
}); });

View File

@@ -1,11 +1,32 @@
/**
* Check whether a timezone string is a valid IANA identifier
* that Intl.DateTimeFormat can use.
*/
export function isValidTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Return the given timezone if valid IANA, otherwise fall back to UTC.
*/
export function resolveTimezone(tz: string): string {
return isValidTimezone(tz) ? tz : 'UTC';
}
/** /**
* Convert a UTC ISO timestamp to a localized display string. * Convert a UTC ISO timestamp to a localized display string.
* Uses the Intl API (no external dependencies). * Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/ */
export function formatLocalTime(utcIso: string, timezone: string): string { export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso); const date = new Date(utcIso);
return date.toLocaleString('en-US', { return date.toLocaleString('en-US', {
timeZone: timezone, timeZone: resolveTimezone(timezone),
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',