diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 318de7ba4..d09db6192 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -60,7 +60,7 @@ pnpm run build 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** 2. Name it (e.g., "NanoClaw") and select your workspace 3. Go to **OAuth & Permissions** and add Bot Token Scopes: - - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` + - `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) 5. Go to **Basic Information** and copy the **Signing Secret** @@ -76,7 +76,13 @@ pnpm run build 10. Under **Subscribe to bot events**, add: - `message.channels`, `message.groups`, `message.im`, `app_mention` 11. Click **Save Changes** -12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions + +### Interactivity + +12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on +13. Set the **Request URL** to the same `https://your-domain/webhook/slack` +14. Click **Save Changes** +15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings ### Configure environment diff --git a/CLAUDE.md b/CLAUDE.md index 7115c4c92..6565e8fe5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart systemctl --user start|stop|restart nanoclaw ``` -Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here). +## Troubleshooting + +Check these first when something goes wrong: + +| What | Where | +|------|-------| +| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain | +| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) | +| Session DBs | `data/v2-sessions///` — `inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) | + +Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect. ## Supply Chain Security (pnpm) diff --git a/package.json b/package.json index ee88d9285..9b3b6fbe8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.14", + "version": "2.0.15", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index f41b3e5c0..5a0fe824b 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 66% of context window + + 133k tokens, 67% of context window diff --git a/setup/auto.ts b/setup/auto.ts index 5ce271224..392bc133f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -46,13 +46,14 @@ import { } from './lib/setup-config-parse.js'; import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js'; import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; +import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -121,12 +122,47 @@ async function main(): Promise { } } + // Detect existing .env and offer to reuse it so the user doesn't have to + // paste credentials again on a re-run. + const existingEnv = detectExistingEnv(); + if (existingEnv) { + const lines = Object.values(existingEnv.groups).map( + (g) => ` ${k.green('✓')} ${g.label}`, + ); + note(lines.join('\n'), 'Found existing configuration'); + + const reuseChoice = ensureAnswer( + await brightSelect({ + message: 'Use this existing environment?', + options: [ + { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, + { value: 'fresh', label: 'No, start fresh' }, + ], + initialValue: 'reuse', + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('existing_env_choice', reuseChoice); + + if (reuseChoice === 'reuse') { + for (const [key, value] of Object.entries(existingEnv.raw)) { + if (!process.env[key]) process.env[key] = value; + } + if (existingEnv.groups.onecli) skip.add('onecli'); + if (detectRegisteredGroups(process.cwd())) { + skip.add('cli-agent'); + skip.add('first-chat'); + } + } + } + if (!skip.has('container')) { - p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); + p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4))); p.log.message( - dimWrap( - 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.', - 4, + brandBody( + dimWrap( + 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.', + 4, + ), ), ); const res = await runWindowedStep('container', { @@ -161,9 +197,11 @@ async function main(): Promise { if (!skip.has('onecli')) { p.log.message( - dimWrap( - 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', - 4, + brandBody( + dimWrap( + 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + 4, + ), ), ); @@ -287,22 +325,27 @@ async function main(): Promise { await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.'); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker."); + p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker.")); p.log.message( - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, + brandBody( + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, + ), ); } } let displayName: string | undefined; - const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); - if (needsDisplayName) { - const fallback = process.env.USER?.trim() || 'Operator'; + async function resolveDisplayName(): Promise { + if (displayName) return displayName; const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); - displayName = preset || (await askDisplayName(fallback)); + const existing = detectExistingDisplayName(process.cwd()); + const fallback = process.env.USER?.trim() || 'Operator'; + displayName = preset || existing || (await askDisplayName(fallback)); + return displayName; } if (!skip.has('cli-agent')) { + await resolveDisplayName(); const res = await runQuietStep( 'cli-agent', { @@ -320,16 +363,18 @@ async function main(): Promise { } if (!skip.has('first-chat')) { p.log.message( - dimWrap( - "Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.", - 4, + brandBody( + dimWrap( + "Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.", + 4, + ), ), ); const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); const next = ensureAnswer( - await p.select({ + await brightSelect<'continue' | 'chat'>({ message: 'What next?', options: [ { @@ -371,6 +416,9 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip') { + await resolveDisplayName(); + } if (channelChoice === 'telegram') { await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { @@ -387,9 +435,11 @@ async function main(): Promise { await runIMessageChannel(displayName!); } else { p.log.info( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), ); } @@ -435,7 +485,7 @@ async function main(): Promise { ); } if (notes.length > 0) { - p.note(notes.join('\n'), "What's left"); + note(notes.join('\n'), "What's left"); } // "What's left" is a soft failure — we don't abort like fail(), but the // user is still stuck and a fix is exactly what claude-assist is for. @@ -467,11 +517,11 @@ async function main(): Promise { ]; const labelWidth = Math.max(...rows.map(([l]) => l.length)); const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n'); - p.note(nextSteps, 'Try these'); + note(nextSteps, 'Try these'); // Always-on warning goes before the "check your DMs" directive so the // caveat doesn't land after the user's already looked away at their phone. - p.note( + note( wrapForGutter( "NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.", 6, @@ -488,7 +538,7 @@ async function main(): Promise { // that the welcome-message signal was too easy to miss. Use p.note so it // renders with a visible box, cyan-bold the directive line, and put it // as the last thing before outro. - p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi'); + note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi'); p.outro(k.green("You're set.")); } else { p.outro(k.green("You're ready! Chat with `pnpm run chat hi`.")); @@ -510,10 +560,7 @@ function channelDmLabel(choice: ChannelChoice): string | null { case 'imessage': return 'iMessage'; case 'slack': - // Slack install doesn't wire an agent or send a welcome DM — the - // driver prints its own "finish in your Slack app" note. Falling - // through to null avoids a misleading "check your Slack DMs" banner. - return null; + return 'Slack DMs'; default: return null; } @@ -570,7 +617,7 @@ function renderPingFailureNote(result: PingResult): void { 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', 6, ); - p.note(body, 'Skipping the first chat'); + note(body, 'Skipping the first chat'); } /** @@ -585,7 +632,7 @@ function renderPingFailureNote(result: PingResult): void { * clearly optional. */ async function runFirstChat(): Promise { - p.note( + note( wrapForGutter( [ 'Your assistant runs in a sandbox on this machine.', @@ -632,7 +679,7 @@ function sendChatMessage(message: string): Promise { async function runAuthStep(): Promise { if (anthropicSecretExists()) { - p.log.success('Your Claude account is already connected.'); + p.log.success(brandBody('Your Claude account is already connected.')); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); return; } @@ -680,7 +727,7 @@ async function runAuthStep(): Promise { } async function runSubscriptionAuth(): Promise { - p.log.step('Opening the Claude sign-in flow…'); + p.log.step(brandBody('Opening the Claude sign-in flow…')); console.log(k.dim(' (a browser will open for sign-in; this part is interactive)')); console.log(); const start = Date.now(); @@ -699,7 +746,7 @@ async function runSubscriptionAuth(): Promise { ); } setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); - p.log.success('Claude account connected.'); + p.log.success(brandBody('Claude account connected.')); } async function runPasteAuth(method: 'oauth' | 'api'): Promise { @@ -709,6 +756,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { const answer = ensureAnswer( await p.password({ message: `Paste your ${label}`, + clearOnError: true, validate: (v) => { if (!v || !v.trim()) return 'Required'; if (!v.trim().startsWith(prefix)) { @@ -922,9 +970,11 @@ async function runTimezoneStep(): Promise { tz = await resolveTimezoneViaClaude(raw); } else { p.log.warn( - wrapForGutter( - "That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.", - 4, + brandBody( + wrapForGutter( + "That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.", + 4, + ), ), ); } @@ -967,7 +1017,7 @@ async function runTimezoneStep(): Promise { async function askDisplayName(fallback: string): Promise { const answer = ensureAnswer( await p.text({ - message: 'What should your assistant call you?', + message: `What should your assistant call ${accentGreen('you')}?`, placeholder: fallback, defaultValue: fallback, }), @@ -1013,6 +1063,56 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +interface ExistingEnvGroup { + label: string; + keys: string[]; +} + +const ENV_KEY_GROUPS: Record = { + onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] }, + telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] }, + discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] }, + slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] }, + signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] }, + teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] }, + whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] }, + imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] }, +}; + +function detectExistingEnv(): { groups: Record; raw: Record } | null { + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return null; + + let content: string; + try { + content = fs.readFileSync(envPath, 'utf-8'); + } catch { + return null; + } + + const raw: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + + if (Object.keys(raw).length === 0) return null; + + const groups: Record = {}; + for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) { + const found = def.keys.filter((key) => raw[key] !== undefined); + if (found.length > 0) { + groups[id] = { label: def.label, keys: found }; + } + } + + if (Object.keys(groups).length === 0) return null; + return { groups, raw }; +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { @@ -1089,7 +1189,7 @@ function maybeReexecUnderSg(): void { if (!/permission denied/i.test(err)) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.'); + p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.')); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 366868692..3638e4eec 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,6 +31,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { accentGreen, brandBody, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -155,7 +156,7 @@ async function askHasBotToken(): Promise { async function walkThroughBotCreation(): Promise { const url = 'https://discord.com/developers/applications'; - p.note( + note( [ "You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.", '', @@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void { // to find it — tokens in the Dev Portal aren't visible after first reveal, // and "Reset Token" issues a new one. if (hasExistingBot) { - p.note( + note( [ "Where to find your bot token:", '', @@ -216,7 +217,7 @@ async function walkThroughServerCreation(): Promise { // the web client and rely on the + button being visible. The steps below // are the same whether they're in the desktop app or the browser. const url = 'https://discord.com/channels/@me'; - p.note( + note( [ "A Discord server is just a private space for you and the bot. Free and takes 30 seconds.", '', @@ -239,9 +240,22 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { + const existing = process.env.DISCORD_BOT_TOKEN?.trim(); + if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('discord_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Token is required'; @@ -385,14 +399,14 @@ async function resolveOwnerUserId( } } else { p.log.info( - "Your bot is owned by a Developer Team, so we need your Discord user ID directly.", + brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."), ); } return await promptForUserIdWithDevMode(); } async function promptForUserIdWithDevMode(): Promise { - p.note( + note( [ "To get your Discord user ID:", '', @@ -430,7 +444,7 @@ async function promptInviteBot( `&scope=bot` + `&permissions=${INVITE_PERMISSIONS}`; - p.note( + note( [ `@${botUsername} needs to share a server with you before it can DM you.`, '', @@ -506,7 +520,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129fa8..a2654c083 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,7 @@ import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { wrapForGutter } from '../lib/theme.js'; +import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -189,7 +189,7 @@ async function walkThroughFullDiskAccess(): Promise { } const nodeDir = path.dirname(nodePath); - p.note( + note( wrapForGutter( [ `iMessage needs Full Disk Access granted to the Node binary:`, @@ -222,7 +222,20 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - p.note( + const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); + const existingKey = process.env.IMESSAGE_API_KEY?.trim(); + if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Photon credentials (${existingUrl}). Use them?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('imessage_remote_creds', 'reused-existing'); + return { serverUrl: existingUrl, apiKey: existingKey }; + } + } + + note( [ "Photon is a separate service that owns an iMessage account and", "exposes it over HTTP. NanoClaw will talk to it via its API.", @@ -250,6 +263,7 @@ async function collectRemoteCreds(): Promise { const keyAnswer = ensureAnswer( await p.password({ message: 'Photon API key', + clearOnError: true, validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'), }), ); @@ -264,7 +278,7 @@ async function collectRemoteCreds(): Promise { } async function askOperatorHandle(): Promise { - p.note( + note( [ "What phone number or email do you iMessage with?", "That's where your assistant will send its welcome message.", @@ -303,7 +317,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 9e54cb971..0c5718ec7 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,6 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; +import { accentGreen, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise { if (!probe.error && probe.status === 0) return; if (process.platform === 'darwin') { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', @@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise { 'signal-cli not found', ); } else { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', @@ -346,7 +347,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index f66c29afb..167fa726a 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -1,24 +1,23 @@ /** * Slack channel flow for setup:auto. * - * `runSlackChannel(displayName)` walks the operator from a bare Slack - * workspace through a running bot, then stops before wiring an agent: + * `runSlackChannel(displayName)` owns the full branch from creating a + * Slack app through the welcome DM: * * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, * event subscriptions, and signing secret * 2. Paste the bot token + signing secret (clack password prompts) * 3. Validate via auth.test → resolves workspace + bot identity * 4. Install the adapter (setup/add-slack.sh, non-interactive) - * 5. Print the post-install checklist: set the public webhook URL in - * Slack's Event Subscriptions, DM the bot to bootstrap the channel, - * then `/manage-channels` to wire an agent. + * 5. Ask for the operator's Slack user ID + * 6. conversations.open to get the DM channel ID + * 7. Ask for the messaging-agent name (defaulting to "Nano") + * 8. Wire the agent via scripts/init-first-agent.ts * - * Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll), - * Slack needs a public Event Subscriptions URL for inbound events, and - * opening an unsolicited DM would need `im:write` scope we don't force - * the SKILL.md to require. Shipping a honest "here's what's left" note - * is better than a welcome DM the user won't receive until they - * configure the webhook anyway. + * The welcome DM is sent via outbound delivery (chat.postMessage), which + * works without Event Subscriptions being configured. The user sees the + * greeting in Slack immediately; inbound replies require webhooks, so the + * post-install note covers that. * * All output obeys the three-level contract. See docs/setup-flow.md. */ @@ -27,11 +26,13 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { wrapForGutter } from '../lib/theme.js'; +import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; +const DEFAULT_AGENT_NAME = 'Nano'; interface WorkspaceInfo { teamName: string; @@ -40,10 +41,7 @@ interface WorkspaceInfo { botUserId: string; } -// displayName is reserved for when we start wiring the first agent here. -// Kept to match the `runChannel(displayName)` signature every other -// channel driver uses, so auto.ts can dispatch without a branch. -export async function runSlackChannel(_displayName: string): Promise { +export async function runSlackChannel(displayName: string): Promise { await walkThroughAppCreation(); const token = await collectBotToken(); @@ -78,19 +76,61 @@ export async function runSlackChannel(_displayName: string): Promise { ); } + const ownerUserId = await collectSlackUserId(); + const dmChannelId = await openDmChannel(token, ownerUserId); + const platformId = `slack:${dmChannelId}`; + + const role = await askOperatorRole('Slack'); + setupLog.userInput('slack_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'slack', + '--user-id', `slack:${ownerUserId}`, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Wiring ${agentName} to your Slack DMs…`, + done: 'Agent wired.', + }, + { + extraFields: { + CHANNEL: 'slack', + AGENT_NAME: agentName, + PLATFORM_ID: platformId, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/init-first-agent` in Claude Code.', + ); + } + showPostInstallChecklist(info); } async function walkThroughAppCreation(): Promise { - p.note( + note( [ "You'll create a Slack app that the assistant talks through.", "Free and stays inside the workspaces you pick.", '', ' 1. Create a new app "From scratch", name it, pick a workspace', ' 2. OAuth & Permissions → add Bot Token Scopes:', - ' chat:write, channels:history, groups:history, im:history,', - ' channels:read, groups:read, users:read, reactions:write', + ' chat:write, im:write, channels:history, groups:history,', + ' im:history, channels:read, groups:read, users:read,', + ' reactions:write', ' 3. App Home → enable "Messages Tab" and "Allow users to send', ' slash commands and messages from the messages tab"', ' 4. Basic Information → copy the "Signing Secret"', @@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { + const existing = process.env.SLACK_BOT_TOKEN?.trim(); + if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_bot_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack bot token', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Token is required'; @@ -132,9 +185,22 @@ async function collectBotToken(): Promise { } async function collectSigningSecret(): Promise { + const existing = process.env.SLACK_SIGNING_SECRET?.trim(); + if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: 'Found an existing Slack signing secret. Use it?', + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_signing_secret', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack signing secret', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Signing secret is required'; @@ -221,26 +287,135 @@ async function validateSlackToken(token: string): Promise { } } +async function collectSlackUserId(): Promise { + note( + [ + "To get your Slack member ID:", + '', + ' 1. In Slack, click your profile picture (top right)', + ' 2. Click "Profile"', + ' 3. Click the three dots (⋯) → "Copy member ID"', + ].join('\n'), + 'Find your Slack user ID', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Paste your Slack member ID', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Member ID is required'; + if (!/^U[A-Z0-9]{8,}$/.test(t)) { + return "That doesn't look like a Slack member ID (starts with U)"; + } + return undefined; + }, + }), + ); + const id = (answer as string).trim(); + setupLog.userInput('slack_user_id', id); + return id; +} + +async function openDmChannel(token: string, userId: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Opening a DM channel…'); + try { + const res = await fetch(`${SLACK_API}/conversations.open`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ users: userId }), + }); + const data = (await res.json()) as { + ok?: boolean; + channel?: { id?: string }; + error?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.channel?.id) { + s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('slack-open-dm', 'success', Date.now() - start, { + DM_CHANNEL_ID: data.channel.id, + }); + return data.channel.id; + } + const reason = data.error ?? `HTTP ${res.status}`; + s.stop(`Couldn't open a DM channel: ${reason}`, 1); + setupLog.step('slack-open-dm', 'failed', Date.now() - start, { + ERROR: reason, + }); + if (reason === 'missing_scope') { + await fail( + 'slack-open-dm', + "Your Slack app is missing the im:write scope.", + 'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.', + ); + } + await fail( + 'slack-open-dm', + "Couldn't open a DM channel with you.", + `Slack said "${reason}". Check the member ID and app permissions, then retry.`, + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('slack-open-dm', 'failed', Date.now() - start, { + ERROR: message, + }); + await fail( + 'slack-open-dm', + "Couldn't reach Slack.", + 'Check your internet connection and retry setup.', + ); + } +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: `What should your ${accentGreen('assistant')} be called?`, + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} + function showPostInstallChecklist(info: WorkspaceInfo): void { - p.note( + note( wrapForGutter( [ - `The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`, + `Your agent is wired to Slack and a welcome DM is on its way.`, + `To receive replies, Slack needs a public URL for delivering events:`, '', - ' 1. A public URL so Slack can deliver events.', - ' NanoClaw serves a webhook on port 3000 by default — expose it', - ' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.', + ' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,', + ' Cloudflare Tunnel, or a reverse proxy on a VPS.', '', ' 2. In your Slack app → Event Subscriptions:', ' • Toggle "Enable Events" on', ` • Request URL: https:///webhook/slack`, ' • Subscribe to bot events: message.channels, message.groups,', ' message.im, app_mention', - ' • Save, then reinstall the app when Slack prompts', + ' • Save Changes', '', - ` 3. DM @${info.botName} from Slack once — that bootstraps the`, - ' messaging group. Then run `/manage-channels` in `claude` to', - ' wire an agent to it.', + ' 3. In your Slack app → Interactivity & Shortcuts:', + ' • Toggle "Interactivity" on', + ` • Request URL: https:///webhook/slack`, + ' • Save Changes', + '', + ' 4. Slack will prompt you to reinstall the app — do it to apply', + ' the new settings', ].join('\n'), 6, ), diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878de..01839c40d 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -40,6 +40,7 @@ import { } from '../lib/claude-handoff.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; +import { note } from '../lib/theme.js'; import * as setupLog from '../logs.js'; const CHANNEL = 'teams'; @@ -59,6 +60,28 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; + const existingAppId = process.env.TEAMS_APP_ID?.trim(); + const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); + if (existingAppId && existingPassword) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, + initialValue: true, + })); + if (reuse) { + collected.appId = existingAppId; + collected.appPassword = existingPassword; + collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + if (collected.appType === 'SingleTenant') { + collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); + } + setupLog.userInput('teams_credentials', 'reused-existing'); + await installAdapter(collected); + completed.push('Adapter installed and service restarted (reused existing credentials).'); + await finishWithHandoff(collected, completed); + return; + } + } + printIntro(); await confirmPrereqs({ collected, completed }); @@ -79,7 +102,7 @@ export async function runTeamsChannel(_displayName: string): Promise { // ─── step: intro / prereqs ────────────────────────────────────────────── function printIntro(): void { - p.note( + note( [ 'Setting up Teams is more involved than the other channels — about', '7 steps across the Azure portal and Teams admin.', @@ -93,7 +116,7 @@ function printIntro(): void { } async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ 'Before we start, confirm you have:', '', @@ -119,7 +142,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] // ─── step: public URL ────────────────────────────────────────────────── async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ "Azure Bot Service delivers messages to an HTTPS endpoint you", "control. The endpoint needs to reach this machine's webhook", @@ -175,7 +198,7 @@ async function stepAppRegistration(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, '2. Name it (e.g. "NanoClaw")', @@ -259,7 +282,7 @@ async function stepClientSecret(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In your app registration, open "Certificates & secrets"`, '2. Click "New client secret"', @@ -276,6 +299,7 @@ async function stepClientSecret(args: { const answer = ensureAnswer( await p.password({ message: 'Paste the client secret Value', + clearOnError: true, validate: validateWithHelpEscape((v) => { const t = (v ?? '').trim(); if (!t) return 'Required'; @@ -328,7 +352,7 @@ async function stepAzureBot(args: { ` --appid ${args.collected.appId} \\\n` + ` ${tenantFlag}--endpoint "${endpoint}"`; - p.note( + note( [ `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, '', @@ -365,7 +389,7 @@ async function stepEnableTeamsChannel(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ '1. Open your Azure Bot resource → Channels', '2. Click Microsoft Teams → Accept terms → Apply', @@ -435,7 +459,7 @@ async function stepSideload(args: { completed: string[]; zipPath: string; }): Promise { - p.note( + note( [ '1. Open Microsoft Teams', '2. Go to Apps → Manage your apps → Upload an app', @@ -501,7 +525,7 @@ async function finishWithHandoff( collected: Collected, completed: string[], ): Promise { - p.note( + note( [ 'The Teams adapter is live and the service is running.', '', @@ -530,7 +554,7 @@ async function finishWithHandoff( ); if (choice === 'self') { - p.note( + note( [ ' 1. Find your bot in Teams (search by name, or via the sideloaded', ' app) and send it a message ("hi" is fine)', diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcff1..5681fd110 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold } from '../lib/theme.js'; +import { accentGreen, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -47,7 +47,7 @@ export async function runTelegramChannel(displayName: string): Promise { // installed, or the bot's web profile if not. tg://resolve?domain= is // more direct but silently fails when the scheme isn't registered. const botUrl = `https://t.me/${botUsername}`; - p.note( + note( [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, '', @@ -132,7 +132,19 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - p.note( + const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); + if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('telegram_token', 'reused-existing'); + return existing; + } + } + + note( [ "Your assistant talks to you through a Telegram bot you create.", "Here's how:", @@ -150,6 +162,7 @@ async function collectTelegramToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', + clearOnError: true, validate: (v) => { if (!v || !v.trim()) return "Token is required"; if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { @@ -240,7 +253,7 @@ async function runPairTelegram(): Promise< } else { stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); s.start('Waiting for you to send the code from Telegram…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { @@ -291,7 +304,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 85c98663a..96d23d53c 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBold } from '../lib/theme.js'; +import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -171,7 +171,7 @@ async function askAuthMethod(): Promise { } async function askPhoneNumber(): Promise { - p.note( + note( [ "Enter your phone number the way WhatsApp expects it:", '', @@ -249,7 +249,7 @@ async function runWhatsAppAuth( } else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') { const code = block.fields.CODE ?? '????'; stopSpinner('Your pairing code is ready.'); - p.note(formatPairingCard(code), 'Pairing code'); + note(formatPairingCard(code), 'Pairing code'); s.start('Waiting for you to enter the code…'); spinnerActive = true; } else if (block.type === 'WHATSAPP_AUTH') { @@ -267,7 +267,7 @@ async function runWhatsAppAuth( if (spinnerActive) { stopSpinner('WhatsApp linked.'); } else { - p.log.success('WhatsApp linked.'); + p.log.success(brandBody('WhatsApp linked.')); } } else if (status === 'failed') { if (qrLinesPrinted > 0) { @@ -395,7 +395,7 @@ async function restartService(): Promise { } async function askChatPhone(authedPhone: string): Promise { - p.note( + note( [ `Authenticated with ${k.cyan('+' + authedPhone)}.`, '', @@ -462,7 +462,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/environment.ts b/setup/environment.ts index 6986396d7..c351023ab 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,24 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectExistingDisplayName(projectRoot: string): string | null { + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return null; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`) + .get() as { display_name: string } | undefined; + return row?.display_name?.trim() || null; + } catch { + return null; + } finally { + db?.close(); + } +} + export function detectRegisteredGroups(projectRoot: string): boolean { if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { return true; diff --git a/setup/lib/bright-select.ts b/setup/lib/bright-select.ts index 94c48385c..96c5de48b 100644 --- a/setup/lib/bright-select.ts +++ b/setup/lib/bright-select.ts @@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core'; import { isCancel } from '@clack/prompts'; import { styleText } from 'node:util'; +import { brandBody } from './theme.js'; + const BULLET_ACTIVE = '●'; const BULLET_INACTIVE = '○'; const BAR = '│'; @@ -95,7 +97,7 @@ export function brightSelect( const shown = st === 'cancel' ? styleText(['strikethrough', 'dim'], selected) - : styleText('dim', selected); + : styleText('dim', brandBody(selected)); lines.push(`${grayBar} ${shown}`); return lines.join('\n'); } @@ -104,11 +106,12 @@ export function brightSelect( options.forEach((opt, idx) => { const label = opt.label ?? String(opt.value); const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : ''; - const marker = - idx === cursor - ? styleText('green', BULLET_ACTIVE) - : styleText('dim', BULLET_INACTIVE); - lines.push(`${bar} ${marker} ${label}${hint}`); + const isActive = idx === cursor; + const marker = isActive + ? styleText('green', BULLET_ACTIVE) + : styleText('dim', BULLET_INACTIVE); + const shownLabel = isActive ? brandBody(label) : label; + lines.push(`${bar} ${marker} ${shownLabel}${hint}`); }); lines.push(styleText(color, CAP_BOT)); return lines.join('\n'); diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c12..e76a4fcfd 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -2,8 +2,11 @@ * Offer Claude-assisted debugging when a setup step fails. * * Flow: - * 1. Check `claude` is on PATH and has a working credential. If not, - * silently skip — pre-auth failures can't use this path. + * 1. Check `claude` is on PATH — if not, offer to install it via + * setup/install-claude.sh. Then check auth via `claude auth status` + * — if not signed in, offer to run `claude setup-token` (browser + * OAuth with code-paste fallback for headless/remote systems). + * If either is declined or fails, silently skip. * 2. Ask the user for consent ("Want me to ask Claude for a fix?"). * 3. Build a minimal prompt: the one-paragraph situation, the failing * step's name/message/hint, and a short list of *file references* @@ -16,15 +19,16 @@ * * Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs. */ -import { execSync, spawn } from 'child_process'; +import { execSync, spawn, spawnSync } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -90,7 +94,7 @@ export async function offerClaudeAssist( projectRoot: string = process.cwd(), ): Promise { if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; - if (!isClaudeUsable()) return false; + if (!(await ensureClaudeReady(projectRoot))) return false; const want = ensureAnswer( await p.confirm({ @@ -106,12 +110,12 @@ export async function offerClaudeAssist( const parsed = parseResponse(response); if (!parsed) { - p.log.warn("Claude responded but I couldn't parse a command out of it."); + p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it.")); p.log.message(k.dim(response.trim().slice(0, 500))); return false; } - p.note( + note( `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, "Claude's suggestion", ); @@ -128,15 +132,101 @@ export async function offerClaudeAssist( return true; } -function isClaudeUsable(): boolean { +function isClaudeInstalled(): boolean { try { execSync('command -v claude', { stdio: 'ignore' }); + return true; } catch { return false; } - // Availability without auth is half the story; a real query will still - // fail if the token isn't registered. We try first and surface the error - // rather than pre-checking auth with a separate round trip. +} + +function isClaudeAuthenticated(): boolean { + try { + execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +async function ensureClaudeReady(projectRoot: string): Promise { + if (!isClaudeInstalled()) { + const install = ensureAnswer( + await p.confirm({ + message: + 'Claude CLI is needed to diagnose this. Install it now?', + initialValue: true, + }), + ); + if (!install) return false; + + const code = spawnSync('bash', ['setup/install-claude.sh'], { + cwd: projectRoot, + stdio: 'inherit', + }).status; + if (code !== 0 || !isClaudeInstalled()) { + p.log.error("Couldn't install the Claude CLI."); + return false; + } + p.log.success('Claude CLI installed.'); + } + + if (!isClaudeAuthenticated()) { + const auth = ensureAnswer( + await p.confirm({ + message: + "Claude CLI isn't signed in. Sign in now? (a browser will open)", + initialValue: true, + }), + ); + if (!auth) return false; + + // setup-token has an interactive TUI; reset terminal to cooked mode + // so its prompts render correctly after clack's raw-mode prompts. + spawnSync('stty', ['sane'], { stdio: 'inherit' }); + + // Run under script(1) to capture the OAuth token from PTY output + // while preserving interactive TTY for the browser OAuth flow. + // Same approach as register-claude-token.sh, but we set the env var + // instead of writing to OneCLI. + const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`); + try { + const isUtilLinux = (() => { + try { + return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux'); + } catch { return false; } + })(); + const scriptArgs = isUtilLinux + ? ['-q', '-c', 'claude setup-token', tmpfile] + : ['-q', tmpfile, 'claude', 'setup-token']; + + spawnSync('script', scriptArgs, { + cwd: projectRoot, + stdio: 'inherit', + }); + + if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) { + const raw = fs.readFileSync(tmpfile, 'utf-8'); + const stripped = raw + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + .replace(/[\n\r]/g, ''); + const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g); + if (matches) { + process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1]; + } + } + } finally { + try { fs.unlinkSync(tmpfile); } catch {} + } + + if (!isClaudeAuthenticated()) { + p.log.error("Couldn't complete Claude sign-in."); + return false; + } + p.log.success('Claude CLI signed in.'); + } + return true; } @@ -268,7 +358,7 @@ async function queryClaudeUnderSpinner( const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${elapsed}s)`; if (kind === 'ok') { - p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`); + p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`); resolve(payload); } else { p.log.error( diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 9c931f25a..87023ef26 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { brandBody, note } from './theme.js'; + export interface HandoffContext { /** Channel this handoff is happening in (e.g., 'teams'). */ channel: string; @@ -62,14 +64,14 @@ export interface HandoffContext { export async function offerClaudeHandoff(ctx: HandoffContext): Promise { if (!isClaudeUsable()) { p.log.warn( - "Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.", + brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."), ); return false; } const systemPrompt = buildSystemPrompt(ctx); - p.note( + note( [ "I'm handing you off to Claude in interactive mode.", "It has the context of where you are in setup.", @@ -91,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise { stdio: 'inherit' }, ); child.on('close', () => { - p.log.success("Back from Claude. Let's continue."); + p.log.success(brandBody("Back from Claude. Let's continue.")); resolve(true); }); child.on('error', () => { diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index c1599e4db..cf7a86da8 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -20,7 +20,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { offerClaudeAssist } from './claude-assist.js'; import { emit as phEmit } from './diagnostics.js'; -import { fitToWidth } from './theme.js'; +import { brandBody, fitToWidth } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -390,7 +390,7 @@ export async function fail( const skipList = [ ...new Set([...existingSkip, ...setupLog.completedStepNames()]), ].join(','); - p.log.step(`Retrying from ${stepName}…`); + p.log.step(brandBody(`Retrying from ${stepName}…`)); const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_SKIP: skipList }, diff --git a/setup/lib/setup-config-screen.ts b/setup/lib/setup-config-screen.ts index ad8ae6253..88b10d52e 100644 --- a/setup/lib/setup-config-screen.ts +++ b/setup/lib/setup-config-screen.ts @@ -115,7 +115,7 @@ async function promptOne(e: Entry, values: ConfigValues): Promise { }; const ans = ensureAnswer( e.secret - ? await p.password({ message: e.label, validate }) + ? await p.password({ message: e.label, clearOnError: true, validate }) : await p.text({ message: e.label, placeholder: e.placeholder ?? e.default, diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 35b5ca343..0dfa53f74 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -11,6 +11,7 @@ * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) * - Otherwise → kleur's 16-color cyan (closest fallback) */ +import * as p from '@clack/prompts'; import k from 'kleur'; const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; @@ -38,6 +39,41 @@ export function brandChip(s: string): string { return k.bgCyan(k.black(k.bold(s))); } +/** + * Accent green (#3fba50) for emphasizing a single word inside prompt + * messages — currently the "you" in "What should your assistant call + * you?" so the operator parses at a glance who the question is about. + * Same TTY/NO_COLOR/truecolor gating as the rest of the palette. + */ +export function accentGreen(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`; + return k.green(s); +} + +/** + * Brand body color for setup-flow prose. Used for card bodies (via the + * `note()` formatter) and `p.log.*` body arguments — anywhere the + * previous "dim" treatment was making prose hard to read or washing + * out embedded brand emphasis. + * + * Multi-line input is colored line-by-line so embedded line breaks + * don't bleed the SGR sequence across clack's gutter prefix. + */ +export function brandBody(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) { + return s + .split('\n') + .map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line)) + .join('\n'); + } + return s + .split('\n') + .map((line) => (line.length > 0 ? k.cyan(line) : line)) + .join('\n'); +} + /** * Wrap text so it fits inside clack's gutter without the terminal's soft * wrap breaking the `│ …` bar on long lines. Works on a single string with @@ -68,6 +104,16 @@ export function dimWrap(text: string, gutter: number): string { return wrapForGutter(text, gutter); } +/** + * Wrap clack's `p.note` so card bodies render in the brand body color + * (#2b6fdc) instead of clack's default dim. Clack runs the formatter + * on each line individually, so `brandBody` colors each line cleanly + * without bleeding across the gutter prefix. + */ +export function note(message: string, title?: string): void { + p.note(message, title, { format: brandBody }); +} + const ANSI_RE = /\x1b\[[0-9;]*m/g; function visibleLength(s: string): number { diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 875aba613..6f165a47e 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; import * as setupLog from '../logs.js'; -import { fitToWidth } from './theme.js'; +import { brandBody, fitToWidth } from './theme.js'; const WINDOW_SIZE = 3; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; @@ -169,7 +169,7 @@ async function runUnderWindow( if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; - p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`); + p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`); } else { const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`); @@ -185,7 +185,7 @@ async function handleStall( ): Promise { render.pauseRender(); p.log.warn( - `This looks stuck — no output from the ${stepName} step for the last 60 seconds.`, + brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`), ); phEmit('step_stalled', { step: stepName }); diff --git a/src/attachment-safety.ts b/src/attachment-safety.ts new file mode 100644 index 000000000..85467f935 --- /dev/null +++ b/src/attachment-safety.ts @@ -0,0 +1,23 @@ +import path from 'path'; + +/** + * Is `name` safe to use as the last segment of a path inside an + * attachment-staging directory? Filenames originate from untrusted sources — + * channel messages from any chat participant, agent-to-agent forwards from + * a possibly-compromised peer agent — and land in `path.join(dir, name)` + * sinks on the host. Without this guard, a `..`-laden name escapes the + * inbox and writes anywhere the host process has filesystem permission. + * + * Rejects: + * - non-string / empty + * - `.` / `..` (traversal sentinels that path.basename returns as-is) + * - anything containing a path separator (`/` or `\`) or NUL + * - any value where `path.basename(name) !== name`, catching OS-specific + * separators and covering drives/prefixes on Windows runtimes + */ +export function isSafeAttachmentName(name: string): boolean { + if (typeof name !== 'string' || name.length === 0) return false; + if (name === '.' || name === '..') return false; + if (/[\\/\0]/.test(name)) return false; + return path.basename(name) === name; +} diff --git a/src/circuit-breaker.test.ts b/src/circuit-breaker.test.ts new file mode 100644 index 000000000..d8c996c1c --- /dev/null +++ b/src/circuit-breaker.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the startup circuit breaker. + * + * Covers state transitions, the documented backoff schedule, and the + * fresh-install case where DATA_DIR doesn't exist yet (the breaker runs + * before initDb, so it has to create the dir itself). + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// vi.mock factories are hoisted above imports, so they can't close over local +// consts. vi.hoisted is hoisted alongside the mock and runs before any +// `import` — so it can only use globals (no path/os modules). Use require() +// inside the callback to compute the test dir. +const { TEST_DIR } = vi.hoisted(() => { + const nodePath = require('path') as typeof import('path'); + const nodeOs = require('os') as typeof import('os'); + return { TEST_DIR: nodePath.join(nodeOs.tmpdir(), 'nanoclaw-cb-test') }; +}); +const CB_PATH = path.join(TEST_DIR, 'circuit-breaker.json'); + +vi.mock('./config.js', async () => { + const actual = await vi.importActual('./config.js'); + return { ...actual, DATA_DIR: TEST_DIR }; +}); + +vi.mock('./log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; + +function readState(): { attempt: number; timestamp: string } { + return JSON.parse(fs.readFileSync(CB_PATH, 'utf-8')); +} + +function seedState(attempt: number, timestamp = new Date().toISOString()): void { + fs.writeFileSync(CB_PATH, JSON.stringify({ attempt, timestamp })); +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + vi.useRealTimers(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +describe('resetCircuitBreaker', () => { + it('deletes the state file', () => { + seedState(3); + expect(fs.existsSync(CB_PATH)).toBe(true); + resetCircuitBreaker(); + expect(fs.existsSync(CB_PATH)).toBe(false); + }); + + it('is a no-op when the file does not exist', () => { + expect(fs.existsSync(CB_PATH)).toBe(false); + expect(() => resetCircuitBreaker()).not.toThrow(); + }); +}); + +describe('enforceStartupBackoff — state transitions', () => { + it('first run writes attempt=1 and does not delay', async () => { + vi.useFakeTimers(); + const start = Date.now(); + await enforceStartupBackoff(); + // No timers should have been queued — clean first start is 0s. + expect(Date.now() - start).toBe(0); + expect(readState().attempt).toBe(1); + }); + + it('within reset window, attempt is incremented', async () => { + seedState(1); + vi.useFakeTimers(); + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + expect(readState().attempt).toBe(2); + }); + + it('outside reset window (>1h), attempt resets to 1', async () => { + const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + seedState(5, longAgo); + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); + + it('exactly at the reset window boundary still counts as "within"', async () => { + // RESET_WINDOW_MS = 60min. Use 59min59s to stay inside even if the test + // takes a few ms to execute. + const justInside = new Date(Date.now() - (60 * 60 * 1000 - 1000)).toISOString(); + seedState(2, justInside); + vi.useFakeTimers(); + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + expect(readState().attempt).toBe(3); + }); + + it('treats a malformed state file as no prior state', async () => { + fs.writeFileSync(CB_PATH, '{ this is not json'); + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); + + it('resetCircuitBreaker after a startup actually clears the counter for the next startup', async () => { + // Simulate: crash, restart (attempt=2), graceful shutdown, restart again. + seedState(1); + vi.useFakeTimers(); + const p1 = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await p1; + expect(readState().attempt).toBe(2); + + resetCircuitBreaker(); + expect(fs.existsSync(CB_PATH)).toBe(false); + + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); +}); + +describe('enforceStartupBackoff — backoff schedule', () => { + /** + * Documented schedule: + * + * clean start → 1 crash → 2 crash → 3 crash → 4 crash → 5 crash → 6+ crash + * 0s → 0s → 10s → 30s → 2min → 5min → 15min cap + * + * Each row is [priorAttempt seeded in the file, expected delay this run + * produces in seconds]. priorAttempt=null = no file = very first start. + * + * To assert the *requested* delay (not just observed elapsed real time), + * we spy on global.setTimeout and look at the longest call. runAllTimersAsync + * lets the function complete so we can move on. + */ + const cases: Array<{ label: string; priorAttempt: number | null; expectedDelaySec: number }> = [ + { label: 'clean first start (no file)', priorAttempt: null, expectedDelaySec: 0 }, + { label: 'first crash (attempt=2)', priorAttempt: 1, expectedDelaySec: 0 }, + { label: 'second crash (attempt=3)', priorAttempt: 2, expectedDelaySec: 10 }, + { label: 'third crash (attempt=4)', priorAttempt: 3, expectedDelaySec: 30 }, + { label: 'fourth crash (attempt=5)', priorAttempt: 4, expectedDelaySec: 120 }, + { label: 'fifth crash (attempt=6)', priorAttempt: 5, expectedDelaySec: 300 }, + { label: 'sixth crash (attempt=7) — cap', priorAttempt: 6, expectedDelaySec: 900 }, + { label: 'far past cap (attempt=20)', priorAttempt: 19, expectedDelaySec: 900 }, + ]; + + for (const { label, priorAttempt, expectedDelaySec } of cases) { + it(`${label}: delays ${expectedDelaySec}s`, async () => { + if (priorAttempt !== null) seedState(priorAttempt); + + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + + // enforceStartupBackoff only calls setTimeout when delaySec > 0. Pick + // the longest delay it requested (vitest may queue small internal + // timers we don't care about). + const requestedDelays = setTimeoutSpy.mock.calls.map((c) => c[1] ?? 0); + const maxDelayMs = requestedDelays.length ? Math.max(...requestedDelays) : 0; + + expect(maxDelayMs).toBe(expectedDelaySec * 1000); + }); + } +}); + +describe('enforceStartupBackoff — fresh install (DATA_DIR missing)', () => { + /** + * The breaker runs before initDb (which is what creates DATA_DIR). On a + * fresh checkout the dir doesn't exist yet, so write() must create it + * before writing the state file — otherwise the host crashes on its very + * first start. + */ + it('creates DATA_DIR on demand and does not throw', async () => { + fs.rmSync(TEST_DIR, { recursive: true }); + expect(fs.existsSync(TEST_DIR)).toBe(false); + + await expect(enforceStartupBackoff()).resolves.toBeUndefined(); + expect(fs.existsSync(TEST_DIR)).toBe(true); + expect(fs.existsSync(CB_PATH)).toBe(true); + expect(readState().attempt).toBe(1); + }); +}); diff --git a/src/circuit-breaker.ts b/src/circuit-breaker.ts new file mode 100644 index 000000000..20211f049 --- /dev/null +++ b/src/circuit-breaker.ts @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { log } from './log.js'; + +const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json'); +const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour +// Index = number of consecutive crashes (0 = clean start, attempt 1). +// 6+ crashes capped at 15min. +const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; + +interface CircuitBreakerState { + attempt: number; + timestamp: string; +} + +function read(): CircuitBreakerState | null { + try { + const raw = fs.readFileSync(CB_PATH, 'utf-8'); + return JSON.parse(raw) as CircuitBreakerState; + } catch { + return null; + } +} + +function write(state: CircuitBreakerState): void { + // The breaker runs before initDb (which is what creates DATA_DIR), so on a + // fresh checkout the dir may not exist yet. + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n'); +} + +function getDelay(attempt: number): number { + const idx = Math.min(attempt - 1, BACKOFF_SCHEDULE_S.length - 1); + return BACKOFF_SCHEDULE_S[idx]; +} + +export function resetCircuitBreaker(): void { + try { + fs.unlinkSync(CB_PATH); + log.info('Circuit breaker reset on clean shutdown'); + } catch {} +} + +export async function enforceStartupBackoff(): Promise { + const now = new Date(); + const prev = read(); + + let attempt: number; + if (!prev) { + attempt = 1; + } else { + const elapsedMs = now.getTime() - new Date(prev.timestamp).getTime(); + if (elapsedMs < RESET_WINDOW_MS) { + attempt = prev.attempt + 1; + log.warn('Previous startup was not a clean shutdown', { + previousAttempt: prev.attempt, + previousTimestamp: prev.timestamp, + elapsedSec: Math.round(elapsedMs / 1000), + }); + } else { + attempt = 1; + log.info('Circuit breaker reset — last startup was over 1h ago', { + previousAttempt: prev.attempt, + previousTimestamp: prev.timestamp, + }); + } + } + + write({ attempt, timestamp: now.toISOString() }); + + const delaySec = getDelay(attempt); + if (delaySec > 0) { + const resumeAt = new Date(now.getTime() + delaySec * 1000).toISOString(); + log.warn('Circuit breaker: delaying startup due to repeated crashes', { + attempt, + delaySec, + resumeAt, + }); + await new Promise((resolve) => setTimeout(resolve, delaySec * 1000)); + log.info('Circuit breaker: backoff complete, resuming startup', { attempt }); + } +} diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 9906c4b83..2bb72d4b6 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -173,6 +173,43 @@ describe('session manager', () => { expect(getSession(session.id)!.last_active).not.toBeNull(); }); + + it('should refuse path-traversal in attachment filenames', () => { + // Regression: attachment.name comes from untrusted senders (E2EE-protected + // chat platforms can't sanitize it server-side). Without the guard, a + // `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere + // the host process can reach. + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox'); + const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary'); + if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-attack', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'pwn', + attachments: [ + { + type: 'document', + name: '../../../../../../../../tmp/nanoclaw-traversal-canary', + data: Buffer.from('owned').toString('base64'), + }, + ], + }), + }); + + expect(fs.existsSync(escapeTarget)).toBe(false); + // The bytes should still land — under a synthesized safe name inside the + // inbox — so the agent doesn't lose data on a malicious filename. + const inboxDir = path.join(inboxBase, 'msg-attack'); + expect(fs.existsSync(inboxDir)).toBe(true); + const written = fs.readdirSync(inboxDir); + expect(written).toHaveLength(1); + expect(written[0]).not.toContain('/'); + expect(written[0]).not.toContain('..'); + }); }); describe('router', () => { diff --git a/src/index.ts b/src/index.ts index ea9fba63c..9ded3d665 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; +import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; @@ -58,6 +59,9 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from async function main(): Promise { log.info('NanoClaw starting'); + // 0. Circuit breaker — backoff on rapid restarts + await enforceStartupBackoff(); + // 1. Init central DB const dbPath = path.join(DATA_DIR, 'v2.db'); const db = initDb(dbPath); @@ -174,8 +178,15 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); - await teardownChannelAdapters(); - process.exit(0); + try { + await teardownChannelAdapters(); + } finally { + // Always reset on graceful shutdown — even if teardown threw, we got here + // via SIGTERM/SIGINT, not a crash, so the next start shouldn't be counted + // as one. + resetCircuitBreaker(); + process.exit(0); + } } process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 812cb8eb5..613a1edfb 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -21,6 +21,7 @@ import fs from 'fs'; import path from 'path'; +import { isSafeAttachmentName } from '../../attachment-safety.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; @@ -29,6 +30,8 @@ import { resolveSession, sessionDir, writeSessionMessage } from '../../session-m import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; +export { isSafeAttachmentName }; + export interface ForwardedAttachment { name: string; filename: string; @@ -36,26 +39,6 @@ export interface ForwardedAttachment { localPath: string; } -/** - * Is `name` safe to use as the last segment of a path inside the target - * agent's inbox directory? Filenames arrive in messages_out content from - * the source agent — under a multi-agent setup with heterogenous providers - * (or a compromised / hallucinating sub-agent) they can't be trusted. - * - * Rejects: - * - empty string - * - `.` / `..` (traversal sentinels that path.basename returns as-is) - * - anything containing a path separator (`/` or `\`) or NUL - * - any value where `path.basename(name) !== name`, catching OS-specific - * separators and covering drives/prefixes on Windows runtimes - */ -export function isSafeAttachmentName(name: string): boolean { - if (typeof name !== 'string' || name.length === 0) return false; - if (name === '.' || name === '..') return false; - if (/[\\/\0]/.test(name)) return false; - return path.basename(name) === name; -} - /** * Copy file attachments from the source agent's outbox into the target * agent's inbox. Returns attachments using the formatter's existing diff --git a/src/router.ts b/src/router.ts index 69d73131e..ad4cd56fa 100644 --- a/src/router.ts +++ b/src/router.ts @@ -289,7 +289,14 @@ export async function routeInbound(event: InboundEvent): Promise { log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err }); }); } - } else if (agent.ignored_message_policy === 'accumulate') { + } else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) { + // Accumulate stores the message as silent context. We allow it when + // engagement simply didn't fire, but NOT when engagement fired and + // the access/scope gate refused — those refusals are security + // decisions about an untrusted sender, and silently storing their + // message (which also stages their attachments to disk via + // writeSessionMessage → extractAttachmentFiles) is exactly what the + // gate is meant to prevent. await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false); accumulatedCount++; } else { diff --git a/src/session-manager.ts b/src/session-manager.ts index 38eaa0d76..996a750bc 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,6 +14,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { isSafeAttachmentName } from './attachment-safety.js'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; import { getMessagingGroup } from './db/messaging-groups.js'; @@ -252,11 +253,26 @@ function extractAttachmentFiles( let changed = false; for (const att of attachments) { if (typeof att.data === 'string') { + // The name field is attacker-controlled: chat platforms with E2E + // attachment encryption (WhatsApp, Matrix) cannot sanitize filename + // server-side, and other adapters pass att.name through raw. Without + // this guard, `path.join(inboxDir, '../../...')` writes anywhere the + // host process has fs permission — see Signal Desktop's Nov 2025 + // attachment-fileName advisory for the same archetype. + const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`; + const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; + if (filename !== rawName) { + log.warn('Refused unsafe attachment filename — would escape inbox', { + messageId, + rawName, + replacement: filename, + }); + } const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); fs.mkdirSync(inboxDir, { recursive: true }); - const filename = (att.name as string) || `attachment-${Date.now()}`; const filePath = path.join(inboxDir, filename); fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); + att.name = filename; att.localPath = `inbox/${messageId}/${filename}`; delete att.data; changed = true;