diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 318de7b..d09db61 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 b584c73..e070bce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,17 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist. +## PR Hygiene + +Before creating a PR, run these checks: + +```bash +git diff upstream/main --stat HEAD +git log upstream/main..HEAD --oneline +``` + +Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included. + ## Development Run commands directly — don't tell the user to run them. @@ -187,7 +198,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/CONTRIBUTING.md b/CONTRIBUTING.md index 7a7816a..413e542 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. -3. **Check the right box** in the PR template. Labels are auto-applied based on your selection: +3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md). +4. **Check the right box** in the PR template. Labels are auto-applied based on your selection: | Checkbox | Label | |----------|-------| diff --git a/assets/setup-splash.txt b/assets/setup-splash.txt new file mode 100644 index 0000000..e4b77ec --- /dev/null +++ b/assets/setup-splash.txt @@ -0,0 +1,30 @@ + + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ° + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀ + ⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧ + o ⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿ + ⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇ + ⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o + ⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀ + ° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀ + ⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀ + ⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ O + ⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀ + ⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀ + ⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀ + o ⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀ + + _ _  ___ _  +| \| |__ _ _ _ ___  / __| |__ ___ __ __ +| .` / _` | ' \/ _ \| (__| / _` \ V V / +|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/  + + Small. + Runs on your machine. + Yours to modify. + +════════════════════════════════════════ diff --git a/container/Dockerfile b/container/Dockerfile index 4b4cf22..efa58b6 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false # across all users. ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest -ARG VERCEL_VERSION=latest +ARG VERCEL_VERSION=52.2.1 ARG BUN_VERSION=1.3.12 # ---- System dependencies ----------------------------------------------------- diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 00e41bb..9b8451d 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = { script, processAfter, recurrence, + platformId: r.platform_id, + channelType: r.channel_type, + threadId: r.thread_id, }), }); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..986489f 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -260,31 +260,69 @@ async function processQuery( // Stream liveness is decided host-side via the heartbeat file + processing // claim age (see src/host-sweep.ts); if something is truly stuck, the host // will kill the container and messages get reset to pending. + let pollInFlight = false; const pollHandle = setInterval(() => { - if (done) return; + if (done || pollInFlight) return; + pollInFlight = true; - // Skip system messages (MCP tool responses) and /clear (needs fresh query). - // Thread routing is the router's concern — if a message landed in this - // session, the agent should see it. Per-thread sessions already isolate - // threads into separate containers; shared sessions intentionally merge - // everything. Filtering on thread_id here caused deadlocks when the - // initial batch and follow-ups had mismatched thread_ids (e.g. a - // host-generated welcome trigger with null thread vs a Discord DM reply). - const newMessages = getPendingMessages().filter((m) => { - if (m.kind === 'system') return false; - if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; - return true; - }); - if (newMessages.length > 0) { - const newIds = newMessages.map((m) => m.id); - markProcessing(newIds); + void (async () => { + try { + // Skip system messages (MCP tool responses) and /clear (needs fresh query). + // Thread routing is the router's concern — if a message landed in this + // session, the agent should see it. Per-thread sessions already isolate + // threads into separate containers; shared sessions intentionally merge + // everything. Filtering on thread_id here caused deadlocks when the + // initial batch and follow-ups had mismatched thread_ids (e.g. a + // host-generated welcome trigger with null thread vs a Discord DM reply). + const newMessages = getPendingMessages().filter((m) => { + if (m.kind === 'system') return false; + if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; + return true; + }); + if (newMessages.length === 0) return; - const prompt = formatMessages(newMessages); - log(`Pushing ${newMessages.length} follow-up message(s) into active query`); - query.push(prompt); + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); - markCompleted(newIds); - } + // Run pre-task scripts on follow-ups too — without this, a task that + // arrives during an active query (e.g. a */10 monitoring cron) bypasses + // its script gate and always wakes the agent, defeating the gate. + // Mirrors the initial-batch hook above. + let keep = newMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task-followup:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(newMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task-followup:end + + if (keep.length === 0) return; + // Re-check done — the outer query may have finished while the script + // was awaited. Pushing into a closed stream is wasted work; the + // claimed messages get released by the host's processing-claim sweep. + if (done) return; + + const keptIds = keep.map((m) => m.id); + const prompt = formatMessages(keep); + log(`Pushing ${keep.length} follow-up message(s) into active query`); + query.push(prompt); + markCompleted(keptIds); + } catch (err) { + // Without this catch the rejection escapes the void IIFE and Node + // terminates the container on unhandled-rejection. The initial-batch + // path is wrapped by processQuery's outer try/catch; the follow-up + // path is not, so it needs its own. + const errMsg = err instanceof Error ? err.message : String(err); + log(`Follow-up poll error: ${errMsg}`); + } finally { + pollInFlight = false; + } + })(); }, ACTIVE_POLL_INTERVAL_MS); try { diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..c9478b8 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback { /** * Claude Code auto-compacts context at this window (tokens). Kept here so * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. */ -const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; /** * Stale-session detection. Matches Claude Code's error text when a diff --git a/nanoclaw.sh b/nanoclaw.sh index f8b58e7..82d445a 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -129,10 +129,46 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1 -# and skip printing these again, so the flow stays visually continuous. -printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" -printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" +# NanoClaw splash — under-the-sea lobster mascot in truecolor braille, +# with the figlet wordmark and taglines below. Pre-rendered into +# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa + +# figlet); the bash script just streams the literal frame. clack's intro +# then carries the "let's get you set up" framing — setup:auto sees +# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. +cat "$PROJECT_ROOT/assets/setup-splash.txt" + +# ─── pre-flight: root user warning (Linux) ──────────────────────────── +if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then + printf ' %s\n' \ + "$(red 'Warning: you are running as root.')" + printf ' %s\n' \ + "$(dim "Running NanoClaw as root is not recommended. It can cause permission")" + printf ' %s\n\n' \ + "$(dim "issues with containers, services, and file ownership.")" + printf ' %s\n' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')" + printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')" + read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts` # preamble so the flow continues visually from "Basics installed" straight # into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. -exec pnpm --silent run setup:auto +# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto. +exec pnpm --silent run setup:auto -- "$@" diff --git a/package.json b/package.json index 6029e0b..146af58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.13", + "version": "2.0.23", "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 0dfb9a2..d6afa67 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 132k tokens, 66% of context window + + 139k tokens, 69% of context window @@ -15,8 +15,8 @@ tokens - - 132k + + 139k diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts new file mode 100644 index 0000000..c85679f --- /dev/null +++ b/scripts/delete-cli-agent.ts @@ -0,0 +1,75 @@ +/** + * Delete the scratch CLI agent created during setup's ping-pong test. + * + * Dynamically finds and removes all rows referencing the agent group + * (any table with an agent_group_id column), deletes the agent group + * itself, and removes the groups// directory. Leaves the CLI + * messaging group intact so it can be reused for a new agent. + * + * Usage: + * pnpm exec tsx scripts/delete-cli-agent.ts --folder + */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from '../src/config.js'; +import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; + +interface Args { + folder: string; +} + +function parseArgs(): Args { + const argv = process.argv.slice(2); + let folder = ''; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i]; + } + if (!folder) { + console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder '); + process.exit(1); + } + return { folder }; +} + +const args = parseArgs(); + +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); + +const ag = getAgentGroupByFolder(args.folder); +if (!ag) { + console.log(`No agent group with folder "${args.folder}" — nothing to delete.`); + process.exit(0); +} + +const cleanup = db.transaction(() => { + const tables = db + .prepare( + `SELECT DISTINCT m.name FROM sqlite_master m + JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' + WHERE m.type = 'table' AND m.name != 'agent_groups'`, + ) + .all() as { name: string }[]; + for (const { name } of tables) { + db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); + } + deleteAgentGroup(ag.id); +}); +cleanup(); + +// Remove the groups// directory. +const groupDir = path.join(process.cwd(), 'groups', args.folder); +if (fs.existsSync(groupDir)) { + fs.rmSync(groupDir, { recursive: true }); +} + +// Remove session data on disk. +const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id); +if (fs.existsSync(sessionsDir)) { + fs.rmSync(sessionsDir, { recursive: true }); +} + +console.log(`Deleted agent group ${ag.id} (${args.folder}).`); diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts index 4a56827..73fb9d1 100644 --- a/scripts/init-cli-agent.ts +++ b/scripts/init-cli-agent.ts @@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; interface Args { displayName: string; agentName: string; + folder?: string; } function parseArgs(argv: string[]): Args { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; @@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args { } else if (key === '--agent-name') { agentName = val; i++; + } else if (key === '--folder') { + folder = val; + i++; } } @@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args { return { displayName, agentName: agentName?.trim() || displayName, + folder, }; } @@ -95,7 +101,7 @@ async function main(): Promise { const promotedToOwner = false; // 2. Agent group + filesystem. - const folder = `cli-with-${normalizeName(args.displayName)}`; + const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`; let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); if (!ag) { const agId = generateId('ag'); diff --git a/setup/auto.ts b/setup/auto.ts index 1720d72..ab0cbb4 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -24,6 +24,9 @@ * headless `claude -p` call for IANA-zone resolution. */ import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; +import * as os from 'os'; +import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; @@ -38,37 +41,81 @@ import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; -import { runWindowedStep } from './lib/windowed-runner.js'; import { runMigrateV1 } from './migrate-v1.js'; -import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { - claudeCliAvailable, - resolveTimezoneViaClaude, -} from './lib/tz-from-claude.js'; + applyToEnv, + parseFlags, + printHelp, + readFromEnv, +} 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 { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } 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, fmtDuration, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); -type ChannelChoice = - | 'telegram' - | 'discord' - | 'whatsapp' - | 'signal' - | 'teams' - | 'slack' - | 'imessage' - | 'skip'; +type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; async function main(): Promise { + // Make sure ~/.local/bin is on PATH for every child process we spawn. + // Installers we run mid-setup (OneCLI, claude) drop binaries there and + // append a PATH line to the user's shell rc, but rc updates don't reach + // an already-running Node process — so without this patch a freshly + // installed `onecli` is invisible to a subsequent `runInheritScript`. + ensureLocalBinOnPath(); + + // Parse CLI flags first — `--help` short-circuits before we render anything, + // and flag values get folded into process.env so existing step code reading + // NANOCLAW_* sees them unchanged. + const flagResult = parseFlags(process.argv.slice(2)); + if (flagResult.help) { + printHelp(); + process.exit(0); + } + if (flagResult.errors.length > 0) { + for (const err of flagResult.errors) console.error(`error: ${err}`); + console.error(''); + console.error('Run with --help for the full list of supported flags.'); + process.exit(1); + } + let configValues = { ...readFromEnv(), ...flagResult.values }; + applyToEnv(configValues); + printIntro(); initProgressionLog(); phEmit('auto_started'); + // Welcome menu — default path or open advanced overrides before any setup + // work begins. Default lands on standard so Enter is the happy path. + // On sg re-exec, the user already chose — skip straight to standard. + let startChoice: 'default' | 'advanced' = 'default'; + if (process.env.NANOCLAW_REEXEC_SG !== '1') { + startChoice = ensureAnswer( + await brightSelect<'default' | 'advanced'>({ + message: 'How would you like to begin?', + options: [ + { value: 'default', label: 'Standard setup' }, + { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, + ], + initialValue: 'default', + }), + ) as 'default' | 'advanced'; + setupLog.userInput('start_choice', startChoice); + } + if (startChoice === 'advanced') { + configValues = await runAdvancedScreen(configValues); + applyToEnv(configValues); + } + const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') .split(',') @@ -91,16 +138,13 @@ async function main(): Promise { } if (!skip.has('container')) { + 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( - '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', { @@ -135,63 +179,103 @@ 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, + ), ), ); - // Respect an existing OneCLI install. Re-running the installer would - // rebind the listener and knock any other app using that gateway - // offline — confirm with the user before doing that. - const existing = detectExistingOnecli(); - let reuse = false; - if (existing) { - const choice = ensureAnswer( - await brightSelect({ - message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`, - options: [ - { - value: 'reuse', - label: 'Use the existing instance', - hint: 'recommended — keeps other apps bound to this vault working', - }, - { - value: 'fresh', - label: 'Install a fresh instance for NanoClaw', - hint: 'reinstalls onecli; other apps may need to reconnect', - }, - ], - }), - ) as 'reuse' | 'fresh'; - setupLog.userInput('onecli_choice', choice); - reuse = choice === 'reuse'; - } + const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim(); - const res = await runQuietStep( - 'onecli', - { - running: reuse - ? 'Hooking up to your existing OneCLI…' - : "Setting up OneCLI, your agent's vault…", - done: 'OneCLI vault ready.', - }, - reuse ? ['--reuse'] : [], - ); - if (!res.ok) { - const err = res.terminal?.fields.ERROR; - if (err === 'onecli_not_on_path_after_install') { + if (remoteHost) { + // Advanced-settings override: user has already named a remote vault, + // so skip the local-vs-fresh prompt entirely. Health-check it here + // rather than letting the step fail silently — a typo in the URL is a + // common mistake and the answer is human-fixable. + const s = p.spinner(); + s.start(`Checking remote OneCLI at ${remoteHost}…`); + const healthy = await pollHealth(remoteHost, 5000); + if (!healthy) { + s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1); await fail( 'onecli', - 'OneCLI was installed but your shell needs to refresh to see it.', - 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', + `Couldn't reach OneCLI at ${remoteHost}.`, + 'Check the URL and that OneCLI is running on the remote machine, then retry.', ); } - await fail( + s.stop('Remote OneCLI is reachable.'); + + const res = await runQuietStep( 'onecli', - `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, - 'Make sure curl is installed and ~/.local/bin is writable, then retry.', + { + running: `Connecting to remote OneCLI at ${remoteHost}…`, + done: 'OneCLI vault ready.', + }, + ['--remote-url', remoteHost], ); + if (!res.ok) { + const err = res.terminal?.fields.ERROR; + await fail( + 'onecli', + `Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`, + 'Check the URL and that OneCLI is running on the remote machine, then retry.', + ); + } + } else { + // Respect an existing OneCLI install. Re-running the installer would + // rebind the listener and knock any other app using that gateway + // offline — confirm with the user before doing that. + const existing = detectExistingOnecli(); + let reuse = false; + if (existing) { + const choice = ensureAnswer( + await brightSelect({ + message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`, + options: [ + { + value: 'reuse', + label: 'Use the existing instance', + hint: 'recommended — keeps other apps bound to this vault working', + }, + { + value: 'fresh', + label: 'Install a fresh instance for NanoClaw', + hint: 'reinstalls onecli; other apps may need to reconnect', + }, + ], + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('onecli_choice', choice); + reuse = choice === 'reuse'; + } + + const res = await runQuietStep( + 'onecli', + { + running: reuse + ? 'Hooking up to your existing OneCLI…' + : "Setting up OneCLI, your agent's vault…", + done: 'OneCLI vault ready.', + }, + reuse ? ['--reuse'] : [], + ); + if (!res.ok) { + const err = res.terminal?.fields.ERROR; + if (err === 'onecli_not_on_path_after_install') { + await fail( + 'onecli', + 'OneCLI was installed but your shell needs to refresh to see it.', + 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', + ); + } + await fail( + 'onecli', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', + ); + } } } @@ -220,39 +304,42 @@ async function main(): Promise { done: 'NanoClaw is running.', }); if (!res.ok) { - await fail( - 'service', - "Couldn't start NanoClaw.", - 'See logs/nanoclaw.error.log for details.', - ); + 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') && detectRegisteredGroups(process.cwd())) { + skip.add('cli-agent'); + skip.add('first-chat'); } if (!skip.has('cli-agent')) { + await resolveDisplayName(); const res = await runQuietStep( 'cli-agent', { running: 'Bringing your assistant online…', done: 'Assistant wired up.', }, - ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], + ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'], ); if (!res.ok) { await fail( @@ -263,16 +350,39 @@ 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 cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent'); + const cleanupStart = Date.now(); + const cleanup = await spawnQuiet( + 'pnpm', + ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], + cleanupRawLog, + ); + setupLog.step( + 'cleanup-cli-agent', + cleanup.ok ? 'success' : 'failed', + Date.now() - cleanupStart, + { exit_code: cleanup.exitCode }, + cleanupRawLog, + ); + if (!cleanup.ok) { + p.log.warn( + brandBody( + `Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`, + ), + ); + } const next = ensureAnswer( - await p.select({ + await brightSelect<'continue' | 'chat'>({ message: 'What next?', options: [ { @@ -288,7 +398,23 @@ async function main(): Promise { }), ) as 'continue' | 'chat'; setupLog.userInput('first_chat_choice', next); - if (next === 'chat') await runFirstChat(); + if (next === 'chat') { + const terminalAgentName = `${displayName!}'s Terminal`; + const createRes = await runQuietChild( + 'create-terminal-agent', + 'pnpm', + ['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName], + { running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` }, + ); + if (!createRes.ok) { + await fail( + 'create-terminal-agent', + `Couldn't create ${terminalAgentName}.`, + 'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.', + ); + } + await runFirstChat(); + } } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); @@ -297,7 +423,7 @@ async function main(): Promise { msg: ping === 'socket_error' ? "NanoClaw service isn't listening on its CLI socket." - : "No reply from the assistant within 30 seconds.", + : 'No reply from the assistant within 30 seconds.', hint: ping === 'socket_error' ? 'Socket at data/cli.sock did not accept a connection.' @@ -323,6 +449,9 @@ async function main(): Promise { if (!skip.has('channel')) { channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip') { + await resolveDisplayName(); + } if (channelChoice === 'telegram') { await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { @@ -339,9 +468,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, + ), ), ); } @@ -356,7 +487,7 @@ async function main(): Promise { if (!res.ok) { const notes: string[] = []; if (res.terminal?.fields.CREDENTIALS !== 'configured') { - notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); + notes.push("• Your Claude account isn't connected. Re-run setup and try again."); } const service = res.terminal?.fields.SERVICE; if (service === 'running_other_checkout') { @@ -382,10 +513,12 @@ async function main(): Promise { } } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { - notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); + notes.push( + '• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.', + ); } 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. @@ -416,14 +549,12 @@ async function main(): Promise { ['Open Claude Code:', 'claude'], ]; 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'); + const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n'); + 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, @@ -440,10 +571,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`.")); @@ -465,10 +593,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; } @@ -487,25 +612,21 @@ async function confirmAssistantResponds(): Promise { const s = p.spinner(); const start = Date.now(); const label = 'Waking your assistant…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const result = await pingCliAgent(); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result === 'ok') { s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); } else { const msg = - result === 'socket_error' - ? "Couldn't reach the NanoClaw service." - : "Your assistant didn't reply in time."; + result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time."; s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1); } return result; @@ -527,7 +648,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'); } /** @@ -542,7 +663,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.', @@ -561,9 +682,7 @@ async function runFirstChat(): Promise { message: first ? 'Try a quick hello — or press Enter to continue setup' : 'Another message? Press Enter to continue setup', - placeholder: first - ? 'e.g. "hi, what can you do?"' - : 'press Enter to continue', + placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue', }), ); first = false; @@ -579,11 +698,9 @@ function sendChatMessage(message: string): Promise { // agent's reply reads as a clean block under the prompt. Splitting on // whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv // with spaces on the far side. - const child = spawn( - 'pnpm', - ['--silent', 'run', 'chat', ...message.split(/\s+/)], - { stdio: ['ignore', 'inherit', 'inherit'] }, - ); + const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], { + stdio: ['ignore', 'inherit', 'inherit'], + }); child.on('close', () => resolve()); child.on('error', () => resolve()); }); @@ -593,11 +710,21 @@ 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; } + // Custom Anthropic-compatible endpoint flow. Both URL and token must be set; + // OneCLI stores the token as a generic Bearer secret keyed to the URL host, + // so the container only ever sees ANTHROPIC_BASE_URL + a placeholder. + const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim(); + const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim(); + if (customBaseUrl && customAuthToken) { + await runCustomEndpointAuth(customBaseUrl, customAuthToken); + return; + } + const method = ensureAnswer( await brightSelect({ message: 'How would you like to connect to Claude?', @@ -631,15 +758,11 @@ async function runAuthStep(): Promise { } async function runSubscriptionAuth(): Promise { - p.log.step("Opening the Claude sign-in flow…"); - console.log( - k.dim(' (a browser will open for sign-in; this part is interactive)'), - ); + 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(); - const code = await runInheritScript('bash', [ - 'setup/register-claude-token.sh', - ]); + const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); const durationMs = Date.now() - start; console.log(); if (code !== 0) { @@ -654,7 +777,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 { @@ -664,6 +787,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)) { @@ -679,11 +803,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { 'auth', 'onecli', [ - 'secrets', 'create', - '--name', 'Anthropic', - '--type', 'anthropic', - '--value', token, - '--host-pattern', 'api.anthropic.com', + 'secrets', + 'create', + '--name', + 'Anthropic', + '--type', + 'anthropic', + '--value', + token, + '--host-pattern', + 'api.anthropic.com', ], { running: `Saving your ${label} to your OneCLI vault…`, @@ -702,6 +831,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { } } +/** + * Set up Anthropic auth for a custom endpoint. The token is stored as a + * OneCLI generic secret with header injection so the proxy rewrites the + * Authorization header on the wire — the container only ever sees + * ANTHROPIC_BASE_URL + a placeholder bearer. + */ +async function runCustomEndpointAuth( + baseUrl: string, + token: string, +): Promise { + let host: string; + try { + host = new URL(baseUrl).hostname; + } catch { + await fail( + 'auth', + `Invalid Anthropic base URL: ${baseUrl}`, + 'Check --anthropic-base-url and retry.', + ); + return; + } + + const res = await runQuietChild( + 'auth', + 'onecli', + [ + 'secrets', + 'create', + '--name', + 'Anthropic', + '--type', + 'generic', + '--value', + token, + '--host-pattern', + host, + '--header-name', + 'Authorization', + '--value-format', + 'Bearer {value}', + ], + { + running: `Saving your Anthropic auth token to your OneCLI vault…`, + done: 'Claude account connected.', + }, + { extraFields: { METHOD: 'custom-endpoint', HOST: host } }, + ); + if (!res.ok) { + await fail( + 'auth', + `Couldn't save your Anthropic auth token to the vault.`, + 'Make sure OneCLI is running (`onecli version`), then retry.', + ); + } + + // ANTHROPIC_BASE_URL has to be in .env so the runtime provider config + // reads it when building container env. The token is *not* written — + // OneCLI holds it. + writeEnvLine('ANTHROPIC_BASE_URL', baseUrl); + + // Register the claude provider so the runtime passes ANTHROPIC_BASE_URL + // and the placeholder bearer into the container. Only appended when the + // user has configured a custom endpoint; standard installs don't load + // the file at all. + appendProviderImport('./claude.js'); +} + +function writeEnvLine(key: string, value: string): void { + const envFile = path.join(process.cwd(), '.env'); + const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; + const re = new RegExp(`^${key}=.*$`, 'm'); + const next = re.test(content) + ? content.replace(re, `${key}=${value}`) + : content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`; + fs.writeFileSync(envFile, next); +} + +function appendProviderImport(modulePath: string): void { + const file = path.join(process.cwd(), 'src', 'providers', 'index.ts'); + const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const line = `import '${modulePath}';`; + if (content.includes(line)) return; + const sep = content && !content.endsWith('\n') ? '\n' : ''; + fs.writeFileSync(file, content + sep + line + '\n'); +} + // ─── timezone step ───────────────────────────────────────────────────── /** @@ -722,10 +937,7 @@ async function runTimezoneStep(): Promise { const fields = res.terminal?.fields ?? {}; const resolvedTz = fields.RESOLVED_TZ; const needsInput = fields.NEEDS_USER_INPUT === 'true'; - const isUtc = - resolvedTz === 'UTC' || - resolvedTz === 'Etc/UTC' || - resolvedTz === 'Universal'; + const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal'; // Three branches: // - no TZ detected: ask where they are (or leave as UTC) @@ -747,8 +959,8 @@ async function runTimezoneStep(): Promise { const message = needsInput ? "Your system didn't expose a timezone. Which one are you in?" : !isUtc - ? "Where are you, then?" - : "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; + ? 'Where are you, then?' + : 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?'; // For the non-UTC "detected-but-wrong" branch we skip the select and jump // straight to the free-text prompt — the user already said "not that". @@ -775,7 +987,7 @@ async function runTimezoneStep(): Promise { const answer = ensureAnswer( await p.text({ - message: "Where are you? (city, region, or IANA zone)", + message: 'Where are you? (city, region, or IANA zone)', placeholder: 'e.g. New York, London, Asia/Tokyo', validate: (v) => (v && v.trim() ? undefined : 'Required'), }), @@ -789,9 +1001,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, + ), ), ); } @@ -834,7 +1048,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, }), @@ -880,6 +1094,14 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +function ensureLocalBinOnPath(): void { + const localBin = path.join(os.homedir(), '.local', 'bin'); + const current = process.env.PATH ?? ''; + const segments = current.split(path.delimiter).filter(Boolean); + if (segments.includes(localBin)) return; + process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin; +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { @@ -956,10 +1178,12 @@ 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 existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean); + const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(','); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', - env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) }, }); process.exit(res.status ?? 1); } @@ -971,17 +1195,15 @@ function printIntro(): void { const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { - p.intro( - `${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`, - ); + p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`); return; } - // Always include the wordmark inside the clack intro line. When bash ran - // first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark - // above us; the small repeat is worth it to keep the brand anchored at - // the visible top of the clack session once the bash output scrolls away. - p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`); + // bash already printed the wordmark above us; the clack intro carries the + // welcome framing alone so the two don't double up. Standalone runs of + // setup:auto still see this as the first line — fine without the wordmark + // since the line itself signals the start of the flow. + p.intro("Let's get you set up."); } /** diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..28c0254 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -28,9 +28,11 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { readEnvKey } from '../environment.js'; +import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -155,7 +157,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.", '', @@ -163,9 +165,8 @@ async function walkThroughBotCreation(): Promise { ' 2. In the "Bot" tab, click "Reset Token" and copy the token', ' 3. On the same tab, enable "Message Content Intent"', ' (under Privileged Gateway Intents)', - '', - k.dim(url), - ].join('\n'), + formatNoteLink(url), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Discord bot', ); await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); @@ -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,16 +217,15 @@ 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.", '', ' 1. In Discord, click the "+" at the bottom of the server list', ' 2. Choose "Create My Own" → "For me and my friends"', ' 3. Give it any name (e.g. "NanoClaw")', - '', - k.dim(url), - ].join('\n'), + formatNoteLink(url), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Discord server', ); await confirmThenOpen(url, 'Press Enter to open Discord'); @@ -239,9 +239,22 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { + const existing = readEnvKey('DISCORD_BOT_TOKEN'); + 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'; @@ -275,9 +288,8 @@ async function validateDiscordToken(token: string): Promise { username?: string; message?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (res.ok && data.username) { - s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-validate', 'success', Date.now() - start, { BOT_USERNAME: data.username, BOT_ID: data.id ?? '', @@ -295,8 +307,7 @@ async function validateDiscordToken(token: string): Promise { 'Copy the token again from the Developer Portal and retry setup.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-validate', 'failed', Date.now() - start, { ERROR: message, @@ -324,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise { team?: unknown; message?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (!res.ok || !data.id || !data.verify_key) { const reason = data.message ?? `HTTP ${res.status}`; s.stop(`Couldn't read application info: ${reason}`, 1); @@ -337,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise { 'Re-run setup. If it keeps failing, check the bot token has the right scopes.', ); } - s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); // owner is populated for solo applications; team-owned apps return a // team object instead and we'll fall back to a manual user-id prompt. const owner = @@ -355,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise { owner, }; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-app-info', 'failed', Date.now() - start, { ERROR: message, @@ -385,14 +394,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,15 +439,14 @@ async function promptInviteBot( `&scope=bot` + `&permissions=${INVITE_PERMISSIONS}`; - p.note( + note( [ `@${botUsername} needs to share a server with you before it can DM you.`, '', ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', - '', - k.dim(url), - ].join('\n'), + formatNoteLink(url), + ].filter((line): line is string => line !== null).join('\n'), 'Add bot to a server', ); await confirmThenOpen(url, 'Press Enter to open the invite page'); @@ -465,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise { body: JSON.stringify({ recipient_id: userId }), }); const data = (await res.json()) as { id?: string; message?: string }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (!res.ok || !data.id) { const reason = data.message ?? `HTTP ${res.status}`; s.stop(`Couldn't open a DM channel: ${reason}`, 1); @@ -478,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise { 'Make sure the bot is in a server you\'re also in, then retry setup.', ); } - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-open-dm', 'success', Date.now() - start, { DM_CHANNEL_ID: data.id, }); return data.id; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-open-dm', 'failed', Date.now() - start, { ERROR: message, @@ -506,7 +512,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 d8b129f..8c0b78d 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,8 @@ 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'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -189,7 +190,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 +223,20 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - p.note( + const existingUrl = readEnvKey('IMESSAGE_SERVER_URL'); + const existingKey = readEnvKey('IMESSAGE_API_KEY'); + 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 +264,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 +279,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 +318,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 9e54cb9..8462a56 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, fmtDuration, 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.", '', @@ -323,8 +324,7 @@ async function restartService(): Promise { // Give the adapter a moment to connect to signal-cli before // init-first-agent's welcome DM hits the delivery path. await new Promise((r) => setTimeout(r, 5000)); - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('signal-restart', 'success', Date.now() - start, { PLATFORM: platform, }); @@ -346,7 +346,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 f66c29a..0e3f052 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. */ @@ -26,12 +25,15 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } 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 { readEnvKey } from '../environment.js'; +import { accentGreen, fmtDuration, 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 +42,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,26 +77,67 @@ 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"', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', - '', - k.dim(SLACK_APPS_URL), - ].join('\n'), + formatNoteLink(SLACK_APPS_URL), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Slack app', ); await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); @@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { + const existing = readEnvKey('SLACK_BOT_TOKEN'); + 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 = readEnvKey('SLACK_SIGNING_SECRET'); + 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'; @@ -175,10 +241,9 @@ async function validateSlackToken(token: string): Promise { user_id?: string; error?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.team && data.user) { s.stop( - `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, + `Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, ); const info: WorkspaceInfo = { teamName: data.team, @@ -207,8 +272,7 @@ async function validateSlackToken(token: string): Promise { : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-validate', 'failed', Date.now() - start, { ERROR: message, @@ -221,26 +285,133 @@ 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; + }; + if (data.ok && data.channel?.id) { + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); + 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) { + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 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 fb4d878..41e2070 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -40,7 +40,9 @@ 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'; +import { readEnvKey } from '../environment.js'; const CHANNEL = 'teams'; const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); @@ -59,6 +61,28 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; + const existingAppId = readEnvKey('TEAMS_APP_ID'); + const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); + 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 = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + if (collected.appType === 'SingleTenant') { + collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined; + } + 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 +103,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 +117,7 @@ function printIntro(): void { } async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ 'Before we start, confirm you have:', '', @@ -119,7 +143,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 +199,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 +283,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 +300,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 +353,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 +390,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 +460,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 +526,7 @@ async function finishWithHandoff( collected: Collected, completed: string[], ): Promise { - p.note( + note( [ 'The Teams adapter is live and the service is running.', '', @@ -530,7 +555,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 df97fcf..41ee407 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -33,7 +33,8 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; +import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -47,12 +48,11 @@ 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.`, - '', - k.dim(botUrl), - ].join('\n'), + formatNoteLink(botUrl), + ].filter((line): line is string => line !== null).join('\n'), 'Open Telegram', ); await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); @@ -132,7 +132,19 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - p.note( + const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); + 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())) { @@ -178,10 +191,9 @@ async function validateTelegramToken(token: string): Promise { result?: { username?: string; id?: number }; description?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.result?.username) { const username = data.result.username; - s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -199,8 +211,7 @@ async function validateTelegramToken(token: string): Promise { 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, @@ -240,12 +251,12 @@ async function runPairTelegram(): Promise< } else { stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); - s.start('Waiting for you to send the code from Telegram…'); + note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + s.start(fitToWidth('Waiting for you to send the code from Telegram…', '')); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); - s.start('Waiting for the correct code…'); + s.start(fitToWidth('Waiting for the correct code…', '')); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM') { if (block.fields.STATUS === 'success') { @@ -291,7 +302,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 85c9866..922c985 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, fmtDuration, 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) { @@ -379,8 +379,7 @@ async function restartService(): Promise { // Give the adapter a moment to reconnect before init-first-agent's // welcome DM hits the delivery path. await new Promise((r) => setTimeout(r, 5000)); - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('whatsapp-restart', 'success', Date.now() - start, { PLATFORM: platform, }); @@ -395,7 +394,7 @@ async function restartService(): Promise { } async function askChatPhone(authedPhone: string): Promise { - p.note( + note( [ `Authenticated with ${k.cyan('+' + authedPhone)}.`, '', @@ -462,7 +461,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/cli-agent.ts b/setup/cli-agent.ts index d9a90c5..73b8557 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -8,6 +8,7 @@ * Args: * --display-name (required) operator's display name * --agent-name (optional) agent persona name, defaults to display-name + * --folder (optional) explicit folder name, defaults to cli-with- */ import { execFileSync } from 'child_process'; import path from 'path'; @@ -18,9 +19,11 @@ import { emitStatus } from './status.js'; function parseArgs(args: string[]): { displayName: string; agentName?: string; + folder?: string; } { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < args.length; i++) { const key = args[i]; @@ -34,6 +37,10 @@ function parseArgs(args: string[]): { agentName = val; i++; break; + case '--folder': + folder = val; + i++; + break; } } @@ -46,17 +53,18 @@ function parseArgs(args: string[]): { process.exit(2); } - return { displayName, agentName }; + return { displayName, agentName, folder }; } export async function run(args: string[]): Promise { - const { displayName, agentName } = parseArgs(args); + const { displayName, agentName, folder } = parseArgs(args); const projectRoot = process.cwd(); const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts'); const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName]; if (agentName) scriptArgs.push('--agent-name', agentName); + if (folder) scriptArgs.push('--folder', folder); log.info('Invoking init-cli-agent', { displayName, agentName }); diff --git a/setup/container.ts b/setup/container.ts index 6ecd032..18de61a 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -127,11 +127,22 @@ export async function run(args: string[]): Promise { } // Socket is unreachable due to group perms — current shell's supplementary - // groups are fixed at login, so `usermod -aG docker` (via install-docker.sh - // or a prior install) doesn't affect us until next login. Re-exec this - // step under `sg docker` so the child picks up docker as its primary - // group and can talk to /var/run/docker.sock without a logout. + // groups are fixed at login, so `usermod -aG docker` doesn't affect us + // until next login. Ensure the user is in the docker group (install-docker.sh + // does this on fresh installs, but skips when Docker is already present), + // then re-exec under `sg docker` so the child picks up docker as its + // primary group and can talk to /var/run/docker.sock without a logout. if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) { + // Ensure the current user is in the docker group — without this, + // sg will ask for the (typically unset) group password and fail. + const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' }); + if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) { + log.info('Adding current user to docker group'); + spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], { + stdio: 'inherit', + }); + } + log.info('Re-executing container step under `sg docker`'); const res = spawnSync( 'sg', diff --git a/setup/environment.ts b/setup/environment.ts index 6986396..5960b0e 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,48 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +/** + * Read a single key from `.env` on disk (not process.env). + * Returns the trimmed value or null if the key isn't set / file doesn't exist. + */ +export function readEnvKey(key: string, projectRoot?: string): string | null { + const envPath = path.join(projectRoot ?? process.cwd(), '.env'); + let content: string; + try { + content = fs.readFileSync(envPath, 'utf-8'); + } catch { + return null; + } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + if (trimmed.slice(0, eq) === key) { + return trimmed.slice(eq + 1).trim() || null; + } + } + return null; +} + +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 94c4838..96c5de4 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/browser.ts b/setup/lib/browser.ts index 9d801fa..7c5c970 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -9,12 +9,19 @@ * `confirmThenOpen` pauses for the operator before triggering the open — * the browser tends to steal focus when it pops, and a split-second * "wait what just happened" moment is worse than letting the user hit - * Enter when they're ready. + * Enter when they're ready. On headless devices (no graphical session + * available) it skips both the prompt and the open: there's no browser + * to launch, the surrounding `note(...)` already shows the URL for + * copy-paste on another device, and the next prompt in the channel + * flow ("Got your bot token?" etc.) provides the natural completion + * confirmation. */ import { spawn } from 'child_process'; import * as p from '@clack/prompts'; +import k from 'kleur'; +import { isHeadless } from '../platform.js'; import { ensureAnswer } from './runner.js'; /** Best-effort open of a URL in the user's default browser. Silent on failure. */ @@ -32,18 +39,43 @@ export function openUrl(url: string): void { } } +/** + * Format a URL for inclusion in a setup `note(...)` card. On + * headless devices we surface the URL inside the card with a + * "Get started:" label at full strength — copy-pasting onto + * another device is the actual action, not an incidental + * reference. The leading `\n` acts as a visual separator from + * the body steps above; callers `.filter(line => line !== null)` + * before joining, so on GUI we drop the line entirely (and the + * URL ends up below the next-step confirm prompt as a "if + * browser does not appear, please visit" fallback — see + * `confirmThenOpen`). + */ +export function formatNoteLink(url: string): string | null { + if (isHeadless()) return `\nGet started: ${url}`; + return null; +} + /** * Gate a browser-open on a confirm so the user is ready for their browser - * to take focus. Proceeds on cancel as well — the user can always copy the - * URL from the note that precedes the prompt. + * to take focus. Proceeds on cancel as well. On headless devices both the + * prompt and the open are skipped — the URL is already surfaced inside + * the surrounding note (via `formatNoteLink`). + * + * On GUI devices the confirm message includes the fallback URL on the + * lines below the action ("If browser does not appear, please visit: + * " in dim) so the user has a copy-paste path right next to the + * action button without needing to scroll back up to the card. */ export async function confirmThenOpen( url: string, message = 'Press Enter to open your browser', ): Promise { + if (isHeadless()) return; + const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`; ensureAnswer( await p.confirm({ - message, + message: `${message}${fallback}`, initialValue: true, }), ); diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c..187377e 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, fmtDuration, 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; } @@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner( // Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window). out.write(`\x1b[${WINDOW_SIZE + 1}A`); - const elapsed = Math.round((Date.now() - start) / 1000); const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth('Asking Claude to diagnose…', suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner( clearBlock(); out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; 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 9c931f2..87023ef 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 c1599e4..6ffffed 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, fmtDuration } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -307,18 +307,16 @@ async function runUnderSpinner< ): Promise { const s = p.spinner(); const start = Date.now(); - s.start(fitToWidth(labels.running, ' (999s)')); + s.start(fitToWidth(labels.running, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`); }, 1000); const result = await work(); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; @@ -390,7 +388,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-parse.ts b/setup/lib/setup-config-parse.ts new file mode 100644 index 0000000..85e3572 --- /dev/null +++ b/setup/lib/setup-config-parse.ts @@ -0,0 +1,161 @@ +/** + * Parser/reader/writer for the advanced-config registry (setup-config.ts). + * + * readFromEnv() → values found in process.env + * parseFlags() → values from argv, plus --help and any pass-through args + * applyToEnv() → write resolved values back to process.env so existing + * step code keeps reading env vars unchanged + * printHelp() → render --help from the registry + * + * Flag parsing supports: + * --key value space form + * --key=value equals form + * --key booleans only (sets true) + * --no-key booleans only (sets false) + */ +import { + CONFIG, + envVarFor, + flagFor, + findByFlag, + type Entry, +} from './setup-config.js'; + +export type ConfigValues = Record; + +function coerce(e: Entry, raw: string): string | number | boolean | undefined { + switch (e.type) { + case 'boolean': { + const v = raw.toLowerCase(); + if (['true', '1', 'yes'].includes(v)) return true; + if (['false', '0', 'no'].includes(v)) return false; + return undefined; + } + case 'integer': { + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + } + default: + return raw; + } +} + +export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues { + const out: ConfigValues = {}; + for (const e of CONFIG) { + const raw = env[envVarFor(e)]; + if (raw === undefined || raw === '') continue; + const v = coerce(e, raw); + if (v !== undefined) out[e.key] = v; + } + return out; +} + +export type FlagParseResult = { + values: ConfigValues; + rest: string[]; + help: boolean; + errors: string[]; +}; + +export function parseFlags(argv: string[]): FlagParseResult { + const values: ConfigValues = {}; + const rest: string[] = []; + const errors: string[] = []; + let help = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--help' || arg === '-h') { + help = true; + continue; + } + // POSIX end-of-options. pnpm passes a bare `--` through when invoked as + // `pnpm run script --` with nothing after it; treat the rest as + // pass-through positional args. + if (arg === '--') { + rest.push(...argv.slice(i + 1)); + break; + } + if (!arg.startsWith('--')) { + rest.push(arg); + continue; + } + + const eq = arg.indexOf('='); + let name = eq === -1 ? arg : arg.slice(0, eq); + const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1); + + let negated = false; + if (name.startsWith('--no-')) { + negated = true; + name = `--${name.slice(5)}`; + } + + const entry = findByFlag(name); + if (!entry) { + errors.push(`Unknown flag: ${arg}`); + continue; + } + + if (entry.type === 'boolean') { + if (negated) values[entry.key] = false; + else if (inline !== undefined) { + const v = coerce(entry, inline); + if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`); + else values[entry.key] = v; + } else values[entry.key] = true; + continue; + } + + const raw = inline !== undefined ? inline : argv[++i]; + if (raw === undefined) { + errors.push(`Missing value for ${name}`); + continue; + } + const v = coerce(entry, raw); + if (v === undefined) { + errors.push(`Invalid ${entry.type} for ${name}: ${raw}`); + continue; + } + if (entry.type === 'string' || entry.type === 'url') { + const err = entry.validate?.(raw); + if (err) { + errors.push(`${name}: ${err}`); + continue; + } + } + values[entry.key] = v; + } + + return { values, rest, help, errors }; +} + +export function applyToEnv( + values: ConfigValues, + env: NodeJS.ProcessEnv = process.env, +): void { + for (const e of CONFIG) { + if (!(e.key in values)) continue; + const v = values[e.key]; + env[envVarFor(e)] = + typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v); + } +} + +export function printHelp(stream: NodeJS.WritableStream = process.stdout): void { + const lines: string[] = []; + lines.push('Usage: bash nanoclaw.sh [flags...]'); + lines.push(''); + lines.push('Flags:'); + const width = Math.max(...CONFIG.map((e) => flagFor(e).length)); + for (const e of CONFIG) { + const flag = flagFor(e).padEnd(width + 2); + lines.push(` ${flag}${e.help}`); + } + lines.push(''); + lines.push('Each flag also reads from its corresponding NANOCLAW_ env var.'); + lines.push('Run without flags for the default interactive flow.'); + stream.write(lines.join('\n') + '\n'); +} diff --git a/setup/lib/setup-config-screen.ts b/setup/lib/setup-config-screen.ts new file mode 100644 index 0000000..88b10d5 --- /dev/null +++ b/setup/lib/setup-config-screen.ts @@ -0,0 +1,127 @@ +/** + * Advanced-settings screen — menu of UI-visible entries from the config + * registry. The user picks one entry, edits it, returns to the menu, and + * exits via "Done". Returns a fresh values object; the caller passes it to + * applyToEnv() so downstream step code reads them via env vars. + * + * Per-entry edit contract: + * - Blank input on text/password/integer = leave current value unchanged. + * - Enums get a synthetic "leave unchanged" first option. + * - Booleans use confirm with the current value as initialValue. + * - Secret entries mask the current value as bullets in hints/labels. + */ +import * as p from '@clack/prompts'; + +import { brightSelect } from './bright-select.js'; +import { ensureAnswer } from './runner.js'; +import { CONFIG, type Entry } from './setup-config.js'; +import type { ConfigValues } from './setup-config-parse.js'; + +const SKIP_SENTINEL = '__leave_unchanged__'; +const DONE_SENTINEL = '__done__'; +const MASK = '••••••••'; + +export async function runAdvancedScreen( + initial: ConfigValues, +): Promise { + const result: ConfigValues = { ...initial }; + const visible = CONFIG.filter((e) => e.surface === 'flag+ui'); + + while (true) { + const options = [ + ...visible.map((e) => ({ + value: e.key, + label: e.label, + hint: hintFor(e, result), + })), + { value: DONE_SENTINEL, label: 'Done — continue with setup' }, + ]; + + const choice = ensureAnswer( + await brightSelect({ + message: 'Pick a setting to override', + options, + initialValue: DONE_SENTINEL, + }), + ) as string; + + if (choice === DONE_SENTINEL) return result; + const entry = visible.find((e) => e.key === choice); + if (entry) await promptOne(entry, result); + } +} + +function hintFor(e: Entry, values: ConfigValues): string { + const v = values[e.key]; + if (v === undefined) return 'not set'; + if (e.secret) return MASK; + return String(v); +} + +async function promptOne(e: Entry, values: ConfigValues): Promise { + if (e.type === 'boolean') { + const init = + typeof values[e.key] === 'boolean' + ? (values[e.key] as boolean) + : (e.default ?? false); + const ans = ensureAnswer( + await p.confirm({ message: e.label, initialValue: init }), + ); + values[e.key] = ans as boolean; + return; + } + + if (e.type === 'enum') { + const ans = ensureAnswer( + await brightSelect({ + message: e.label, + options: [ + { value: SKIP_SENTINEL, label: 'Leave unchanged' }, + ...e.options, + ], + initialValue: SKIP_SENTINEL, + }), + ); + if (ans !== SKIP_SENTINEL) values[e.key] = ans as string; + return; + } + + if (e.type === 'integer') { + const ans = ensureAnswer( + await p.text({ + message: e.label, + placeholder: e.default !== undefined ? String(e.default) : undefined, + validate: (v) => { + const s = (v ?? '').trim(); + if (!s) return undefined; + const n = Number(s); + if (!Number.isFinite(n)) return 'Must be a number'; + if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`; + if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`; + return undefined; + }, + }), + ); + const trimmed = ((ans as string) ?? '').trim(); + if (trimmed) values[e.key] = Number(trimmed); + return; + } + + // string | url + const validate = (v: string | undefined): string | undefined => { + const s = (v ?? '').trim(); + if (!s) return undefined; + return e.validate?.(s); + }; + const ans = ensureAnswer( + e.secret + ? await p.password({ message: e.label, clearOnError: true, validate }) + : await p.text({ + message: e.label, + placeholder: e.placeholder ?? e.default, + validate, + }), + ); + const trimmed = ((ans as string) ?? '').trim(); + if (trimmed) values[e.key] = trimmed; +} diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts new file mode 100644 index 0000000..1fa6ad4 --- /dev/null +++ b/setup/lib/setup-config.ts @@ -0,0 +1,142 @@ +/** + * Setup-time advanced-config registry. + * + * One source of truth for: CLI flags, env-var names, the advanced-settings + * screen, and `--help` output. The flag parser, env reader, and UI screen + * all consume this list and write resolved values back to `process.env` so + * existing step code keeps reading env vars unchanged. + * + * Default name conventions (overridable per entry): + * key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar' + * + * Surface levels: + * 'flag' — CLI flag + env var only (debug/internal knobs) + * 'flag+ui' — also shown in the advanced-settings screen + */ + +export type EntrySurface = 'flag' | 'flag+ui'; + +interface BaseEntry { + /** Canonical camelCase key. */ + key: string; + /** Override of the auto-derived NANOCLAW_ env var. */ + envVar?: string; + /** Override of the auto-derived --kebab-case flag. */ + flag?: string; + label: string; + help: string; + surface: EntrySurface; + /** UI section header. Entries without a group land in 'Other'. */ + group?: string; + /** Mask in UI, redact in logs. */ + secret?: boolean; +} + +interface StringEntry extends BaseEntry { + type: 'string' | 'url'; + default?: string; + placeholder?: string; + validate?: (v: string) => string | undefined; +} + +interface EnumEntry extends BaseEntry { + type: 'enum'; + options: { value: string; label: string; hint?: string }[]; + default?: string; +} + +interface BoolEntry extends BaseEntry { + type: 'boolean'; + default?: boolean; +} + +interface IntEntry extends BaseEntry { + type: 'integer'; + default?: number; + min?: number; + max?: number; +} + +export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry; + +const httpUrl = (v: string): string | undefined => + /^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…'; + +export const CONFIG: Entry[] = [ + { + key: 'onecliApiHost', + label: 'OneCLI vault URL', + help: 'Use a remote OneCLI vault instead of installing one locally.', + surface: 'flag+ui', + group: 'OneCLI', + type: 'url', + default: 'https://app.onecli.sh', + placeholder: 'https://app.onecli.sh', + validate: httpUrl, + }, + { + key: 'onecliApiToken', + label: 'OneCLI access token', + help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.', + surface: 'flag+ui', + group: 'OneCLI', + type: 'string', + secret: true, + placeholder: 'oc_…', + validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'), + }, + { + key: 'anthropicBaseUrl', + label: 'Anthropic API base URL', + help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.', + surface: 'flag+ui', + group: 'Anthropic', + type: 'url', + placeholder: 'https://api.anthropic.com', + validate: httpUrl, + }, + { + key: 'anthropicAuthToken', + label: 'Anthropic auth token', + help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.', + surface: 'flag+ui', + group: 'Anthropic', + type: 'string', + secret: true, + validate: (v) => (v.trim() ? undefined : 'Required'), + }, + + // Existing env-var knobs — flag-only so they don't clutter the UI screen. + { + key: 'skip', + envVar: 'NANOCLAW_SKIP', + label: 'Skip steps', + help: 'Comma-separated step names to skip (debugging only).', + surface: 'flag', + type: 'string', + }, + { + key: 'displayName', + envVar: 'NANOCLAW_DISPLAY_NAME', + label: 'Display name', + help: 'Skip the "what should your assistant call you?" prompt.', + surface: 'flag', + type: 'string', + }, +]; + +// ─── name derivation ─────────────────────────────────────────────────── + +export function envVarFor(e: Entry): string { + if (e.envVar) return e.envVar; + return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`; +} + +export function flagFor(e: Entry): string { + if (e.flag) return e.flag; + return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`; +} + +export function findByFlag(flag: string): Entry | null { + return CONFIG.find((e) => flagFor(e) === flag) ?? null; +} diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 35b5ca3..2c80c8a 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,57 @@ 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); +} + +/** + * Format an elapsed-time duration (in milliseconds) for the spinner + * suffixes setup writes everywhere. Sub-minute durations stay in plain + * seconds (`47s`); once the timer crosses 60 seconds we switch to the + * `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or + * similar. The format is consistent above 60s — `4m 0s` over `4m` — + * so live spinner output doesn't change shape at every whole minute. + */ +export function fmtDuration(ms: number): string { + const totalSec = Math.round(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}m ${s}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 +120,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/tz-from-claude.ts b/setup/lib/tz-from-claude.ts index 5486fbb..f861f64 100644 --- a/setup/lib/tz-from-claude.ts +++ b/setup/lib/tz-from-claude.ts @@ -17,7 +17,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { isValidTimezone } from '../../src/timezone.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, fmtDuration } from './theme.js'; export function claudeCliAvailable(): boolean { try { @@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude( const s = p.spinner(); const start = Date.now(); const label = 'Looking up that timezone…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const reply = await queryClaude(prompt); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const resolved = reply ? extractTimezone(reply) : null; if (resolved) { diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 875aba6..6f165a4 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/setup/onecli.ts b/setup/onecli.ts index 3ceb1e8..fbf76a9 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -86,23 +86,35 @@ function ensureShellProfilePath(): void { } } -function writeEnvOnecliUrl(url: string): void { +function writeEnvVar(name: string, value: string): void { const envFile = path.join(process.cwd(), '.env'); let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; - if (/^ONECLI_URL=/m.test(content)) { - content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`); + const re = new RegExp(`^${name}=.*$`, 'm'); + if (re.test(content)) { + content = content.replace(re, `${name}=${value}`); } else { - content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`; + content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`; } fs.writeFileSync(envFile, content); } +function writeEnvOnecliUrl(url: string): void { + writeEnvVar('ONECLI_URL', url); +} + // Last-known-good CLI release. Used only if BOTH the upstream installer // and the redirect-based version probe fail. Bump deliberately when a // new CLI release ships. const ONECLI_CLI_FALLBACK_VERSION = '1.3.0'; const ONECLI_CLI_REPO = 'onecli/onecli-cli'; +function installOnecliCliOnly(): { stdout: string; ok: boolean } { + const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh'); + if (upstream.ok) return { stdout: upstream.stdout, ok: true }; + const fallback = installOnecliCliDirect(); + return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok }; +} + function installOnecli(): { stdout: string; ok: boolean } { let stdout = ''; @@ -163,14 +175,12 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } { lines.push(s); }; - const osName = - process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null; + const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null; if (!osName) { append(`Unsupported platform: ${process.platform}`); return { stdout: lines.join('\n'), ok: false }; } - const arch = - process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null; + const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null; if (!arch) { append(`Unsupported arch: ${process.arch}`); return { stdout: lines.join('\n'), ok: false }; @@ -201,10 +211,9 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } { try { append(`Downloading ${url}`); - execSync( - `curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); + execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, { + stdio: ['ignore', 'pipe', 'pipe'], + }); execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, { stdio: ['ignore', 'pipe', 'pipe'], }); @@ -231,7 +240,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } { } } -async function pollHealth(url: string, timeoutMs: number): Promise { +export async function pollHealth(url: string, timeoutMs: number): Promise { // `/api/health` matches the path probe.sh uses — keep them aligned. const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -248,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise { export async function run(args: string[]): Promise { const reuse = args.includes('--reuse'); + const remoteUrlIdx = args.indexOf('--remote-url'); + const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null; ensureShellProfilePath(); + if (remoteUrl) { + // Remote-mode: install only the CLI, point it at the remote gateway, and + // record the URL in .env. No local gateway is started. + log.info('Installing OneCLI CLI for remote gateway', { remoteUrl }); + const res = installOnecliCliOnly(); + if (!res.ok || !onecliVersion()) { + emitStatus('ONECLI', { + INSTALLED: false, + STATUS: 'failed', + ERROR: 'cli_install_failed', + HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + try { + execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], { + stdio: 'ignore', + env: childEnv(), + }); + } catch (err) { + log.warn('onecli config set api-host failed', { err }); + } + writeEnvOnecliUrl(remoteUrl); + log.info('Wrote ONECLI_URL to .env', { url: remoteUrl }); + const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim(); + if (remoteToken) { + // Two auth surfaces: `onecli auth login` persists the key for CLI + // calls during setup itself (e.g. detecting an existing Anthropic + // secret via `onecli secrets list`), and ONECLI_API_KEY in .env is + // read by the runtime SDK at request time. Both are needed. + try { + execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], { + stdio: 'ignore', + env: childEnv(), + }); + } catch (err) { + log.warn('onecli auth login failed', { err }); + } + writeEnvVar('ONECLI_API_KEY', remoteToken); + log.info('Wrote ONECLI_API_KEY to .env'); + } + const healthy = await pollHealth(remoteUrl, 5000); + emitStatus('ONECLI', { + INSTALLED: true, + REMOTE: true, + ONECLI_URL: remoteUrl, + HEALTHY: healthy, + STATUS: 'success', + LOG: 'logs/setup.log', + }); + return; + } + if (reuse) { // Reuse-mode: don't touch the running gateway at all. Just verify it // exists, read its api-host, write ONECLI_URL to .env, and move on. diff --git a/src/attachment-naming.test.ts b/src/attachment-naming.test.ts new file mode 100644 index 0000000..5ca13f1 --- /dev/null +++ b/src/attachment-naming.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +import { deriveAttachmentName, extForMime } from './attachment-naming.js'; + +describe('extForMime', () => { + it('returns empty for undefined / non-string / empty', () => { + expect(extForMime(undefined)).toBe(''); + expect(extForMime('')).toBe(''); + expect(extForMime({})).toBe(''); + expect(extForMime(null)).toBe(''); + expect(extForMime(42)).toBe(''); + }); + + it('maps common MIME types to canonical extensions', () => { + expect(extForMime('image/jpeg')).toBe('jpg'); + expect(extForMime('application/pdf')).toBe('pdf'); + expect(extForMime('audio/ogg')).toBe('ogg'); + }); + + it('strips parameters and is case-insensitive', () => { + expect(extForMime('image/JPEG; foo=bar')).toBe('jpg'); + expect(extForMime(' Application/PDF ')).toBe('pdf'); + expect(extForMime('text/plain; charset=utf-8')).toBe('txt'); + }); + + it('returns empty for unknown MIMEs', () => { + expect(extForMime('application/octet-stream')).toBe(''); + expect(extForMime('application/x-totally-made-up')).toBe(''); + }); +}); + +describe('deriveAttachmentName', () => { + it('returns explicit name when set, no derivation', () => { + expect(deriveAttachmentName({ name: 'photo.jpg', mimeType: 'application/pdf' })).toBe('photo.jpg'); + }); + + it('ignores empty / non-string explicit name and falls through to derivation', () => { + const out = deriveAttachmentName({ name: '', mimeType: 'application/pdf' }); + expect(out).toMatch(/^attachment-\d+\.pdf$/); + + const out2 = deriveAttachmentName({ name: 42, mimeType: 'application/pdf' }); + expect(out2).toMatch(/^attachment-\d+\.pdf$/); + }); + + it('derives extension from mimeType when no name', () => { + expect(deriveAttachmentName({ mimeType: 'application/pdf' })).toMatch(/^attachment-\d+\.pdf$/); + expect(deriveAttachmentName({ mimeType: 'image/jpeg' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('falls back to att.type when mimeType is missing (Telegram photos/stickers)', () => { + expect(deriveAttachmentName({ type: 'photo' })).toMatch(/^attachment-\d+\.jpg$/); + expect(deriveAttachmentName({ type: 'sticker' })).toMatch(/^attachment-\d+\.webp$/); + expect(deriveAttachmentName({ type: 'voice' })).toMatch(/^attachment-\d+\.ogg$/); + expect(deriveAttachmentName({ type: 'animation' })).toMatch(/^attachment-\d+\.mp4$/); + }); + + it('case-insensitive att.type lookup', () => { + expect(deriveAttachmentName({ type: 'PHOTO' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('returns bare timestamp when nothing matches', () => { + expect(deriveAttachmentName({})).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ mimeType: 'application/octet-stream' })).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ type: 'mystery-class' })).toMatch(/^attachment-\d+$/); + }); + + it('does not crash on non-string mimeType (defensive against buggy bridges)', () => { + expect(() => deriveAttachmentName({ mimeType: { foo: 'bar' } })).not.toThrow(); + expect(deriveAttachmentName({ mimeType: { foo: 'bar' } })).toMatch(/^attachment-\d+$/); + }); +}); diff --git a/src/attachment-naming.ts b/src/attachment-naming.ts new file mode 100644 index 0000000..2dfe8c1 --- /dev/null +++ b/src/attachment-naming.ts @@ -0,0 +1,69 @@ +/** + * Derive a safe, extensioned filename for inbound attachments when the + * channel bridge passes data without an explicit `name`. + * + * Two-step lookup: + * 1. `mimeType` → extension (Discord/Slack documents, Telegram document + * uploads — channels that set the MIME but not a filename). + * 2. `att.type` → extension (Telegram photos/stickers/voice/animations — + * coarse media-class set by the chat-sdk bridge with no MIME). + * + * Output is still passed through `isSafeAttachmentName` at the call site. + * The maps emit static values, so no derivation path can construct a + * traversal payload — only an attacker-controlled `att.name` can, and that + * goes through the safety guard unchanged. + */ + +// Map common MIME types to canonical file extensions. Without an extension, +// agents (and humans) can't tell what kind of file landed in the inbox, and +// tools keyed on extension (image viewers, exiftool, etc.) misbehave. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +export function extForMime(mime: unknown): string { + if (typeof mime !== 'string' || !mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +export function deriveAttachmentName(att: Record): string { + const explicit = att.name; + if (typeof explicit === 'string' && explicit) return explicit; + let ext = extForMime(att.mimeType); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + const ts = Date.now(); + return ext ? `attachment-${ts}.${ext}` : `attachment-${ts}`; +} diff --git a/src/attachment-safety.ts b/src/attachment-safety.ts new file mode 100644 index 0000000..85467f9 --- /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/channels/adapter.ts b/src/channels/adapter.ts index 82247a1..a2a7069 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -135,6 +135,7 @@ export interface ChannelAdapter { // Optional setTyping?(platformId: string, threadId: string | null): Promise; syncConversations?(): Promise; + resolveChannelName?(platformId: string): Promise; /** * Subscribe the bot to a thread so follow-up messages route via the diff --git a/src/circuit-breaker.test.ts b/src/circuit-breaker.test.ts new file mode 100644 index 0000000..d8c996c --- /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 0000000..20211f0 --- /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/container-runner.ts b/src/container-runner.ts index 029b5fe..27b0f5c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -58,7 +58,7 @@ const activeContainers = new Map>(); +const wakePromises = new Map>(); export function getActiveContainerCount(): number { return activeContainers.size; @@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean { * (the in-flight wake promise is reused). * * The container runs the v2 agent-runner which polls the session DB. + * + * Contract: never throws. Returns `true` on successful spawn, `false` on + * transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't + * need to wrap — the inbound row stays pending and host-sweep retries on + * its next tick. Callers that care (e.g. the router's typing indicator) + * can branch on the boolean. */ -export function wakeContainer(session: Session): Promise { +export function wakeContainer(session: Session): Promise { if (activeContainers.has(session.id)) { log.debug('Container already running', { sessionId: session.id }); - return Promise.resolve(); + return Promise.resolve(true); } const existing = wakePromises.get(session.id); if (existing) { log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); return existing; } - const promise = spawnContainer(session).finally(() => { - wakePromises.delete(session.id); - }); + const promise = spawnContainer(session) + .then(() => true) + .catch((err) => { + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err }); + return false; + }) + .finally(() => { + wakePromises.delete(session.id); + }); wakePromises.set(session.id, promise); return promise; } @@ -435,20 +447,18 @@ async function buildContainerArgs( } // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls - // are routed through the agent vault for credential injection. - try { - if (agentIdentifier) { - await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); - } - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.info('OneCLI gateway applied', { containerName }); - } else { - log.warn('OneCLI gateway not applied — container will have no credentials', { containerName }); - } - } catch (err) { - log.warn('OneCLI gateway error — container will have no credentials', { containerName, err }); + // are routed through the agent vault for credential injection. Treated as + // a transient hard failure: if we can't wire the gateway, we don't spawn. + // The caller (router or host-sweep) catches the throw, leaves the inbound + // message pending, and the next sweep tick retries. + if (agentIdentifier) { + await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); } + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (!onecliApplied) { + throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials'); + } + log.info('OneCLI gateway applied', { containerName }); // Host gateway args.push(...hostGatewayArgs()); diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 9906c4b..043b6b1 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -23,6 +23,8 @@ import { sessionDir, inboundDbPath, outboundDbPath, + readOutboxFiles, + clearOutbox, } from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; import type { InboundEvent } from './channels/adapter.js'; @@ -108,6 +110,147 @@ describe('session manager', () => { outDb.close(); }); + it('should reject outbound attachment filenames that escape the message outbox', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'outside secret'); + + expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined(); + }); + + it('should reject outbound attachment symlinks that escape the message outbox', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'outside secret'); + fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt')); + + expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined(); + }); + + it('should not recursively delete outside the outbox for unsafe message ids', () => { + initSessionFolder('ag-1', 'sess-test'); + const victimDir = path.join(TEST_DIR, 'victim-dir'); + fs.mkdirSync(victimDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete'); + + clearOutbox('ag-1', 'sess-test', '../../../../victim-dir'); + + expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true); + }); + + it('should still read and clear normal basename outbox files', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok'); + + const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']); + expect(files).toHaveLength(1); + expect(files?.[0]?.filename).toBe('result.txt'); + expect(files?.[0]?.data.toString()).toBe('ok'); + + clearOutbox('ag-1', 'sess-test', 'msg-1'); + expect(fs.existsSync(msgOutbox)).toBe(false); + }); + + it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // The container has /workspace write access, so it can pre create + // inbox/ as a symlink to escape. + const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox'); + fs.mkdirSync(inboxRoot, { recursive: true }); + const evilTarget = path.join(TEST_DIR, 'evil-target'); + fs.mkdirSync(evilTarget, { recursive: true }); + fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil')); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-evil', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'evil', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false); + }); + + it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // The container pre creates inbox//photo.png as a symlink to a + // host file. Without the wx flag, writeFileSync would follow it. + const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym'); + fs.mkdirSync(inboxDir, { recursive: true }); + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'ORIGINAL'); + fs.symlinkSync(outside, path.join(inboxDir, 'photo.png')); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-sym', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'sym', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL'); + }); + + it('should reject inbound attachments when messageId is unsafe', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: '../../escape', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'msgid', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox'); + if (fs.existsSync(inboxRoot)) { + expect(fs.readdirSync(inboxRoot)).toEqual([]); + } + }); + + it('should still save inbound attachments with safe basenames', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-ok', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'ok', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png'); + expect(fs.existsSync(expected)).toBe(true); + expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES'); + }); + it('should resolve to existing session (shared mode)', () => { const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); expect(c1).toBe(true); @@ -173,6 +316,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/host-sweep.ts b/src/host-sweep.ts index 4dc2fb7..69a4d61 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,6 +168,8 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); + // wakeContainer never throws — transient spawn failures (OneCLI down, + // etc.) return false and leave messages pending for the next tick. await wakeContainer(session); } diff --git a/src/index.ts b/src/index.ts index ea9fba6..9ded3d6 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 812cb8e..613a1ed 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/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index da992d2..a2e6690 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => { expect(kind).toBe('chat-sdk'); const payload = JSON.parse(content as string); expect(payload.type).toBe('ask_question'); - // Card names the target agent so the owner knows what they're wiring to. - expect(payload.question).toContain('Andy'); + // Single-agent card offers a direct "Connect to " button. + const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:')); + expect(connectOption).toBeDefined(); + expect(connectOption.label).toContain('Andy'); const { getDb } = await import('../../db/connection.js'); const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{ @@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => { }; expect(pending).toBeDefined(); - // Owner clicks approve. + // Owner clicks "Connect to Andy" (single-agent card). for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', // raw platform id — handler namespaces it channelType: 'telegram', platformId: 'dm-owner', @@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => { if (claimed) break; } - // Wiring created with MVP defaults. + // Wiring created with defaults. const mga = getDb() .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?') .get(pending.messaging_group_id) as { @@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => { for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', channelType: 'telegram', platformId: 'dm-owner', diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 8ab41bc..6127cea 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -5,24 +5,32 @@ * addressed to the bot (SDK-confirmed mention or DM), it calls * `requestChannelApproval` instead of silently dropping. The flow: * - * 1. Pick the target agent group we'd wire to (MVP: first by name). - * Multi-agent picker is a follow-up — see ACTION-ITEMS. + * 1. Gather all existing agent groups. * 2. Pick an eligible approver (owner / admin) and a reachable DM for * them, reusing the same primitives the sender-approval flow uses. - * 3. Deliver an Approve / Ignore card that names the target agent - * explicitly so the owner knows what they're wiring to. + * 3. Deliver a card with three action families: + * a. Connect to [agent] — one button per existing agent group. + * Single-agent installs get a one-click connect. + * b. Connect new agent — prompts for a free-text name, creates + * the agent immediately on reply. + * c. Reject — deny the channel. * 4. Record a `pending_channel_approvals` row holding the original event - * so it can be re-routed on approve. + * so it can be re-routed on connect/create. * - * On approve (handler in index.ts): - * - Create `messaging_group_agents` with MVP defaults + * On connect (handler in index.ts): + * - Create `messaging_group_agents` with defaults * (mention-sticky for groups / pattern='.' for DMs, * sender_scope='known', ignored_message_policy='accumulate') * - Add the triggering sender to `agent_group_members` so sender_scope * doesn't bounce the replayed message into a sender-approval cascade * - Delete the pending row, replay the original event * - * On ignore: + * On connect new agent (handler in index.ts): + * - Prompt for a free-text agent name via DM + * - On reply: create the agent group + filesystem, then wire + * and replay as above + * + * On reject: * - Set `messaging_groups.denied_at = now()` so the router stops * escalating on this channel until an admin explicitly re-wires * - Delete the pending row @@ -36,19 +44,81 @@ * - Approver has no reachable DM. * - Delivery adapter missing. */ -import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; -import { getAllAgentGroups } from '../../db/agent-groups.js'; -import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js'; +import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js'; +import { getChannelAdapter } from '../../channels/channel-registry.js'; +import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.js'; +import { initGroupFilesystem } from '../../group-init.js'; import { log } from '../../log.js'; import type { InboundEvent } from '../../channels/adapter.js'; +import type { AgentGroup } from '../../types.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; -const APPROVAL_OPTIONS: RawOption[] = [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, -]; +// ── Value constants (response handler in index.ts parses these) ── + +export const CONNECT_PREFIX = 'connect:'; +export const NEW_AGENT_VALUE = 'new_agent'; +export const CHOOSE_EXISTING_VALUE = 'choose_existing'; +export const REJECT_VALUE = 'reject'; + +// ── Utilities ── + +function toFolder(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} + +// ── Card builders ── + +function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] { + const options: RawOption[] = []; + if (agentGroups.length === 1) { + options.push({ + label: `Connect to ${agentGroups[0].name}`, + selectedLabel: `✅ Connected to ${agentGroups[0].name}`, + value: `${CONNECT_PREFIX}${agentGroups[0].id}`, + }); + } else { + options.push({ + label: 'Choose existing agent', + selectedLabel: '📋 Choosing…', + value: CHOOSE_EXISTING_VALUE, + }); + } + options.push({ + label: 'Connect new agent', + selectedLabel: '🆕 Connecting new agent…', + value: NEW_AGENT_VALUE, + }); + options.push({ + label: 'Reject', + selectedLabel: '🙅 Rejected', + value: REJECT_VALUE, + }); + return options; +} + +function buildQuestionText( + isGroup: boolean, + senderName: string | undefined, + channelName: string | null, + channelType: string, +): string { + const who = senderName ?? 'Someone'; + if (isGroup) { + const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`; + return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`; + } + return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`; +} + +// ── Main flow ── export interface RequestChannelApprovalInput { messagingGroupId: string; @@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput { export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise { const { messagingGroupId, event } = input; - // In-flight dedup: don't spam the owner if the same unwired channel - // gets more mentions / DMs while a card is already pending. if (hasInFlightChannelApproval(messagingGroupId)) { - log.debug('Channel registration already in flight — dropping retry', { - messagingGroupId, - }); + log.debug('Channel registration already in flight — dropping retry', { messagingGroupId }); return; } - // MVP: pick the first agent group by name. Multi-agent systems will get - // a richer card later (user picks the target from a list). const agentGroups = getAllAgentGroups(); if (agentGroups.length === 0) { log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', { @@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) }); return; } - const target = agentGroups[0]; + // Use first agent group for approver resolution — owners and global admins + // are returned regardless of which group we pass. + const referenceGroup = agentGroups[0]; - // pickApprover takes the target agent group's id — gets scoped admins + - // global admins + owners. For fresh installs with only an owner, the - // owner is returned. - const approvers = pickApprover(target.id); + const approvers = pickApprover(referenceGroup.id); if (approvers.length === 0) { log.warn('Channel registration skipped — no owner or admin configured', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const originMg = getMessagingGroup(messagingGroupId); const originChannelType = originMg?.channel_type ?? ''; + + // Resolve channel name if not yet persisted. + if (originMg && !originMg.name) { + const channelAdapter = getChannelAdapter(originChannelType); + if (channelAdapter?.resolveChannelName) { + try { + const name = await channelAdapter.resolveChannelName(originMg.platform_id); + if (name) { + updateMessagingGroup(originMg.id, { name }); + originMg.name = name; + } + } catch { + /* non-critical */ + } + } + } + const delivery = await pickApprovalDelivery(approvers, originChannelType); if (!delivery) { log.warn('Channel registration skipped — no DM channel for any approver', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; - // Extract sender name from the event content for a human-readable card. let senderName: string | undefined; try { const parsed = JSON.parse(event.message.content) as Record; senderName = (parsed.senderName ?? parsed.sender) as string | undefined; } catch { - // non-critical — fall through to generic wording + // non-critical } - const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; - const question = isGroup - ? senderName - ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` - : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` - : senderName - ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` - : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; - const options = normalizeOptions(APPROVAL_OPTIONS); + const channelName = originMg?.name ?? null; + const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message'; + const question = buildQuestionText(isGroup, senderName, channelName, originChannelType); + const options = normalizeOptions(buildApprovalOptions(agentGroups)); createPendingChannelApproval({ messaging_group_id: messagingGroupId, - agent_group_id: target.id, + agent_group_id: referenceGroup.id, original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), @@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) const adapter = getDeliveryAdapter(); if (!adapter) { - log.error('Channel registration row created but no delivery adapter is wired', { - messagingGroupId, - }); + log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId }); return; } @@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) 'chat-sdk', JSON.stringify({ type: 'ask_question', - // Use messaging_group_id as the questionId — it's unique per card - // (PK on pending table dedups) and lets the response handler look - // up the pending row directly without another index. questionId: messagingGroupId, title, question, @@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) ); log.info('Channel registration card delivered', { messagingGroupId, - targetAgentGroupId: target.id, + agentGroupCount: agentGroups.length, approver: delivery.userId, }); } catch (err) { - log.error('Channel registration card delivery failed', { - messagingGroupId, - err, - }); + log.error('Channel registration card delivery failed', { messagingGroupId, err }); } } -export const APPROVE_VALUE = 'approve'; -export const REJECT_VALUE = 'reject'; +// ── Helpers for the response handler (index.ts) ── + +/** + * Build normalized options for the agent-selection follow-up card. + */ +export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] { + const options: RawOption[] = agentGroups.map((ag) => ({ + label: ag.name, + selectedLabel: `✅ Connected to ${ag.name}`, + value: `${CONNECT_PREFIX}${ag.id}`, + })); + options.push({ + label: 'Cancel', + selectedLabel: '🙅 Cancelled', + value: REJECT_VALUE, + }); + return normalizeOptions(options); +} + +/** + * Create a new agent group and initialize its filesystem. Handles + * folder-name collisions with numeric suffixes. + */ +export function createNewAgentGroup(name: string): AgentGroup { + let folder = toFolder(name); + const baseFolder = folder; + let suffix = 2; + while (getAgentGroupByFolder(folder)) { + folder = `${baseFolder}-${suffix}`; + suffix++; + } + + const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createAgentGroup({ + id: agId, + name, + folder, + agent_provider: null, + created_at: new Date().toISOString(), + }); + + const ag = getAgentGroup(agId)!; + initGroupFilesystem(ag); + return ag; +} diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d402074..24f7209 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean { return row !== undefined; } +export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void { + getDb() + .prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?') + .run(title, optionsJson, messagingGroupId); +} + export function deletePendingChannelApproval(messagingGroupId: string): void { getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId); } diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 83390d8..98a9463 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,27 +16,53 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; +import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js'; import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { routeInbound, setAccessGate, setChannelRequestGate, + setMessageInterceptor, setSenderResolver, setSenderScopeGate, type AccessGateResult, } from '../../router.js'; import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; +import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; -import { requestChannelApproval } from './channel-approval.js'; +import { + buildAgentSelectionOptions, + CHOOSE_EXISTING_VALUE, + CONNECT_PREFIX, + createNewAgentGroup, + NEW_AGENT_VALUE, + REJECT_VALUE, + requestChannelApproval, +} from './channel-approval.js'; import { addMember } from './db/agent-group-members.js'; -import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js'; +import { + deletePendingChannelApproval, + getPendingChannelApproval, + updatePendingChannelApprovalCard, +} from './db/pending-channel-approvals.js'; import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; import { hasAdminPrivilege } from './db/user-roles.js'; import { getUser, upsertUser } from './db/users.js'; import { requestSenderApproval } from './sender-approval.js'; +import { ensureUserDm } from './user-dm.js'; + +// ── Free-text name input state ── +// Tracks approvers waiting for a text reply with the agent name. Keyed by +// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart. +interface PendingNameInput { + channelMgId: string; + dmChannelType: string; + dmPlatformId: string; +} +const awaitingNameInput = new Map(); function extractAndUpsertUser(event: InboundEvent): string | null { let content: Record; @@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => { * by messaging_group_id). If no such row, return false so downstream * handlers get a shot. * - * Approve: create the wiring with MVP defaults (mention-sticky for - * groups / pattern='.' for DMs; sender_scope='known'; - * ignored_message_policy='accumulate'), add the triggering sender as a - * member so sender_scope doesn't immediately bounce them into a - * sender-approval card, then replay the original event. - * - * Deny: set `messaging_groups.denied_at = now()` so future mentions on - * this channel drop silently until an admin explicitly wires it. + * Value dispatch: + * connect: — wire to an existing agent group, replay the message + * choose_existing — send a follow-up card listing all agents + * new_agent — prompt for a free-text agent name (interceptor + * captures the reply and creates immediately) + * reject — set denied_at, delete pending row */ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise { const row = getPendingChannelApproval(payload.questionId); if (!row) return false; - // Click-auth: same pattern as sender-approval (see commit 68058cb). - // Raw platform userId → namespace with channelType → must match the - // designated approver OR have admin privilege over the target agent. const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null; const isAuthorized = clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); @@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< clickerId, expectedApprover: row.approver_user_id, }); - return true; // claim but take no action + return true; } const approverId = clickerId; - const approved = payload.value === 'approve'; - if (!approved) { + // ── Reject / Cancel ── + if (payload.value === REJECT_VALUE) { setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString()); deletePendingChannelApproval(row.messaging_group_id); log.info('Channel registration denied', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, approverId, }); return true; } - // Rehydrate the original event to know (a) whether it was a DM or group - // (chooses engage_mode default), and (b) who the triggering sender was - // (auto-member-add so sender_scope='known' doesn't bounce the replay). + // ── Choose existing agent — send agent-selection follow-up card ── + if (payload.value === CHOOSE_EXISTING_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) return true; + + const agentGroups = getAllAgentGroups(); + const options = buildAgentSelectionOptions(agentGroups); + const title = '📋 Choose an agent'; + updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options)); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: row.messaging_group_id, + title, + question: 'Which agent should handle this channel?', + options, + }), + ); + } catch (err) { + log.error('Channel registration: agent-selection card delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + return true; + } + + // ── Create new agent — prompt for free-text name ── + if (payload.value === NEW_AGENT_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) { + log.error('Channel registration: no delivery adapter for name prompt', { + messagingGroupId: row.messaging_group_id, + }); + return true; + } + + awaitingNameInput.set(row.approver_user_id, { + channelMgId: row.messaging_group_id, + dmChannelType: approverDm.channel_type, + dmPlatformId: approverDm.platform_id, + }); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: 'Reply with the name for your new agent:' }), + ); + } catch (err) { + log.error('Channel registration: name prompt delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + awaitingNameInput.delete(row.approver_user_id); + } + return true; + } + + // ── Resolve target agent group (connect to existing or create new) ── + let targetAgentGroupId: string; + + if (payload.value.startsWith(CONNECT_PREFIX)) { + targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length); + const ag = getAgentGroup(targetAgentGroupId); + if (!ag) { + log.error('Channel registration: target agent group no longer exists', { + messagingGroupId: row.messaging_group_id, + targetAgentGroupId, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + } else { + log.warn('Channel registration: unknown response value', { + messagingGroupId: row.messaging_group_id, + value: payload.value, + }); + return true; + } + + // ── Wire + replay (shared path for connect and create) ── let event: InboundEvent; try { event = JSON.parse(row.original_message) as InboundEvent; @@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< return true; } - // Decide engage_mode from the original event. DMs (`isMention=true` & - // not in a group) get `pattern='.'` (always respond). Group mentions - // get `mention-sticky` (respond now + follow the thread). - // - // We can't read `mg.is_group` reliably here because we only auto-create - // the mg with `is_group=0` on first sight — the adapter hasn't told us - // yet whether it's actually a group. Fall back to the InboundEvent's - // `threadId`: a non-null threadId implies a threaded platform (Slack - // channel thread, Discord thread), which we treat as a group. const isGroup = event.threadId !== null; const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; const engagePattern = isGroup ? null : '.'; @@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< createMessagingGroupAgent({ id: mgaId, messaging_group_id: row.messaging_group_id, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, engage_mode: engageMode, engage_pattern: engagePattern, sender_scope: 'known', @@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< }); log.info('Channel registration approved — wiring created', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, + agentGroupId: targetAgentGroupId, mgaId, engageMode, approverId, }); - // Auto-admit the triggering sender. Without this, the replay below - // would bounce through sender-approval (sender_scope='known' + - // sender-is-not-a-member). const senderUserId = extractAndUpsertUser(event); if (senderUserId) { addMember({ user_id: senderUserId, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, added_by: approverId, added_at: new Date().toISOString(), }); } - // Clear the pending row BEFORE replay so the gate check on the second - // attempt sees a wired channel (agentCount > 0) and takes the fan-out - // path normally. deletePendingChannelApproval(row.messaging_group_id); try { @@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< } registerResponseHandler(handleChannelApprovalResponse); + +// ── Free-text name interceptor ── +// Captures the next DM from an approver who clicked "Create new agent", +// creates the agent immediately, wires the channel, and replays. + +setMessageInterceptor(async (event: InboundEvent): Promise => { + const userId = extractAndUpsertUser(event); + if (!userId) return false; + + const pending = awaitingNameInput.get(userId); + if (!pending) return false; + if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false; + + awaitingNameInput.delete(userId); + + let text: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim(); + } catch { + /* fall through */ + } + + if (!text) { + log.warn('Channel registration: empty name reply, ignoring', { userId }); + return true; + } + + const row = getPendingChannelApproval(pending.channelMgId); + if (!row) return true; + + const ag = createNewAgentGroup(text); + log.info('Channel registration: new agent group created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + agentName: ag.name, + folder: ag.folder, + }); + + let originalEvent: InboundEvent; + try { + originalEvent = JSON.parse(row.original_message) as InboundEvent; + } catch (err) { + log.error('Channel registration: failed to parse stored event', { + messagingGroupId: row.messaging_group_id, + err, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + + const isGroup = originalEvent.threadId !== null; + const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; + const engagePattern = isGroup ? null : '.'; + + const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createMessagingGroupAgent({ + id: mgaId, + messaging_group_id: row.messaging_group_id, + agent_group_id: ag.id, + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'known', + ignored_message_policy: 'accumulate', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), + }); + log.info('Channel registration approved — wiring created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + mgaId, + engageMode, + approverId: userId, + }); + + const senderUserId = extractAndUpsertUser(originalEvent); + if (senderUserId) { + addMember({ + user_id: senderUserId, + agent_group_id: ag.id, + added_by: userId, + added_at: new Date().toISOString(), + }); + } + + deletePendingChannelApproval(row.messaging_group_id); + + try { + await routeInbound(originalEvent); + } catch (err) { + log.error('Failed to replay message after channel approval', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + + const adapter = getDeliveryAdapter(); + if (adapter) { + const dm = await ensureUserDm(row.approver_user_id); + if (dm) { + adapter + .deliver( + dm.channel_type, + dm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }), + ) + .catch(() => {}); + } + } + return true; +}); diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..e61d721 --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,28 @@ +/** + * Claude provider container config — only registered when the user has + * configured a custom Anthropic-compatible endpoint via setup. Setup + * appends `import './claude.js'` to providers/index.ts at that point; + * standard installs hitting api.anthropic.com don't need this file + * loaded. + * + * The real auth token never enters the container. Setup creates an + * OneCLI generic secret (host-pattern = base URL hostname, header-name + * = Authorization, value-format = "Bearer {value}") so the proxy + * rewrites the Authorization header on the wire. The container only + * needs: + * - ANTHROPIC_BASE_URL — so the SDK knows where to call + * - ANTHROPIC_AUTH_TOKEN=placeholder — so the SDK adds an + * Authorization: Bearer header for OneCLI to overwrite + */ +import { readEnvFile } from '../env.js'; +import { registerProviderContainerConfig } from './provider-container-registry.js'; + +registerProviderContainerConfig('claude', () => { + const dotenv = readEnvFile(['ANTHROPIC_BASE_URL']); + const env: Record = {}; + if (dotenv.ANTHROPIC_BASE_URL) { + env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL; + env.ANTHROPIC_AUTH_TOKEN = 'placeholder'; + } + return { env }; +}); diff --git a/src/router.ts b/src/router.ts index 3cf0192..9d4765b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,7 +27,7 @@ import { getMessagingGroupWithAgentCount, } from './db/messaging-groups.js'; import { findSessionForAgent } from './db/sessions.js'; -import { startTypingRefresh } from './modules/typing/index.js'; +import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; @@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { senderScopeGate = fn; } +/** + * Message-interceptor hook. Runs at the very top of routeInbound, before + * messaging-group resolution. When the interceptor returns true the message + * is consumed and routing stops. Used by the permissions module to capture + * free-text replies during multi-step approval flows (e.g. agent naming). + */ +export type MessageInterceptorFn = (event: InboundEvent) => Promise; + +let messageInterceptor: MessageInterceptorFn | null = null; + +export function setMessageInterceptor(fn: MessageInterceptorFn): void { + messageInterceptor = fn; +} + /** * Channel-registration hook. Runs when the router sees a mention/DM on a * messaging group that has no wirings AND hasn't been denied. The hook is @@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender * Creates messaging group + session if they don't exist yet. */ export async function routeInbound(event: InboundEvent): Promise { + // Pre-route interceptor — lets modules consume messages before any routing + // (e.g. free-text replies during multi-step approval flows). + if (messageInterceptor && (await messageInterceptor(event))) return; + // 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram, // WhatsApp, iMessage, email) collapse threads to the channel. const adapter = getChannelAdapter(event.channelType); @@ -289,7 +307,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 { @@ -450,7 +475,11 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - await wakeContainer(freshSession); + const woke = await wakeContainer(freshSession); + // wakeContainer never throws — it returns false on transient spawn + // failure (host-sweep retries). Stop the typing indicator we just + // started so it doesn't leak; the inbound row stays pending. + if (!woke) stopTypingRefresh(freshSession.id); } } } diff --git a/src/session-manager.ts b/src/session-manager.ts index 38eaa0d..6b00655 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,12 +14,13 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { deriveAttachmentName } from './attachment-naming.js'; +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'; import { createSession, - findSession, findSessionByAgentGroup, findSessionForAgent, getSession, @@ -36,6 +37,11 @@ import { import { log } from './log.js'; import type { Session } from './types.js'; +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + /** Root directory for all session data. */ export function sessionsBaseDir(): string { return path.join(DATA_DIR, 'v2-sessions'); @@ -232,6 +238,20 @@ export function writeSessionMessage( /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. + * + * Both `messageId` and `att.name` originate in untrusted input. WhatsApp + * passes `msg.key.id` through raw (and that field is client generated, so a + * peer can craft it), and other adapters may follow. The session dir is + * mounted writable into the container, so a compromised agent can also + * pre-place a symlink at `inbox//` and wait for a chat message + * with a matching id to redirect the host's write. + * + * Defenses, mirrored from the outbound side: + * 1. basename check on `messageId` and `filename`. + * 2. lstat of the inbox dir to refuse pre-placed symlinks. + * 3. realpath-based containment under the session inbox root. + * 4. `wx` flag on writeFileSync to refuse following a pre-existing symlink + * at the target file path or overwriting any existing file. */ function extractAttachmentFiles( agentGroupId: string, @@ -249,19 +269,75 @@ function extractAttachmentFiles( const attachments = parsed.attachments as Array> | undefined; if (!Array.isArray(attachments)) return contentStr; + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe inbound message id', { messageId }); + return contentStr; + } + let changed = false; for (const att of attachments) { - if (typeof att.data === 'string') { - 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.localPath = `inbox/${messageId}/${filename}`; - delete att.data; - changed = true; - log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); + if (typeof att.data !== 'string') continue; + + const rawName = deriveAttachmentName(att); + 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); + + // Refuse to mkdir through a symlink that the container may have pre placed + // at inboxDir. With recursive:true, mkdirSync would silently no op on a + // pre existing symlink and the subsequent writeFileSync would follow it. + if (fs.existsSync(inboxDir)) { + const stat = fs.lstatSync(inboxDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir }); + continue; + } + } + fs.mkdirSync(inboxDir, { recursive: true }); + + let realInboxDir: string; + try { + realInboxDir = fs.realpathSync(inboxDir); + } catch (err) { + log.warn('Failed to resolve inbox directory', { messageId, err }); + continue; + } + const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox'); + if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) { + log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir }); + continue; + } + + const filePath = path.join(inboxDir, filename); + try { + // wx = exclusive create. Refuses to follow a pre existing symlink or + // overwrite any existing file. The host expects to be the sole writer + // of these attachments. + fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'), { flag: 'wx' }); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EEXIST') { + log.warn('Inbox attachment target already exists, refusing to overwrite', { + messageId, + filename, + }); + continue; + } + throw err; + } + + att.name = filename; + att.localPath = `inbox/${messageId}/${filename}`; + delete att.data; + changed = true; + log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); } return changed ? JSON.stringify(parsed) : contentStr; @@ -352,14 +428,48 @@ export function readOutboxFiles( messageId: string, filenames: string[], ): OutboundFile[] | undefined { + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe outbox message id', { messageId }); + return undefined; + } + const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); if (!fs.existsSync(outboxDir)) return undefined; + + let realOutboxDir: string; + try { + const stat = fs.lstatSync(outboxDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox directory', { messageId, outboxDir }); + return undefined; + } + realOutboxDir = fs.realpathSync(outboxDir); + } catch (err) { + log.warn('Failed to inspect outbox directory', { messageId, err }); + return undefined; + } + const files: OutboundFile[] = []; for (const filename of filenames) { + if (!isSafeAttachmentName(filename)) { + log.warn('Refused unsafe outbox filename, would escape outbox', { messageId, filename }); + continue; + } + const filePath = path.join(outboxDir, filename); - if (fs.existsSync(filePath)) { - files.push({ filename, data: fs.readFileSync(filePath) }); - } else { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox file', { messageId, filename }); + continue; + } + const realFilePath = fs.realpathSync(filePath); + if (!isPathInside(realOutboxDir, realFilePath)) { + log.warn('Rejecting outbox file outside message directory', { messageId, filename }); + continue; + } + files.push({ filename, data: fs.readFileSync(realFilePath) }); + } catch { log.warn('Outbox file not found', { messageId, filename }); } } @@ -373,10 +483,26 @@ export function readOutboxFiles( * thrown error would trigger the delivery retry path and deliver twice. */ export function clearOutbox(agentGroupId: string, sessionId: string, messageId: string): void { + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe outbox cleanup message id', { messageId }); + return; + } + const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); if (!fs.existsSync(outboxDir)) return; try { - fs.rmSync(outboxDir, { recursive: true, force: true }); + const stat = fs.lstatSync(outboxDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox cleanup directory', { messageId, outboxDir }); + return; + } + const realOutboxBase = fs.realpathSync(path.join(sessionDir(agentGroupId, sessionId), 'outbox')); + const realOutboxDir = fs.realpathSync(outboxDir); + if (!isPathInside(realOutboxBase, realOutboxDir)) { + log.warn('Rejecting outbox cleanup outside session outbox', { messageId, outboxDir }); + return; + } + fs.rmSync(realOutboxDir, { recursive: true, force: true }); } catch (err) { log.warn('Outbox cleanup failed (message already delivered)', { messageId, err }); }