diff --git a/docs/skills.md b/docs/skills.md index d64e759..b15d59b 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -25,7 +25,7 @@ LettaBot scans these directories in priority order. Same-name skills at higher p On Railway with a mounted volume, LettaBot stores `.letta` skill paths under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/` by default, so agent-scoped and global skills survive redeploys. -Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped. +Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`, or `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/{id}/skills/` on Railway) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped. ## Feature-gated skills @@ -113,7 +113,7 @@ Run `lettabot skills status` to see which skills are eligible and which have mis When a session starts, `prependSkillDirsToPath()` in `src/skills/loader.ts` prepends skill directories to `PATH` immediately before `createSession`/`resumeSession` is called. The SDK spawns the Letta Code subprocess at session-creation time, so the subprocess inherits the augmented PATH at fork. Two sources are combined: -1. **Agent-scoped skills** (`~/.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup. +1. **Agent-scoped skills** (`.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup. 2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable ` or the interactive `lettabot skills` wizard. On Railway with a mounted volume, `WORKING_DIR` defaults to `$RAILWAY_VOLUME_MOUNT_PATH/data` when not explicitly set, so working-dir skills are persisted on the volume by default. diff --git a/src/config/io.test.ts b/src/config/io.test.ts index 12e1413..45ee29f 100644 --- a/src/config/io.test.ts +++ b/src/config/io.test.ts @@ -17,11 +17,89 @@ vi.mock('../logger.js', () => ({ }), })); -import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js'; +import { + saveConfig, + loadConfig, + loadConfigStrict, + configToEnv, + didLoadFail, + decodeYamlOrBase64, + hasInlineConfig, +} from './io.js'; import { normalizeAgents, DEFAULT_CONFIG } from './types.js'; import { wasLoadedFromFleetConfig, setLoadedFromFleetConfig } from './fleet-adapter.js'; import type { LettaBotConfig } from './types.js'; +describe('inline config helpers', () => { + let originalInline: string | undefined; + + beforeEach(() => { + originalInline = process.env.LETTABOT_CONFIG_YAML; + delete process.env.LETTABOT_CONFIG_YAML; + }); + + afterEach(() => { + if (originalInline === undefined) { + delete process.env.LETTABOT_CONFIG_YAML; + } else { + process.env.LETTABOT_CONFIG_YAML = originalInline; + } + }); + + it('treats empty inline env vars as unset', () => { + expect(hasInlineConfig()).toBe(false); + + process.env.LETTABOT_CONFIG_YAML = ''; + expect(hasInlineConfig()).toBe(false); + + process.env.LETTABOT_CONFIG_YAML = ' '; + expect(hasInlineConfig()).toBe(false); + }); + + it('detects non-empty inline env vars', () => { + process.env.LETTABOT_CONFIG_YAML = 'server:\n mode: api\n'; + expect(hasInlineConfig()).toBe(true); + }); + + it('decodes raw yaml inline config', () => { + const yaml = 'server:\n mode: api\n'; + expect(decodeYamlOrBase64(yaml)).toBe(yaml); + }); + + it('decodes base64 inline config', () => { + const yaml = 'server:\n mode: api\n'; + const encoded = Buffer.from(yaml, 'utf-8').toString('base64'); + expect(decodeYamlOrBase64(encoded)).toBe(yaml); + }); + + it('decodes unpadded base64 inline config', () => { + const yaml = 'server:\n mode: api\n'; + const encoded = Buffer.from(yaml, 'utf-8').toString('base64').replace(/=+$/, ''); + expect(decodeYamlOrBase64(encoded)).toBe(yaml); + }); + + it('decodes URL-safe base64 inline config', () => { + const yaml = 'server:\n mode: api\n'; + const encoded = Buffer.from(yaml, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + expect(decodeYamlOrBase64(encoded)).toBe(yaml); + }); + + it('throws for empty inline config values', () => { + expect(() => decodeYamlOrBase64('')).toThrow('LETTABOT_CONFIG_YAML is empty'); + expect(() => decodeYamlOrBase64(' ')).toThrow('LETTABOT_CONFIG_YAML is empty'); + }); + + it('throws for invalid inline config values', () => { + expect(() => decodeYamlOrBase64('###not-yaml-not-base64###')).toThrow( + 'LETTABOT_CONFIG_YAML must be raw YAML or base64-encoded YAML', + ); + }); +}); + describe('saveConfig with agents[] format', () => { let tmpDir: string; let configPath: string; diff --git a/src/config/io.ts b/src/config/io.ts index 48426a5..ce0175d 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -21,6 +21,13 @@ import { LETTA_API_URL } from '../auth/oauth.js'; import { createLogger } from '../logger.js'; const log = createLogger('Config'); + +function getInlineConfigEnvValue(): string | undefined { + const raw = process.env.LETTABOT_CONFIG_YAML; + if (raw === undefined) return undefined; + return raw.trim().length > 0 ? raw : undefined; +} + // Config file locations (checked in order) function getConfigPaths(): string[] { return [ @@ -40,26 +47,71 @@ const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml'); * When set, this takes priority over all file-based config sources. */ export function hasInlineConfig(): boolean { - return !!process.env.LETTABOT_CONFIG_YAML; + return getInlineConfigEnvValue() !== undefined; } /** * Decode a value that may be raw YAML or base64-encoded YAML. - * Detection: if the value contains a colon, it's raw YAML (every valid config - * has key: value pairs). Otherwise it's base64 (which uses only [A-Za-z0-9+/=]). + * Detection strategy: + * 1) Treat values that parse as YAML objects as raw YAML. + * 2) Otherwise, require strict base64 and decode to YAML object. */ export function decodeYamlOrBase64(value: string): string { - if (value.includes(':')) { - return value; + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('LETTABOT_CONFIG_YAML is empty'); } - return Buffer.from(value, 'base64').toString('utf-8'); + + // Prefer raw YAML when it parses successfully. + try { + const parsed = YAML.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return value; + } + } catch { + // Fall through to base64 decoding. + } + + const normalized = trimmed.replace(/\s+/g, ''); + const base64Standard = normalized.replace(/-/g, '+').replace(/_/g, '/'); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64Standard)) { + throw new Error('LETTABOT_CONFIG_YAML must be raw YAML or base64-encoded YAML'); + } + + const normalizedNoPad = base64Standard.replace(/=+$/, ''); + if (normalizedNoPad.length === 0 || normalizedNoPad.length % 4 === 1) { + throw new Error('LETTABOT_CONFIG_YAML must be raw YAML or base64-encoded YAML'); + } + + const padded = normalizedNoPad + '='.repeat((4 - (normalizedNoPad.length % 4)) % 4); + + const decoded = Buffer.from(padded, 'base64').toString('utf-8'); + const roundTrip = Buffer.from(decoded, 'utf-8').toString('base64').replace(/=+$/, ''); + if (roundTrip !== normalizedNoPad) { + throw new Error('LETTABOT_CONFIG_YAML is not valid base64'); + } + + try { + const parsed = YAML.parse(decoded); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Decoded YAML must be an object'); + } + } catch { + throw new Error('LETTABOT_CONFIG_YAML decoded from base64 but is not valid YAML'); + } + + return decoded; } /** * Decode inline config from LETTABOT_CONFIG_YAML env var. */ function decodeInlineConfig(): string { - return decodeYamlOrBase64(process.env.LETTABOT_CONFIG_YAML!); + const value = getInlineConfigEnvValue(); + if (!value) { + throw new Error('LETTABOT_CONFIG_YAML is empty'); + } + return decodeYamlOrBase64(value); } /** diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index b6e7470..b399fc8 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -116,7 +116,7 @@ describe('normalizeAgents', () => { name: 'Bot1', channels: { telegram: { enabled: true, token: 'token1' }, - slack: { enabled: true, botToken: 'missing-app-token' }, + slack: { enabled: true, botToken: 'token1', appToken: 'app1' }, }, }, { @@ -140,7 +140,7 @@ describe('normalizeAgents', () => { expect(agents).toHaveLength(2); expect(agents[0].channels.telegram?.token).toBe('token1'); - expect(agents[0].channels.slack).toBeUndefined(); + expect(agents[0].channels.slack?.botToken).toBe('token1'); expect(agents[1].channels.slack?.botToken).toBe('token2'); expect(agents[1].channels.discord).toBeUndefined(); }); @@ -173,7 +173,7 @@ describe('normalizeAgents', () => { expect(agents[0].name).toBe('LettaBot'); }); - it('should drop channels without required credentials', () => { + it('should fail fast when enabled channels are missing required credentials', () => { const config: LettaBotConfig = { server: { mode: 'cloud' }, agent: { name: 'TestBot', model: 'test' }, @@ -198,9 +198,40 @@ describe('normalizeAgents', () => { }, }; - const agents = normalizeAgents(config); + expect(() => normalizeAgents(config)).toThrow('Invalid channel configuration'); + }); - expect(agents[0].channels).toEqual({}); + it('should fail fast when telegram-mtproto is missing required credentials', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: { + 'telegram-mtproto': { + enabled: true, + apiId: 12345, + // Missing apiHash and phoneNumber + }, + }, + }; + + expect(() => normalizeAgents(config)).toThrow('channels.telegram-mtproto'); + }); + + it('should fail fast when telegram-mtproto apiId is not a positive integer', () => { + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: { + 'telegram-mtproto': { + enabled: true, + apiId: 0, + apiHash: 'hash', + phoneNumber: '+15550001111', + }, + }, + }; + + expect(() => normalizeAgents(config)).toThrow('channels.telegram-mtproto'); }); it('should preserve agent id when provided', () => { @@ -613,6 +644,26 @@ describe('normalizeAgents', () => { expect(agents[0].channels.signal?.readReceipts).toBe(false); }); + it('treats empty boolean env vars as unset for channel defaults', () => { + process.env.WHATSAPP_ENABLED = 'true'; + process.env.WHATSAPP_SELF_CHAT_MODE = ' '; + process.env.SIGNAL_PHONE_NUMBER = '+1234567890'; + process.env.SIGNAL_READ_RECEIPTS = ''; + process.env.SIGNAL_SELF_CHAT_MODE = ' '; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.whatsapp?.selfChat).toBe(true); + expect(agents[0].channels.signal?.readReceipts).toBe(true); + expect(agents[0].channels.signal?.selfChat).toBe(true); + }); + it('should pick up allowedUsers from env vars for all channels', () => { process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; process.env.TELEGRAM_DM_POLICY = 'allowlist'; @@ -658,6 +709,20 @@ describe('normalizeAgents', () => { expect(agents[0].channels.signal?.dmPolicy).toBe('allowlist'); expect(agents[0].channels.signal?.allowedUsers).toEqual(['+1555111111']); }); + + it('treats empty allowed-users env vars as unset', () => { + process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; + process.env.TELEGRAM_ALLOWED_USERS = ' , , '; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + expect(agents[0].channels.telegram?.allowedUsers).toBeUndefined(); + }); }); it('should preserve features, polling, and integrations', () => { diff --git a/src/config/types.ts b/src/config/types.ts index a21a41f..ad8ed10 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -568,6 +568,9 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { const normalized: AgentConfig['channels'] = {}; if (!channels) return normalized; + const hasValidMtprotoApiId = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value > 0; + // Merge env vars into YAML blocks that are missing their key credential. // Without this, `signal: enabled: true` + SIGNAL_PHONE_NUMBER env var // silently fails because the env-var-only fallback (below) only fires @@ -585,14 +588,32 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { if (channels.discord && !channels.discord.token && process.env.DISCORD_BOT_TOKEN) { channels.discord.token = process.env.DISCORD_BOT_TOKEN; } + if (channels['telegram-mtproto']) { + if (channels['telegram-mtproto'].apiId === undefined && process.env.TELEGRAM_API_ID) { + const parsedApiId = parseInt(process.env.TELEGRAM_API_ID, 10); + if (hasValidMtprotoApiId(parsedApiId)) { + channels['telegram-mtproto'].apiId = parsedApiId; + } + } + if (!channels['telegram-mtproto'].apiHash && process.env.TELEGRAM_API_HASH) { + channels['telegram-mtproto'].apiHash = process.env.TELEGRAM_API_HASH; + } + if (!channels['telegram-mtproto'].phoneNumber && process.env.TELEGRAM_PHONE_NUMBER) { + channels['telegram-mtproto'].phoneNumber = process.env.TELEGRAM_PHONE_NUMBER; + } + } if (channels.telegram?.enabled !== false && channels.telegram?.token) { const telegram = { ...channels.telegram }; normalizeLegacyGroupFields(telegram, `${sourcePath}.telegram`); normalized.telegram = telegram; } - // telegram-mtproto: check apiId as the key credential - if (channels['telegram-mtproto']?.enabled !== false && channels['telegram-mtproto']?.apiId) { + if ( + channels['telegram-mtproto']?.enabled !== false + && hasValidMtprotoApiId(channels['telegram-mtproto']?.apiId) + && !!channels['telegram-mtproto']?.apiHash + && !!channels['telegram-mtproto']?.phoneNumber + ) { normalized['telegram-mtproto'] = channels['telegram-mtproto']; } if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) { @@ -627,17 +648,23 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { } } - // Warn when a channel block exists but was dropped due to missing credentials - const channelCredentials: Array<[string, unknown, boolean]> = [ - ['telegram', channels.telegram, !!normalized.telegram], - ['slack', channels.slack, !!normalized.slack], - ['signal', channels.signal, !!normalized.signal], - ['discord', channels.discord, !!normalized.discord], + const channelCredentials: Array<{ name: string; raw: unknown; included: boolean; required: string }> = [ + { name: 'telegram', raw: channels.telegram, included: !!normalized.telegram, required: 'token' }, + { name: 'telegram-mtproto', raw: channels['telegram-mtproto'], included: !!normalized['telegram-mtproto'], required: 'apiId, apiHash, phoneNumber' }, + { name: 'slack', raw: channels.slack, included: !!normalized.slack, required: 'botToken, appToken' }, + { name: 'signal', raw: channels.signal, included: !!normalized.signal, required: 'phone' }, + { name: 'discord', raw: channels.discord, included: !!normalized.discord, required: 'token' }, ]; - for (const [name, raw, included] of channelCredentials) { - if (raw && (raw as Record).enabled !== false && !included) { - log.warn(`Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`); - } + + const invalidChannels = channelCredentials + .filter(({ raw, included }) => !!raw && (raw as Record).enabled !== false && !included) + .map(({ name, required }) => `- ${sourcePath}.${name}: missing required field(s): ${required}`); + + if (invalidChannels.length > 0) { + throw new Error( + `Invalid channel configuration:\n${invalidChannels.join('\n')}\n` + + 'Set required credentials in lettabot.yaml or environment variables, or set enabled: false.' + ); } return normalized; @@ -662,8 +689,20 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { // Env var fallback for container deploys without lettabot.yaml (e.g. Railway) // Helper: parse comma-separated env var into string array (or undefined) - const parseList = (envVar?: string): string[] | undefined => - envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined; + const parseList = (envVar?: string): string[] | undefined => { + if (envVar === undefined) return undefined; + const values = envVar.split(',').map(s => s.trim()).filter(Boolean); + return values.length > 0 ? values : undefined; + }; + + const parseOptionalBooleanEnv = (envVar?: string): boolean | undefined => { + if (envVar === undefined) return undefined; + const normalized = envVar.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized === 'true') return true; + if (normalized === 'false') return false; + return undefined; + }; if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) { channels.telegram = { @@ -699,7 +738,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') { channels.whatsapp = { enabled: true, - selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', + selfChat: parseOptionalBooleanEnv(process.env.WHATSAPP_SELF_CHAT_MODE) ?? true, dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS), }; @@ -708,8 +747,8 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { channels.signal = { enabled: true, phone: process.env.SIGNAL_PHONE_NUMBER, - readReceipts: process.env.SIGNAL_READ_RECEIPTS !== 'false', - selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', + readReceipts: parseOptionalBooleanEnv(process.env.SIGNAL_READ_RECEIPTS) ?? true, + selfChat: parseOptionalBooleanEnv(process.env.SIGNAL_SELF_CHAT_MODE) ?? true, dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS), }; diff --git a/src/core/bot.ts b/src/core/bot.ts index c23b2f9..9c94ac9 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -19,6 +19,7 @@ import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, re import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js'; import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js'; import type { GroupBatcher } from './group-batcher.js'; +import { recoverPendingApprovalsWithSdk } from './session-sdk-compat.js'; import { redactOutbound } from './redact.js'; import { hasIncompleteActionsTag, @@ -1562,7 +1563,7 @@ export class LettaBot implements AgentSession { // Try SDK-level recovery first (through CLI control protocol) if (session) { - const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000); if (sdkResult.recovered) { log.info('SDK approval recovery succeeded, retrying message...'); this.sessionManager.invalidateSession(retryConvKey); @@ -1886,7 +1887,7 @@ export class LettaBot implements AgentSession { && (lastErrorDetail?.message?.toLowerCase().includes('waiting for approval') || false)); if (isApprovalIssue && !retried) { log.info('sendToAgent: approval conflict detected -- attempting SDK recovery...'); - const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000); if (sdkResult.recovered) { log.info('sendToAgent: SDK approval recovery succeeded'); } else { diff --git a/src/core/errors.ts b/src/core/errors.ts index 5776f41..949b3ad 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -112,7 +112,7 @@ export function formatApiErrorForUser(error: { message: string; stopReason: stri || apiMsg.includes('409') || stopReason === 'requires_approval'; if (hasApprovalSignal && hasConflictSignal) { - return '(A stuck tool approval is blocking this conversation. Run `lettabot reset-conversation` to clear it, or approve/deny the pending request at app.letta.com.)'; + return '(A stuck tool approval is blocking this conversation. Send /reset to start a new conversation, or approve/deny the pending request at app.letta.com. Note: /reset creates a fresh conversation -- previous context will no longer be active.)'; } // 409 CONFLICT (concurrent request on same conversation) diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index cd39b22..07268da 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -16,6 +16,7 @@ import { loadMemoryBlocks } from './memory.js'; import { SYSTEM_PROMPT } from './system-prompt.js'; import { createManageTodoTool } from '../tools/todo.js'; import { syncTodosFromTool } from '../todo/store.js'; +import { recoverPendingApprovalsWithSdk } from './session-sdk-compat.js'; import { createLogger } from '../logger.js'; const log = createLogger('Session'); @@ -376,7 +377,7 @@ export class SessionManager { log.warn(`Pending approval detected at session startup (key=${key}, conv=${convId}), recovering...`); // Try SDK-level recovery first (goes through CLI control protocol) - const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000); if (sdkResult.recovered) { log.info('Proactive SDK approval recovery succeeded'); return this._createSessionForKey(key, true, generation); @@ -574,7 +575,7 @@ export class SessionManager { // 409 CONFLICT from orphaned approval -- use SDK recovery first, fall back to API if (!retried && isApprovalConflictError(error) && this.store.agentId) { log.info('CONFLICT detected - attempting SDK approval recovery...'); - const sdkResult = await session.recoverPendingApprovals({ timeoutMs: 10_000 }); + const sdkResult = await recoverPendingApprovalsWithSdk(session, 10_000); if (sdkResult.recovered) { log.info('SDK approval recovery succeeded, retrying...'); return this.runSession(message, { retried: true, canUseTool, convKey }); diff --git a/src/core/session-sdk-compat.ts b/src/core/session-sdk-compat.ts new file mode 100644 index 0000000..8dd838b --- /dev/null +++ b/src/core/session-sdk-compat.ts @@ -0,0 +1,39 @@ +import type { Session } from '@letta-ai/letta-code-sdk'; + +type ApprovalRecoveryResult = { + recovered: boolean; + detail?: string; +}; + +type SessionWithApprovalRecovery = Session & { + recoverPendingApprovals?: (options?: { timeoutMs?: number }) => Promise; +}; + +/** + * SDK compatibility shim for approval recovery. + * + * Some SDK versions expose `recoverPendingApprovals` at runtime before the + * TypeScript Session type includes it. This helper keeps compile-time safety + * while preserving the existing runtime fallback behavior. + */ +export async function recoverPendingApprovalsWithSdk( + session: Session, + timeoutMs = 10_000, +): Promise { + const recover = (session as SessionWithApprovalRecovery).recoverPendingApprovals; + if (typeof recover !== 'function') { + return { + recovered: false, + detail: 'Session.recoverPendingApprovals is unavailable in this SDK version', + }; + } + + try { + return await recover.call(session, { timeoutMs }); + } catch (error) { + return { + recovered: false, + detail: error instanceof Error ? error.message : String(error), + }; + } +} \ No newline at end of file diff --git a/src/onboard.ts b/src/onboard.ts index 20c2d4a..a3c5f91 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -23,6 +23,15 @@ function parseOptionalCsvList(value?: string): string[] | undefined { return items.length > 0 ? items : undefined; } +function parseOptionalBoolean(value?: string): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized === 'true') return true; + if (normalized === 'false') return false; + return undefined; +} + function readConfigFromEnv(existingConfig: any): any { return { baseUrl: process.env.LETTA_BASE_URL || existingConfig.server?.baseUrl || 'https://api.letta.com', @@ -34,7 +43,8 @@ function readConfigFromEnv(existingConfig: any): any { enabled: !!process.env.TELEGRAM_BOT_TOKEN, botToken: process.env.TELEGRAM_BOT_TOKEN || existingConfig.channels?.telegram?.token, dmPolicy: process.env.TELEGRAM_DM_POLICY || existingConfig.channels?.telegram?.dmPolicy || 'pairing', - allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.telegram?.allowedUsers, + allowedUsers: parseOptionalCsvList(process.env.TELEGRAM_ALLOWED_USERS) + ?? existingConfig.channels?.telegram?.allowedUsers, groupDebounceSec: parseOptionalInt(process.env.TELEGRAM_GROUP_DEBOUNCE_SEC) ?? existingConfig.channels?.telegram?.groupDebounceSec, groupPollIntervalMin: parseOptionalInt(process.env.TELEGRAM_GROUP_POLL_INTERVAL_MIN) @@ -50,7 +60,8 @@ function readConfigFromEnv(existingConfig: any): any { botToken: process.env.SLACK_BOT_TOKEN || existingConfig.channels?.slack?.botToken, appToken: process.env.SLACK_APP_TOKEN || existingConfig.channels?.slack?.appToken, dmPolicy: process.env.SLACK_DM_POLICY || existingConfig.channels?.slack?.dmPolicy || 'pairing', - allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.slack?.allowedUsers, + allowedUsers: parseOptionalCsvList(process.env.SLACK_ALLOWED_USERS) + ?? existingConfig.channels?.slack?.allowedUsers, groupDebounceSec: parseOptionalInt(process.env.SLACK_GROUP_DEBOUNCE_SEC) ?? existingConfig.channels?.slack?.groupDebounceSec, groupPollIntervalMin: parseOptionalInt(process.env.SLACK_GROUP_POLL_INTERVAL_MIN) @@ -65,7 +76,8 @@ function readConfigFromEnv(existingConfig: any): any { enabled: !!process.env.DISCORD_BOT_TOKEN, botToken: process.env.DISCORD_BOT_TOKEN || existingConfig.channels?.discord?.token, dmPolicy: process.env.DISCORD_DM_POLICY || existingConfig.channels?.discord?.dmPolicy || 'pairing', - allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.discord?.allowedUsers, + allowedUsers: parseOptionalCsvList(process.env.DISCORD_ALLOWED_USERS) + ?? existingConfig.channels?.discord?.allowedUsers, groupDebounceSec: parseOptionalInt(process.env.DISCORD_GROUP_DEBOUNCE_SEC) ?? existingConfig.channels?.discord?.groupDebounceSec, groupPollIntervalMin: parseOptionalInt(process.env.DISCORD_GROUP_POLL_INTERVAL_MIN) @@ -77,10 +89,13 @@ function readConfigFromEnv(existingConfig: any): any { }, whatsapp: { - enabled: process.env.WHATSAPP_ENABLED === 'true' || !!existingConfig.channels?.whatsapp?.enabled, - selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.whatsapp?.selfChat !== false), + enabled: parseOptionalBoolean(process.env.WHATSAPP_ENABLED) + ?? !!existingConfig.channels?.whatsapp?.enabled, + selfChat: parseOptionalBoolean(process.env.WHATSAPP_SELF_CHAT_MODE) + ?? (existingConfig.channels?.whatsapp?.selfChat ?? true), dmPolicy: process.env.WHATSAPP_DM_POLICY || existingConfig.channels?.whatsapp?.dmPolicy || 'pairing', - allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.whatsapp?.allowedUsers, + allowedUsers: parseOptionalCsvList(process.env.WHATSAPP_ALLOWED_USERS) + ?? existingConfig.channels?.whatsapp?.allowedUsers, groupDebounceSec: parseOptionalInt(process.env.WHATSAPP_GROUP_DEBOUNCE_SEC) ?? existingConfig.channels?.whatsapp?.groupDebounceSec, groupPollIntervalMin: parseOptionalInt(process.env.WHATSAPP_GROUP_POLL_INTERVAL_MIN) @@ -94,9 +109,11 @@ function readConfigFromEnv(existingConfig: any): any { signal: { enabled: !!process.env.SIGNAL_PHONE_NUMBER, phoneNumber: process.env.SIGNAL_PHONE_NUMBER || existingConfig.channels?.signal?.phoneNumber, - selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false' && (existingConfig.channels?.signal?.selfChat !== false), + selfChat: parseOptionalBoolean(process.env.SIGNAL_SELF_CHAT_MODE) + ?? (existingConfig.channels?.signal?.selfChat ?? true), dmPolicy: process.env.SIGNAL_DM_POLICY || existingConfig.channels?.signal?.dmPolicy || 'pairing', - allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').map(s => s.trim()) || existingConfig.channels?.signal?.allowedUsers, + allowedUsers: parseOptionalCsvList(process.env.SIGNAL_ALLOWED_USERS) + ?? existingConfig.channels?.signal?.allowedUsers, groupDebounceSec: parseOptionalInt(process.env.SIGNAL_GROUP_DEBOUNCE_SEC) ?? existingConfig.channels?.signal?.groupDebounceSec, groupPollIntervalMin: parseOptionalInt(process.env.SIGNAL_GROUP_POLL_INTERVAL_MIN) diff --git a/src/skills/loader.test.ts b/src/skills/loader.test.ts index 8a3f3dc..45e8f39 100644 --- a/src/skills/loader.test.ts +++ b/src/skills/loader.test.ts @@ -63,8 +63,9 @@ describe('skills loader', () => { }); describe('getAgentSkillsDir', () => { - it('uses Railway volume path for agent-scoped skills when mounted', async () => { + it('uses Railway volume for agent-scoped skills when mounted', async () => { process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume'; + delete process.env.WORKING_DIR; const mod = await importFreshLoader(); const dir = mod.getAgentSkillsDir('agent-railway'); diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts index b8a49be..92a254f 100644 --- a/src/utils/paths.test.ts +++ b/src/utils/paths.test.ts @@ -63,6 +63,14 @@ describe('cron path resolution', () => { expect(getCronDataDir()).toBe('/custom/work'); }); + it('normalizes WORKING_DIR for cron data paths', () => { + process.env.WORKING_DIR = '~/cron-work'; + expect(getCronDataDir()).toBe(resolve(homedir(), 'cron-work')); + + process.env.WORKING_DIR = 'relative/cron-work'; + expect(getCronDataDir()).toBe(resolve('relative/cron-work')); + }); + it('falls back to /tmp/lettabot when no overrides are set', () => { expect(getCronDataDir()).toBe('/tmp/lettabot'); expect(getCronStorePath()).toBe('/tmp/lettabot/cron-jobs.json'); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index b169e45..5783e7d 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -91,7 +91,7 @@ export function getCronDataDir(): string { } if (process.env.WORKING_DIR) { - return process.env.WORKING_DIR; + return resolveWorkingDirPath(process.env.WORKING_DIR); } return '/tmp/lettabot';