From 355be7d5820c8e8987b934aa169ed33af73677a7 Mon Sep 17 00:00:00 2001 From: Daisy Hollman Date: Fri, 20 Mar 2026 21:48:07 +0000 Subject: [PATCH] feat(telegram,discord): migrate to plugin userConfig secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes H1 #3617646 via the proper path — plugin userConfig with sensitive: true routes tokens to keychain (macOS) or .credentials.json 0600 (elsewhere) instead of world-readable .env files. Requires claude-cli-internal#23383 (PLUGIN_OPTIONS ungate + per-server sensitive split). Changes per plugin: - plugin.json: add userConfig.{PLATFORM}_BOT_TOKEN with sensitive: true - .mcp.json: add env block with ${user_config.{PLATFORM}_BOT_TOKEN} - server.ts: update comment + error message to point to /plugin reconfigure instead of .env file The .env read loop stays as a legacy fallback for existing users — process.env wins (injected value takes precedence), so no migration forced. New users get prompted at enable time via CC's built-in dialog; token lands in keychain, never touches settings.json. /telegram:configure and /discord:configure skills are NOT removed in this PR — they still work for the legacy .env path. Follow-up: repurpose or remove after a grace period once cli#23383 is released. :house: Remote-Dev: homespace --- .../discord/.claude-plugin/plugin.json | 15 ++++++++++++--- external_plugins/discord/.mcp.json | 5 ++++- external_plugins/discord/server.ts | 12 +++++++----- .../telegram/.claude-plugin/plugin.json | 15 ++++++++++++--- external_plugins/telegram/.mcp.json | 5 ++++- external_plugins/telegram/server.ts | 12 +++++++----- 6 files changed, 46 insertions(+), 18 deletions(-) diff --git a/external_plugins/discord/.claude-plugin/plugin.json b/external_plugins/discord/.claude-plugin/plugin.json index 7447381..f97dd26 100644 --- a/external_plugins/discord/.claude-plugin/plugin.json +++ b/external_plugins/discord/.claude-plugin/plugin.json @@ -1,11 +1,20 @@ { "name": "discord", - "description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.", - "version": "0.0.1", + "description": "Discord channel for Claude Code — messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.", + "version": "0.0.2", "keywords": [ "discord", "messaging", "channel", "mcp" - ] + ], + "userConfig": { + "DISCORD_BOT_TOKEN": { + "type": "string", + "title": "Bot Token", + "description": "Bot token from the Discord Developer Portal. Stored in keychain (macOS) or ~/.claude/.credentials.json with 0600 permissions elsewhere. Never written to settings.json.", + "required": true, + "sensitive": true + } + } } diff --git a/external_plugins/discord/.mcp.json b/external_plugins/discord/.mcp.json index 081e9ee..cfca609 100644 --- a/external_plugins/discord/.mcp.json +++ b/external_plugins/discord/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "discord": { "command": "bun", - "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"] + "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"], + "env": { + "DISCORD_BOT_TOKEN": "${user_config.DISCORD_BOT_TOKEN}" + } } } } diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 078c29a..6929027 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -34,10 +34,12 @@ const ACCESS_FILE = join(STATE_DIR, 'access.json') const APPROVED_DIR = join(STATE_DIR, 'approved') const ENV_FILE = join(STATE_DIR, '.env') -// Load ~/.claude/channels/discord/.env into process.env. Real env wins. -// Plugin-spawned servers don't get an env block — this is where the token lives. +// Token is injected via ${user_config.DISCORD_BOT_TOKEN} from .mcp.json — +// prompted at enable time, stored in keychain (macOS) or .credentials.json 0600 +// elsewhere. The .env file below is a legacy fallback for users configured +// before H1 #3617646 — real env wins, so the injected value takes precedence. try { - // Token is a credential — lock to owner. No-op on Windows (would need ACLs). + // Defensive chmod for legacy .env files (no-op on Windows). chmodSync(ENV_FILE, 0o600) for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) { const m = line.match(/^(\w+)=(.*)$/) @@ -51,8 +53,8 @@ const STATIC = process.env.DISCORD_ACCESS_MODE === 'static' if (!TOKEN) { process.stderr.write( `discord channel: DISCORD_BOT_TOKEN required\n` + - ` set in ${ENV_FILE}\n` + - ` format: DISCORD_BOT_TOKEN=MTIz...\n`, + ` configure via: /plugin reconfigure discord\n` + + ` (stored in keychain/credentials.json, not settings.json)\n`, ) process.exit(1) } diff --git a/external_plugins/telegram/.claude-plugin/plugin.json b/external_plugins/telegram/.claude-plugin/plugin.json index ac3472e..3593bb4 100644 --- a/external_plugins/telegram/.claude-plugin/plugin.json +++ b/external_plugins/telegram/.claude-plugin/plugin.json @@ -1,11 +1,20 @@ { "name": "telegram", - "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", - "version": "0.0.1", + "description": "Telegram channel for Claude Code — messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", + "version": "0.0.2", "keywords": [ "telegram", "messaging", "channel", "mcp" - ] + ], + "userConfig": { + "TELEGRAM_BOT_TOKEN": { + "type": "string", + "title": "Bot Token", + "description": "Bot token from @BotFather — format is 123456789:AAH... Stored in keychain (macOS) or ~/.claude/.credentials.json with 0600 permissions elsewhere. Never written to settings.json.", + "required": true, + "sensitive": true + } + } } diff --git a/external_plugins/telegram/.mcp.json b/external_plugins/telegram/.mcp.json index cf7195b..6ea6d25 100644 --- a/external_plugins/telegram/.mcp.json +++ b/external_plugins/telegram/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "telegram": { "command": "bun", - "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"] + "args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"], + "env": { + "TELEGRAM_BOT_TOKEN": "${user_config.TELEGRAM_BOT_TOKEN}" + } } } } diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..a919306 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -27,10 +27,12 @@ const ACCESS_FILE = join(STATE_DIR, 'access.json') const APPROVED_DIR = join(STATE_DIR, 'approved') const ENV_FILE = join(STATE_DIR, '.env') -// Load ~/.claude/channels/telegram/.env into process.env. Real env wins. -// Plugin-spawned servers don't get an env block — this is where the token lives. +// Token is injected via ${user_config.TELEGRAM_BOT_TOKEN} from .mcp.json — +// prompted at enable time, stored in keychain (macOS) or .credentials.json 0600 +// elsewhere. The .env file below is a legacy fallback for users configured +// before H1 #3617646 — real env wins, so the injected value takes precedence. try { - // Token is a credential — lock to owner. No-op on Windows (would need ACLs). + // Defensive chmod for legacy .env files (no-op on Windows). chmodSync(ENV_FILE, 0o600) for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) { const m = line.match(/^(\w+)=(.*)$/) @@ -44,8 +46,8 @@ const STATIC = process.env.TELEGRAM_ACCESS_MODE === 'static' if (!TOKEN) { process.stderr.write( `telegram channel: TELEGRAM_BOT_TOKEN required\n` + - ` set in ${ENV_FILE}\n` + - ` format: TELEGRAM_BOT_TOKEN=123456789:AAH...\n`, + ` configure via: /plugin reconfigure telegram\n` + + ` (stored in keychain/credentials.json, not settings.json)\n`, ) process.exit(1) }