Merge branch 'main' into feat/migrate-from-v1

Resolve import conflict in setup/auto.ts — keep runMigrateV1 import,
deduplicate runWindowedStep and getLaunchdLabel/getSystemdUnit imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gabi-simons
2026-05-01 04:52:41 +00:00
54 changed files with 3097 additions and 536 deletions

View File

@@ -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** 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 2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes: 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-...`) 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret** 5. Go to **Basic Information** and copy the **Signing Secret**
@@ -76,7 +76,13 @@ pnpm run build
10. Under **Subscribe to bot events**, add: 10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention` - `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes** 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 ### Configure environment

View File

@@ -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. 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 ## Development
Run commands directly — don't tell the user to run them. 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 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/<agent-group>/<session>/``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) ## Supply Chain Security (pnpm)

View File

@@ -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. 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. 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 | | Checkbox | Label |
|----------|-------| |----------|-------|

30
assets/setup-splash.txt Normal file
View File

@@ -0,0 +1,30 @@
⠰⣄⠘⣦
⢹⡆⢸⡆ °
⢸⡇⢸⡇
⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⢀⣤⣤⢀⣄
⣴⡿⡋⢤⣾⣿⢛⢿⣏⢰⣟⣽⡏⣸⡿⣧
o ⢀⣾⠋⠘⠈⣧⣀⣿⣧⣿⣼⣿⣇⣾⠋⢠⣿
⣾⢃⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⣿⢻⣉⠉⠙⠠⣼⠇
⣼⡏⠃⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋ o
⢠⣿⡃⠉⠙⠁⠐⣿⣿⣟⠁⣿⣿⠟⠋
° ⢸⣿⣧⡀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁
⢸⣿⣿⣷⣤⣤⣀⢀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁
⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀ O
⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠩⢻⣆
⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⣿⡆
⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠠⣤⣿⡇
⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄
o ⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧
⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋
 _ _  ___ _ 
| \| |__ _ _ _ ___  / __| |__ ___ __ __
| .` / _` | ' \/ _ \| (__| / _` \ V V /
|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/ 
Small.
Runs on your machine.
Yours to modify.
════════════════════════════════════════

View File

@@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false
# across all users. # across all users.
ARG CLAUDE_CODE_VERSION=2.1.116 ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12 ARG BUN_VERSION=1.3.12
# ---- System dependencies ----------------------------------------------------- # ---- System dependencies -----------------------------------------------------

View File

@@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = {
script, script,
processAfter, processAfter,
recurrence, recurrence,
platformId: r.platform_id,
channelType: r.channel_type,
threadId: r.thread_id,
}), }),
}); });

View File

@@ -260,9 +260,13 @@ async function processQuery(
// Stream liveness is decided host-side via the heartbeat file + processing // 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 // claim age (see src/host-sweep.ts); if something is truly stuck, the host
// will kill the container and messages get reset to pending. // will kill the container and messages get reset to pending.
let pollInFlight = false;
const pollHandle = setInterval(() => { const pollHandle = setInterval(() => {
if (done) return; if (done || pollInFlight) return;
pollInFlight = true;
void (async () => {
try {
// Skip system messages (MCP tool responses) and /clear (needs fresh query). // Skip system messages (MCP tool responses) and /clear (needs fresh query).
// Thread routing is the router's concern — if a message landed in this // Thread routing is the router's concern — if a message landed in this
// session, the agent should see it. Per-thread sessions already isolate // session, the agent should see it. Per-thread sessions already isolate
@@ -275,16 +279,50 @@ async function processQuery(
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
return true; return true;
}); });
if (newMessages.length > 0) { if (newMessages.length === 0) return;
const newIds = newMessages.map((m) => m.id); const newIds = newMessages.map((m) => m.id);
markProcessing(newIds); markProcessing(newIds);
const prompt = formatMessages(newMessages); // Run pre-task scripts on follow-ups too — without this, a task that
log(`Pushing ${newMessages.length} follow-up message(s) into active query`); // arrives during an active query (e.g. a */10 monitoring cron) bypasses
query.push(prompt); // its script gate and always wakes the agent, defeating the gate.
// Mirrors the initial-batch hook above.
markCompleted(newIds); 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); }, ACTIVE_POLL_INTERVAL_MS);
try { try {

View File

@@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback {
/** /**
* Claude Code auto-compacts context at this window (tokens). Kept here so * 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. * 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 * Stale-session detection. Matches Claude Code's error text when a

View File

@@ -129,10 +129,46 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR" mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header write_header
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1 # NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
# and skip printing these again, so the flow stays visually continuous. # with the figlet wordmark and taglines below. Pre-rendered into
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" # assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" # 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 </dev/tty
case "${ROOT_ANS:-1}" in
2)
ph_event setup_root_continued
printf '\n'
;;
*)
ph_event setup_root_aborted
printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')"
printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')"
printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')"
printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')"
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
printf ' %s\n' "$(dim '4. Log out: exit')"
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
exit 1
;;
esac
fi
# ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
# setup/install-node.sh and setup/install-docker.sh both require `brew` on # setup/install-node.sh and setup/install-docker.sh both require `brew` on
@@ -188,9 +224,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_LABEL="Installing the basics"
BOOTSTRAP_START=$(date +%s) BOOTSTRAP_START=$(date +%s)
# One-line "why" that teaches a differentiator while the user waits.
printf '%s %s\n' "$(gray '│')" \
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
spinner_start "$BOOTSTRAP_LABEL" spinner_start "$BOOTSTRAP_LABEL"
# Run in the background so we can tick elapsed time. Capture exit code via # Run in the background so we can tick elapsed time. Capture exit code via
@@ -222,7 +255,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
if [ "$BOOTSTRAP_RC" -eq 0 ]; then if [ "$BOOTSTRAP_RC" -eq 0 ]; then
spinner_success "Basics installed" "$BOOTSTRAP_DUR" spinner_success "Basics ready" "$BOOTSTRAP_DUR"
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
else else
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
@@ -259,4 +292,5 @@ fi
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts` # --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
# preamble so the flow continues visually from "Basics installed" straight # preamble so the flow continues visually from "Basics installed" straight
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. # 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 -- "$@"

View File

@@ -1,6 +1,6 @@
{ {
"name": "nanoclaw", "name": "nanoclaw",
"version": "2.0.13", "version": "2.0.23",
"description": "Personal Claude assistant. Lightweight, secure, customizable.", "description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="132k tokens, 66% of context window"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="139k tokens, 69% of context window">
<title>132k tokens, 66% of context window</title> <title>139k tokens, 69% of context window</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text> <text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text> <text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">132k</text> <text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">139k</text>
<text x="71" y="14">132k</text> <text x="71" y="14">139k</text>
</g> </g>
</g> </g>
</a> </a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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/<folder>/ 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 <folder-name>
*/
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 <folder-name>');
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/<folder>/ 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}).`);

View File

@@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
interface Args { interface Args {
displayName: string; displayName: string;
agentName: string; agentName: string;
folder?: string;
} }
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
let displayName: string | undefined; let displayName: string | undefined;
let agentName: string | undefined; let agentName: string | undefined;
let folder: string | undefined;
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const key = argv[i]; const key = argv[i];
const val = argv[i + 1]; const val = argv[i + 1];
@@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args {
} else if (key === '--agent-name') { } else if (key === '--agent-name') {
agentName = val; agentName = val;
i++; i++;
} else if (key === '--folder') {
folder = val;
i++;
} }
} }
@@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args {
return { return {
displayName, displayName,
agentName: agentName?.trim() || displayName, agentName: agentName?.trim() || displayName,
folder,
}; };
} }
@@ -95,7 +101,7 @@ async function main(): Promise<void> {
const promotedToOwner = false; const promotedToOwner = false;
// 2. Agent group + filesystem. // 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); let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) { if (!ag) {
const agId = generateId('ag'); const agId = generateId('ag');

View File

@@ -24,6 +24,9 @@
* headless `claude -p` call for IANA-zone resolution. * headless `claude -p` call for IANA-zone resolution.
*/ */
import { spawn, spawnSync } from 'child_process'; 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 * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
@@ -38,37 +41,81 @@ import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js'; import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js'; import { offerClaudeAssist } from './lib/claude-assist.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { runMigrateV1 } from './migrate-v1.js'; import { runMigrateV1 } from './migrate-v1.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { import {
claudeCliAvailable, applyToEnv,
resolveTimezoneViaClaude, parseFlags,
} from './lib/tz-from-claude.js'; 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 * 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 { 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'; import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent'; const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now(); const RUN_START = Date.now();
type ChannelChoice = type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
| 'telegram'
| 'discord'
| 'whatsapp'
| 'signal'
| 'teams'
| 'slack'
| 'imessage'
| 'skip';
async function main(): Promise<void> { async function main(): Promise<void> {
// 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(); printIntro();
initProgressionLog(); initProgressionLog();
phEmit('auto_started'); 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( const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '') (process.env.NANOCLAW_SKIP ?? '')
.split(',') .split(',')
@@ -91,17 +138,14 @@ async function main(): Promise<void> {
} }
if (!skip.has('container')) { 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( p.log.message(
dimWrap( brandBody(
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
4,
),
);
p.log.message(
dimWrap( dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.', 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4, 4,
), ),
),
); );
const res = await runWindowedStep('container', { const res = await runWindowedStep('container', {
running: "Preparing your assistant's sandbox…", running: "Preparing your assistant's sandbox…",
@@ -135,12 +179,51 @@ async function main(): Promise<void> {
if (!skip.has('onecli')) { if (!skip.has('onecli')) {
p.log.message( p.log.message(
brandBody(
dimWrap( dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4, 4,
), ),
),
); );
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
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',
`Couldn't reach OneCLI at ${remoteHost}.`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
s.stop('Remote OneCLI is reachable.');
const res = await runQuietStep(
'onecli',
{
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 // Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway // rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that. // offline — confirm with the user before doing that.
@@ -194,6 +277,7 @@ async function main(): Promise<void> {
); );
} }
} }
}
if (!skip.has('auth')) { if (!skip.has('auth')) {
await runAuthStep(); await runAuthStep();
@@ -220,39 +304,42 @@ async function main(): Promise<void> {
done: 'NanoClaw is running.', done: 'NanoClaw is running.',
}); });
if (!res.ok) { if (!res.ok) {
await fail( await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
'service',
"Couldn't start NanoClaw.",
'See logs/nanoclaw.error.log for details.',
);
} }
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn( p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
"NanoClaw's permissions need a tweak before it can reach Docker.",
);
p.log.message( p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + brandBody(
` systemctl --user restart ${getSystemdUnit()}`, ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
),
); );
} }
} }
let displayName: string | undefined; let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); async function resolveDisplayName(): Promise<string> {
if (needsDisplayName) { if (displayName) return displayName;
const fallback = process.env.USER?.trim() || 'Operator';
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); 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')) { if (!skip.has('cli-agent')) {
await resolveDisplayName();
const res = await runQuietStep( const res = await runQuietStep(
'cli-agent', 'cli-agent',
{ {
running: 'Bringing your assistant online…', running: 'Bringing your assistant online…',
done: 'Assistant wired up.', 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) { if (!res.ok) {
await fail( await fail(
@@ -263,16 +350,39 @@ async function main(): Promise<void> {
} }
if (!skip.has('first-chat')) { if (!skip.has('first-chat')) {
p.log.message( p.log.message(
brandBody(
dimWrap( 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 3060 seconds while the sandbox warms up.", "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 3060 seconds while the sandbox warms up.",
4, 4,
), ),
),
); );
const ping = await confirmAssistantResponds(); const ping = await confirmAssistantResponds();
if (ping === 'ok') { if (ping === 'ok') {
phEmit('first_chat_ready'); 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( const next = ensureAnswer(
await p.select({ await brightSelect<'continue' | 'chat'>({
message: 'What next?', message: 'What next?',
options: [ options: [
{ {
@@ -288,7 +398,23 @@ async function main(): Promise<void> {
}), }),
) as 'continue' | 'chat'; ) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next); 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 { } else {
phEmit('first_chat_failed', { reason: ping }); phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping); renderPingFailureNote(ping);
@@ -297,7 +423,7 @@ async function main(): Promise<void> {
msg: msg:
ping === 'socket_error' ping === 'socket_error'
? "NanoClaw service isn't listening on its CLI socket." ? "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: hint:
ping === 'socket_error' ping === 'socket_error'
? 'Socket at data/cli.sock did not accept a connection.' ? 'Socket at data/cli.sock did not accept a connection.'
@@ -323,6 +449,9 @@ async function main(): Promise<void> {
if (!skip.has('channel')) { if (!skip.has('channel')) {
channelChoice = await askChannelChoice(); channelChoice = await askChannelChoice();
if (channelChoice !== 'skip') {
await resolveDisplayName();
}
if (channelChoice === 'telegram') { if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!); await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') { } else if (channelChoice === 'discord') {
@@ -339,10 +468,12 @@ async function main(): Promise<void> {
await runIMessageChannel(displayName!); await runIMessageChannel(displayName!);
} else { } else {
p.log.info( p.log.info(
brandBody(
wrapForGutter( wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4, 4,
), ),
),
); );
} }
} }
@@ -356,7 +487,7 @@ async function main(): Promise<void> {
if (!res.ok) { if (!res.ok) {
const notes: string[] = []; const notes: string[] = [];
if (res.terminal?.fields.CREDENTIALS !== 'configured') { 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; const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') { if (service === 'running_other_checkout') {
@@ -382,10 +513,12 @@ async function main(): Promise<void> {
} }
} }
if (!res.terminal?.fields.CONFIGURED_CHANNELS) { 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) { 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 // "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. // user is still stuck and a fix is exactly what claude-assist is for.
@@ -416,14 +549,12 @@ async function main(): Promise<void> {
['Open Claude Code:', 'claude'], ['Open Claude Code:', 'claude'],
]; ];
const labelWidth = Math.max(...rows.map(([l]) => l.length)); const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`) note(nextSteps, 'Try these');
.join('\n');
p.note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the // 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. // caveat doesn't land after the user's already looked away at their phone.
p.note( note(
wrapForGutter( 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.", "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, 6,
@@ -440,10 +571,7 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it // 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 // renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro. // as the last thing before outro.
p.note( note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
'Go say hi',
);
p.outro(k.green("You're set.")); p.outro(k.green("You're set."));
} else { } else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`.")); 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': case 'imessage':
return 'iMessage'; return 'iMessage';
case 'slack': case 'slack':
// Slack install doesn't wire an agent or send a welcome DM — the return 'Slack DMs';
// driver prints its own "finish in your Slack app" note. Falling
// through to null avoids a misleading "check your Slack DMs" banner.
return null;
default: default:
return null; return null;
} }
@@ -487,25 +612,21 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const s = p.spinner(); const s = p.spinner();
const start = Date.now(); const start = Date.now();
const label = 'Waking your assistant…'; const label = 'Waking your assistant…';
s.start(fitToWidth(label, ' (999s)')); s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => { const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000); }, 1000);
const result = await pingCliAgent(); const result = await pingCliAgent();
clearInterval(tick); clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
if (result === 'ok') { if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else { } else {
const msg = const msg =
result === 'socket_error' result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
? "Couldn't reach the NanoClaw service."
: "Your assistant didn't reply in time.";
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1); s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
} }
return result; 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`.', 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
6, 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. * clearly optional.
*/ */
async function runFirstChat(): Promise<void> { async function runFirstChat(): Promise<void> {
p.note( note(
wrapForGutter( wrapForGutter(
[ [
'Your assistant runs in a sandbox on this machine.', 'Your assistant runs in a sandbox on this machine.',
@@ -561,9 +682,7 @@ async function runFirstChat(): Promise<void> {
message: first message: first
? 'Try a quick hello — or press Enter to continue setup' ? 'Try a quick hello — or press Enter to continue setup'
: 'Another message? Press Enter to continue setup', : 'Another message? Press Enter to continue setup',
placeholder: first placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
? 'e.g. "hi, what can you do?"'
: 'press Enter to continue',
}), }),
); );
first = false; first = false;
@@ -579,11 +698,9 @@ function sendChatMessage(message: string): Promise<void> {
// agent's reply reads as a clean block under the prompt. Splitting on // agent's reply reads as a clean block under the prompt. Splitting on
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv // whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
// with spaces on the far side. // with spaces on the far side.
const child = spawn( const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
'pnpm', stdio: ['ignore', 'inherit', 'inherit'],
['--silent', 'run', 'chat', ...message.split(/\s+/)], });
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
child.on('close', () => resolve()); child.on('close', () => resolve());
child.on('error', () => resolve()); child.on('error', () => resolve());
}); });
@@ -593,11 +710,21 @@ function sendChatMessage(message: string): Promise<void> {
async function runAuthStep(): Promise<void> { async function runAuthStep(): Promise<void> {
if (anthropicSecretExists()) { 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' }); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
return; 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( const method = ensureAnswer(
await brightSelect({ await brightSelect({
message: 'How would you like to connect to Claude?', message: 'How would you like to connect to Claude?',
@@ -631,15 +758,11 @@ async function runAuthStep(): Promise<void> {
} }
async function runSubscriptionAuth(): Promise<void> { async function runSubscriptionAuth(): Promise<void> {
p.log.step("Opening the Claude sign-in flow…"); p.log.step(brandBody('Opening the Claude sign-in flow…'));
console.log( console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
k.dim(' (a browser will open for sign-in; this part is interactive)'),
);
console.log(); console.log();
const start = Date.now(); const start = Date.now();
const code = await runInheritScript('bash', [ const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
'setup/register-claude-token.sh',
]);
const durationMs = Date.now() - start; const durationMs = Date.now() - start;
console.log(); console.log();
if (code !== 0) { if (code !== 0) {
@@ -654,7 +777,7 @@ async function runSubscriptionAuth(): Promise<void> {
); );
} }
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); 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<void> { async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
@@ -664,6 +787,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.password({ await p.password({
message: `Paste your ${label}`, message: `Paste your ${label}`,
clearOnError: true,
validate: (v) => { validate: (v) => {
if (!v || !v.trim()) return 'Required'; if (!v || !v.trim()) return 'Required';
if (!v.trim().startsWith(prefix)) { if (!v.trim().startsWith(prefix)) {
@@ -679,11 +803,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
'auth', 'auth',
'onecli', 'onecli',
[ [
'secrets', 'create', 'secrets',
'--name', 'Anthropic', 'create',
'--type', 'anthropic', '--name',
'--value', token, 'Anthropic',
'--host-pattern', 'api.anthropic.com', '--type',
'anthropic',
'--value',
token,
'--host-pattern',
'api.anthropic.com',
], ],
{ {
running: `Saving your ${label} to your OneCLI vault…`, running: `Saving your ${label} to your OneCLI vault…`,
@@ -702,6 +831,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
} }
} }
/**
* 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<void> {
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 ───────────────────────────────────────────────────── // ─── timezone step ─────────────────────────────────────────────────────
/** /**
@@ -722,10 +937,7 @@ async function runTimezoneStep(): Promise<void> {
const fields = res.terminal?.fields ?? {}; const fields = res.terminal?.fields ?? {};
const resolvedTz = fields.RESOLVED_TZ; const resolvedTz = fields.RESOLVED_TZ;
const needsInput = fields.NEEDS_USER_INPUT === 'true'; const needsInput = fields.NEEDS_USER_INPUT === 'true';
const isUtc = const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
resolvedTz === 'UTC' ||
resolvedTz === 'Etc/UTC' ||
resolvedTz === 'Universal';
// Three branches: // Three branches:
// - no TZ detected: ask where they are (or leave as UTC) // - no TZ detected: ask where they are (or leave as UTC)
@@ -747,8 +959,8 @@ async function runTimezoneStep(): Promise<void> {
const message = needsInput const message = needsInput
? "Your system didn't expose a timezone. Which one are you in?" ? "Your system didn't expose a timezone. Which one are you in?"
: !isUtc : !isUtc
? "Where are you, then?" ? 'Where are you, then?'
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?"; : '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 // 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". // straight to the free-text prompt — the user already said "not that".
@@ -775,7 +987,7 @@ async function runTimezoneStep(): Promise<void> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ 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', placeholder: 'e.g. New York, London, Asia/Tokyo',
validate: (v) => (v && v.trim() ? undefined : 'Required'), validate: (v) => (v && v.trim() ? undefined : 'Required'),
}), }),
@@ -789,10 +1001,12 @@ async function runTimezoneStep(): Promise<void> {
tz = await resolveTimezoneViaClaude(raw); tz = await resolveTimezoneViaClaude(raw);
} else { } else {
p.log.warn( p.log.warn(
brandBody(
wrapForGutter( 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`.", "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, 4,
), ),
),
); );
} }
} }
@@ -834,7 +1048,7 @@ async function runTimezoneStep(): Promise<void> {
async function askDisplayName(fallback: string): Promise<string> { async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant call you?', message: `What should your assistant call ${accentGreen('you')}?`,
placeholder: fallback, placeholder: fallback,
defaultValue: fallback, defaultValue: fallback,
}), }),
@@ -880,6 +1094,14 @@ async function askChannelChoice(): Promise<ChannelChoice> {
// ─── interactive / env helpers ───────────────────────────────────────── // ─── 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 { function anthropicSecretExists(): boolean {
try { try {
const res = spawnSync('onecli', ['secrets', 'list'], { const res = spawnSync('onecli', ['secrets', 'list'], {
@@ -956,10 +1178,12 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return; if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) 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'], { const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit', 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); process.exit(res.status ?? 1);
} }
@@ -971,17 +1195,15 @@ function printIntro(): void {
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) { if (isReexec) {
p.intro( p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
return; return;
} }
// Always include the wordmark inside the clack intro line. When bash ran // bash already printed the wordmark above us; the clack intro carries the
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark // welcome framing alone so the two don't double up. Standalone runs of
// above us; the small repeat is worth it to keep the brand anchored at // setup:auto still see this as the first line — fine without the wordmark
// the visible top of the clack session once the bash output scrolls away. // since the line itself signals the start of the flow.
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`); p.intro("Let's get you set up.");
} }
/** /**

View File

@@ -28,9 +28,11 @@ import k from 'kleur';
import * as setupLog from '../logs.js'; import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.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 { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.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 DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10'; const DISCORD_API = 'https://discord.com/api/v10';
@@ -155,7 +157,7 @@ async function askHasBotToken(): Promise<boolean> {
async function walkThroughBotCreation(): Promise<void> { async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications'; 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.", "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<void> {
' 2. In the "Bot" tab, click "Reset Token" and copy the token', ' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"', ' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)', ' (under Privileged Gateway Intents)',
'', formatNoteLink(url),
k.dim(url), ].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Create a Discord bot', 'Create a Discord bot',
); );
await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); 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, // to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one. // and "Reset Token" issues a new one.
if (hasExistingBot) { if (hasExistingBot) {
p.note( note(
[ [
"Where to find your bot token:", "Where to find your bot token:",
'', '',
@@ -216,16 +217,15 @@ async function walkThroughServerCreation(): Promise<void> {
// the web client and rely on the + button being visible. The steps below // 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. // are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me'; 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.", "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', ' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"', ' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")', ' 3. Give it any name (e.g. "NanoClaw")',
'', formatNoteLink(url),
k.dim(url), ].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Create a Discord server', 'Create a Discord server',
); );
await confirmThenOpen(url, 'Press Enter to open Discord'); await confirmThenOpen(url, 'Press Enter to open Discord');
@@ -239,9 +239,22 @@ async function walkThroughServerCreation(): Promise<void> {
} }
async function collectDiscordToken(): Promise<string> { async function collectDiscordToken(): Promise<string> {
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( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste your bot token', message: 'Paste your bot token',
clearOnError: true,
validate: (v) => { validate: (v) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
if (!t) return 'Token is required'; if (!t) return 'Token is required';
@@ -275,9 +288,8 @@ async function validateDiscordToken(token: string): Promise<string> {
username?: string; username?: string;
message?: string; message?: string;
}; };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) { 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, { setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username, BOT_USERNAME: data.username,
BOT_ID: data.id ?? '', BOT_ID: data.id ?? '',
@@ -295,8 +307,7 @@ async function validateDiscordToken(token: string): Promise<string> {
'Copy the token again from the Developer Portal and retry setup.', 'Copy the token again from the Developer Portal and retry setup.',
); );
} catch (err) { } catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000); s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, { setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message, ERROR: message,
@@ -324,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
team?: unknown; team?: unknown;
message?: string; message?: string;
}; };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) { if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`; const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1); s.stop(`Couldn't read application info: ${reason}`, 1);
@@ -337,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
'Re-run setup. If it keeps failing, check the bot token has the right scopes.', '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 // 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. // team object instead and we'll fall back to a manual user-id prompt.
const owner = const owner =
@@ -355,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
owner, owner,
}; };
} catch (err) { } catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000); s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, { setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message, ERROR: message,
@@ -385,14 +394,14 @@ async function resolveOwnerUserId(
} }
} else { } else {
p.log.info( 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(); return await promptForUserIdWithDevMode();
} }
async function promptForUserIdWithDevMode(): Promise<string> { async function promptForUserIdWithDevMode(): Promise<string> {
p.note( note(
[ [
"To get your Discord user ID:", "To get your Discord user ID:",
'', '',
@@ -430,15 +439,14 @@ async function promptInviteBot(
`&scope=bot` + `&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`; `&permissions=${INVITE_PERMISSIONS}`;
p.note( note(
[ [
`@${botUsername} needs to share a server with you before it can DM you.`, `@${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)', ' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"', ' 2. Click "Authorize"',
'', formatNoteLink(url),
k.dim(url), ].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Add bot to a server', 'Add bot to a server',
); );
await confirmThenOpen(url, 'Press Enter to open the invite page'); await confirmThenOpen(url, 'Press Enter to open the invite page');
@@ -465,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
body: JSON.stringify({ recipient_id: userId }), body: JSON.stringify({ recipient_id: userId }),
}); });
const data = (await res.json()) as { id?: string; message?: string }; const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) { if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`; const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1); s.stop(`Couldn't open a DM channel: ${reason}`, 1);
@@ -478,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
'Make sure the bot is in a server you\'re also in, then retry setup.', '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, { setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id, DM_CHANNEL_ID: data.id,
}); });
return data.id; return data.id;
} catch (err) { } catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000); s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, { setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message, ERROR: message,
@@ -506,7 +512,7 @@ async function resolveAgentName(): Promise<string> {
} }
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant be called?', message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME, placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME,
}), }),

View File

@@ -36,7 +36,8 @@ import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js'; import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js'; import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.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'; const DEFAULT_AGENT_NAME = 'Nano';
@@ -189,7 +190,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
} }
const nodeDir = path.dirname(nodePath); const nodeDir = path.dirname(nodePath);
p.note( note(
wrapForGutter( wrapForGutter(
[ [
`iMessage needs Full Disk Access granted to the Node binary:`, `iMessage needs Full Disk Access granted to the Node binary:`,
@@ -222,7 +223,20 @@ async function walkThroughFullDiskAccess(): Promise<void> {
} }
async function collectRemoteCreds(): Promise<RemoteCreds> { async function collectRemoteCreds(): Promise<RemoteCreds> {
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", "Photon is a separate service that owns an iMessage account and",
"exposes it over HTTP. NanoClaw will talk to it via its API.", "exposes it over HTTP. NanoClaw will talk to it via its API.",
@@ -250,6 +264,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
const keyAnswer = ensureAnswer( const keyAnswer = ensureAnswer(
await p.password({ await p.password({
message: 'Photon API key', message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'), validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}), }),
); );
@@ -264,7 +279,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
} }
async function askOperatorHandle(): Promise<string> { async function askOperatorHandle(): Promise<string> {
p.note( note(
[ [
"What phone number or email do you iMessage with?", "What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.", "That's where your assistant will send its welcome message.",
@@ -303,7 +318,7 @@ async function resolveAgentName(): Promise<string> {
} }
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant be called?', message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME, placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME,
}), }),

View File

@@ -44,6 +44,7 @@ import {
writeStepEntry, writeStepEntry,
} from '../lib/runner.js'; } from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js'; import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano'; const DEFAULT_AGENT_NAME = 'Nano';
@@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise<void> {
if (!probe.error && probe.status === 0) return; if (!probe.error && probe.status === 0) return;
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
p.note( note(
[ [
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.", "NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'', '',
@@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise<void> {
'signal-cli not found', 'signal-cli not found',
); );
} else { } else {
p.note( note(
[ [
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.", "NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'', '',
@@ -323,8 +324,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to connect to signal-cli before // Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path. // init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000); s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, { setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform, PLATFORM: platform,
}); });
@@ -346,7 +346,7 @@ async function resolveAgentName(): Promise<string> {
} }
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant be called?', message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME, placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME,
}), }),

View File

@@ -1,24 +1,23 @@
/** /**
* Slack channel flow for setup:auto. * Slack channel flow for setup:auto.
* *
* `runSlackChannel(displayName)` walks the operator from a bare Slack * `runSlackChannel(displayName)` owns the full branch from creating a
* workspace through a running bot, then stops before wiring an agent: * Slack app through the welcome DM:
* *
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret * event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts) * 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity * 3. Validate via auth.test → resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive) * 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Print the post-install checklist: set the public webhook URL in * 5. Ask for the operator's Slack user ID
* Slack's Event Subscriptions, DM the bot to bootstrap the channel, * 6. conversations.open to get the DM channel ID
* then `/manage-channels` to wire an agent. * 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), * The welcome DM is sent via outbound delivery (chat.postMessage), which
* Slack needs a public Event Subscriptions URL for inbound events, and * works without Event Subscriptions being configured. The user sees the
* opening an unsolicited DM would need `im:write` scope we don't force * greeting in Slack immediately; inbound replies require webhooks, so the
* the SKILL.md to require. Shipping a honest "here's what's left" note * post-install note covers that.
* is better than a welcome DM the user won't receive until they
* configure the webhook anyway.
* *
* All output obeys the three-level contract. See docs/setup-flow.md. * 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 k from 'kleur';
import * as setupLog from '../logs.js'; 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 { 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_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps'; const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo { interface WorkspaceInfo {
teamName: string; teamName: string;
@@ -40,10 +42,7 @@ interface WorkspaceInfo {
botUserId: string; botUserId: string;
} }
// displayName is reserved for when we start wiring the first agent here. export async function runSlackChannel(displayName: string): Promise<void> {
// Kept to match the `run<X>Channel(displayName)` signature every other
// channel driver uses, so auto.ts can dispatch without a branch.
export async function runSlackChannel(_displayName: string): Promise<void> {
await walkThroughAppCreation(); await walkThroughAppCreation();
const token = await collectBotToken(); const token = await collectBotToken();
@@ -78,26 +77,67 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
); );
} }
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); showPostInstallChecklist(info);
} }
async function walkThroughAppCreation(): Promise<void> { async function walkThroughAppCreation(): Promise<void> {
p.note( note(
[ [
"You'll create a Slack app that the assistant talks through.", "You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.", "Free and stays inside the workspaces you pick.",
'', '',
' 1. Create a new app "From scratch", name it, pick a workspace', ' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:', ' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, channels:history, groups:history, im:history,', ' chat:write, im:write, channels:history, groups:history,',
' channels:read, groups:read, users:read, reactions:write', ' im:history, channels:read, groups:read, users:read,',
' reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send', ' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"', ' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"', ' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'', formatNoteLink(SLACK_APPS_URL),
k.dim(SLACK_APPS_URL), ].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Create a Slack app', 'Create a Slack app',
); );
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
@@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise<void> {
} }
async function collectBotToken(): Promise<string> { async function collectBotToken(): Promise<string> {
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( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste your Slack bot token', message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => { validate: (v) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
if (!t) return 'Token is required'; if (!t) return 'Token is required';
@@ -132,9 +185,22 @@ async function collectBotToken(): Promise<string> {
} }
async function collectSigningSecret(): Promise<string> { async function collectSigningSecret(): Promise<string> {
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( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste your Slack signing secret', message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => { validate: (v) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
if (!t) return 'Signing secret is required'; if (!t) return 'Signing secret is required';
@@ -175,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
user_id?: string; user_id?: string;
error?: string; error?: string;
}; };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) { if (data.ok && data.team && data.user) {
s.stop( 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 = { const info: WorkspaceInfo = {
teamName: data.team, teamName: data.team,
@@ -207,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
); );
} catch (err) { } catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000); s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, { setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message, ERROR: message,
@@ -221,26 +285,133 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
} }
} }
async function collectSlackUserId(): Promise<string> {
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<string> {
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<string> {
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 { function showPostInstallChecklist(info: WorkspaceInfo): void {
p.note( note(
wrapForGutter( 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.', ' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' NanoClaw serves a webhook on port 3000 by default — expose it', ' Cloudflare Tunnel, or a reverse proxy on a VPS.',
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
'', '',
' 2. In your Slack app → Event Subscriptions:', ' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on', ' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`, ` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,', ' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention', ' message.im, app_mention',
' • Save, then reinstall the app when Slack prompts', ' • Save Changes',
'', '',
` 3. DM @${info.botName} from Slack once — that bootstraps the`, ' 3. In your Slack app → Interactivity & Shortcuts:',
' messaging group. Then run `/manage-channels` in `claude` to', ' • Toggle "Interactivity" on',
' wire an agent to it.', ` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'), ].join('\n'),
6, 6,
), ),

View File

@@ -40,7 +40,9 @@ import {
} from '../lib/claude-handoff.js'; } from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js'; import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams'; const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
@@ -59,6 +61,28 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
const collected: Collected = {}; const collected: Collected = {};
const completed: string[] = []; 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(); printIntro();
await confirmPrereqs({ collected, completed }); await confirmPrereqs({ collected, completed });
@@ -79,7 +103,7 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
// ─── step: intro / prereqs ────────────────────────────────────────────── // ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void { function printIntro(): void {
p.note( note(
[ [
'Setting up Teams is more involved than the other channels — about', 'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.', '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<void> { async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note( note(
[ [
'Before we start, confirm you have:', 'Before we start, confirm you have:',
'', '',
@@ -119,7 +143,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
// ─── step: public URL ────────────────────────────────────────────────── // ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> { async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note( note(
[ [
"Azure Bot Service delivers messages to an HTTPS endpoint you", "Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook", "control. The endpoint needs to reach this machine's webhook",
@@ -175,7 +199,7 @@ async function stepAppRegistration(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<void> {
p.note( note(
[ [
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")', '2. Name it (e.g. "NanoClaw")',
@@ -259,7 +283,7 @@ async function stepClientSecret(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<void> {
p.note( note(
[ [
`1. In your app registration, open "Certificates & secrets"`, `1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"', '2. Click "New client secret"',
@@ -276,6 +300,7 @@ async function stepClientSecret(args: {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste the client secret Value', message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => { validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
if (!t) return 'Required'; if (!t) return 'Required';
@@ -328,7 +353,7 @@ async function stepAzureBot(args: {
` --appid ${args.collected.appId} \\\n` + ` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`; ` ${tenantFlag}--endpoint "${endpoint}"`;
p.note( note(
[ [
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'', '',
@@ -365,7 +390,7 @@ async function stepEnableTeamsChannel(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<void> {
p.note( note(
[ [
'1. Open your Azure Bot resource → Channels', '1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply', '2. Click Microsoft Teams → Accept terms → Apply',
@@ -435,7 +460,7 @@ async function stepSideload(args: {
completed: string[]; completed: string[];
zipPath: string; zipPath: string;
}): Promise<void> { }): Promise<void> {
p.note( note(
[ [
'1. Open Microsoft Teams', '1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app', '2. Go to Apps → Manage your apps → Upload an app',
@@ -501,7 +526,7 @@ async function finishWithHandoff(
collected: Collected, collected: Collected,
completed: string[], completed: string[],
): Promise<void> { ): Promise<void> {
p.note( note(
[ [
'The Teams adapter is live and the service is running.', 'The Teams adapter is live and the service is running.',
'', '',
@@ -530,7 +555,7 @@ async function finishWithHandoff(
); );
if (choice === 'self') { if (choice === 'self') {
p.note( note(
[ [
' 1. Find your bot in Teams (search by name, or via the sideloaded', ' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)', ' app) and send it a message ("hi" is fine)',

View File

@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import * as setupLog from '../logs.js'; 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 { askOperatorRole } from '../lib/role-prompt.js';
import { import {
type Block, type Block,
@@ -33,7 +33,8 @@ import {
spawnStep, spawnStep,
writeStepEntry, writeStepEntry,
} from '../lib/runner.js'; } 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'; const DEFAULT_AGENT_NAME = 'Nano';
@@ -47,12 +48,11 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
// installed, or the bot's web profile if not. tg://resolve?domain= is // installed, or the bot's web profile if not. tg://resolve?domain= is
// more direct but silently fails when the scheme isn't registered. // more direct but silently fails when the scheme isn't registered.
const botUrl = `https://t.me/${botUsername}`; const botUrl = `https://t.me/${botUsername}`;
p.note( note(
[ [
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'', formatNoteLink(botUrl),
k.dim(botUrl), ].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Open Telegram', 'Open Telegram',
); );
await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
@@ -132,7 +132,19 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
} }
async function collectTelegramToken(): Promise<string> { async function collectTelegramToken(): Promise<string> {
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.", "Your assistant talks to you through a Telegram bot you create.",
"Here's how:", "Here's how:",
@@ -150,6 +162,7 @@ async function collectTelegramToken(): Promise<string> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste your bot token', message: 'Paste your bot token',
clearOnError: true,
validate: (v) => { validate: (v) => {
if (!v || !v.trim()) return "Token is required"; if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
@@ -178,10 +191,9 @@ async function validateTelegramToken(token: string): Promise<string> {
result?: { username?: string; id?: number }; result?: { username?: string; id?: number };
description?: string; description?: string;
}; };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) { if (data.ok && data.result?.username) {
const username = 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, { setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username, BOT_USERNAME: username,
BOT_ID: data.result.id ?? '', BOT_ID: data.result.id ?? '',
@@ -199,8 +211,7 @@ async function validateTelegramToken(token: string): Promise<string> {
'Copy the token again from @BotFather and try setup once more.', 'Copy the token again from @BotFather and try setup once more.',
); );
} catch (err) { } catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000); s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, { setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message, ERROR: message,
@@ -240,12 +251,12 @@ async function runPairTelegram(): Promise<
} else { } else {
stopSpinner("Old code expired. Here's a fresh one."); stopSpinner("Old code expired. Here's a fresh one.");
} }
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start('Waiting for you to send the code from Telegram…'); s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true; spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); 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; spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') { } else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') { if (block.fields.STATUS === 'success') {
@@ -291,7 +302,7 @@ async function resolveAgentName(): Promise<string> {
} }
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant be called?', message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME, placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME,
}), }),

View File

@@ -46,7 +46,7 @@ import {
writeStepEntry, writeStepEntry,
} from '../lib/runner.js'; } from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.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 DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
@@ -171,7 +171,7 @@ async function askAuthMethod(): Promise<AuthMethod> {
} }
async function askPhoneNumber(): Promise<string> { async function askPhoneNumber(): Promise<string> {
p.note( note(
[ [
"Enter your phone number the way WhatsApp expects it:", "Enter your phone number the way WhatsApp expects it:",
'', '',
@@ -249,7 +249,7 @@ async function runWhatsAppAuth(
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') { } else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
const code = block.fields.CODE ?? '????'; const code = block.fields.CODE ?? '????';
stopSpinner('Your pairing code is ready.'); 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…'); s.start('Waiting for you to enter the code…');
spinnerActive = true; spinnerActive = true;
} else if (block.type === 'WHATSAPP_AUTH') { } else if (block.type === 'WHATSAPP_AUTH') {
@@ -267,7 +267,7 @@ async function runWhatsAppAuth(
if (spinnerActive) { if (spinnerActive) {
stopSpinner('WhatsApp linked.'); stopSpinner('WhatsApp linked.');
} else { } else {
p.log.success('WhatsApp linked.'); p.log.success(brandBody('WhatsApp linked.'));
} }
} else if (status === 'failed') { } else if (status === 'failed') {
if (qrLinesPrinted > 0) { if (qrLinesPrinted > 0) {
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to reconnect before init-first-agent's // Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path. // welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000); s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, { setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform, PLATFORM: platform,
}); });
@@ -395,7 +394,7 @@ async function restartService(): Promise<void> {
} }
async function askChatPhone(authedPhone: string): Promise<string> { async function askChatPhone(authedPhone: string): Promise<string> {
p.note( note(
[ [
`Authenticated with ${k.cyan('+' + authedPhone)}.`, `Authenticated with ${k.cyan('+' + authedPhone)}.`,
'', '',
@@ -462,7 +461,7 @@ async function resolveAgentName(): Promise<string> {
} }
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant be called?', message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME, placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME,
}), }),

View File

@@ -8,6 +8,7 @@
* Args: * Args:
* --display-name <name> (required) operator's display name * --display-name <name> (required) operator's display name
* --agent-name <name> (optional) agent persona name, defaults to display-name * --agent-name <name> (optional) agent persona name, defaults to display-name
* --folder <name> (optional) explicit folder name, defaults to cli-with-<normalized-display-name>
*/ */
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
import path from 'path'; import path from 'path';
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
function parseArgs(args: string[]): { function parseArgs(args: string[]): {
displayName: string; displayName: string;
agentName?: string; agentName?: string;
folder?: string;
} { } {
let displayName: string | undefined; let displayName: string | undefined;
let agentName: string | undefined; let agentName: string | undefined;
let folder: string | undefined;
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const key = args[i]; const key = args[i];
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
agentName = val; agentName = val;
i++; i++;
break; break;
case '--folder':
folder = val;
i++;
break;
} }
} }
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
process.exit(2); process.exit(2);
} }
return { displayName, agentName }; return { displayName, agentName, folder };
} }
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const { displayName, agentName } = parseArgs(args); const { displayName, agentName, folder } = parseArgs(args);
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts'); const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName]; const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName); if (agentName) scriptArgs.push('--agent-name', agentName);
if (folder) scriptArgs.push('--folder', folder);
log.info('Invoking init-cli-agent', { displayName, agentName }); log.info('Invoking init-cli-agent', { displayName, agentName });

View File

@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise<void> {
} }
// Socket is unreachable due to group perms — current shell's supplementary // Socket is unreachable due to group perms — current shell's supplementary
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh // groups are fixed at login, so `usermod -aG docker` doesn't affect us
// or a prior install) doesn't affect us until next login. Re-exec this // until next login. Ensure the user is in the docker group (install-docker.sh
// step under `sg docker` so the child picks up docker as its primary // does this on fresh installs, but skips when Docker is already present),
// group and can talk to /var/run/docker.sock without a logout. // 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')) { 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`'); log.info('Re-executing container step under `sg docker`');
const res = spawnSync( const res = spawnSync(
'sg', 'sg',

View File

@@ -11,6 +11,48 @@ import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.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 { export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true; return true;

View File

@@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core';
import { isCancel } from '@clack/prompts'; import { isCancel } from '@clack/prompts';
import { styleText } from 'node:util'; import { styleText } from 'node:util';
import { brandBody } from './theme.js';
const BULLET_ACTIVE = '●'; const BULLET_ACTIVE = '●';
const BULLET_INACTIVE = '○'; const BULLET_INACTIVE = '○';
const BAR = '│'; const BAR = '│';
@@ -95,7 +97,7 @@ export function brightSelect<T>(
const shown = const shown =
st === 'cancel' st === 'cancel'
? styleText(['strikethrough', 'dim'], selected) ? styleText(['strikethrough', 'dim'], selected)
: styleText('dim', selected); : styleText('dim', brandBody(selected));
lines.push(`${grayBar} ${shown}`); lines.push(`${grayBar} ${shown}`);
return lines.join('\n'); return lines.join('\n');
} }
@@ -104,11 +106,12 @@ export function brightSelect<T>(
options.forEach((opt, idx) => { options.forEach((opt, idx) => {
const label = opt.label ?? String(opt.value); const label = opt.label ?? String(opt.value);
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : ''; const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
const marker = const isActive = idx === cursor;
idx === cursor const marker = isActive
? styleText('green', BULLET_ACTIVE) ? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE); : styleText('dim', BULLET_INACTIVE);
lines.push(`${bar} ${marker} ${label}${hint}`); const shownLabel = isActive ? brandBody(label) : label;
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
}); });
lines.push(styleText(color, CAP_BOT)); lines.push(styleText(color, CAP_BOT));
return lines.join('\n'); return lines.join('\n');

View File

@@ -9,12 +9,19 @@
* `confirmThenOpen` pauses for the operator before triggering the open — * `confirmThenOpen` pauses for the operator before triggering the open —
* the browser tends to steal focus when it pops, and a split-second * 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 * "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 { spawn } from 'child_process';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import k from 'kleur';
import { isHeadless } from '../platform.js';
import { ensureAnswer } from './runner.js'; import { ensureAnswer } from './runner.js';
/** Best-effort open of a URL in the user's default browser. Silent on failure. */ /** 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 * 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 * to take focus. Proceeds on cancel as well. On headless devices both the
* URL from the note that precedes the prompt. * 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:
* <url>" 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( export async function confirmThenOpen(
url: string, url: string,
message = 'Press Enter to open your browser', message = 'Press Enter to open your browser',
): Promise<void> { ): Promise<void> {
if (isHeadless()) return;
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer( ensureAnswer(
await p.confirm({ await p.confirm({
message, message: `${message}${fallback}`,
initialValue: true, initialValue: true,
}), }),
); );

View File

@@ -2,8 +2,11 @@
* Offer Claude-assisted debugging when a setup step fails. * Offer Claude-assisted debugging when a setup step fails.
* *
* Flow: * Flow:
* 1. Check `claude` is on PATH and has a working credential. If not, * 1. Check `claude` is on PATH — if not, offer to install it via
* silently skip — pre-auth failures can't use this path. * 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?"). * 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 * 3. Build a minimal prompt: the one-paragraph situation, the failing
* step's name/message/hint, and a short list of *file references* * 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. * 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 fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import { ensureAnswer } from './runner.js'; import { ensureAnswer } from './runner.js';
import { fitToWidth } from './theme.js'; import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
export interface AssistContext { export interface AssistContext {
stepName: string; stepName: string;
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
projectRoot: string = process.cwd(), projectRoot: string = process.cwd(),
): Promise<boolean> { ): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!isClaudeUsable()) return false; if (!(await ensureClaudeReady(projectRoot))) return false;
const want = ensureAnswer( const want = ensureAnswer(
await p.confirm({ await p.confirm({
@@ -106,12 +110,12 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response); const parsed = parseResponse(response);
if (!parsed) { 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))); p.log.message(k.dim(response.trim().slice(0, 500)));
return false; return false;
} }
p.note( note(
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
"Claude's suggestion", "Claude's suggestion",
); );
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
return true; return true;
} }
function isClaudeUsable(): boolean { function isClaudeInstalled(): boolean {
try { try {
execSync('command -v claude', { stdio: 'ignore' }); execSync('command -v claude', { stdio: 'ignore' });
return true;
} catch { } catch {
return false; 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<boolean> {
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; return true;
} }
@@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner(
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window). // Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
out.write(`\x1b[${WINDOW_SIZE + 1}A`); out.write(`\x1b[${WINDOW_SIZE + 1}A`);
const elapsed = Math.round((Date.now() - start) / 1000);
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; 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); const header = fitToWidth('Asking Claude to diagnose…', suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
@@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner(
clearBlock(); clearBlock();
out.write(SHOW_CURSOR); out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit); process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
if (kind === 'ok') { 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); resolve(payload);
} else { } else {
p.log.error( p.log.error(

View File

@@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import { brandBody, note } from './theme.js';
export interface HandoffContext { export interface HandoffContext {
/** Channel this handoff is happening in (e.g., 'teams'). */ /** Channel this handoff is happening in (e.g., 'teams'). */
channel: string; channel: string;
@@ -62,14 +64,14 @@ export interface HandoffContext {
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> { export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
if (!isClaudeUsable()) { if (!isClaudeUsable()) {
p.log.warn( 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; return false;
} }
const systemPrompt = buildSystemPrompt(ctx); const systemPrompt = buildSystemPrompt(ctx);
p.note( note(
[ [
"I'm handing you off to Claude in interactive mode.", "I'm handing you off to Claude in interactive mode.",
"It has the context of where you are in setup.", "It has the context of where you are in setup.",
@@ -91,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
{ stdio: 'inherit' }, { stdio: 'inherit' },
); );
child.on('close', () => { child.on('close', () => {
p.log.success("Back from Claude. Let's continue."); p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true); resolve(true);
}); });
child.on('error', () => { child.on('error', () => {

View File

@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js'; import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js'; import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js'; import { emit as phEmit } from './diagnostics.js';
import { fitToWidth } from './theme.js'; import { brandBody, fitToWidth, fmtDuration } from './theme.js';
export type Fields = Record<string, string>; export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields }; export type Block = { type: string; fields: Fields };
@@ -307,18 +307,16 @@ async function runUnderSpinner<
): Promise<T> { ): Promise<T> {
const s = p.spinner(); const s = p.spinner();
const start = Date.now(); const start = Date.now();
s.start(fitToWidth(labels.running, ' (999s)')); s.start(fitToWidth(labels.running, ' (99m 59s)'));
const tick = setInterval(() => { const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`); s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
}, 1000); }, 1000);
const result = await work(); const result = await work();
clearInterval(tick); clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
if (result.ok) { if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
@@ -390,7 +388,7 @@ export async function fail(
const skipList = [ const skipList = [
...new Set([...existingSkip, ...setupLog.completedStepNames()]), ...new Set([...existingSkip, ...setupLog.completedStepNames()]),
].join(','); ].join(',');
p.log.step(`Retrying from ${stepName}`); p.log.step(brandBody(`Retrying from ${stepName}`));
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], { const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
stdio: 'inherit', stdio: 'inherit',
env: { ...process.env, NANOCLAW_SKIP: skipList }, env: { ...process.env, NANOCLAW_SKIP: skipList },

View File

@@ -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<string, string | boolean | number>;
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_<KEY> env var.');
lines.push('Run without flags for the default interactive flow.');
stream.write(lines.join('\n') + '\n');
}

View File

@@ -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<ConfigValues> {
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<string>({
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<void> {
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<string>({
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;
}

142
setup/lib/setup-config.ts Normal file
View File

@@ -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_<UPPER_SNAKE> 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;
}

View File

@@ -11,6 +11,7 @@
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
* - Otherwise → kleur's 16-color cyan (closest fallback) * - Otherwise → kleur's 16-color cyan (closest fallback)
*/ */
import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; 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))); 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 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 * 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); 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; const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(s: string): number { function visibleLength(s: string): number {

View File

@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import { isValidTimezone } from '../../src/timezone.js'; import { isValidTimezone } from '../../src/timezone.js';
import { fitToWidth } from './theme.js'; import { fitToWidth, fmtDuration } from './theme.js';
export function claudeCliAvailable(): boolean { export function claudeCliAvailable(): boolean {
try { try {
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
const s = p.spinner(); const s = p.spinner();
const start = Date.now(); const start = Date.now();
const label = 'Looking up that timezone…'; const label = 'Looking up that timezone…';
s.start(fitToWidth(label, ' (999s)')); s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => { const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000); }, 1000);
const reply = await queryClaude(prompt); const reply = await queryClaude(prompt);
clearInterval(tick); clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
const resolved = reply ? extractTimezone(reply) : null; const resolved = reply ? extractTimezone(reply) : null;
if (resolved) { if (resolved) {

View File

@@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js'; import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
import * as setupLog from '../logs.js'; import * as setupLog from '../logs.js';
import { fitToWidth } from './theme.js'; import { brandBody, fitToWidth } from './theme.js';
const WINDOW_SIZE = 3; const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
@@ -169,7 +169,7 @@ async function runUnderWindow(
if (result.ok) { if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; 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 { } else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`); p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
@@ -185,7 +185,7 @@ async function handleStall(
): Promise<void> { ): Promise<void> {
render.pauseRender(); render.pauseRender();
p.log.warn( 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 }); phEmit('step_stalled', { step: stepName });

View File

@@ -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'); const envFile = path.join(process.cwd(), '.env');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
if (/^ONECLI_URL=/m.test(content)) { const re = new RegExp(`^${name}=.*$`, 'm');
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`); if (re.test(content)) {
content = content.replace(re, `${name}=${value}`);
} else { } else {
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`; content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
} }
fs.writeFileSync(envFile, content); 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 // Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a // and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships. // new CLI release ships.
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0'; const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli'; 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 } { function installOnecli(): { stdout: string; ok: boolean } {
let stdout = ''; let stdout = '';
@@ -163,14 +175,12 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
lines.push(s); lines.push(s);
}; };
const osName = const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
if (!osName) { if (!osName) {
append(`Unsupported platform: ${process.platform}`); append(`Unsupported platform: ${process.platform}`);
return { stdout: lines.join('\n'), ok: false }; return { stdout: lines.join('\n'), ok: false };
} }
const arch = const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
if (!arch) { if (!arch) {
append(`Unsupported arch: ${process.arch}`); append(`Unsupported arch: ${process.arch}`);
return { stdout: lines.join('\n'), ok: false }; return { stdout: lines.join('\n'), ok: false };
@@ -201,10 +211,9 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
try { try {
append(`Downloading ${url}`); append(`Downloading ${url}`);
execSync( execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, stdio: ['ignore', 'pipe', 'pipe'],
{ stdio: ['ignore', 'pipe', 'pipe'] }, });
);
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, { execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
@@ -231,7 +240,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
} }
} }
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> { export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned. // `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
@@ -248,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const reuse = args.includes('--reuse'); const reuse = args.includes('--reuse');
const remoteUrlIdx = args.indexOf('--remote-url');
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
ensureShellProfilePath(); 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) { if (reuse) {
// Reuse-mode: don't touch the running gateway at all. Just verify it // 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. // exists, read its api-host, write ONECLI_URL to .env, and move on.

View File

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

69
src/attachment-naming.ts Normal file
View File

@@ -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<string, string> = {
'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<string, string> = {
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, unknown>): 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}`;
}

23
src/attachment-safety.ts Normal file
View File

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

View File

@@ -135,6 +135,7 @@ export interface ChannelAdapter {
// Optional // Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>; setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>; syncConversations?(): Promise<ConversationInfo[]>;
resolveChannelName?(platformId: string): Promise<string | null>;
/** /**
* Subscribe the bot to a thread so follow-up messages route via the * Subscribe the bot to a thread so follow-up messages route via the

197
src/circuit-breaker.test.ts Normal file
View File

@@ -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<typeof import('./config.js')>('./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);
});
});

84
src/circuit-breaker.ts Normal file
View File

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

View File

@@ -58,7 +58,7 @@ const activeContainers = new Map<string, { process: ChildProcess; containerName:
* a duplicate container against the same session directory, producing * a duplicate container against the same session directory, producing
* racy double-replies. * racy double-replies.
*/ */
const wakePromises = new Map<string, Promise<void>>(); const wakePromises = new Map<string, Promise<boolean>>();
export function getActiveContainerCount(): number { export function getActiveContainerCount(): number {
return activeContainers.size; return activeContainers.size;
@@ -73,18 +73,30 @@ export function isContainerRunning(sessionId: string): boolean {
* (the in-flight wake promise is reused). * (the in-flight wake promise is reused).
* *
* The container runs the v2 agent-runner which polls the session DB. * 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<void> { export function wakeContainer(session: Session): Promise<boolean> {
if (activeContainers.has(session.id)) { if (activeContainers.has(session.id)) {
log.debug('Container already running', { sessionId: session.id }); log.debug('Container already running', { sessionId: session.id });
return Promise.resolve(); return Promise.resolve(true);
} }
const existing = wakePromises.get(session.id); const existing = wakePromises.get(session.id);
if (existing) { if (existing) {
log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id });
return existing; return existing;
} }
const promise = spawnContainer(session).finally(() => { 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.delete(session.id);
}); });
wakePromises.set(session.id, promise); wakePromises.set(session.id, promise);
@@ -435,20 +447,18 @@ async function buildContainerArgs(
} }
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection. // are routed through the agent vault for credential injection. Treated as
try { // 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) { if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
} }
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (onecliApplied) { if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
log.info('OneCLI gateway applied', { containerName }); 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 });
}
// Host gateway // Host gateway
args.push(...hostGatewayArgs()); args.push(...hostGatewayArgs());

View File

@@ -23,6 +23,8 @@ import {
sessionDir, sessionDir,
inboundDbPath, inboundDbPath,
outboundDbPath, outboundDbPath,
readOutboxFiles,
clearOutbox,
} from './session-manager.js'; } from './session-manager.js';
import { getSession, findSession } from './db/sessions.js'; import { getSession, findSession } from './db/sessions.js';
import type { InboundEvent } from './channels/adapter.js'; import type { InboundEvent } from './channels/adapter.js';
@@ -108,6 +110,147 @@ describe('session manager', () => {
outDb.close(); 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/<msgId> 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/<msgId>/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)', () => { it('should resolve to existing session (shared mode)', () => {
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
expect(c1).toBe(true); expect(c1).toBe(true);
@@ -173,6 +316,43 @@ describe('session manager', () => {
expect(getSession(session.id)!.last_active).not.toBeNull(); 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', () => { describe('router', () => {

View File

@@ -168,6 +168,8 @@ async function sweepSession(session: Session): Promise<void> {
const dueCount = countDueMessages(inDb); const dueCount = countDueMessages(inDb);
if (dueCount > 0 && !isContainerRunning(session.id)) { if (dueCount > 0 && !isContainerRunning(session.id)) {
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); 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); await wakeContainer(session);
} }

View File

@@ -7,6 +7,7 @@
import path from 'path'; import path from 'path';
import { DATA_DIR } from './config.js'; import { DATA_DIR } from './config.js';
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
import { initDb } from './db/connection.js'; import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js'; import { runMigrations } from './db/migrations/index.js';
@@ -58,6 +59,9 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from
async function main(): Promise<void> { async function main(): Promise<void> {
log.info('NanoClaw starting'); log.info('NanoClaw starting');
// 0. Circuit breaker — backoff on rapid restarts
await enforceStartupBackoff();
// 1. Init central DB // 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db'); const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath); const db = initDb(dbPath);
@@ -174,8 +178,15 @@ async function shutdown(signal: string): Promise<void> {
} }
stopDeliveryPolls(); stopDeliveryPolls();
stopHostSweep(); stopHostSweep();
try {
await teardownChannelAdapters(); 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.exit(0);
}
} }
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));

View File

@@ -21,6 +21,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { isSafeAttachmentName } from '../../attachment-safety.js';
import { getAgentGroup } from '../../db/agent-groups.js'; import { getAgentGroup } from '../../db/agent-groups.js';
import { getSession } from '../../db/sessions.js'; import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.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 type { Session } from '../../types.js';
import { hasDestination } from './db/agent-destinations.js'; import { hasDestination } from './db/agent-destinations.js';
export { isSafeAttachmentName };
export interface ForwardedAttachment { export interface ForwardedAttachment {
name: string; name: string;
filename: string; filename: string;
@@ -36,26 +39,6 @@ export interface ForwardedAttachment {
localPath: string; 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 * Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing * agent's inbox. Returns attachments using the formatter's existing

View File

@@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => {
expect(kind).toBe('chat-sdk'); expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string); const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question'); expect(payload.type).toBe('ask_question');
// Card names the target agent so the owner knows what they're wiring to. // Single-agent card offers a direct "Connect to <name>" button.
expect(payload.question).toContain('Andy'); 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 { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{ const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
@@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => {
}; };
expect(pending).toBeDefined(); expect(pending).toBeDefined();
// Owner clicks approve. // Owner clicks "Connect to Andy" (single-agent card).
for (const handler of getResponseHandlers()) { for (const handler of getResponseHandlers()) {
const claimed = await handler({ const claimed = await handler({
questionId: pending.messaging_group_id, questionId: pending.messaging_group_id,
value: 'approve', value: 'connect:ag-1',
userId: 'owner', // raw platform id — handler namespaces it userId: 'owner', // raw platform id — handler namespaces it
channelType: 'telegram', channelType: 'telegram',
platformId: 'dm-owner', platformId: 'dm-owner',
@@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => {
if (claimed) break; if (claimed) break;
} }
// Wiring created with MVP defaults. // Wiring created with defaults.
const mga = getDb() const mga = getDb()
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?') .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as { .get(pending.messaging_group_id) as {
@@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => {
for (const handler of getResponseHandlers()) { for (const handler of getResponseHandlers()) {
const claimed = await handler({ const claimed = await handler({
questionId: pending.messaging_group_id, questionId: pending.messaging_group_id,
value: 'approve', value: 'connect:ag-1',
userId: 'owner', userId: 'owner',
channelType: 'telegram', channelType: 'telegram',
platformId: 'dm-owner', platformId: 'dm-owner',

View File

@@ -5,24 +5,32 @@
* addressed to the bot (SDK-confirmed mention or DM), it calls * addressed to the bot (SDK-confirmed mention or DM), it calls
* `requestChannelApproval` instead of silently dropping. The flow: * `requestChannelApproval` instead of silently dropping. The flow:
* *
* 1. Pick the target agent group we'd wire to (MVP: first by name). * 1. Gather all existing agent groups.
* Multi-agent picker is a follow-up — see ACTION-ITEMS.
* 2. Pick an eligible approver (owner / admin) and a reachable DM for * 2. Pick an eligible approver (owner / admin) and a reachable DM for
* them, reusing the same primitives the sender-approval flow uses. * them, reusing the same primitives the sender-approval flow uses.
* 3. Deliver an Approve / Ignore card that names the target agent * 3. Deliver a card with three action families:
* explicitly so the owner knows what they're wiring to. * 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 * 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): * On connect (handler in index.ts):
* - Create `messaging_group_agents` with MVP defaults * - Create `messaging_group_agents` with defaults
* (mention-sticky for groups / pattern='.' for DMs, * (mention-sticky for groups / pattern='.' for DMs,
* sender_scope='known', ignored_message_policy='accumulate') * sender_scope='known', ignored_message_policy='accumulate')
* - Add the triggering sender to `agent_group_members` so sender_scope * - Add the triggering sender to `agent_group_members` so sender_scope
* doesn't bounce the replayed message into a sender-approval cascade * doesn't bounce the replayed message into a sender-approval cascade
* - Delete the pending row, replay the original event * - 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 * - Set `messaging_groups.denied_at = now()` so the router stops
* escalating on this channel until an admin explicitly re-wires * escalating on this channel until an admin explicitly re-wires
* - Delete the pending row * - Delete the pending row
@@ -36,19 +44,81 @@
* - Approver has no reachable DM. * - Approver has no reachable DM.
* - Delivery adapter missing. * - Delivery adapter missing.
*/ */
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
import { getAllAgentGroups } from '../../db/agent-groups.js'; import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getChannelAdapter } from '../../channels/channel-registry.js';
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js'; import { getDeliveryAdapter } from '../../delivery.js';
import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { InboundEvent } from '../../channels/adapter.js'; import type { InboundEvent } from '../../channels/adapter.js';
import type { AgentGroup } from '../../types.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
const APPROVAL_OPTIONS: RawOption[] = [ // ── Value constants (response handler in index.ts parses these) ──
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, 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 { export interface RequestChannelApprovalInput {
messagingGroupId: string; messagingGroupId: string;
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> { export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
const { messagingGroupId, event } = input; 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)) { if (hasInFlightChannelApproval(messagingGroupId)) {
log.debug('Channel registration already in flight — dropping retry', { log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
messagingGroupId,
});
return; 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(); const agentGroups = getAllAgentGroups();
if (agentGroups.length === 0) { if (agentGroups.length === 0) {
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', { log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
}); });
return; 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 + const approvers = pickApprover(referenceGroup.id);
// global admins + owners. For fresh installs with only an owner, the
// owner is returned.
const approvers = pickApprover(target.id);
if (approvers.length === 0) { if (approvers.length === 0) {
log.warn('Channel registration skipped — no owner or admin configured', { log.warn('Channel registration skipped — no owner or admin configured', {
messagingGroupId, messagingGroupId,
targetAgentGroupId: target.id, targetAgentGroupId: referenceGroup.id,
}); });
return; return;
} }
const originMg = getMessagingGroup(messagingGroupId); const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? ''; 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); const delivery = await pickApprovalDelivery(approvers, originChannelType);
if (!delivery) { if (!delivery) {
log.warn('Channel registration skipped — no DM channel for any approver', { log.warn('Channel registration skipped — no DM channel for any approver', {
messagingGroupId, messagingGroupId,
targetAgentGroupId: target.id, targetAgentGroupId: referenceGroup.id,
}); });
return; return;
} }
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; 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; let senderName: string | undefined;
try { try {
const parsed = JSON.parse(event.message.content) as Record<string, unknown>; const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
senderName = (parsed.senderName ?? parsed.sender) as string | undefined; senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
} catch { } catch {
// non-critical — fall through to generic wording // non-critical
} }
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; const channelName = originMg?.name ?? null;
const question = isGroup const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
? senderName const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` const options = normalizeOptions(buildApprovalOptions(agentGroups));
: `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);
createPendingChannelApproval({ createPendingChannelApproval({
messaging_group_id: messagingGroupId, messaging_group_id: messagingGroupId,
agent_group_id: target.id, agent_group_id: referenceGroup.id,
original_message: JSON.stringify(event), original_message: JSON.stringify(event),
approver_user_id: delivery.userId, approver_user_id: delivery.userId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const adapter = getDeliveryAdapter(); const adapter = getDeliveryAdapter();
if (!adapter) { if (!adapter) {
log.error('Channel registration row created but no delivery adapter is wired', { log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
messagingGroupId,
});
return; return;
} }
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
'chat-sdk', 'chat-sdk',
JSON.stringify({ JSON.stringify({
type: 'ask_question', 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, questionId: messagingGroupId,
title, title,
question, question,
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
); );
log.info('Channel registration card delivered', { log.info('Channel registration card delivered', {
messagingGroupId, messagingGroupId,
targetAgentGroupId: target.id, agentGroupCount: agentGroups.length,
approver: delivery.userId, approver: delivery.userId,
}); });
} catch (err) { } catch (err) {
log.error('Channel registration card delivery failed', { log.error('Channel registration card delivery failed', { messagingGroupId, err });
messagingGroupId,
err,
});
} }
} }
export const APPROVE_VALUE = 'approve'; // ── Helpers for the response handler (index.ts) ──
export const REJECT_VALUE = 'reject';
/**
* 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;
}

View File

@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
return row !== undefined; 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 { export function deletePendingChannelApproval(messagingGroupId: string): void {
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId); getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
} }

View File

@@ -16,27 +16,53 @@
* access gate is not registered and core defaults to allow-all. * access gate is not registered and core defaults to allow-all.
*/ */
import { recordDroppedMessage } from '../../db/dropped-messages.js'; import { recordDroppedMessage } from '../../db/dropped-messages.js';
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import { import {
routeInbound, routeInbound,
setAccessGate, setAccessGate,
setChannelRequestGate, setChannelRequestGate,
setMessageInterceptor,
setSenderResolver, setSenderResolver,
setSenderScopeGate, setSenderScopeGate,
type AccessGateResult, type AccessGateResult,
} from '../../router.js'; } from '../../router.js';
import type { InboundEvent } from '../../channels/adapter.js'; import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { canAccessAgentGroup } from './access.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 { 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 { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js'; import { hasAdminPrivilege } from './db/user-roles.js';
import { getUser, upsertUser } from './db/users.js'; import { getUser, upsertUser } from './db/users.js';
import { requestSenderApproval } from './sender-approval.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<string, PendingNameInput>();
function extractAndUpsertUser(event: InboundEvent): string | null { function extractAndUpsertUser(event: InboundEvent): string | null {
let content: Record<string, unknown>; let content: Record<string, unknown>;
@@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => {
* by messaging_group_id). If no such row, return false so downstream * by messaging_group_id). If no such row, return false so downstream
* handlers get a shot. * handlers get a shot.
* *
* Approve: create the wiring with MVP defaults (mention-sticky for * Value dispatch:
* groups / pattern='.' for DMs; sender_scope='known'; * connect:<id> — wire to an existing agent group, replay the message
* ignored_message_policy='accumulate'), add the triggering sender as a * choose_existing — send a follow-up card listing all agents
* member so sender_scope doesn't immediately bounce them into a * new_agent — prompt for a free-text agent name (interceptor
* sender-approval card, then replay the original event. * captures the reply and creates immediately)
* * reject — set denied_at, delete pending row
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
* this channel drop silently until an admin explicitly wires it.
*/ */
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> { async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
const row = getPendingChannelApproval(payload.questionId); const row = getPendingChannelApproval(payload.questionId);
if (!row) return false; 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 clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
const isAuthorized = const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
clickerId, clickerId,
expectedApprover: row.approver_user_id, expectedApprover: row.approver_user_id,
}); });
return true; // claim but take no action return true;
} }
const approverId = clickerId; 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()); setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
deletePendingChannelApproval(row.messaging_group_id); deletePendingChannelApproval(row.messaging_group_id);
log.info('Channel registration denied', { log.info('Channel registration denied', {
messagingGroupId: row.messaging_group_id, messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id,
approverId, approverId,
}); });
return true; return true;
} }
// Rehydrate the original event to know (a) whether it was a DM or group // ── Choose existing agent — send agent-selection follow-up card ──
// (chooses engage_mode default), and (b) who the triggering sender was if (payload.value === CHOOSE_EXISTING_VALUE) {
// (auto-member-add so sender_scope='known' doesn't bounce the replay). 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; let event: InboundEvent;
try { try {
event = JSON.parse(row.original_message) as InboundEvent; event = JSON.parse(row.original_message) as InboundEvent;
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
return true; 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 isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.'; const engagePattern = isGroup ? null : '.';
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
createMessagingGroupAgent({ createMessagingGroupAgent({
id: mgaId, id: mgaId,
messaging_group_id: row.messaging_group_id, messaging_group_id: row.messaging_group_id,
agent_group_id: row.agent_group_id, agent_group_id: targetAgentGroupId,
engage_mode: engageMode, engage_mode: engageMode,
engage_pattern: engagePattern, engage_pattern: engagePattern,
sender_scope: 'known', sender_scope: 'known',
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
}); });
log.info('Channel registration approved — wiring created', { log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id, messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id, agentGroupId: targetAgentGroupId,
mgaId, mgaId,
engageMode, engageMode,
approverId, 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); const senderUserId = extractAndUpsertUser(event);
if (senderUserId) { if (senderUserId) {
addMember({ addMember({
user_id: senderUserId, user_id: senderUserId,
agent_group_id: row.agent_group_id, agent_group_id: targetAgentGroupId,
added_by: approverId, added_by: approverId,
added_at: new Date().toISOString(), 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); deletePendingChannelApproval(row.messaging_group_id);
try { try {
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
} }
registerResponseHandler(handleChannelApprovalResponse); 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<boolean> => {
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<string, unknown>;
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;
});

28
src/providers/claude.ts Normal file
View File

@@ -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<string, string> = {};
if (dotenv.ANTHROPIC_BASE_URL) {
env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL;
env.ANTHROPIC_AUTH_TOKEN = 'placeholder';
}
return { env };
});

View File

@@ -27,7 +27,7 @@ import {
getMessagingGroupWithAgentCount, getMessagingGroupWithAgentCount,
} from './db/messaging-groups.js'; } from './db/messaging-groups.js';
import { findSessionForAgent } from './db/sessions.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 { log } from './log.js';
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
import { wakeContainer } from './container-runner.js'; import { wakeContainer } from './container-runner.js';
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
senderScopeGate = fn; 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<boolean>;
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 * 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 * 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. * Creates messaging group + session if they don't exist yet.
*/ */
export async function routeInbound(event: InboundEvent): Promise<void> { export async function routeInbound(event: InboundEvent): Promise<void> {
// 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, // 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
// WhatsApp, iMessage, email) collapse threads to the channel. // WhatsApp, iMessage, email) collapse threads to the channel.
const adapter = getChannelAdapter(event.channelType); const adapter = getChannelAdapter(event.channelType);
@@ -289,7 +307,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err }); 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); await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
accumulatedCount++; accumulatedCount++;
} else { } else {
@@ -450,7 +475,11 @@ async function deliverToAgent(
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
const freshSession = getSession(session.id); const freshSession = getSession(session.id);
if (freshSession) { 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);
} }
} }
} }

View File

@@ -14,12 +14,13 @@ import type Database from 'better-sqlite3';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { deriveAttachmentName } from './attachment-naming.js';
import { isSafeAttachmentName } from './attachment-safety.js';
import type { OutboundFile } from './channels/adapter.js'; import type { OutboundFile } from './channels/adapter.js';
import { DATA_DIR } from './config.js'; import { DATA_DIR } from './config.js';
import { getMessagingGroup } from './db/messaging-groups.js'; import { getMessagingGroup } from './db/messaging-groups.js';
import { import {
createSession, createSession,
findSession,
findSessionByAgentGroup, findSessionByAgentGroup,
findSessionForAgent, findSessionForAgent,
getSession, getSession,
@@ -36,6 +37,11 @@ import {
import { log } from './log.js'; import { log } from './log.js';
import type { Session } from './types.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. */ /** Root directory for all session data. */
export function sessionsBaseDir(): string { export function sessionsBaseDir(): string {
return path.join(DATA_DIR, 'v2-sessions'); 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 * If message content has attachments with base64 `data`, save them to
* the session's inbox directory and replace with `localPath`. * 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/<future msgId>/` 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( function extractAttachmentFiles(
agentGroupId: string, agentGroupId: string,
@@ -249,20 +269,76 @@ function extractAttachmentFiles(
const attachments = parsed.attachments as Array<Record<string, unknown>> | undefined; const attachments = parsed.attachments as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(attachments)) return contentStr; if (!Array.isArray(attachments)) return contentStr;
if (!isSafeAttachmentName(messageId)) {
log.warn('Rejecting unsafe inbound message id', { messageId });
return contentStr;
}
let changed = false; let changed = false;
for (const att of attachments) { for (const att of attachments) {
if (typeof att.data === 'string') { 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); 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 }); fs.mkdirSync(inboxDir, { recursive: true });
const filename = (att.name as string) || `attachment-${Date.now()}`;
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); const filePath = path.join(inboxDir, filename);
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); 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}`; att.localPath = `inbox/${messageId}/${filename}`;
delete att.data; delete att.data;
changed = true; changed = true;
log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); log.debug('Saved attachment to inbox', { messageId, filename, size: att.size });
} }
}
return changed ? JSON.stringify(parsed) : contentStr; return changed ? JSON.stringify(parsed) : contentStr;
} }
@@ -352,14 +428,48 @@ export function readOutboxFiles(
messageId: string, messageId: string,
filenames: string[], filenames: string[],
): OutboundFile[] | undefined { ): OutboundFile[] | undefined {
if (!isSafeAttachmentName(messageId)) {
log.warn('Rejecting unsafe outbox message id', { messageId });
return undefined;
}
const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId);
if (!fs.existsSync(outboxDir)) return undefined; 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[] = []; const files: OutboundFile[] = [];
for (const filename of filenames) { 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); const filePath = path.join(outboxDir, filename);
if (fs.existsSync(filePath)) { try {
files.push({ filename, data: fs.readFileSync(filePath) }); const stat = fs.lstatSync(filePath);
} else { 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 }); 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. * thrown error would trigger the delivery retry path and deliver twice.
*/ */
export function clearOutbox(agentGroupId: string, sessionId: string, messageId: string): void { 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); const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId);
if (!fs.existsSync(outboxDir)) return; if (!fs.existsSync(outboxDir)) return;
try { 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) { } catch (err) {
log.warn('Outbox cleanup failed (message already delivered)', { messageId, err }); log.warn('Outbox cleanup failed (message already delivered)', { messageId, err });
} }