From 6225aaab25f574c2b98fd0cb5354572bb1082526 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 26 Aug 2026 10:15:59 +0300 Subject: [PATCH 1/2] [verified] feat: add isolated multi-account profiles --- README.md | 38 ++- cli.js | 139 +++++++--- core/accounts.js | 412 +++++++++++++++++++++++++++++ core/config.js | 3 +- core/service-identity.js | 22 ++ core/services.js | 25 +- docs/cli.md | 13 + telegram-client.js | 79 +++++- tests/accounts-concurrency.test.js | 51 ++++ tests/accounts.test.js | 246 +++++++++++++++++ tests/cli-accounts.test.js | 162 ++++++++++++ tests/cli-auth.test.js | 31 +++ tests/download-path.test.js | 51 ++++ tests/service-identity.test.js | 27 ++ tests/services.test.js | 39 +++ tests/telegram-client-auth.test.js | 65 +++++ 16 files changed, 1360 insertions(+), 43 deletions(-) create mode 100644 core/accounts.js create mode 100644 core/service-identity.js create mode 100644 tests/accounts-concurrency.test.js create mode 100644 tests/accounts.test.js create mode 100644 tests/cli-accounts.test.js create mode 100644 tests/download-path.test.js create mode 100644 tests/service-identity.test.js diff --git a/README.md b/README.md index 0aa1a2a..4fa288a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,38 @@ MTCUTE_LOG_LEVEL=5 tgcli auth MTCUTE_LOG_LEVEL=5 tgcli auth --qr ``` +## Multiple accounts + +The account used without `--account` remains the `default` account in the +legacy tgcli store. Adding another profile does not move, rewrite, or log out +that session. + +```bash +# Register an isolated profile. This does not contact Telegram yet. +tgcli accounts add work --phone "+7 707 111 22 33" --alias office + +# Authenticate and use only that profile. +tgcli --account work auth +tgcli --account office auth status +tgcli --account +77071112233 messages search "invoice" + +# Environment selection is also supported. +TGCLI_ACCOUNT=work tgcli sync --follow + +tgcli accounts list --json +``` + +Named profiles use separate `config.json`, `session.json`, `messages.db`, +downloads, locks, sync jobs, and service state under +`/accounts//`. After login tgcli binds the profile to the +authenticated Telegram user ID. Every authorization check verifies both the +configured phone and that immutable user ID; a crossed or copied session fails +closed instead of changing the profile. + +Background services are isolated too. The default account keeps the legacy +service name, while named accounts use labels such as +`com.dapi.tgcli.work` (launchd) or `tgcli-work` (systemd) and separate logs. + ## Quick start ```bash @@ -99,6 +131,7 @@ tgcli server ```bash tgcli auth Authentication and session setup +tgcli accounts Manage isolated Telegram account profiles tgcli config View and edit config tgcli sync Archive backfill and realtime sync tgcli server Run background sync service (MCP optional) @@ -116,6 +149,7 @@ tgcli doctor Diagnostics and sanity checks ``` Use `tgcli [command] --help` for details. Add `--json` for machine-readable output. +Select a profile with the global `--account ` option. ### send text @@ -235,6 +269,8 @@ The proxy is optional; when it is unset, tgcli connects directly to Telegram. ## Configuration & Store The tgcli store lives in the OS app-data directory and contains `config.json`, sessions, and `messages.db`. -Override the location with `TGCLI_STORE`. +Override the base/default location with `TGCLI_STORE`. Named accounts remain +under that base store's `accounts/` directory unless a generated background +service pins their resolved store directly. Legacy version: see `MIGRATION.md`. diff --git a/cli.js b/cli.js index 116b522..5e4daa8 100755 --- a/cli.js +++ b/cli.js @@ -9,6 +9,13 @@ import readline from 'readline'; import { Command, Option } from 'commander'; import { acquireStoreLock, acquireReadLock, readStoreLock } from './store-lock.js'; +import { + addAccount, + bindAccountIdentity, + listAccounts, + normalizePhoneNumber, + resolveAccountContext, +} from './core/accounts.js'; import { loadConfig, normalizeConfig, saveConfig, validateConfig } from './core/config.js'; import { createMessageSyncService, createServices, createTelegramClient } from './core/services.js'; import { @@ -20,13 +27,12 @@ import { parseRetryBackoff, SendCommandError, } from './core/send-utils.js'; +import { resolveServiceIdentity } from './core/service-identity.js'; import { resolveStoreDir } from './core/store.js'; import { formatErrorMessage, parseRequiredWaitSeconds, withSendRetry } from './core/retry.js'; const CLI_PATH = fileURLToPath(import.meta.url); const SERVICE_STATE_FILE = 'service-state.json'; -const LAUNCHD_LABEL = 'com.dapi.tgcli'; -const SYSTEMD_SERVICE_NAME = 'tgcli'; const AUTH_SYNC_HINT = 'Run `tgcli sync --once` or `tgcli sync --follow` when you need archive data.'; const DEFAULT_SEND_PHOTO_RETRIES = 2; const CONFIG_SPECS = [ @@ -48,6 +54,7 @@ function buildProgram() { .name('tgcli') .description('Telegram CLI + MCP server') .usage('[options] ') + .option('--account ', 'Telegram account profile') .option('--json', 'Machine-readable output') .option('--timeout ', 'Wall-clock timeout (e.g. 30s, 5m)') .version(readVersion(), '--version', 'Print version and exit') @@ -92,6 +99,19 @@ function buildProgram() { .argument('', 'Config key') .action(withGlobalOptions((globalFlags, key) => runConfigUnset(globalFlags, key))); + const accounts = program.command('accounts').description('Manage isolated Telegram account profiles'); + accounts + .command('list') + .description('List account profiles') + .action(withGlobalOptions((globalFlags) => runAccountsList(globalFlags))); + accounts + .command('add') + .description('Add an isolated account profile') + .argument('', 'Stable account profile id') + .requiredOption('--phone ', 'Telegram phone number with country code') + .option('--alias ', 'Additional account selector', collectList) + .action(withGlobalOptions((globalFlags, id, options) => runAccountsAdd(globalFlags, id, options))); + const sync = program.command('sync').description('Archive backfill and realtime sync'); sync .option('--once', 'Run once and exit') @@ -578,7 +598,15 @@ function disableHelpCommand(command) { function getGlobalFlags(command) { const options = command.optsWithGlobals(); const timeoutMs = options.timeout ? parseDuration(options.timeout) : null; + const baseStoreDir = resolveStoreDir(); + const accountContext = resolveAccountContext({ + baseStoreDir, + selector: options.account || process.env.TGCLI_ACCOUNT || 'default', + }); + process.env.TGCLI_STORE = accountContext.storeDir; return { + account: accountContext, + baseStoreDir, json: Boolean(options.json), timeout: options.timeout ?? null, timeoutMs, @@ -599,6 +627,44 @@ function withGlobalOptions(handler) { }; } +async function runAccountsList(globalFlags) { + const accounts = listAccounts(globalFlags.baseStoreDir); + if (globalFlags.json) { + writeJson(accounts); + return; + } + if (accounts.length === 0) { + console.log('No additional account profiles configured.'); + return; + } + for (const account of accounts) { + const aliases = account.aliases.length > 0 ? ` (${account.aliases.join(', ')})` : ''; + console.log(`${account.id}: ${account.phoneNumber}${aliases}`); + } +} + +async function runAccountsAdd(globalFlags, id, options = {}) { + const requestedPhone = normalizePhoneNumber(options.phone); + const { config: defaultConfig } = loadConfig(globalFlags.baseStoreDir); + const accountConfig = normalizeConfig(defaultConfig ?? {}); + if (accountConfig.phoneNumber + && normalizePhoneNumber(accountConfig.phoneNumber) === requestedPhone) { + throw new Error(`Phone ${requestedPhone} already belongs to the default account.`); + } + const account = addAccount(globalFlags.baseStoreDir, { + id, + phoneNumber: requestedPhone, + aliases: options.alias ?? [], + }); + accountConfig.phoneNumber = account.phoneNumber; + saveConfig(account.storeDir, accountConfig); + if (globalFlags.json) { + writeJson(account); + return; + } + console.log(`Added account ${account.id} (${account.phoneNumber}). Run \`tgcli --account ${account.id} auth\`.`); +} + function writeJson(payload) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); } @@ -838,17 +904,17 @@ function readServiceState(storeDir) { } } -function getLaunchdPaths() { +function getLaunchdPaths(identity = resolveServiceIdentity()) { const baseDir = path.join(os.homedir(), 'Library', 'LaunchAgents'); return { - plistPath: path.join(baseDir, `${LAUNCHD_LABEL}.plist`), - logPath: path.join(os.homedir(), 'Library', 'Logs', 'tgcli.log'), - errorLogPath: path.join(os.homedir(), 'Library', 'Logs', 'tgcli.error.log'), + plistPath: path.join(baseDir, `${identity.launchdLabel}.plist`), + logPath: path.join(os.homedir(), 'Library', 'Logs', `${identity.logBasename}.log`), + errorLogPath: path.join(os.homedir(), 'Library', 'Logs', `${identity.logBasename}.error.log`), }; } -function getSystemdPath() { - return path.join(os.homedir(), '.config', 'systemd', 'user', `${SYSTEMD_SERVICE_NAME}.service`); +function getSystemdPath(identity = resolveServiceIdentity()) { + return path.join(os.homedir(), '.config', 'systemd', 'user', `${identity.systemdServiceName}.service`); } function xmlEscape(value) { @@ -860,7 +926,7 @@ function xmlEscape(value) { .replace(/'/g, '''); } -function buildLaunchdPlist({ nodePath, cliPath, envVars, logPath, errorLogPath }) { +function buildLaunchdPlist({ label, nodePath, cliPath, envVars, logPath, errorLogPath }) { const envEntries = Object.entries(envVars || {}) .map(([key, value]) => ` ${xmlEscape(key)}\n ${xmlEscape(value)}`) .join('\n'); @@ -874,7 +940,7 @@ function buildLaunchdPlist({ nodePath, cliPath, envVars, logPath, errorLogPath } '', '', ` Label`, - ` ${LAUNCHD_LABEL}`, + ` ${xmlEscape(label)}`, ' ProgramArguments', ' ', ` ${xmlEscape(nodePath)}`, @@ -958,9 +1024,9 @@ function detectBrewService() { }; } -function resolveServiceManager() { +function resolveServiceManager(accountId = 'default') { const brewInfo = detectBrewService(); - if (brewInfo.available && brewInfo.installed && brewInfo.serviceAvailable) { + if (accountId === 'default' && brewInfo.available && brewInfo.installed && brewInfo.serviceAvailable) { return { manager: 'brew', brewInfo }; } if (process.platform === 'darwin') { @@ -1497,6 +1563,13 @@ async function runAuthLogin(globalFlags, options = {}) { if (!loginSuccess) { throw new Error('Failed to login to Telegram.'); } + if (globalFlags.account?.id && globalFlags.account.id !== 'default') { + const me = await telegramClient.getCurrentUser(); + if (!me) { + throw new Error(`Cannot bind account ${globalFlags.account.id}: Telegram identity is unavailable.`); + } + bindAccountIdentity(storeDir, me); + } if (options.follow) { let archiveError = null; try { @@ -1708,7 +1781,8 @@ async function runServer(globalFlags) { async function runServiceInstall(globalFlags) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { - const { manager, brewInfo } = resolveServiceManager(); + const identity = resolveServiceIdentity(globalFlags.account?.id); + const { manager, brewInfo } = resolveServiceManager(identity.accountId); const envVars = { TGCLI_SERVICE_MANAGER: manager, }; @@ -1733,9 +1807,10 @@ async function runServiceInstall(globalFlags) { } if (manager === 'launchd') { - const { plistPath, logPath, errorLogPath } = getLaunchdPaths(); + const { plistPath, logPath, errorLogPath } = getLaunchdPaths(identity); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); const content = buildLaunchdPlist({ + label: identity.launchdLabel, nodePath: process.execPath, cliPath: CLI_PATH, envVars, @@ -1752,7 +1827,7 @@ async function runServiceInstall(globalFlags) { } if (manager === 'systemd') { - const servicePath = getSystemdPath(); + const servicePath = getSystemdPath(identity); fs.mkdirSync(path.dirname(servicePath), { recursive: true }); const content = buildSystemdService({ nodePath: process.execPath, @@ -1774,7 +1849,8 @@ async function runServiceInstall(globalFlags) { async function runServiceStart(globalFlags) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { - const { manager, brewInfo } = resolveServiceManager(); + const identity = resolveServiceIdentity(globalFlags.account?.id); + const { manager, brewInfo } = resolveServiceManager(identity.accountId); if (manager === 'brew') { const result = runCommand('brew', ['services', 'start', 'tgcli'], { stdio: 'inherit' }); @@ -1795,7 +1871,7 @@ async function runServiceStart(globalFlags) { } if (manager === 'launchd') { - const { plistPath } = getLaunchdPaths(); + const { plistPath } = getLaunchdPaths(identity); if (!fs.existsSync(plistPath)) { throw new Error(`Service not installed. Run \`tgcli service install\` first.`); } @@ -1813,11 +1889,11 @@ async function runServiceStart(globalFlags) { } if (manager === 'systemd') { - const servicePath = getSystemdPath(); + const servicePath = getSystemdPath(identity); if (!fs.existsSync(servicePath)) { throw new Error(`Service not installed. Run \`tgcli service install\` first.`); } - const result = runCommand('systemctl', ['--user', 'enable', '--now', SYSTEMD_SERVICE_NAME]); + const result = runCommand('systemctl', ['--user', 'enable', '--now', identity.systemdServiceName]); if (result.status !== 0) { throw new Error(result.stderr || 'Failed to start systemd service.'); } @@ -1833,7 +1909,8 @@ async function runServiceStart(globalFlags) { async function runServiceStop(globalFlags) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { - const { manager } = resolveServiceManager(); + const identity = resolveServiceIdentity(globalFlags.account?.id); + const { manager } = resolveServiceManager(identity.accountId); if (manager === 'brew') { const result = runCommand('brew', ['services', 'stop', 'tgcli'], { stdio: 'inherit' }); @@ -1851,7 +1928,7 @@ async function runServiceStop(globalFlags) { } if (manager === 'launchd') { - const { plistPath } = getLaunchdPaths(); + const { plistPath } = getLaunchdPaths(identity); if (!fs.existsSync(plistPath)) { throw new Error(`Service not installed. Run \`tgcli service install\` first.`); } @@ -1869,7 +1946,7 @@ async function runServiceStop(globalFlags) { } if (manager === 'systemd') { - const result = runCommand('systemctl', ['--user', 'stop', SYSTEMD_SERVICE_NAME]); + const result = runCommand('systemctl', ['--user', 'stop', identity.systemdServiceName]); if (result.status !== 0) { throw new Error(result.stderr || 'Failed to stop systemd service.'); } @@ -1885,7 +1962,8 @@ async function runServiceStop(globalFlags) { async function runServiceStatus(globalFlags) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { - const { manager, brewInfo } = resolveServiceManager(); + const identity = resolveServiceIdentity(globalFlags.account?.id); + const { manager, brewInfo } = resolveServiceManager(identity.accountId); const storeDir = resolveStoreDir(); const serviceState = readServiceState(storeDir); const cliVersion = readVersion(); @@ -1902,13 +1980,13 @@ async function runServiceStatus(globalFlags) { running = brewInfo.serviceStatus === 'started'; } } else if (manager === 'launchd') { - const { plistPath } = getLaunchdPaths(); + const { plistPath } = getLaunchdPaths(identity); installed = fs.existsSync(plistPath); const list = runCommand('launchctl', ['list']); if (list.status === 0) { const lines = list.stdout.split('\n'); for (const line of lines) { - if (!line.includes(LAUNCHD_LABEL)) continue; + if (!line.includes(identity.launchdLabel)) continue; const parts = line.trim().split(/\s+/); const pidValue = parts[0]; pid = pidValue && pidValue !== '-' ? Number(pidValue) : null; @@ -1918,9 +1996,9 @@ async function runServiceStatus(globalFlags) { } } } else if (manager === 'systemd') { - const servicePath = getSystemdPath(); + const servicePath = getSystemdPath(identity); installed = fs.existsSync(servicePath); - const active = runCommand('systemctl', ['--user', 'is-active', SYSTEMD_SERVICE_NAME]); + const active = runCommand('systemctl', ['--user', 'is-active', identity.systemdServiceName]); running = active.status === 0 && active.stdout.trim() === 'active'; statusLabel = active.stdout.trim(); } else { @@ -1980,7 +2058,8 @@ async function runServiceStatus(globalFlags) { async function runServiceLogs(globalFlags) { const timeoutMs = globalFlags.timeoutMs; return runWithTimeout(async () => { - const { manager } = resolveServiceManager(); + const identity = resolveServiceIdentity(globalFlags.account?.id); + const { manager } = resolveServiceManager(identity.accountId); if (manager === 'brew') { const info = runCommand('brew', ['services', 'info', 'tgcli']); @@ -2002,7 +2081,7 @@ async function runServiceLogs(globalFlags) { } if (manager === 'launchd') { - const { logPath, errorLogPath } = getLaunchdPaths(); + const { logPath, errorLogPath } = getLaunchdPaths(identity); if (globalFlags.json) { writeJson({ manager, logPath, errorLogPath }); return; @@ -2022,7 +2101,7 @@ async function runServiceLogs(globalFlags) { writeJson({ manager, journal: true }); return; } - runCommand('journalctl', ['--user', '-u', SYSTEMD_SERVICE_NAME, '-n', '200', '--no-pager'], { + runCommand('journalctl', ['--user', '-u', identity.systemdServiceName, '-n', '200', '--no-pager'], { stdio: 'inherit', }); return; diff --git a/core/accounts.js b/core/accounts.js new file mode 100644 index 0000000..a2366d6 --- /dev/null +++ b/core/accounts.js @@ -0,0 +1,412 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const REGISTRY_FILE = 'accounts.json'; +const ACCOUNT_METADATA_FILE = 'account.json'; +const REGISTRY_LOCK_FILE = '.accounts.lock'; +const ACCOUNT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +function normalizeAccountId(value) { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!ACCOUNT_ID_PATTERN.test(normalized) || normalized === 'default') { + throw new Error('Account ID must use 1-64 lowercase letters, numbers, underscores, or hyphens and cannot be "default".'); + } + return normalized; +} + +function normalizeAlias(value) { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized || normalized === 'default' || normalized.startsWith('+')) { + throw new Error('Account alias must not be empty, reserved, or phone-shaped.'); + } + return normalized; +} + +export function normalizePhoneNumber(value) { + const raw = String(value ?? '').trim(); + if (!raw.startsWith('+')) { + throw new Error('Phone number must start with + and include country code.'); + } + const digits = raw.slice(1).replace(/[\s().-]/g, ''); + if (!/^\d{7,15}$/.test(digits)) { + throw new Error('Phone number must contain 7-15 digits after the country code.'); + } + return `+${digits}`; +} + +export function resolveAccountsRegistryPath(baseStoreDir) { + return path.join(path.resolve(baseStoreDir), REGISTRY_FILE); +} + +function readRegistry(baseStoreDir) { + const registryPath = resolveAccountsRegistryPath(baseStoreDir); + try { + const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.accounts)) { + throw new Error(`Invalid tgcli accounts registry: ${registryPath}`); + } + const accounts = parsed.accounts.map((account) => { + if (!account || !Array.isArray(account.aliases)) { + throw new Error(`Invalid tgcli accounts registry: ${registryPath}`); + } + return { + id: normalizeAccountId(account.id), + phoneNumber: normalizePhoneNumber(account.phoneNumber), + aliases: account.aliases.map(normalizeAlias), + }; + }); + return { version: 1, accounts }; + } catch (error) { + if (error?.code === 'ENOENT') { + return { version: 1, accounts: [] }; + } + throw error; + } +} + +function isPidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function acquireRegistryLock(baseStoreDir, timeoutMs = 5000) { + const resolvedBaseStoreDir = path.resolve(baseStoreDir); + fs.mkdirSync(resolvedBaseStoreDir, { recursive: true, mode: 0o700 }); + const lockPath = path.join(resolvedBaseStoreDir, REGISTRY_LOCK_FILE); + const token = randomUUID(); + const payload = { pid: process.pid, token }; + const deadline = Date.now() + timeoutMs; + const sleeper = new Int32Array(new SharedArrayBuffer(4)); + + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, JSON.stringify(payload)); + } finally { + fs.closeSync(fd); + } + break; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + try { + const existing = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (!isPidAlive(existing?.pid)) { + throw new Error(`Stale tgcli accounts registry lock references dead pid ${existing?.pid ?? 'unknown'}: ${lockPath}. Remove it explicitly after verifying no account command is running.`); + } + } catch (lockError) { + if (lockError?.code === 'ENOENT') continue; + if (lockError instanceof SyntaxError) { + const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + if (ageMs < 1000 && Date.now() < deadline) { + Atomics.wait(sleeper, 0, 0, 5); + continue; + } + throw new Error(`Invalid or stale tgcli accounts registry lock: ${lockPath}. Remove it explicitly after verifying no account command is running.`); + } + throw lockError; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for tgcli accounts registry lock: ${lockPath}`); + } + Atomics.wait(sleeper, 0, 0, 20); + } + } + + return () => { + try { + const current = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (current?.token === token) fs.unlinkSync(lockPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + }; +} + +function writeRegistry(baseStoreDir, registry) { + const registryPath = resolveAccountsRegistryPath(baseStoreDir); + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + const temporaryPath = `${registryPath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { + flag: 'wx', + mode: 0o600, + }); + fs.renameSync(temporaryPath, registryPath); + try { + fs.chmodSync(registryPath, 0o600); + } catch { + // Some filesystems do not support POSIX permissions. + } +} + +function writeJsonPrivate(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { + flag: 'wx', + mode: 0o600, + }); + fs.renameSync(temporaryPath, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Some filesystems do not support POSIX permissions. + } +} + +function resolveAccountMetadataPath(storeDir) { + return path.join(path.resolve(storeDir), ACCOUNT_METADATA_FILE); +} + +export function loadAccountMetadata(storeDir) { + const metadataPath = resolveAccountMetadataPath(storeDir); + try { + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!metadata || !Array.isArray(metadata.aliases)) { + throw new Error(`Invalid tgcli account metadata: ${metadataPath}`); + } + const normalized = { + id: normalizeAccountId(metadata.id), + phoneNumber: normalizePhoneNumber(metadata.phoneNumber), + aliases: metadata.aliases.map(normalizeAlias), + }; + if (metadata.telegramUserId !== undefined) { + const telegramUserId = String(metadata.telegramUserId); + if (!/^\d+$/.test(telegramUserId)) { + throw new Error(`Invalid Telegram user ID in account metadata: ${metadataPath}`); + } + normalized.telegramUserId = telegramUserId; + } + return normalized; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function normalizeAuthenticatedPhone(value) { + const raw = String(value ?? '').trim(); + return normalizePhoneNumber(raw.startsWith('+') ? raw : `+${raw}`); +} + +export function assertAccountIdentity(storeDir, user) { + const metadata = loadAccountMetadata(storeDir); + if (!metadata) return null; + if (!user?.id) { + throw new Error(`Cannot verify identity for account ${metadata.id}: Telegram user ID is missing.`); + } + const actualPhone = normalizeAuthenticatedPhone(user.phoneNumber ?? user.phone); + if (actualPhone !== metadata.phoneNumber) { + throw new Error(`Account phone mismatch for ${metadata.id}: expected ${metadata.phoneNumber}, authenticated as ${actualPhone}.`); + } + const actualUserId = String(user.id); + if (metadata.telegramUserId && metadata.telegramUserId !== actualUserId) { + throw new Error(`Account identity mismatch for ${metadata.id}: expected Telegram user ID ${metadata.telegramUserId}, session belongs to ${actualUserId}. No files were modified.`); + } + return metadata; +} + +export function bindAccountIdentity(storeDir, user) { + const metadata = assertAccountIdentity(storeDir, user); + if (!metadata) return null; + const telegramUserId = String(user.id); + if (metadata.telegramUserId === telegramUserId) return metadata; + const bound = { ...metadata, telegramUserId }; + writeJsonPrivate(resolveAccountMetadataPath(storeDir), bound); + return bound; +} + +function lstatOrNull(filePath) { + try { + return fs.lstatSync(filePath); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function assertDirectoryIsNotSymlink(filePath, label) { + const stat = lstatOrNull(filePath); + if (!stat) return false; + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Unsafe account store: ${label} must be a real directory, not a symlink.`); + } + return true; +} + +function resolveAccountStorePath(baseStoreDir, accountId) { + return path.join(path.resolve(baseStoreDir), 'accounts', accountId); +} + +function ensureAccountStoreDir(baseStoreDir, accountId) { + const accountsDir = path.join(path.resolve(baseStoreDir), 'accounts'); + if (!assertDirectoryIsNotSymlink(accountsDir, 'accounts directory')) { + fs.mkdirSync(accountsDir, { recursive: true, mode: 0o700 }); + } + assertDirectoryIsNotSymlink(accountsDir, 'accounts directory'); + + const storeDir = resolveAccountStorePath(baseStoreDir, accountId); + const existingStore = lstatOrNull(storeDir); + if (existingStore) { + if (existingStore.isSymbolicLink() || !existingStore.isDirectory()) { + throw new Error(`Unsafe account store: account ${accountId} must be a real directory, not a symlink.`); + } + throw new Error(`Account store already exists for ${accountId}; refusing to adopt existing files.`); + } + fs.mkdirSync(storeDir, { recursive: false, mode: 0o700 }); + assertDirectoryIsNotSymlink(storeDir, `account ${accountId}`); + + const realAccountsDir = fs.realpathSync(accountsDir); + const realStoreDir = fs.realpathSync(storeDir); + if (path.dirname(realStoreDir) !== realAccountsDir) { + throw new Error(`Unsafe account store for ${accountId}: path escapes the accounts directory.`); + } + return storeDir; +} + +export function isNamedAccountStore(storeDir) { + const resolved = path.resolve(storeDir); + const accountId = path.basename(resolved); + return path.basename(path.dirname(resolved)) === 'accounts' + && ACCOUNT_ID_PATTERN.test(accountId) + && accountId !== 'default'; +} + +export function assertSafeNamedAccountStore(storeDir) { + if (!isNamedAccountStore(storeDir)) return false; + const resolved = path.resolve(storeDir); + const accountsDir = path.dirname(resolved); + assertDirectoryIsNotSymlink(accountsDir, 'accounts directory'); + assertDirectoryIsNotSymlink(resolved, `account ${path.basename(resolved)}`); + for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + throw new Error(`Unsafe account store: symlinked entry ${entry.name} is not allowed.`); + } + if (entry.isFile() && fs.lstatSync(path.join(resolved, entry.name)).nlink > 1) { + throw new Error(`Unsafe account store: hard-linked entry ${entry.name} is not allowed.`); + } + } + if (path.dirname(fs.realpathSync(resolved)) !== fs.realpathSync(accountsDir)) { + throw new Error('Unsafe account store: path escapes the accounts directory.'); + } + return true; +} + +export function assertVerifiedAccountMetadata(storeDir, expectedAccount = null) { + assertSafeNamedAccountStore(storeDir); + const metadata = loadAccountMetadata(storeDir); + if (!metadata) { + throw new Error(`Account metadata is missing from named account store: ${storeDir}`); + } + const pathAccountId = path.basename(path.resolve(storeDir)); + const expected = expectedAccount ?? readRegistry(path.dirname(path.dirname(path.resolve(storeDir)))) + .accounts.find((account) => account.id === pathAccountId); + if (!expected + || metadata.id !== pathAccountId + || metadata.id !== expected.id + || metadata.phoneNumber !== expected.phoneNumber + || JSON.stringify([...metadata.aliases].sort()) !== JSON.stringify([...(expected.aliases ?? [])].sort())) { + throw new Error(`Account metadata mismatch for ${pathAccountId}.`); + } + return metadata; +} + +function withStoreDir(baseStoreDir, account) { + const storeDir = resolveAccountStorePath(baseStoreDir, account.id); + assertVerifiedAccountMetadata(storeDir, account); + return { + ...account, + storeDir, + }; +} + +export function listAccounts(baseStoreDir) { + return readRegistry(baseStoreDir).accounts.map((account) => withStoreDir(baseStoreDir, account)); +} + +function accountSelectors(account) { + return [account.id, account.phoneNumber, ...(account.aliases ?? [])]; +} + +function addAccountUnlocked(baseStoreDir, input = {}) { + const id = normalizeAccountId(input.id); + const phoneNumber = normalizePhoneNumber(input.phoneNumber); + const aliases = [...new Set((input.aliases ?? []).map(normalizeAlias))]; + const registry = readRegistry(baseStoreDir); + const requestedSelectors = new Set([id, phoneNumber, ...aliases]); + + for (const existing of registry.accounts) { + for (const selector of accountSelectors(existing)) { + if (!requestedSelectors.has(selector)) continue; + if (selector === phoneNumber) { + throw new Error(`Phone number ${phoneNumber} already belongs to account ${existing.id}.`); + } + if (aliases.includes(selector)) { + throw new Error(`Alias ${selector} already belongs to account ${existing.id}.`); + } + throw new Error(`Account selector ${selector} already belongs to account ${existing.id}.`); + } + } + + const account = { id, phoneNumber, aliases }; + const storeDir = ensureAccountStoreDir(baseStoreDir, id); + const result = { ...account, storeDir }; + writeJsonPrivate(resolveAccountMetadataPath(result.storeDir), account); + registry.accounts.push(account); + writeRegistry(baseStoreDir, registry); + return result; +} + +export function addAccount(baseStoreDir, input = {}) { + const release = acquireRegistryLock(baseStoreDir); + try { + return addAccountUnlocked(baseStoreDir, input); + } finally { + release(); + } +} + +function normalizeSelector(value) { + const raw = String(value ?? 'default').trim(); + if (!raw || raw.toLowerCase() === 'default') return 'default'; + if (raw.startsWith('+')) return normalizePhoneNumber(raw); + return raw.toLowerCase(); +} + +export function resolveAccountContext({ baseStoreDir, selector = 'default' } = {}) { + if (!baseStoreDir) { + throw new Error('baseStoreDir is required.'); + } + const resolvedBaseStoreDir = path.resolve(baseStoreDir); + const normalizedSelector = normalizeSelector(selector); + if (normalizedSelector === 'default') { + return { + id: 'default', + selector: 'default', + storeDir: resolvedBaseStoreDir, + account: null, + }; + } + + const matches = listAccounts(resolvedBaseStoreDir).filter((account) => + accountSelectors(account).includes(normalizedSelector)); + if (matches.length === 0) { + throw new Error(`Unknown tgcli account "${selector}". Run "tgcli accounts list".`); + } + if (matches.length > 1) { + throw new Error(`Ambiguous tgcli account selector "${selector}".`); + } + const account = matches[0]; + return { + id: account.id, + selector: normalizedSelector, + storeDir: account.storeDir, + account, + }; +} diff --git a/core/config.js b/core/config.js index 6da6865..0037eaf 100644 --- a/core/config.js +++ b/core/config.js @@ -41,7 +41,8 @@ export function normalizeConfig(raw = {}) { const apiId = normalizeValue(raw.apiId ?? raw.api_id ?? raw.apiID); const apiHash = normalizeValue(raw.apiHash ?? raw.api_hash); const phoneNumber = normalizeValue(raw.phoneNumber ?? raw.phone ?? raw.phone_number); - const proxy = normalizeValue(process.env.TELEGRAM_PROXY ?? raw.proxy ?? raw.proxyUrl ?? raw.proxy_url); + const proxy = normalizeValue(process.env.TELEGRAM_PROXY) + || normalizeValue(raw.proxy ?? raw.proxyUrl ?? raw.proxy_url); const mcpRaw = raw.mcp && typeof raw.mcp === 'object' ? raw.mcp : {}; const mcpEnabled = normalizeBoolean(raw.mcpEnabled ?? raw.mcp_enabled ?? mcpRaw.enabled, false); const mcp = { diff --git a/core/service-identity.js b/core/service-identity.js new file mode 100644 index 0000000..c0a3767 --- /dev/null +++ b/core/service-identity.js @@ -0,0 +1,22 @@ +const ACCOUNT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export function resolveServiceIdentity(accountId = 'default') { + const normalized = String(accountId ?? 'default').trim().toLowerCase(); + if (normalized !== 'default' && !ACCOUNT_ID_PATTERN.test(normalized)) { + throw new Error('Account ID is unsafe for service names.'); + } + if (normalized === 'default') { + return { + accountId: 'default', + launchdLabel: 'com.dapi.tgcli', + systemdServiceName: 'tgcli', + logBasename: 'tgcli', + }; + } + return { + accountId: normalized, + launchdLabel: `com.dapi.tgcli.${normalized}`, + systemdServiceName: `tgcli-${normalized}`, + logBasename: `tgcli.${normalized}`, + }; +} diff --git a/core/services.js b/core/services.js index f062c39..2fe5a46 100644 --- a/core/services.js +++ b/core/services.js @@ -2,6 +2,11 @@ import path from 'path'; import TelegramClient from '../telegram-client.js'; import MessageSyncService from '../message-sync-service.js'; +import { + assertAccountIdentity, + assertVerifiedAccountMetadata, + isNamedAccountStore, +} from './accounts.js'; import { loadConfig, normalizeConfig, validateConfig } from './config.js'; import { resolveStorePaths } from './store.js'; @@ -44,18 +49,26 @@ function resolveValidatedConfig(options = {}, resolvedStoreDir = null) { export function createTelegramClient(options = {}) { const { resolvedStoreDir, sessionPath } = resolveRuntimePaths(options); const config = resolveValidatedConfig(options, resolvedStoreDir); + const namedAccountStore = resolvedStoreDir ? isNamedAccountStore(resolvedStoreDir) : false; + const accountMetadata = namedAccountStore + ? assertVerifiedAccountMetadata(resolvedStoreDir) + : null; + const clientOptions = { + forceSms: options.forceSms ?? false, + useQr: options.useQr ?? false, + disableUpdates: options.disableUpdates ?? false, + proxy: config.proxy || undefined, + }; + if (accountMetadata) { + clientOptions.identityVerifier = (user) => assertAccountIdentity(resolvedStoreDir, user); + } const telegramClient = new TelegramClient( config.apiId, config.apiHash, config.phoneNumber, sessionPath, - { - forceSms: options.forceSms ?? false, - useQr: options.useQr ?? false, - disableUpdates: options.disableUpdates ?? false, - proxy: config.proxy || undefined, - }, + clientOptions, ); return { diff --git a/docs/cli.md b/docs/cli.md index f1f3ab0..a1ba3a8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,7 @@ CLI goal: human-readable output by default with --json for scripting. ## Global flags +- --account ID|ALIAS|PHONE - --json - --timeout DURATION - --version @@ -10,6 +11,17 @@ CLI goal: human-readable output by default with --json for scripting. Store location: OS app data dir (override with TGCLI_STORE). MCP: disabled by default (set `mcp.enabled` in config.json to true to serve MCP). +Account precedence: `--account`, then `TGCLI_ACCOUNT`, then `default`. +The default profile keeps the legacy store. Named profiles use isolated stores +under `/accounts/`. + +## accounts +- accounts list +- accounts add ID --phone +COUNTRY... [--alias NAME ...] + - Adding a profile never authenticates, logs out, or rewrites another profile. + - IDs, aliases, and normalized phone numbers must be unique. + - Authentication binds a named profile to one immutable Telegram user ID. + ## auth - auth - Interactive login (Telegram MTProto), then bootstrap sync. @@ -36,6 +48,7 @@ MCP: disabled by default (set `mcp.enabled` in config.json to true to serve MCP) - service stop - service status - service logs + - Named profiles use per-account launchd/systemd labels, logs, and store state. ## doctor - doctor [--connect] diff --git a/telegram-client.js b/telegram-client.js index 52c4efc..ea61256 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -547,10 +547,20 @@ function buildDownloadFileName(summary, messageId) { return `${baseType}-${messageId}${ext}`; } -function resolveDownloadPath(outputPath, { channelId, messageId, summary }) { +function safeDownloadPathSegment(value) { + const normalized = String(value).replace(/[^a-zA-Z0-9@._-]/g, '_'); + return normalized === '.' || normalized === '..' || !normalized ? '_' : normalized; +} + +export function resolveDownloadPath(outputPath, { + channelId, + messageId, + summary, + defaultDownloadDir = DEFAULT_DOWNLOAD_DIR, +}) { const fileName = buildDownloadFileName(summary, messageId); if (!outputPath) { - return path.resolve(DEFAULT_DOWNLOAD_DIR, String(channelId), fileName); + return path.resolve(defaultDownloadDir, safeDownloadPathSegment(channelId), fileName); } const resolved = path.resolve(outputPath); if (fs.existsSync(resolved)) { @@ -564,6 +574,46 @@ function resolveDownloadPath(outputPath, { channelId, messageId, summary }) { return resolved; } +export function assertSafeDownloadTarget(targetPath, downloadRoot) { + const resolvedRoot = path.resolve(downloadRoot); + const resolvedTarget = path.resolve(targetPath); + if (!resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`Unsafe download target escapes account downloads: ${resolvedTarget}`); + } + + fs.mkdirSync(resolvedRoot, { recursive: true }); + const rootStat = fs.lstatSync(resolvedRoot); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { + throw new Error(`Unsafe download root: ${resolvedRoot}`); + } + + const parentPath = path.dirname(resolvedTarget); + const relativeParent = path.relative(resolvedRoot, parentPath); + let current = resolvedRoot; + for (const segment of relativeParent.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + if (fs.existsSync(current)) { + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Unsafe download directory: ${current}`); + } + } else { + fs.mkdirSync(current, { recursive: false }); + } + } + + if (fs.existsSync(resolvedTarget)) { + const targetStat = fs.lstatSync(resolvedTarget); + if (targetStat.isSymbolicLink()) { + throw new Error(`Unsafe symlinked download target: ${resolvedTarget}`); + } + if (targetStat.isFile() && targetStat.nlink > 1) { + throw new Error(`Unsafe hard-linked download target: ${resolvedTarget}`); + } + } + return resolvedTarget; +} + function resolveDownloadLocation(media) { if (!media || typeof media !== 'object') { return null; @@ -694,9 +744,17 @@ class TelegramClient { return message.includes('AUTH_KEY') || message.includes('AUTHORIZATION') || message.includes('SESSION_PASSWORD_NEEDED'); } + async _verifyIdentity(user) { + if (typeof this.options.identityVerifier === 'function') { + await this.options.identityVerifier(user); + } + return user; + } + async _isAuthorized() { try { - await this.client.getMe(); + const user = await this.client.getMe(); + await this._verifyIdentity(user); return true; } catch (error) { if (this._isUnauthorizedError(error)) { @@ -712,7 +770,8 @@ class TelegramClient { async getCurrentUser() { try { - return await this.client.getMe(); + const user = await this.client.getMe(); + return await this._verifyIdentity(user); } catch (error) { if (this._isUnauthorizedError(error)) { return null; @@ -831,6 +890,10 @@ class TelegramClient { } await this.client.start(this._buildStartParams()); + if (typeof this.options.identityVerifier === 'function') { + const authenticatedUser = await this.client.getMe(); + await this._verifyIdentity(authenticatedUser); + } console.log(hasExistingSession ? 'Existing session is valid.' : 'Logged in successfully!'); return true; @@ -1276,12 +1339,18 @@ class TelegramClient { } const summary = summarizeMedia(message.media); + const accountDownloadDir = path.join(path.dirname(this.sessionPath), 'downloads'); const targetPath = resolveDownloadPath(options.outputPath, { channelId, messageId, summary, + defaultDownloadDir: accountDownloadDir, }); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + if (options.outputPath) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + } else { + assertSafeDownloadTarget(targetPath, accountDownloadDir); + } await this.client.downloadToFile(targetPath, location); const stats = fs.statSync(targetPath); diff --git a/tests/accounts-concurrency.test.js b/tests/accounts-concurrency.test.js new file mode 100644 index 0000000..968c680 --- /dev/null +++ b/tests/accounts-concurrency.test.js @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const ACCOUNTS_MODULE_URL = pathToFileURL(path.resolve('core/accounts.js')).href; + +function spawnAdd(baseStoreDir, startFile, index) { + const script = ` + import fs from 'node:fs'; + import { addAccount } from ${JSON.stringify(ACCOUNTS_MODULE_URL)}; + const sleeper = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(${JSON.stringify(startFile)})) Atomics.wait(sleeper, 0, 0, 5); + addAccount(${JSON.stringify(baseStoreDir)}, { + id: ${JSON.stringify(`account-${index}`)}, + phoneNumber: ${JSON.stringify(`+1707000${String(index).padStart(4, '0')}`)}, + }); + `; + return new Promise((resolve) => { + const child = spawn(process.execPath, ['--input-type=module', '--eval', script], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('exit', (code) => resolve({ code, stderr })); + }); +} + +describe('account registry concurrency', () => { + it('does not lose successful concurrent account registrations', async () => { + const baseStoreDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-accounts-race-test-')); + const startFile = path.join(baseStoreDir, 'start'); + try { + const children = Array.from({ length: 12 }, (_, index) => + spawnAdd(baseStoreDir, startFile, index)); + fs.writeFileSync(startFile, 'go'); + const results = await Promise.all(children); + + expect(results, results.map((result) => result.stderr).join('\n')).toEqual( + Array.from({ length: 12 }, () => ({ code: 0, stderr: '' })), + ); + const registry = JSON.parse(fs.readFileSync(path.join(baseStoreDir, 'accounts.json'), 'utf8')); + expect(registry.accounts).toHaveLength(12); + expect(new Set(registry.accounts.map((account) => account.id)).size).toBe(12); + } finally { + fs.rmSync(baseStoreDir, { recursive: true, force: true }); + } + }, 20_000); +}); diff --git a/tests/accounts.test.js b/tests/accounts.test.js new file mode 100644 index 0000000..a9dd204 --- /dev/null +++ b/tests/accounts.test.js @@ -0,0 +1,246 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + addAccount, + listAccounts, + normalizePhoneNumber, + resolveAccountContext, +} from '../core/accounts.js'; + +describe('multi-account store isolation', () => { + let baseStoreDir; + + beforeEach(() => { + baseStoreDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-accounts-test-')); + }); + + afterEach(() => { + fs.rmSync(baseStoreDir, { recursive: true, force: true }); + }); + + it('keeps the default account in the legacy store', () => { + expect(resolveAccountContext({ baseStoreDir })).toEqual({ + id: 'default', + selector: 'default', + storeDir: baseStoreDir, + account: null, + }); + }); + + it('creates a named account in its own store without touching legacy session files', () => { + const legacySession = path.join(baseStoreDir, 'session.json'); + fs.writeFileSync(legacySession, 'primary-session'); + + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+7 (707) 111-22-33', + aliases: ['office'], + }); + + expect(account.phoneNumber).toBe('+77071112233'); + expect(account.storeDir).toBe(path.join(baseStoreDir, 'accounts', 'work')); + expect(fs.readFileSync(legacySession, 'utf8')).toBe('primary-session'); + expect(fs.existsSync(path.join(account.storeDir, 'session.json'))).toBe(false); + expect(listAccounts(baseStoreDir)).toEqual([account]); + }); + + it.each(['work', 'office', '+7 707 111 22 33'])( + 'resolves %s to the same isolated account store', + (selector) => { + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + aliases: ['office'], + }); + + const context = resolveAccountContext({ baseStoreDir, selector }); + + expect(context.id).toBe('work'); + expect(context.storeDir).toBe(account.storeDir); + expect(context.account).toEqual(account); + }, + ); + + it('rejects duplicate aliases and phone numbers instead of choosing ambiguously', () => { + addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + aliases: ['office'], + }); + + expect(() => addAccount(baseStoreDir, { + id: 'family', + phoneNumber: '+77071112233', + })).toThrow(/phone number.*already belongs to.*work/i); + + expect(() => addAccount(baseStoreDir, { + id: 'family', + phoneNumber: '+77072223344', + aliases: ['office'], + })).toThrow(/alias.*already belongs to.*work/i); + }); + + it('rejects unsafe account ids that could escape the accounts directory', () => { + expect(() => addAccount(baseStoreDir, { + id: '../primary', + phoneNumber: '+77071112233', + })).toThrow(/account id/i); + }); + + it('rejects a tampered registry entry before resolving its store path', () => { + fs.writeFileSync(path.join(baseStoreDir, 'accounts.json'), JSON.stringify({ + version: 1, + accounts: [{ + id: '../../primary', + phoneNumber: '+77071112233', + aliases: ['work'], + }], + })); + + expect(() => resolveAccountContext({ + baseStoreDir, + selector: 'work', + })).toThrow(/invalid.*registry|account id/i); + }); + + it('rejects a symlinked account directory without writing into its target', () => { + const legacyConfig = path.join(baseStoreDir, 'config.json'); + fs.writeFileSync(legacyConfig, '{"phoneNumber":"+77079990000"}\n'); + const accountsDir = path.join(baseStoreDir, 'accounts'); + fs.mkdirSync(accountsDir); + fs.symlinkSync(baseStoreDir, path.join(accountsDir, 'work')); + + expect(() => addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + })).toThrow(/symlink|unsafe account store/i); + + expect(fs.readFileSync(legacyConfig, 'utf8')).toBe('{"phoneNumber":"+77079990000"}\n'); + expect(fs.existsSync(path.join(baseStoreDir, 'account.json'))).toBe(false); + expect(fs.existsSync(path.join(baseStoreDir, 'accounts.json'))).toBe(false); + }); + + it('refuses to adopt a pre-existing directory or session for a new account id', () => { + const accountDir = path.join(baseStoreDir, 'accounts', 'work'); + fs.mkdirSync(accountDir, { recursive: true }); + const foreignSession = path.join(accountDir, 'session.json'); + fs.writeFileSync(foreignSession, 'foreign-session'); + + expect(() => addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + })).toThrow(/already exists|refus.*adopt/i); + + expect(fs.readFileSync(foreignSession, 'utf8')).toBe('foreign-session'); + expect(fs.existsSync(path.join(baseStoreDir, 'accounts.json'))).toBe(false); + }); + + it('rejects symlinked files introduced into an existing named store', () => { + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + const legacyConfig = path.join(baseStoreDir, 'config.json'); + fs.writeFileSync(legacyConfig, 'legacy-config'); + fs.symlinkSync(legacyConfig, path.join(account.storeDir, 'config.json')); + + expect(() => resolveAccountContext({ + baseStoreDir, + selector: 'work', + })).toThrow(/symlink|unsafe account store/i); + expect(fs.readFileSync(legacyConfig, 'utf8')).toBe('legacy-config'); + }); + + it('rejects hard-linked files introduced into an existing named store', () => { + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + const legacyConfig = path.join(baseStoreDir, 'config.json'); + fs.writeFileSync(legacyConfig, 'legacy-config'); + fs.linkSync(legacyConfig, path.join(account.storeDir, 'config.json')); + + expect(() => resolveAccountContext({ + baseStoreDir, + selector: 'work', + })).toThrow(/hard.?link|multiple links|unsafe account store/i); + expect(fs.readFileSync(legacyConfig, 'utf8')).toBe('legacy-config'); + }); + + it('rejects account metadata that disagrees with the registry', () => { + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + fs.writeFileSync(path.join(account.storeDir, 'account.json'), JSON.stringify({ + id: 'work', + phoneNumber: '+77079990000', + aliases: [], + })); + + expect(() => resolveAccountContext({ + baseStoreDir, + selector: 'work', + })).toThrow(/metadata.*mismatch|mismatch.*metadata/i); + }); + + it('normalizes phone selectors without guessing malformed values', () => { + expect(normalizePhoneNumber('+7 (707) 111-22-33')).toBe('+77071112233'); + expect(() => normalizePhoneNumber('work')).toThrow(/phone number/i); + }); + + it('binds a named store to one Telegram user and rejects crossed sessions', async () => { + const { assertAccountIdentity, bindAccountIdentity, loadAccountMetadata } = await import('../core/accounts.js'); + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + + expect(bindAccountIdentity(account.storeDir, { + id: 123456789n, + phoneNumber: '77071112233', + })).toMatchObject({ telegramUserId: '123456789' }); + expect(loadAccountMetadata(account.storeDir)).toMatchObject({ + id: 'work', + phoneNumber: '+77071112233', + telegramUserId: '123456789', + }); + expect(() => assertAccountIdentity(account.storeDir, { + id: 123456789n, + phoneNumber: '77071112233', + })).not.toThrow(); + expect(() => assertAccountIdentity(account.storeDir, { + id: 987654321n, + phoneNumber: '77071112233', + })).toThrow(/identity mismatch.*work/i); + }); + + it('refuses first identity binding when the authenticated phone differs', async () => { + const { bindAccountIdentity } = await import('../core/accounts.js'); + const account = addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + + expect(() => bindAccountIdentity(account.storeDir, { + id: 123456789n, + phoneNumber: '77079990000', + })).toThrow(/phone mismatch/i); + }); + + it('fails closed on a stale registry lock instead of racing to delete it', () => { + const lockPath = path.join(baseStoreDir, '.accounts.lock'); + fs.writeFileSync(lockPath, JSON.stringify({ pid: 99999999, token: 'stale' })); + + expect(() => addAccount(baseStoreDir, { + id: 'work', + phoneNumber: '+77071112233', + })).toThrow(/stale.*registry lock|registry lock.*dead/i); + + expect(fs.existsSync(lockPath)).toBe(true); + expect(fs.existsSync(path.join(baseStoreDir, 'accounts.json'))).toBe(false); + }); +}); diff --git a/tests/cli-accounts.test.js b/tests/cli-accounts.test.js new file mode 100644 index 0000000..f633c6d --- /dev/null +++ b/tests/cli-accounts.test.js @@ -0,0 +1,162 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const CLI_PATH = path.resolve('cli.js'); + +function runCli(baseStoreDir, args, extraEnv = {}) { + return spawnSync(process.execPath, [CLI_PATH, ...args], { + cwd: path.dirname(CLI_PATH), + encoding: 'utf8', + env: { + ...process.env, + TELEGRAM_PROXY: '', + TGCLI_ACCOUNT: '', + TGCLI_STORE: baseStoreDir, + ...extraEnv, + }, + }); +} + +describe('multi-account CLI', { timeout: 20_000 }, () => { + let baseStoreDir; + + beforeEach(() => { + baseStoreDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-accounts-cli-test-')); + }); + + afterEach(() => { + fs.rmSync(baseStoreDir, { recursive: true, force: true }); + }); + + it('adds and lists isolated account profiles', () => { + const add = runCli(baseStoreDir, [ + 'accounts', 'add', 'work', + '--phone', '+7 (707) 111-22-33', + '--alias', 'office', + '--json', + ]); + expect(add.status, add.stderr).toBe(0); + expect(JSON.parse(add.stdout)).toMatchObject({ + id: 'work', + phoneNumber: '+77071112233', + aliases: ['office'], + }); + + const list = runCli(baseStoreDir, ['accounts', 'list', '--json']); + expect(list.status, list.stderr).toBe(0); + expect(JSON.parse(list.stdout)).toHaveLength(1); + }); + + it('seeds a named profile with reusable API settings and its own phone', () => { + expect(runCli(baseStoreDir, ['config', 'set', 'apiId', '12345']).status).toBe(0); + expect(runCli(baseStoreDir, ['config', 'set', 'apiHash', 'shared-api-hash']).status).toBe(0); + + const add = runCli(baseStoreDir, [ + 'accounts', 'add', 'work', '--phone', '+77071112233', + ]); + expect(add.status, add.stderr).toBe(0); + + const namedConfig = JSON.parse(fs.readFileSync( + path.join(baseStoreDir, 'accounts', 'work', 'config.json'), + 'utf8', + )); + expect(namedConfig).toMatchObject({ + apiId: '12345', + apiHash: 'shared-api-hash', + phoneNumber: '+77071112233', + }); + expect(fs.existsSync(path.join(baseStoreDir, 'session.json'))).toBe(false); + }); + + it('rejects registering the default account phone as a named profile', () => { + expect(runCli(baseStoreDir, [ + 'config', 'set', 'phoneNumber', '+77071112233', + ]).status).toBe(0); + + const add = runCli(baseStoreDir, [ + 'accounts', 'add', 'work', '--phone', '+77071112233', + ]); + + expect(add.status).toBe(1); + expect(add.stderr).toMatch(/default account.*phone|phone.*default account/i); + expect(fs.existsSync(path.join(baseStoreDir, 'accounts.json'))).toBe(false); + expect(fs.existsSync(path.join(baseStoreDir, 'accounts', 'work'))).toBe(false); + }); + + it('routes --account and TGCLI_ACCOUNT commands to the selected store', () => { + expect(runCli(baseStoreDir, [ + 'accounts', 'add', 'work', '--phone', '+77071112233', '--alias', 'office', + ]).status).toBe(0); + + const setNamed = runCli(baseStoreDir, [ + '--account', 'work', 'config', 'set', 'phoneNumber', '+77071112233', + ]); + expect(setNamed.status, setNamed.stderr).toBe(0); + + const setDefault = runCli(baseStoreDir, [ + 'config', 'set', 'phoneNumber', '+77079990000', + ]); + expect(setDefault.status, setDefault.stderr).toBe(0); + + const getByEnv = runCli( + baseStoreDir, + ['config', 'get', 'phoneNumber', '--json'], + { TGCLI_ACCOUNT: 'office' }, + ); + expect(getByEnv.status, getByEnv.stderr).toBe(0); + expect(JSON.parse(getByEnv.stdout).value).toBe('+77071112233'); + + const namedConfig = JSON.parse(fs.readFileSync( + path.join(baseStoreDir, 'accounts', 'work', 'config.json'), + 'utf8', + )); + const defaultConfig = JSON.parse(fs.readFileSync(path.join(baseStoreDir, 'config.json'), 'utf8')); + expect(namedConfig.phoneNumber).toBe('+77071112233'); + expect(defaultConfig.phoneNumber).toBe('+77079990000'); + expect(fs.existsSync(path.join(baseStoreDir, 'session.json'))).toBe(false); + }); + + it('fails closed for an unknown account without creating a store', () => { + const result = runCli(baseStoreDir, [ + '--account', 'missing', 'config', 'list', '--json', + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/Unknown tgcli account/i); + expect(fs.existsSync(path.join(baseStoreDir, 'accounts', 'missing'))).toBe(false); + }); + + it.runIf(process.platform === 'darwin')('installs a launchd service isolated to the named account', () => { + const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-service-home-test-')); + try { + expect(runCli(baseStoreDir, [ + 'accounts', 'add', 'work', '--phone', '+77071112233', + ]).status).toBe(0); + + const install = runCli( + baseStoreDir, + ['--account', 'work', 'service', 'install', '--json'], + { HOME: isolatedHome }, + ); + expect(install.status, install.stderr).toBe(0); + const payload = JSON.parse(install.stdout); + expect(payload.manager).toBe('launchd'); + expect(payload.path).toBe(path.join( + isolatedHome, + 'Library', + 'LaunchAgents', + 'com.dapi.tgcli.work.plist', + )); + const plist = fs.readFileSync(payload.path, 'utf8'); + expect(plist).toContain('com.dapi.tgcli.work'); + expect(plist).toContain(path.join(baseStoreDir, 'accounts', 'work')); + expect(plist).toContain(path.join(isolatedHome, 'Library', 'Logs', 'tgcli.work.log')); + expect(plist).not.toContain('com.dapi.tgcli'); + } finally { + fs.rmSync(isolatedHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli-auth.test.js b/tests/cli-auth.test.js index f7cd5f6..86cb57d 100644 --- a/tests/cli-auth.test.js +++ b/tests/cli-auth.test.js @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { acquireStoreLockMock, + bindAccountIdentityMock, createTelegramClientMock, createMessageSyncServiceMock, loadConfigMock, @@ -13,6 +14,7 @@ const { validateConfigMock, } = vi.hoisted(() => ({ acquireStoreLockMock: vi.fn(), + bindAccountIdentityMock: vi.fn(), createTelegramClientMock: vi.fn(), createMessageSyncServiceMock: vi.fn(), loadConfigMock: vi.fn(), @@ -40,6 +42,13 @@ vi.mock('../core/services.js', () => ({ createTelegramClient: createTelegramClientMock, })); +vi.mock('../core/accounts.js', () => ({ + addAccount: vi.fn(), + bindAccountIdentity: bindAccountIdentityMock, + listAccounts: vi.fn(), + resolveAccountContext: vi.fn(), +})); + vi.mock('../core/store.js', () => ({ resolveStoreDir: resolveStoreDirMock, })); @@ -62,6 +71,7 @@ describe('cli auth command', () => { normalizeConfigMock.mockImplementation((config) => config); validateConfigMock.mockReturnValue([]); acquireStoreLockMock.mockReturnValue(vi.fn()); + bindAccountIdentityMock.mockReset(); createMessageSyncServiceMock.mockReset(); createTelegramClientMock.mockReset(); }); @@ -90,6 +100,27 @@ describe('cli auth command', () => { expect(destroy).toHaveBeenCalledTimes(1); }); + it('binds a named account to the authenticated Telegram user after login', async () => { + const me = { id: 123456789n, phone: '77071112233' }; + const destroy = vi.fn().mockResolvedValue(undefined); + createTelegramClientMock.mockReturnValue({ + telegramClient: { + destroy, + getCurrentUser: vi.fn().mockResolvedValue(me), + login: vi.fn().mockResolvedValue(true), + }, + }); + + await runAuthLogin({ + account: { id: 'work', storeDir: '/tmp/tgcli-store' }, + json: false, + timeoutMs: null, + }, {}); + + expect(bindAccountIdentityMock).toHaveBeenCalledWith('/tmp/tgcli-store', me); + expect(destroy).toHaveBeenCalledTimes(1); + }); + it('treats symlinked tgcli binaries as the cli entrypoint', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-cli-entrypoint-')); const symlinkPath = path.join(tmpDir, 'tgcli'); diff --git a/tests/download-path.test.js b/tests/download-path.test.js new file mode 100644 index 0000000..9638f17 --- /dev/null +++ b/tests/download-path.test.js @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { assertSafeDownloadTarget, resolveDownloadPath } from '../telegram-client.js'; + +describe('account-local media downloads', () => { + it('uses the selected account download directory when output is omitted', () => { + const accountDownloads = path.resolve('/tmp/tgcli/accounts/work/downloads'); + + const result = resolveDownloadPath(null, { + channelId: '@channel', + messageId: 42, + summary: { type: 'photo', mimeType: 'image/jpeg' }, + defaultDownloadDir: accountDownloads, + }); + + expect(result).toBe(path.join(accountDownloads, '@channel', 'photo-42.jpg')); + }); + + it('keeps traversal-shaped channel ids inside the selected account downloads', () => { + const accountDownloads = path.resolve('/tmp/tgcli/accounts/work/downloads'); + + const result = resolveDownloadPath(null, { + channelId: '../../default', + messageId: 42, + summary: { type: 'photo', mimeType: 'image/jpeg' }, + defaultDownloadDir: accountDownloads, + }); + + expect(result.startsWith(`${accountDownloads}${path.sep}`)).toBe(true); + }); + + it('rejects a hard-linked automatic download target', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-download-target-test-')); + try { + const victim = path.join(root, 'victim'); + const target = path.join(root, 'downloads', '@channel', 'photo-42.jpg'); + fs.writeFileSync(victim, 'keep'); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.linkSync(victim, target); + + expect(() => assertSafeDownloadTarget(target, path.join(root, 'downloads'))) + .toThrow(/hard.?link|multiple links/i); + expect(fs.readFileSync(victim, 'utf8')).toBe('keep'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/service-identity.test.js b/tests/service-identity.test.js new file mode 100644 index 0000000..0c5deca --- /dev/null +++ b/tests/service-identity.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveServiceIdentity } from '../core/service-identity.js'; + +describe('per-account service identity', () => { + it('preserves legacy service names for the default account', () => { + expect(resolveServiceIdentity('default')).toEqual({ + accountId: 'default', + launchdLabel: 'com.dapi.tgcli', + systemdServiceName: 'tgcli', + logBasename: 'tgcli', + }); + }); + + it('uses collision-free service and log names for named accounts', () => { + expect(resolveServiceIdentity('work')).toEqual({ + accountId: 'work', + launchdLabel: 'com.dapi.tgcli.work', + systemdServiceName: 'tgcli-work', + logBasename: 'tgcli.work', + }); + }); + + it('rejects unsafe account ids in service names', () => { + expect(() => resolveServiceIdentity('../work')).toThrow(/account id/i); + }); +}); diff --git a/tests/services.test.js b/tests/services.test.js index 75e1e06..178447d 100644 --- a/tests/services.test.js +++ b/tests/services.test.js @@ -25,6 +25,7 @@ import { createServices, createTelegramClient, } from '../core/services.js'; +import { addAccount } from '../core/accounts.js'; describe('core services helpers', () => { let storeDir; @@ -96,6 +97,44 @@ describe('core services helpers', () => { expect(result.dbPath).toBe(path.join(storeDir, 'messages.db')); }); + it('installs a fail-closed identity verifier for named account stores', () => { + const account = addAccount(storeDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + fs.writeFileSync(path.join(account.storeDir, 'config.json'), JSON.stringify({ + apiId: '12345', + apiHash: 'hash-value', + phoneNumber: '+77071112233', + })); + + createTelegramClient({ storeDir: account.storeDir, disableUpdates: true }); + + const options = telegramClientCtor.mock.calls[0][4]; + expect(options.identityVerifier).toBeTypeOf('function'); + expect(() => options.identityVerifier({ id: 1n, phoneNumber: '77071112233' })).not.toThrow(); + expect(() => options.identityVerifier({ id: 1n, phone: '77079990000' })).toThrow(/phone mismatch/i); + }); + + it('refuses a named account store whose identity metadata was removed', () => { + const account = addAccount(storeDir, { + id: 'work', + phoneNumber: '+77071112233', + }); + fs.writeFileSync(path.join(account.storeDir, 'config.json'), JSON.stringify({ + apiId: '12345', + apiHash: 'hash-value', + phoneNumber: '+77071112233', + })); + fs.unlinkSync(path.join(account.storeDir, 'account.json')); + + expect(() => createTelegramClient({ + storeDir: account.storeDir, + disableUpdates: true, + })).toThrow(/account metadata.*missing|missing.*account metadata/i); + expect(telegramClientCtor).not.toHaveBeenCalled(); + }); + it('TELEGRAM_PROXY env var overrides proxy from config.json', () => { process.env.TELEGRAM_PROXY = 'mtproto://proxy.example.com:20123?secret=aabbcc'; try { diff --git a/tests/telegram-client-auth.test.js b/tests/telegram-client-auth.test.js index 27b3f93..7c8daf2 100644 --- a/tests/telegram-client-auth.test.js +++ b/tests/telegram-client-auth.test.js @@ -91,4 +91,69 @@ describe('telegram client auth bootstrap options', () => { expect(proxyTransportFromUrlMock).toHaveBeenCalledWith('socks5://127.0.0.1:1080'); expect(transport).toEqual({ proxyUrl: 'socks5://127.0.0.1:1080' }); }); + + it('verifies the selected account identity before reporting authorization', async () => { + const me = { id: 123456789n, phone: '77071112233' }; + const identityVerifier = vi.fn(); + mtcuteClientCtor.mockImplementationOnce(function () { + return { + getMe: vi.fn().mockResolvedValue(me), + destroy: vi.fn().mockResolvedValue(undefined), + stopUpdatesLoop: vi.fn().mockResolvedValue(undefined), + onRawUpdate: { remove: vi.fn() }, + }; + }); + const client = new TelegramClient(12345, 'hash', '+77071112233', '/tmp/tgcli-auth-identity.session', { + identityVerifier, + }); + + await expect(client.isAuthorized()).resolves.toBe(true); + expect(identityVerifier).toHaveBeenCalledWith(me); + }); + + it('fails closed when the account identity verifier rejects the session', async () => { + mtcuteClientCtor.mockImplementationOnce(function () { + return { + getMe: vi.fn().mockResolvedValue({ id: 987654321n, phone: '77071112233' }), + destroy: vi.fn().mockResolvedValue(undefined), + stopUpdatesLoop: vi.fn().mockResolvedValue(undefined), + onRawUpdate: { remove: vi.fn() }, + }; + }); + const client = new TelegramClient(12345, 'hash', '+77071112233', '/tmp/tgcli-auth-crossed.session', { + identityVerifier: () => { + throw new Error('Account identity mismatch for work'); + }, + }); + + await expect(client.isAuthorized()).rejects.toThrow(/identity mismatch/i); + }); + + it('verifies identity immediately after a fresh interactive login', async () => { + const unauthorized = Object.assign(new Error('AUTH_KEY_UNREGISTERED'), { code: 401 }); + const me = { id: 987654321n, phone: '77079990000' }; + const getMe = vi.fn() + .mockRejectedValueOnce(unauthorized) + .mockResolvedValueOnce(me); + const start = vi.fn().mockResolvedValue(undefined); + const identityVerifier = vi.fn(() => { + throw new Error('Account phone mismatch for work'); + }); + mtcuteClientCtor.mockImplementationOnce(function () { + return { + getMe, + start, + destroy: vi.fn().mockResolvedValue(undefined), + stopUpdatesLoop: vi.fn().mockResolvedValue(undefined), + onRawUpdate: { remove: vi.fn() }, + }; + }); + const client = new TelegramClient(12345, 'hash', '+77071112233', '/tmp/tgcli-auth-fresh-crossed.session', { + identityVerifier, + }); + + await expect(client.login()).resolves.toBe(false); + expect(start).toHaveBeenCalledTimes(1); + expect(identityVerifier).toHaveBeenCalledWith(me); + }); }); From 362a9e131394a9c083e688ac81c15ce40a7d82c9 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 26 Aug 2026 10:21:35 +0300 Subject: [PATCH 2/2] [verified] fix: match launchd account labels exactly --- cli.js | 16 ++++++---------- core/service-identity.js | 15 +++++++++++++++ tests/service-identity.test.js | 16 +++++++++++++++- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/cli.js b/cli.js index 5e4daa8..cb6dc85 100755 --- a/cli.js +++ b/cli.js @@ -27,7 +27,7 @@ import { parseRetryBackoff, SendCommandError, } from './core/send-utils.js'; -import { resolveServiceIdentity } from './core/service-identity.js'; +import { parseLaunchdList, resolveServiceIdentity } from './core/service-identity.js'; import { resolveStoreDir } from './core/store.js'; import { formatErrorMessage, parseRequiredWaitSeconds, withSendRetry } from './core/retry.js'; @@ -1984,15 +1984,11 @@ async function runServiceStatus(globalFlags) { installed = fs.existsSync(plistPath); const list = runCommand('launchctl', ['list']); if (list.status === 0) { - const lines = list.stdout.split('\n'); - for (const line of lines) { - if (!line.includes(identity.launchdLabel)) continue; - const parts = line.trim().split(/\s+/); - const pidValue = parts[0]; - pid = pidValue && pidValue !== '-' ? Number(pidValue) : null; - running = Boolean(pid); - statusLabel = running ? 'started' : 'stopped'; - break; + const launchdStatus = parseLaunchdList(list.stdout, identity.launchdLabel); + if (launchdStatus) { + pid = launchdStatus.pid; + running = launchdStatus.running; + statusLabel = launchdStatus.status; } } } else if (manager === 'systemd') { diff --git a/core/service-identity.js b/core/service-identity.js index c0a3767..c3108f0 100644 --- a/core/service-identity.js +++ b/core/service-identity.js @@ -1,5 +1,20 @@ const ACCOUNT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; +export function parseLaunchdList(output, expectedLabel) { + for (const line of String(output ?? '').split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 3 || parts[2] !== expectedLabel) continue; + const pid = parts[0] && parts[0] !== '-' ? Number(parts[0]) : null; + const running = Number.isFinite(pid) && pid > 0; + return { + pid: running ? pid : null, + running, + status: running ? 'started' : 'stopped', + }; + } + return null; +} + export function resolveServiceIdentity(accountId = 'default') { const normalized = String(accountId ?? 'default').trim().toLowerCase(); if (normalized !== 'default' && !ACCOUNT_ID_PATTERN.test(normalized)) { diff --git a/tests/service-identity.test.js b/tests/service-identity.test.js index 0c5deca..138c167 100644 --- a/tests/service-identity.test.js +++ b/tests/service-identity.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveServiceIdentity } from '../core/service-identity.js'; +import { parseLaunchdList, resolveServiceIdentity } from '../core/service-identity.js'; describe('per-account service identity', () => { it('preserves legacy service names for the default account', () => { @@ -24,4 +24,18 @@ describe('per-account service identity', () => { it('rejects unsafe account ids in service names', () => { expect(() => resolveServiceIdentity('../work')).toThrow(/account id/i); }); + + it('matches launchd status by exact label instead of default-label prefix', () => { + const output = [ + '4321\t0\tcom.dapi.tgcli.work', + '-\t0\tcom.dapi.tgcli.family', + ].join('\n'); + + expect(parseLaunchdList(output, 'com.dapi.tgcli')).toBeNull(); + expect(parseLaunchdList(output, 'com.dapi.tgcli.work')).toEqual({ + pid: 4321, + running: true, + status: 'started', + }); + }); });