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
- 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
### 3a. Choose runtime

View File

@@ -1,7 +1,7 @@
# NanoClaw Agent Container
# Runs Claude Agent SDK in isolated Linux VM with browser automation
FROM node:24-slim
FROM node:22-slim
# Install system dependencies for Chromium
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": {
"@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1",
"grammy": "^1.39.3",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
"zod": "^4.3.6",
"grammy": "^1.39.3"
},
"devDependencies": {
"@eslint/js": "^9.35.0",

View File

@@ -9,6 +9,7 @@ const STEPS: Record<
string,
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
timezone: () => import('./timezone.js'),
environment: () => import('./environment.js'),
container: () => import('./container.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 { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'TZ',
]);
export const ASSISTANT_NAME =
@@ -67,7 +69,17 @@ export const TRIGGER_PATTERN = new RegExp(
'i',
);
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
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 { formatLocalTime } from './timezone.js';
import {
formatLocalTime,
isValidTimezone,
resolveTimezone,
} from './timezone.js';
// --- formatLocalTime ---
@@ -26,4 +30,44 @@ describe('formatLocalTime', () => {
expect(ny).toContain('8: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.
* Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/
export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso);
return date.toLocaleString('en-US', {
timeZone: timezone,
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: 'short',
day: 'numeric',