fix: clarify stuck approval error message with /reset and consequences (#590)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-12 23:33:23 -07:00
committed by GitHub
parent 25fbd7b9fa
commit 68056ed21b
13 changed files with 348 additions and 47 deletions

View File

@@ -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 <name>` 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.

View File

@@ -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;

View File

@@ -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);
}
/**

View File

@@ -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', () => {

View File

@@ -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<string, unknown>).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<string, unknown>).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),
};

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 });

View File

@@ -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<ApprovalRecoveryResult>;
};
/**
* 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<ApprovalRecoveryResult> {
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),
};
}
}

View File

@@ -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)

View File

@@ -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');

View File

@@ -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');

View File

@@ -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';