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
+8 -2
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**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
@@ -76,7 +76,13 @@ pnpm run build
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
### Configure environment
+22 -1
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.
## PR Hygiene
Before creating a PR, run these checks:
```bash
git diff upstream/main --stat HEAD
git log upstream/main..HEAD --oneline
```
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
## Development
Run commands directly — don't tell the user to run them.
@@ -187,7 +198,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
systemctl --user start|stop|restart nanoclaw
```
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
## Troubleshooting
Check these first when something goes wrong:
| What | Where |
|------|-------|
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
| Session DBs | `data/v2-sessions/<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)
+2 -1
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.
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 |
|----------|-------|
+30
View File
@@ -0,0 +1,30 @@
⠀⠰⣄⠘⣦⠀
⠀⢹⡆⢸⡆⠀ °
⠀⢸⡇⢸⡇⠀
⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀
⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧
o ⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿
⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇
⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o
⢠⣿⡃⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀
° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀
⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀
⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀ O
⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀
⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀
⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀
o ⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀
⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀
 _ _  ___ _ 
| \| |__ _ _ _ ___  / __| |__ ___ __ __
| .` / _` | ' \/ _ \| (__| / _` \ V V /
|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/ 
Small.
Runs on your machine.
Yours to modify.
════════════════════════════════════════
+1 -1
View File
@@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
# ---- System dependencies -----------------------------------------------------
@@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = {
script,
processAfter,
recurrence,
platformId: r.platform_id,
channelType: r.channel_type,
threadId: r.thread_id,
}),
});
+59 -21
View File
@@ -260,31 +260,69 @@ async function processQuery(
// Stream liveness is decided host-side via the heartbeat file + processing
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
// will kill the container and messages get reset to pending.
let pollInFlight = false;
const pollHandle = setInterval(() => {
if (done) return;
if (done || pollInFlight) return;
pollInFlight = true;
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
// Thread routing is the router's concern — if a message landed in this
// session, the agent should see it. Per-thread sessions already isolate
// threads into separate containers; shared sessions intentionally merge
// everything. Filtering on thread_id here caused deadlocks when the
// initial batch and follow-ups had mismatched thread_ids (e.g. a
// host-generated welcome trigger with null thread vs a Discord DM reply).
const newMessages = getPendingMessages().filter((m) => {
if (m.kind === 'system') return false;
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
return true;
});
if (newMessages.length > 0) {
const newIds = newMessages.map((m) => m.id);
markProcessing(newIds);
void (async () => {
try {
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
// Thread routing is the router's concern — if a message landed in this
// session, the agent should see it. Per-thread sessions already isolate
// threads into separate containers; shared sessions intentionally merge
// everything. Filtering on thread_id here caused deadlocks when the
// initial batch and follow-ups had mismatched thread_ids (e.g. a
// host-generated welcome trigger with null thread vs a Discord DM reply).
const newMessages = getPendingMessages().filter((m) => {
if (m.kind === 'system') return false;
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
return true;
});
if (newMessages.length === 0) return;
const prompt = formatMessages(newMessages);
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
query.push(prompt);
const newIds = newMessages.map((m) => m.id);
markProcessing(newIds);
markCompleted(newIds);
}
// Run pre-task scripts on follow-ups too — without this, a task that
// arrives during an active query (e.g. a */10 monitoring cron) bypasses
// its script gate and always wakes the agent, defeating the gate.
// Mirrors the initial-batch hook above.
let keep = newMessages;
let skipped: string[] = [];
// MODULE-HOOK:scheduling-pre-task-followup:start
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
const preTask = await applyPreTaskScripts(newMessages);
keep = preTask.keep;
skipped = preTask.skipped;
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
}
// MODULE-HOOK:scheduling-pre-task-followup:end
if (keep.length === 0) return;
// Re-check done — the outer query may have finished while the script
// was awaited. Pushing into a closed stream is wasted work; the
// claimed messages get released by the host's processing-claim sweep.
if (done) return;
const keptIds = keep.map((m) => m.id);
const prompt = formatMessages(keep);
log(`Pushing ${keep.length} follow-up message(s) into active query`);
query.push(prompt);
markCompleted(keptIds);
} catch (err) {
// Without this catch the rejection escapes the void IIFE and Node
// terminates the container on unhandled-rejection. The initial-batch
// path is wrapped by processQuery's outer try/catch; the follow-up
// path is not, so it needs its own.
const errMsg = err instanceof Error ? err.message : String(err);
log(`Follow-up poll error: ${errMsg}`);
} finally {
pollInFlight = false;
}
})();
}, ACTIVE_POLL_INTERVAL_MS);
try {
@@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback {
/**
* Claude Code auto-compacts context at this window (tokens). Kept here so
* the generic bootstrap doesn't need to know about Claude-specific env vars.
*
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
* raise or lower the threshold without editing source — useful when running
* with a 1M-context model variant or when emergency-tuning a deployment.
*/
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
/**
* Stale-session detection. Matches Claude Code's error text when a
+43 -9
View File
@@ -129,10 +129,46 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
# and skip printing these again, so the flow stays visually continuous.
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
# NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
# with the figlet wordmark and taglines below. Pre-rendered into
# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
# figlet); the bash script just streams the literal frame. clack's intro
# then carries the "let's get you set up" framing — setup:auto sees
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
cat "$PROJECT_ROOT/assets/setup-splash.txt"
# ─── pre-flight: root user warning (Linux) ────────────────────────────
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
printf ' %s\n' \
"$(red 'Warning: you are running as root.')"
printf ' %s\n' \
"$(dim "Running NanoClaw as root is not recommended. It can cause permission")"
printf ' %s\n\n' \
"$(dim "issues with containers, services, and file ownership.")"
printf ' %s\n' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')"
printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')"
read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS </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 ─────────────────────────────────────
# 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_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"
# 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 ))
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"
else
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`
# preamble so the flow continues visually from "Basics installed" straight
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
exec pnpm --silent run setup:auto
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
exec pnpm --silent run setup:auto -- "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.13",
"version": "2.0.23",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
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">
<title>132k tokens, 66% of context window</title>
<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>139k tokens, 69% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" 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">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">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 x="71" y="14">132k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">139k</text>
<text x="71" y="14">139k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+75
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}).`);
+7 -1
View File
@@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
interface Args {
displayName: string;
agentName: string;
folder?: string;
}
function parseArgs(argv: string[]): Args {
let displayName: string | undefined;
let agentName: string | undefined;
let folder: string | undefined;
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
const val = argv[i + 1];
@@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args {
} else if (key === '--agent-name') {
agentName = val;
i++;
} else if (key === '--folder') {
folder = val;
i++;
}
}
@@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args {
return {
displayName,
agentName: agentName?.trim() || displayName,
folder,
};
}
@@ -95,7 +101,7 @@ async function main(): Promise<void> {
const promotedToOwner = false;
// 2. Agent group + filesystem.
const folder = `cli-with-${normalizeName(args.displayName)}`;
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) {
const agId = generateId('ag');
+387 -165
View File
@@ -24,6 +24,9 @@
* headless `claude -p` call for IANA-zone resolution.
*/
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import * as os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
@@ -38,37 +41,81 @@ import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { runMigrateV1 } from './migrate-v1.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
claudeCliAvailable,
resolveTimezoneViaClaude,
} from './lib/tz-from-claude.js';
applyToEnv,
parseFlags,
printHelp,
readFromEnv,
} from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
import { pollHealth } from './onecli.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js';
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now();
type ChannelChoice =
| 'telegram'
| 'discord'
| 'whatsapp'
| 'signal'
| 'teams'
| 'slack'
| 'imessage'
| 'skip';
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
async function main(): Promise<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();
initProgressionLog();
phEmit('auto_started');
// Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path.
// On sg re-exec, the user already chose — skip straight to standard.
let startChoice: 'default' | 'advanced' = 'default';
if (process.env.NANOCLAW_REEXEC_SG !== '1') {
startChoice = ensureAnswer(
await brightSelect<'default' | 'advanced'>({
message: 'How would you like to begin?',
options: [
{ value: 'default', label: 'Standard setup' },
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
],
initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
}
if (startChoice === 'advanced') {
configValues = await runAdvancedScreen(configValues);
applyToEnv(configValues);
}
const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '')
.split(',')
@@ -91,16 +138,13 @@ async function main(): Promise<void> {
}
if (!skip.has('container')) {
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
p.log.message(
dimWrap(
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
4,
),
);
p.log.message(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
brandBody(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
),
),
);
const res = await runWindowedStep('container', {
@@ -135,63 +179,103 @@ async function main(): Promise<void> {
if (!skip.has('onecli')) {
p.log.message(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
brandBody(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
),
);
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
if (remoteHost) {
// Advanced-settings override: user has already named a remote vault,
// so skip the local-vs-fresh prompt entirely. Health-check it here
// rather than letting the step fail silently — a typo in the URL is a
// common mistake and the answer is human-fixable.
const s = p.spinner();
s.start(`Checking remote OneCLI at ${remoteHost}`);
const healthy = await pollHealth(remoteHost, 5000);
if (!healthy) {
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
await fail(
'onecli',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
`Couldn't reach OneCLI at ${remoteHost}.`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
await fail(
s.stop('Remote OneCLI is reachable.');
const res = await runQuietStep(
'onecli',
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
{
running: `Connecting to remote OneCLI at ${remoteHost}`,
done: 'OneCLI vault ready.',
},
['--remote-url', remoteHost],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
await fail(
'onecli',
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
} else {
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
await fail(
'onecli',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
);
}
await fail(
'onecli',
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
);
}
}
}
@@ -220,39 +304,42 @@ async function main(): Promise<void> {
done: 'NanoClaw is running.',
});
if (!res.ok) {
await fail(
'service',
"Couldn't start NanoClaw.",
'See logs/nanoclaw.error.log for details.',
);
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn(
"NanoClaw's permissions need a tweak before it can reach Docker.",
);
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
` systemctl --user restart ${getSystemdUnit()}`,
brandBody(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
),
);
}
}
let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
if (needsDisplayName) {
const fallback = process.env.USER?.trim() || 'Operator';
async function resolveDisplayName(): Promise<string> {
if (displayName) return displayName;
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
displayName = preset || (await askDisplayName(fallback));
const existing = detectExistingDisplayName(process.cwd());
const fallback = process.env.USER?.trim() || 'Operator';
displayName = preset || existing || (await askDisplayName(fallback));
return displayName;
}
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
}
if (!skip.has('cli-agent')) {
await resolveDisplayName();
const res = await runQuietStep(
'cli-agent',
{
running: 'Bringing your assistant online…',
done: 'Assistant wired up.',
},
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'],
);
if (!res.ok) {
await fail(
@@ -263,16 +350,39 @@ async function main(): Promise<void> {
}
if (!skip.has('first-chat')) {
p.log.message(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
brandBody(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
),
),
);
const ping = await confirmAssistantResponds();
if (ping === 'ok') {
phEmit('first_chat_ready');
const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent');
const cleanupStart = Date.now();
const cleanup = await spawnQuiet(
'pnpm',
['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'],
cleanupRawLog,
);
setupLog.step(
'cleanup-cli-agent',
cleanup.ok ? 'success' : 'failed',
Date.now() - cleanupStart,
{ exit_code: cleanup.exitCode },
cleanupRawLog,
);
if (!cleanup.ok) {
p.log.warn(
brandBody(
`Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`,
),
);
}
const next = ensureAnswer(
await p.select({
await brightSelect<'continue' | 'chat'>({
message: 'What next?',
options: [
{
@@ -288,7 +398,23 @@ async function main(): Promise<void> {
}),
) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next);
if (next === 'chat') await runFirstChat();
if (next === 'chat') {
const terminalAgentName = `${displayName!}'s Terminal`;
const createRes = await runQuietChild(
'create-terminal-agent',
'pnpm',
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
{ running: `Creating ${terminalAgentName}`, done: `${terminalAgentName} is ready.` },
);
if (!createRes.ok) {
await fail(
'create-terminal-agent',
`Couldn't create ${terminalAgentName}.`,
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
);
}
await runFirstChat();
}
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
@@ -297,7 +423,7 @@ async function main(): Promise<void> {
msg:
ping === 'socket_error'
? "NanoClaw service isn't listening on its CLI socket."
: "No reply from the assistant within 30 seconds.",
: 'No reply from the assistant within 30 seconds.',
hint:
ping === 'socket_error'
? 'Socket at data/cli.sock did not accept a connection.'
@@ -323,6 +449,9 @@ async function main(): Promise<void> {
if (!skip.has('channel')) {
channelChoice = await askChannelChoice();
if (channelChoice !== 'skip') {
await resolveDisplayName();
}
if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
@@ -339,9 +468,11 @@ async function main(): Promise<void> {
await runIMessageChannel(displayName!);
} else {
p.log.info(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
brandBody(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
),
);
}
@@ -356,7 +487,7 @@ async function main(): Promise<void> {
if (!res.ok) {
const notes: string[] = [];
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
}
const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') {
@@ -382,10 +513,12 @@ async function main(): Promise<void> {
}
}
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
notes.push(
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
);
}
if (notes.length > 0) {
p.note(notes.join('\n'), "What's left");
note(notes.join('\n'), "What's left");
}
// "What's left" is a soft failure — we don't abort like fail(), but the
// user is still stuck and a fix is exactly what claude-assist is for.
@@ -416,14 +549,12 @@ async function main(): Promise<void> {
['Open Claude Code:', 'claude'],
];
const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
.join('\n');
p.note(nextSteps, 'Try these');
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the
// caveat doesn't land after the user's already looked away at their phone.
p.note(
note(
wrapForGutter(
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
6,
@@ -440,10 +571,7 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro.
p.note(
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
'Go say hi',
);
note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
p.outro(k.green("You're set."));
} else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
@@ -465,10 +593,7 @@ function channelDmLabel(choice: ChannelChoice): string | null {
case 'imessage':
return 'iMessage';
case 'slack':
// Slack install doesn't wire an agent or send a welcome DM — the
// driver prints its own "finish in your Slack app" note. Falling
// through to null avoids a misleading "check your Slack DMs" banner.
return null;
return 'Slack DMs';
default:
return null;
}
@@ -487,25 +612,21 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const s = p.spinner();
const start = Date.now();
const label = 'Waking your assistant…';
s.start(fitToWidth(label, ' (999s)'));
s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await pingCliAgent();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else {
const msg =
result === 'socket_error'
? "Couldn't reach the NanoClaw service."
: "Your assistant didn't reply in time.";
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
}
return result;
@@ -527,7 +648,7 @@ function renderPingFailureNote(result: PingResult): void {
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
6,
);
p.note(body, 'Skipping the first chat');
note(body, 'Skipping the first chat');
}
/**
@@ -542,7 +663,7 @@ function renderPingFailureNote(result: PingResult): void {
* clearly optional.
*/
async function runFirstChat(): Promise<void> {
p.note(
note(
wrapForGutter(
[
'Your assistant runs in a sandbox on this machine.',
@@ -561,9 +682,7 @@ async function runFirstChat(): Promise<void> {
message: first
? 'Try a quick hello — or press Enter to continue setup'
: 'Another message? Press Enter to continue setup',
placeholder: first
? 'e.g. "hi, what can you do?"'
: 'press Enter to continue',
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
}),
);
first = false;
@@ -579,11 +698,9 @@ function sendChatMessage(message: string): Promise<void> {
// agent's reply reads as a clean block under the prompt. Splitting on
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
// with spaces on the far side.
const child = spawn(
'pnpm',
['--silent', 'run', 'chat', ...message.split(/\s+/)],
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
stdio: ['ignore', 'inherit', 'inherit'],
});
child.on('close', () => resolve());
child.on('error', () => resolve());
});
@@ -593,11 +710,21 @@ function sendChatMessage(message: string): Promise<void> {
async function runAuthStep(): Promise<void> {
if (anthropicSecretExists()) {
p.log.success('Your Claude account is already connected.');
p.log.success(brandBody('Your Claude account is already connected.'));
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
return;
}
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
if (customBaseUrl && customAuthToken) {
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
return;
}
const method = ensureAnswer(
await brightSelect({
message: 'How would you like to connect to Claude?',
@@ -631,15 +758,11 @@ async function runAuthStep(): Promise<void> {
}
async function runSubscriptionAuth(): Promise<void> {
p.log.step("Opening the Claude sign-in flow…");
console.log(
k.dim(' (a browser will open for sign-in; this part is interactive)'),
);
p.log.step(brandBody('Opening the Claude sign-in flow…'));
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
console.log();
const start = Date.now();
const code = await runInheritScript('bash', [
'setup/register-claude-token.sh',
]);
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
const durationMs = Date.now() - start;
console.log();
if (code !== 0) {
@@ -654,7 +777,7 @@ async function runSubscriptionAuth(): Promise<void> {
);
}
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> {
@@ -664,6 +787,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
const answer = ensureAnswer(
await p.password({
message: `Paste your ${label}`,
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return 'Required';
if (!v.trim().startsWith(prefix)) {
@@ -679,11 +803,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
'auth',
'onecli',
[
'secrets', 'create',
'--name', 'Anthropic',
'--type', 'anthropic',
'--value', token,
'--host-pattern', 'api.anthropic.com',
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'anthropic',
'--value',
token,
'--host-pattern',
'api.anthropic.com',
],
{
running: `Saving your ${label} to your OneCLI vault…`,
@@ -702,6 +831,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<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 ─────────────────────────────────────────────────────
/**
@@ -722,10 +937,7 @@ async function runTimezoneStep(): Promise<void> {
const fields = res.terminal?.fields ?? {};
const resolvedTz = fields.RESOLVED_TZ;
const needsInput = fields.NEEDS_USER_INPUT === 'true';
const isUtc =
resolvedTz === 'UTC' ||
resolvedTz === 'Etc/UTC' ||
resolvedTz === 'Universal';
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
// Three branches:
// - no TZ detected: ask where they are (or leave as UTC)
@@ -747,8 +959,8 @@ async function runTimezoneStep(): Promise<void> {
const message = needsInput
? "Your system didn't expose a timezone. Which one are you in?"
: !isUtc
? "Where are you, then?"
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
? 'Where are you, then?'
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
// straight to the free-text prompt — the user already said "not that".
@@ -775,7 +987,7 @@ async function runTimezoneStep(): Promise<void> {
const answer = ensureAnswer(
await p.text({
message: "Where are you? (city, region, or IANA zone)",
message: 'Where are you? (city, region, or IANA zone)',
placeholder: 'e.g. New York, London, Asia/Tokyo',
validate: (v) => (v && v.trim() ? undefined : 'Required'),
}),
@@ -789,9 +1001,11 @@ async function runTimezoneStep(): Promise<void> {
tz = await resolveTimezoneViaClaude(raw);
} else {
p.log.warn(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
brandBody(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
),
),
);
}
@@ -834,7 +1048,7 @@ async function runTimezoneStep(): Promise<void> {
async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant call you?',
message: `What should your assistant call ${accentGreen('you')}?`,
placeholder: fallback,
defaultValue: fallback,
}),
@@ -880,6 +1094,14 @@ async function askChannelChoice(): Promise<ChannelChoice> {
// ─── interactive / env helpers ─────────────────────────────────────────
function ensureLocalBinOnPath(): void {
const localBin = path.join(os.homedir(), '.local', 'bin');
const current = process.env.PATH ?? '';
const segments = current.split(path.delimiter).filter(Boolean);
if (segments.includes(localBin)) return;
process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin;
}
function anthropicSecretExists(): boolean {
try {
const res = spawnSync('onecli', ['secrets', 'list'], {
@@ -956,10 +1178,12 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) },
});
process.exit(res.status ?? 1);
}
@@ -971,17 +1195,15 @@ function printIntro(): void {
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) {
p.intro(
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
return;
}
// Always include the wordmark inside the clack intro line. When bash ran
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
// above us; the small repeat is worth it to keep the brand anchored at
// the visible top of the clack session once the bash output scrolls away.
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
// bash already printed the wordmark above us; the clack intro carries the
// welcome framing alone so the two don't double up. Standalone runs of
// setup:auto still see this as the first line — fine without the wordmark
// since the line itself signals the start of the flow.
p.intro("Let's get you set up.");
}
/**
+35 -29
View File
@@ -28,9 +28,11 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
@@ -155,7 +157,7 @@ async function askHasBotToken(): Promise<boolean> {
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
p.note(
note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
@@ -163,9 +165,8 @@ async function walkThroughBotCreation(): Promise<void> {
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
@@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void {
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
p.note(
note(
[
"Where to find your bot token:",
'',
@@ -216,16 +217,15 @@ async function walkThroughServerCreation(): Promise<void> {
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
p.note(
note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
@@ -239,9 +239,22 @@ async function walkThroughServerCreation(): Promise<void> {
}
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(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -275,9 +288,8 @@ async function validateDiscordToken(token: string): Promise<string> {
username?: string;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
@@ -295,8 +307,7 @@ async function validateDiscordToken(token: string): Promise<string> {
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -324,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
team?: unknown;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
@@ -337,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
@@ -355,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
owner,
};
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
@@ -385,14 +394,14 @@ async function resolveOwnerUserId(
}
} else {
p.log.info(
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
p.note(
note(
[
"To get your Discord user ID:",
'',
@@ -430,15 +439,14 @@ async function promptInviteBot(
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
p.note(
note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
@@ -465,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
@@ -478,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
@@ -506,7 +512,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+20 -5
View File
@@ -36,7 +36,8 @@ import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -189,7 +190,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
const nodeDir = path.dirname(nodePath);
p.note(
note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
@@ -222,7 +223,20 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
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",
"exposes it over HTTP. NanoClaw will talk to it via its API.",
@@ -250,6 +264,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
@@ -264,7 +279,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
}
async function askOperatorHandle(): Promise<string> {
p.note(
note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
@@ -303,7 +318,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+5 -5
View File
@@ -44,6 +44,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise<void> {
if (!probe.error && probe.status === 0) return;
if (process.platform === 'darwin') {
p.note(
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
@@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise<void> {
'signal-cli not found',
);
} else {
p.note(
note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
@@ -323,8 +324,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -346,7 +346,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+207 -36
View File
@@ -1,24 +1,23 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` walks the operator from a bare Slack
* workspace through a running bot, then stops before wiring an agent:
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Print the post-install checklist: set the public webhook URL in
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
* then `/manage-channels` to wire an agent.
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
*
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
* Slack needs a public Event Subscriptions URL for inbound events, and
* opening an unsolicited DM would need `im:write` scope we don't force
* the SKILL.md to require. Shipping a honest "here's what's left" note
* is better than a welcome DM the user won't receive until they
* configure the webhook anyway.
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
@@ -26,12 +25,15 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
@@ -40,10 +42,7 @@ interface WorkspaceInfo {
botUserId: string;
}
// displayName is reserved for when we start wiring the first agent here.
// Kept to match the `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> {
export async function runSlackChannel(displayName: string): Promise<void> {
await walkThroughAppCreation();
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);
}
async function walkThroughAppCreation(): Promise<void> {
p.note(
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, channels:history, groups:history, im:history,',
' channels:read, groups:read, users:read, reactions:write',
' chat:write, im:write, channels:history, groups:history,',
' im:history, channels:read, groups:read, users:read,',
' reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
formatNoteLink(SLACK_APPS_URL),
].filter((line): line is string => line !== null).join('\n'),
'Create a Slack app',
);
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
@@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise<void> {
}
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(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -132,9 +185,22 @@ async function collectBotToken(): Promise<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(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
@@ -175,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
user_id?: string;
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
@@ -207,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -221,26 +285,133 @@ async function validateSlackToken(token: string): Promise<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 {
p.note(
note(
wrapForGutter(
[
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'',
' 1. A public URL so Slack can deliver events.',
' NanoClaw serves a webhook on port 3000 by default — expose it',
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save, then reinstall the app when Slack prompts',
' • Save Changes',
'',
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
' messaging group. Then run `/manage-channels` in `claude` to',
' wire an agent to it.',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<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'),
6,
),
+35 -10
View File
@@ -40,7 +40,9 @@ import {
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
@@ -59,6 +61,28 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
const collected: Collected = {};
const completed: string[] = [];
const existingAppId = readEnvKey('TEAMS_APP_ID');
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
if (existingAppId && existingPassword) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
initialValue: true,
}));
if (reuse) {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
completed.push('Adapter installed and service restarted (reused existing credentials).');
await finishWithHandoff(collected, completed);
return;
}
}
printIntro();
await confirmPrereqs({ collected, completed });
@@ -79,7 +103,7 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
// ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void {
p.note(
note(
[
'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.',
@@ -93,7 +117,7 @@ function printIntro(): void {
}
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note(
note(
[
'Before we start, confirm you have:',
'',
@@ -119,7 +143,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
// ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
p.note(
note(
[
"Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook",
@@ -175,7 +199,7 @@ async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
p.note(
note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")',
@@ -259,7 +283,7 @@ async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
p.note(
note(
[
`1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"',
@@ -276,6 +300,7 @@ async function stepClientSecret(args: {
const answer = ensureAnswer(
await p.password({
message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
@@ -328,7 +353,7 @@ async function stepAzureBot(args: {
` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`;
p.note(
note(
[
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'',
@@ -365,7 +390,7 @@ async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
p.note(
note(
[
'1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply',
@@ -435,7 +460,7 @@ async function stepSideload(args: {
completed: string[];
zipPath: string;
}): Promise<void> {
p.note(
note(
[
'1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app',
@@ -501,7 +526,7 @@ async function finishWithHandoff(
collected: Collected,
completed: string[],
): Promise<void> {
p.note(
note(
[
'The Teams adapter is live and the service is running.',
'',
@@ -530,7 +555,7 @@ async function finishWithHandoff(
);
if (choice === 'self') {
p.note(
note(
[
' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)',
+26 -15
View File
@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
@@ -33,7 +33,8 @@ import {
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { brandBold } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -47,12 +48,11 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
// installed, or the bot's web profile if not. tg://resolve?domain= is
// more direct but silently fails when the scheme isn't registered.
const botUrl = `https://t.me/${botUsername}`;
p.note(
note(
[
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'',
k.dim(botUrl),
].join('\n'),
formatNoteLink(botUrl),
].filter((line): line is string => line !== null).join('\n'),
'Open Telegram',
);
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
@@ -132,7 +132,19 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
}
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.",
"Here's how:",
@@ -150,6 +162,7 @@ async function collectTelegramToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
@@ -178,10 +191,9 @@ async function validateTelegramToken(token: string): Promise<string> {
result?: { username?: string; id?: number };
description?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
@@ -199,8 +211,7 @@ async function validateTelegramToken(token: string): Promise<string> {
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -240,12 +251,12 @@ async function runPairTelegram(): Promise<
} else {
stopSpinner("Old code expired. Here's a fresh one.");
}
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start('Waiting for you to send the code from Telegram…');
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start('Waiting for the correct code…');
s.start(fitToWidth('Waiting for the correct code…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
@@ -291,7 +302,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+7 -8
View File
@@ -46,7 +46,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { brandBold } from '../lib/theme.js';
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
@@ -171,7 +171,7 @@ async function askAuthMethod(): Promise<AuthMethod> {
}
async function askPhoneNumber(): Promise<string> {
p.note(
note(
[
"Enter your phone number the way WhatsApp expects it:",
'',
@@ -249,7 +249,7 @@ async function runWhatsAppAuth(
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
const code = block.fields.CODE ?? '????';
stopSpinner('Your pairing code is ready.');
p.note(formatPairingCard(code), 'Pairing code');
note(formatPairingCard(code), 'Pairing code');
s.start('Waiting for you to enter the code…');
spinnerActive = true;
} else if (block.type === 'WHATSAPP_AUTH') {
@@ -267,7 +267,7 @@ async function runWhatsAppAuth(
if (spinnerActive) {
stopSpinner('WhatsApp linked.');
} else {
p.log.success('WhatsApp linked.');
p.log.success(brandBody('WhatsApp linked.'));
}
} else if (status === 'failed') {
if (qrLinesPrinted > 0) {
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -395,7 +394,7 @@ async function restartService(): Promise<void> {
}
async function askChatPhone(authedPhone: string): Promise<string> {
p.note(
note(
[
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
'',
@@ -462,7 +461,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+10 -2
View File
@@ -8,6 +8,7 @@
* Args:
* --display-name <name> (required) operator's 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 path from 'path';
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
function parseArgs(args: string[]): {
displayName: string;
agentName?: string;
folder?: string;
} {
let displayName: string | undefined;
let agentName: string | undefined;
let folder: string | undefined;
for (let i = 0; i < args.length; i++) {
const key = args[i];
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
agentName = val;
i++;
break;
case '--folder':
folder = val;
i++;
break;
}
}
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
process.exit(2);
}
return { displayName, agentName };
return { displayName, agentName, folder };
}
export async function run(args: string[]): Promise<void> {
const { displayName, agentName } = parseArgs(args);
const { displayName, agentName, folder } = parseArgs(args);
const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName);
if (folder) scriptArgs.push('--folder', folder);
log.info('Invoking init-cli-agent', { displayName, agentName });
+15 -4
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
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
// or a prior install) doesn't affect us until next login. Re-exec this
// step under `sg docker` so the child picks up docker as its primary
// group and can talk to /var/run/docker.sock without a logout.
// groups are fixed at login, so `usermod -aG docker` doesn't affect us
// until next login. Ensure the user is in the docker group (install-docker.sh
// does this on fresh installs, but skips when Docker is already present),
// then re-exec under `sg docker` so the child picks up docker as its
// primary group and can talk to /var/run/docker.sock without a logout.
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
// Ensure the current user is in the docker group — without this,
// sg will ask for the (typically unset) group password and fail.
const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
log.info('Adding current user to docker group');
spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
stdio: 'inherit',
});
}
log.info('Re-executing container step under `sg docker`');
const res = spawnSync(
'sg',
+42
View File
@@ -11,6 +11,48 @@ import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
/**
* Read a single key from `.env` on disk (not process.env).
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
*/
export function readEnvKey(key: string, projectRoot?: string): string | null {
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return null;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 1) continue;
if (trimmed.slice(0, eq) === key) {
return trimmed.slice(eq + 1).trim() || null;
}
}
return null;
}
export function detectExistingDisplayName(projectRoot: string): string | null {
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return null;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
.get() as { display_name: string } | undefined;
return row?.display_name?.trim() || null;
} catch {
return null;
} finally {
db?.close();
}
}
export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true;
+9 -6
View File
@@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core';
import { isCancel } from '@clack/prompts';
import { styleText } from 'node:util';
import { brandBody } from './theme.js';
const BULLET_ACTIVE = '●';
const BULLET_INACTIVE = '○';
const BAR = '│';
@@ -95,7 +97,7 @@ export function brightSelect<T>(
const shown =
st === 'cancel'
? styleText(['strikethrough', 'dim'], selected)
: styleText('dim', selected);
: styleText('dim', brandBody(selected));
lines.push(`${grayBar} ${shown}`);
return lines.join('\n');
}
@@ -104,11 +106,12 @@ export function brightSelect<T>(
options.forEach((opt, idx) => {
const label = opt.label ?? String(opt.value);
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
const marker =
idx === cursor
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
lines.push(`${bar} ${marker} ${label}${hint}`);
const isActive = idx === cursor;
const marker = isActive
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
const shownLabel = isActive ? brandBody(label) : label;
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
});
lines.push(styleText(color, CAP_BOT));
return lines.join('\n');
+36 -4
View File
@@ -9,12 +9,19 @@
* `confirmThenOpen` pauses for the operator before triggering the open —
* the browser tends to steal focus when it pops, and a split-second
* "wait what just happened" moment is worse than letting the user hit
* Enter when they're ready.
* Enter when they're ready. On headless devices (no graphical session
* available) it skips both the prompt and the open: there's no browser
* to launch, the surrounding `note(...)` already shows the URL for
* copy-paste on another device, and the next prompt in the channel
* flow ("Got your bot token?" etc.) provides the natural completion
* confirmation.
*/
import { spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { isHeadless } from '../platform.js';
import { ensureAnswer } from './runner.js';
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
}
}
/**
* Format a URL for inclusion in a setup `note(...)` card. On
* headless devices we surface the URL inside the card with a
* "Get started:" label at full strength — copy-pasting onto
* another device is the actual action, not an incidental
* reference. The leading `\n` acts as a visual separator from
* the body steps above; callers `.filter(line => line !== null)`
* before joining, so on GUI we drop the line entirely (and the
* URL ends up below the next-step confirm prompt as a "if
* browser does not appear, please visit" fallback — see
* `confirmThenOpen`).
*/
export function formatNoteLink(url: string): string | null {
if (isHeadless()) return `\nGet started: ${url}`;
return null;
}
/**
* Gate a browser-open on a confirm so the user is ready for their browser
* to take focus. Proceeds on cancel as well — the user can always copy the
* URL from the note that precedes the prompt.
* to take focus. Proceeds on cancel as well. On headless devices both the
* prompt and the open are skipped — the URL is already surfaced inside
* the surrounding note (via `formatNoteLink`).
*
* On GUI devices the confirm message includes the fallback URL on the
* lines below the action ("If browser does not appear, please visit:
* <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(
url: string,
message = 'Press Enter to open your browser',
): Promise<void> {
if (isHeadless()) return;
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer(
await p.confirm({
message,
message: `${message}${fallback}`,
initialValue: true,
}),
);
+104 -16
View File
@@ -2,8 +2,11 @@
* Offer Claude-assisted debugging when a setup step fails.
*
* Flow:
* 1. Check `claude` is on PATH and has a working credential. If not,
* silently skip — pre-auth failures can't use this path.
* 1. Check `claude` is on PATH — if not, offer to install it via
* setup/install-claude.sh. Then check auth via `claude auth status`
* — if not signed in, offer to run `claude setup-token` (browser
* OAuth with code-paste fallback for headless/remote systems).
* If either is declined or fails, silently skip.
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
* 3. Build a minimal prompt: the one-paragraph situation, the failing
* step's name/message/hint, and a short list of *file references*
@@ -16,15 +19,16 @@
*
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
*/
import { execSync, spawn } from 'child_process';
import { execSync, spawn, spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { ensureAnswer } from './runner.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
export interface AssistContext {
stepName: string;
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
projectRoot: string = process.cwd(),
): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!isClaudeUsable()) return false;
if (!(await ensureClaudeReady(projectRoot))) return false;
const want = ensureAnswer(
await p.confirm({
@@ -106,12 +110,12 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response);
if (!parsed) {
p.log.warn("Claude responded but I couldn't parse a command out of it.");
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
p.log.message(k.dim(response.trim().slice(0, 500)));
return false;
}
p.note(
note(
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
"Claude's suggestion",
);
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
return true;
}
function isClaudeUsable(): boolean {
function isClaudeInstalled(): boolean {
try {
execSync('command -v claude', { stdio: 'ignore' });
return true;
} catch {
return false;
}
// Availability without auth is half the story; a real query will still
// fail if the token isn't registered. We try first and surface the error
// rather than pre-checking auth with a separate round trip.
}
function isClaudeAuthenticated(): boolean {
try {
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
return true;
} catch {
return false;
}
}
async function ensureClaudeReady(projectRoot: string): Promise<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;
}
@@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner(
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
const elapsed = Math.round((Date.now() - start) / 1000);
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const header = fitToWidth('Asking Claude to diagnose…', suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
@@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner(
clearBlock();
out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (kind === 'ok') {
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
resolve(payload);
} else {
p.log.error(
+5 -3
View File
@@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
/** Channel this handoff is happening in (e.g., 'teams'). */
channel: string;
@@ -62,14 +64,14 @@ export interface HandoffContext {
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
if (!isClaudeUsable()) {
p.log.warn(
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
);
return false;
}
const systemPrompt = buildSystemPrompt(ctx);
p.note(
note(
[
"I'm handing you off to Claude in interactive mode.",
"It has the context of where you are in setup.",
@@ -91,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success("Back from Claude. Let's continue.");
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {
+5 -7
View File
@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields };
@@ -307,18 +307,16 @@ async function runUnderSpinner<
): Promise<T> {
const s = p.spinner();
const start = Date.now();
s.start(fitToWidth(labels.running, ' (999s)'));
s.start(fitToWidth(labels.running, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await work();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
@@ -390,7 +388,7 @@ export async function fail(
const skipList = [
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
].join(',');
p.log.step(`Retrying from ${stepName}`);
p.log.step(brandBody(`Retrying from ${stepName}`));
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_SKIP: skipList },
+161
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');
}
+127
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
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;
}
+62
View File
@@ -11,6 +11,7 @@
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
* - Otherwise → kleur's 16-color cyan (closest fallback)
*/
import * as p from '@clack/prompts';
import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -38,6 +39,57 @@ export function brandChip(s: string): string {
return k.bgCyan(k.black(k.bold(s)));
}
/**
* Accent green (#3fba50) for emphasizing a single word inside prompt
* messages — currently the "you" in "What should your assistant call
* you?" so the operator parses at a glance who the question is about.
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
*/
export function accentGreen(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
return k.green(s);
}
/**
* Format an elapsed-time duration (in milliseconds) for the spinner
* suffixes setup writes everywhere. Sub-minute durations stay in plain
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
* so live spinner output doesn't change shape at every whole minute.
*/
export function fmtDuration(ms: number): string {
const totalSec = Math.round(ms / 1000);
if (totalSec < 60) return `${totalSec}s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m ${s}s`;
}
/**
* Brand body color for setup-flow prose. Used for card bodies (via the
* `note()` formatter) and `p.log.*` body arguments — anywhere the
* previous "dim" treatment was making prose hard to read or washing
* out embedded brand emphasis.
*
* Multi-line input is colored line-by-line so embedded line breaks
* don't bleed the SGR sequence across clack's gutter prefix.
*/
export function brandBody(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return s
.split('\n')
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
.join('\n');
}
return s
.split('\n')
.map((line) => (line.length > 0 ? k.cyan(line) : line))
.join('\n');
}
/**
* Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with
@@ -68,6 +120,16 @@ export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter);
}
/**
* Wrap clack's `p.note` so card bodies render in the brand body color
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
* on each line individually, so `brandBody` colors each line cleanly
* without bleeding across the gutter prefix.
*/
export function note(message: string, title?: string): void {
p.note(message, title, { format: brandBody });
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(s: string): number {
+4 -6
View File
@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { isValidTimezone } from '../../src/timezone.js';
import { fitToWidth } from './theme.js';
import { fitToWidth, fmtDuration } from './theme.js';
export function claudeCliAvailable(): boolean {
try {
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
const s = p.spinner();
const start = Date.now();
const label = 'Looking up that timezone…';
s.start(fitToWidth(label, ' (999s)'));
s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const reply = await queryClaude(prompt);
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const resolved = reply ? extractTimezone(reply) : null;
if (resolved) {
+3 -3
View File
@@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
import * as setupLog from '../logs.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth } from './theme.js';
const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
@@ -169,7 +169,7 @@ async function runUnderWindow(
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
@@ -185,7 +185,7 @@ async function handleStall(
): Promise<void> {
render.pauseRender();
p.log.warn(
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
);
phEmit('step_stalled', { step: stepName });
+78 -13
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');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
if (/^ONECLI_URL=/m.test(content)) {
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
const re = new RegExp(`^${name}=.*$`, 'm');
if (re.test(content)) {
content = content.replace(re, `${name}=${value}`);
} else {
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
}
fs.writeFileSync(envFile, content);
}
function writeEnvOnecliUrl(url: string): void {
writeEnvVar('ONECLI_URL', url);
}
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
const fallback = installOnecliCliDirect();
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
}
function installOnecli(): { stdout: string; ok: boolean } {
let stdout = '';
@@ -163,14 +175,12 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
lines.push(s);
};
const osName =
process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
if (!osName) {
append(`Unsupported platform: ${process.platform}`);
return { stdout: lines.join('\n'), ok: false };
}
const arch =
process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
if (!arch) {
append(`Unsupported arch: ${process.arch}`);
return { stdout: lines.join('\n'), ok: false };
@@ -201,10 +211,9 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
try {
append(`Downloading ${url}`);
execSync(
`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`,
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
stdio: ['ignore', 'pipe', 'pipe'],
});
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
stdio: ['ignore', 'pipe', 'pipe'],
});
@@ -231,7 +240,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
}
}
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@@ -248,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
export async function run(args: string[]): Promise<void> {
const reuse = args.includes('--reuse');
const remoteUrlIdx = args.indexOf('--remote-url');
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
ensureShellProfilePath();
if (remoteUrl) {
// Remote-mode: install only the CLI, point it at the remote gateway, and
// record the URL in .env. No local gateway is started.
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
const res = installOnecliCliOnly();
if (!res.ok || !onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'cli_install_failed',
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
try {
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
writeEnvOnecliUrl(remoteUrl);
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
if (remoteToken) {
// Two auth surfaces: `onecli auth login` persists the key for CLI
// calls during setup itself (e.g. detecting an existing Anthropic
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
// read by the runtime SDK at request time. Both are needed.
try {
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli auth login failed', { err });
}
writeEnvVar('ONECLI_API_KEY', remoteToken);
log.info('Wrote ONECLI_API_KEY to .env');
}
const healthy = await pollHealth(remoteUrl, 5000);
emitStatus('ONECLI', {
INSTALLED: true,
REMOTE: true,
ONECLI_URL: remoteUrl,
HEALTHY: healthy,
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
if (reuse) {
// Reuse-mode: don't touch the running gateway at all. Just verify it
// exists, read its api-host, write ONECLI_URL to .env, and move on.
+71
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
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
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;
}
+1
View File
@@ -135,6 +135,7 @@ export interface ChannelAdapter {
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>;
resolveChannelName?(platformId: string): Promise<string | null>;
/**
* Subscribe the bot to a thread so follow-up messages route via the
+197
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
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 });
}
}
+29 -19
View File
@@ -58,7 +58,7 @@ const activeContainers = new Map<string, { process: ChildProcess; containerName:
* a duplicate container against the same session directory, producing
* racy double-replies.
*/
const wakePromises = new Map<string, Promise<void>>();
const wakePromises = new Map<string, Promise<boolean>>();
export function getActiveContainerCount(): number {
return activeContainers.size;
@@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean {
* (the in-flight wake promise is reused).
*
* The container runs the v2 agent-runner which polls the session DB.
*
* Contract: never throws. Returns `true` on successful spawn, `false` on
* transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't
* need to wrap the inbound row stays pending and host-sweep retries on
* its next tick. Callers that care (e.g. the router's typing indicator)
* can branch on the boolean.
*/
export function wakeContainer(session: Session): Promise<void> {
export function wakeContainer(session: Session): Promise<boolean> {
if (activeContainers.has(session.id)) {
log.debug('Container already running', { sessionId: session.id });
return Promise.resolve();
return Promise.resolve(true);
}
const existing = wakePromises.get(session.id);
if (existing) {
log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id });
return existing;
}
const promise = spawnContainer(session).finally(() => {
wakePromises.delete(session.id);
});
const promise = spawnContainer(session)
.then(() => true)
.catch((err) => {
log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err });
return false;
})
.finally(() => {
wakePromises.delete(session.id);
});
wakePromises.set(session.id, promise);
return promise;
}
@@ -435,20 +447,18 @@ async function buildContainerArgs(
}
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection.
try {
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (onecliApplied) {
log.info('OneCLI gateway applied', { containerName });
} else {
log.warn('OneCLI gateway not applied — container will have no credentials', { containerName });
}
} catch (err) {
log.warn('OneCLI gateway error — container will have no credentials', { containerName, err });
// are routed through the agent vault for credential injection. Treated as
// a transient hard failure: if we can't wire the gateway, we don't spawn.
// The caller (router or host-sweep) catches the throw, leaves the inbound
// message pending, and the next sweep tick retries.
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
log.info('OneCLI gateway applied', { containerName });
// Host gateway
args.push(...hostGatewayArgs());
+180
View File
@@ -23,6 +23,8 @@ import {
sessionDir,
inboundDbPath,
outboundDbPath,
readOutboxFiles,
clearOutbox,
} from './session-manager.js';
import { getSession, findSession } from './db/sessions.js';
import type { InboundEvent } from './channels/adapter.js';
@@ -108,6 +110,147 @@ describe('session manager', () => {
outDb.close();
});
it('should reject outbound attachment filenames that escape the message outbox', () => {
initSessionFolder('ag-1', 'sess-test');
const dir = sessionDir('ag-1', 'sess-test');
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
fs.mkdirSync(msgOutbox, { recursive: true });
const outside = path.join(TEST_DIR, 'outside.txt');
fs.writeFileSync(outside, 'outside secret');
expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined();
});
it('should reject outbound attachment symlinks that escape the message outbox', () => {
initSessionFolder('ag-1', 'sess-test');
const dir = sessionDir('ag-1', 'sess-test');
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
fs.mkdirSync(msgOutbox, { recursive: true });
const outside = path.join(TEST_DIR, 'outside.txt');
fs.writeFileSync(outside, 'outside secret');
fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt'));
expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined();
});
it('should not recursively delete outside the outbox for unsafe message ids', () => {
initSessionFolder('ag-1', 'sess-test');
const victimDir = path.join(TEST_DIR, 'victim-dir');
fs.mkdirSync(victimDir, { recursive: true });
fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete');
clearOutbox('ag-1', 'sess-test', '../../../../victim-dir');
expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true);
});
it('should still read and clear normal basename outbox files', () => {
initSessionFolder('ag-1', 'sess-test');
const dir = sessionDir('ag-1', 'sess-test');
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
fs.mkdirSync(msgOutbox, { recursive: true });
fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok');
const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']);
expect(files).toHaveLength(1);
expect(files?.[0]?.filename).toBe('result.txt');
expect(files?.[0]?.data.toString()).toBe('ok');
clearOutbox('ag-1', 'sess-test', 'msg-1');
expect(fs.existsSync(msgOutbox)).toBe(false);
});
it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => {
initSessionFolder('ag-1', 'sess-test');
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
// The container has /workspace write access, so it can pre create
// inbox/<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)', () => {
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
expect(c1).toBe(true);
@@ -173,6 +316,43 @@ describe('session manager', () => {
expect(getSession(session.id)!.last_active).not.toBeNull();
});
it('should refuse path-traversal in attachment filenames', () => {
// Regression: attachment.name comes from untrusted senders (E2EE-protected
// chat platforms can't sanitize it server-side). Without the guard, a
// `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere
// the host process can reach.
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox');
const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary');
if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget);
writeSessionMessage('ag-1', session.id, {
id: 'msg-attack',
kind: 'chat',
timestamp: now(),
content: JSON.stringify({
text: 'pwn',
attachments: [
{
type: 'document',
name: '../../../../../../../../tmp/nanoclaw-traversal-canary',
data: Buffer.from('owned').toString('base64'),
},
],
}),
});
expect(fs.existsSync(escapeTarget)).toBe(false);
// The bytes should still land — under a synthesized safe name inside the
// inbox — so the agent doesn't lose data on a malicious filename.
const inboxDir = path.join(inboxBase, 'msg-attack');
expect(fs.existsSync(inboxDir)).toBe(true);
const written = fs.readdirSync(inboxDir);
expect(written).toHaveLength(1);
expect(written[0]).not.toContain('/');
expect(written[0]).not.toContain('..');
});
});
describe('router', () => {
+2
View File
@@ -168,6 +168,8 @@ async function sweepSession(session: Session): Promise<void> {
const dueCount = countDueMessages(inDb);
if (dueCount > 0 && !isContainerRunning(session.id)) {
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
// wakeContainer never throws — transient spawn failures (OneCLI down,
// etc.) return false and leave messages pending for the next tick.
await wakeContainer(session);
}
+13 -2
View File
@@ -7,6 +7,7 @@
import path from 'path';
import { DATA_DIR } from './config.js';
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
@@ -58,6 +59,9 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from
async function main(): Promise<void> {
log.info('NanoClaw starting');
// 0. Circuit breaker — backoff on rapid restarts
await enforceStartupBackoff();
// 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
@@ -174,8 +178,15 @@ async function shutdown(signal: string): Promise<void> {
}
stopDeliveryPolls();
stopHostSweep();
await teardownChannelAdapters();
process.exit(0);
try {
await teardownChannelAdapters();
} finally {
// Always reset on graceful shutdown — even if teardown threw, we got here
// via SIGTERM/SIGINT, not a crash, so the next start shouldn't be counted
// as one.
resetCircuitBreaker();
process.exit(0);
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
+3 -20
View File
@@ -21,6 +21,7 @@
import fs from 'fs';
import path from 'path';
import { isSafeAttachmentName } from '../../attachment-safety.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
@@ -29,6 +30,8 @@ import { resolveSession, sessionDir, writeSessionMessage } from '../../session-m
import type { Session } from '../../types.js';
import { hasDestination } from './db/agent-destinations.js';
export { isSafeAttachmentName };
export interface ForwardedAttachment {
name: string;
filename: string;
@@ -36,26 +39,6 @@ export interface ForwardedAttachment {
localPath: string;
}
/**
* Is `name` safe to use as the last segment of a path inside the target
* agent's inbox directory? Filenames arrive in messages_out content from
* the source agent under a multi-agent setup with heterogenous providers
* (or a compromised / hallucinating sub-agent) they can't be trusted.
*
* Rejects:
* - empty string
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
* - anything containing a path separator (`/` or `\`) or NUL
* - any value where `path.basename(name) !== name`, catching OS-specific
* separators and covering drives/prefixes on Windows runtimes
*/
export function isSafeAttachmentName(name: string): boolean {
if (typeof name !== 'string' || name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (/[\\/\0]/.test(name)) return false;
return path.basename(name) === name;
}
/**
* Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing
@@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => {
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
// Card names the target agent so the owner knows what they're wiring to.
expect(payload.question).toContain('Andy');
// Single-agent card offers a direct "Connect to <name>" button.
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
expect(connectOption).toBeDefined();
expect(connectOption.label).toContain('Andy');
const { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
@@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => {
};
expect(pending).toBeDefined();
// Owner clicks approve.
// Owner clicks "Connect to Andy" (single-agent card).
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
value: 'connect:ag-1',
userId: 'owner', // raw platform id — handler namespaces it
channelType: 'telegram',
platformId: 'dm-owner',
@@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => {
if (claimed) break;
}
// Wiring created with MVP defaults.
// Wiring created with defaults.
const mga = getDb()
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as {
@@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => {
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
value: 'approve',
value: 'connect:ag-1',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
+163 -54
View File
@@ -5,24 +5,32 @@
* addressed to the bot (SDK-confirmed mention or DM), it calls
* `requestChannelApproval` instead of silently dropping. The flow:
*
* 1. Pick the target agent group we'd wire to (MVP: first by name).
* Multi-agent picker is a follow-up see ACTION-ITEMS.
* 1. Gather all existing agent groups.
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
* them, reusing the same primitives the sender-approval flow uses.
* 3. Deliver an Approve / Ignore card that names the target agent
* explicitly so the owner knows what they're wiring to.
* 3. Deliver a card with three action families:
* a. Connect to [agent] one button per existing agent group.
* Single-agent installs get a one-click connect.
* b. Connect new agent prompts for a free-text name, creates
* the agent immediately on reply.
* c. Reject deny the channel.
* 4. Record a `pending_channel_approvals` row holding the original event
* so it can be re-routed on approve.
* so it can be re-routed on connect/create.
*
* On approve (handler in index.ts):
* - Create `messaging_group_agents` with MVP defaults
* On connect (handler in index.ts):
* - Create `messaging_group_agents` with defaults
* (mention-sticky for groups / pattern='.' for DMs,
* sender_scope='known', ignored_message_policy='accumulate')
* - Add the triggering sender to `agent_group_members` so sender_scope
* doesn't bounce the replayed message into a sender-approval cascade
* - Delete the pending row, replay the original event
*
* On ignore:
* On connect new agent (handler in index.ts):
* - Prompt for a free-text agent name via DM
* - On reply: create the agent group + filesystem, then wire
* and replay as above
*
* On reject:
* - Set `messaging_groups.denied_at = now()` so the router stops
* escalating on this channel until an admin explicitly re-wires
* - Delete the pending row
@@ -36,19 +44,81 @@
* - Approver has no reachable DM.
* - Delivery adapter missing.
*/
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
import { getAllAgentGroups } from '../../db/agent-groups.js';
import { getMessagingGroup } from '../../db/messaging-groups.js';
import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
import { getChannelAdapter } from '../../channels/channel-registry.js';
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js';
import type { InboundEvent } from '../../channels/adapter.js';
import type { AgentGroup } from '../../types.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
const APPROVAL_OPTIONS: RawOption[] = [
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' },
];
// ── Value constants (response handler in index.ts parses these) ──
export const CONNECT_PREFIX = 'connect:';
export const NEW_AGENT_VALUE = 'new_agent';
export const CHOOSE_EXISTING_VALUE = 'choose_existing';
export const REJECT_VALUE = 'reject';
// ── Utilities ──
function toFolder(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unnamed'
);
}
// ── Card builders ──
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
const options: RawOption[] = [];
if (agentGroups.length === 1) {
options.push({
label: `Connect to ${agentGroups[0].name}`,
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
});
} else {
options.push({
label: 'Choose existing agent',
selectedLabel: '📋 Choosing…',
value: CHOOSE_EXISTING_VALUE,
});
}
options.push({
label: 'Connect new agent',
selectedLabel: '🆕 Connecting new agent…',
value: NEW_AGENT_VALUE,
});
options.push({
label: 'Reject',
selectedLabel: '🙅 Rejected',
value: REJECT_VALUE,
});
return options;
}
function buildQuestionText(
isGroup: boolean,
senderName: string | undefined,
channelName: string | null,
channelType: string,
): string {
const who = senderName ?? 'Someone';
if (isGroup) {
const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
}
return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
}
// ── Main flow ──
export interface RequestChannelApprovalInput {
messagingGroupId: string;
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
const { messagingGroupId, event } = input;
// In-flight dedup: don't spam the owner if the same unwired channel
// gets more mentions / DMs while a card is already pending.
if (hasInFlightChannelApproval(messagingGroupId)) {
log.debug('Channel registration already in flight — dropping retry', {
messagingGroupId,
});
log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
return;
}
// MVP: pick the first agent group by name. Multi-agent systems will get
// a richer card later (user picks the target from a list).
const agentGroups = getAllAgentGroups();
if (agentGroups.length === 0) {
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
});
return;
}
const target = agentGroups[0];
// Use first agent group for approver resolution — owners and global admins
// are returned regardless of which group we pass.
const referenceGroup = agentGroups[0];
// pickApprover takes the target agent group's id — gets scoped admins +
// global admins + owners. For fresh installs with only an owner, the
// owner is returned.
const approvers = pickApprover(target.id);
const approvers = pickApprover(referenceGroup.id);
if (approvers.length === 0) {
log.warn('Channel registration skipped — no owner or admin configured', {
messagingGroupId,
targetAgentGroupId: target.id,
targetAgentGroupId: referenceGroup.id,
});
return;
}
const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? '';
// Resolve channel name if not yet persisted.
if (originMg && !originMg.name) {
const channelAdapter = getChannelAdapter(originChannelType);
if (channelAdapter?.resolveChannelName) {
try {
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
if (name) {
updateMessagingGroup(originMg.id, { name });
originMg.name = name;
}
} catch {
/* non-critical */
}
}
}
const delivery = await pickApprovalDelivery(approvers, originChannelType);
if (!delivery) {
log.warn('Channel registration skipped — no DM channel for any approver', {
messagingGroupId,
targetAgentGroupId: target.id,
targetAgentGroupId: referenceGroup.id,
});
return;
}
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
// Extract sender name from the event content for a human-readable card.
let senderName: string | undefined;
try {
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
} catch {
// non-critical — fall through to generic wording
// non-critical
}
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
const question = isGroup
? senderName
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
: `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
: senderName
? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
: `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
const options = normalizeOptions(APPROVAL_OPTIONS);
const channelName = originMg?.name ?? null;
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
const options = normalizeOptions(buildApprovalOptions(agentGroups));
createPendingChannelApproval({
messaging_group_id: messagingGroupId,
agent_group_id: target.id,
agent_group_id: referenceGroup.id,
original_message: JSON.stringify(event),
approver_user_id: delivery.userId,
created_at: new Date().toISOString(),
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const adapter = getDeliveryAdapter();
if (!adapter) {
log.error('Channel registration row created but no delivery adapter is wired', {
messagingGroupId,
});
log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
return;
}
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
'chat-sdk',
JSON.stringify({
type: 'ask_question',
// Use messaging_group_id as the questionId — it's unique per card
// (PK on pending table dedups) and lets the response handler look
// up the pending row directly without another index.
questionId: messagingGroupId,
title,
question,
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
);
log.info('Channel registration card delivered', {
messagingGroupId,
targetAgentGroupId: target.id,
agentGroupCount: agentGroups.length,
approver: delivery.userId,
});
} catch (err) {
log.error('Channel registration card delivery failed', {
messagingGroupId,
err,
});
log.error('Channel registration card delivery failed', { messagingGroupId, err });
}
}
export const APPROVE_VALUE = 'approve';
export const REJECT_VALUE = 'reject';
// ── Helpers for the response handler (index.ts) ──
/**
* Build normalized options for the agent-selection follow-up card.
*/
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
const options: RawOption[] = agentGroups.map((ag) => ({
label: ag.name,
selectedLabel: `✅ Connected to ${ag.name}`,
value: `${CONNECT_PREFIX}${ag.id}`,
}));
options.push({
label: 'Cancel',
selectedLabel: '🙅 Cancelled',
value: REJECT_VALUE,
});
return normalizeOptions(options);
}
/**
* Create a new agent group and initialize its filesystem. Handles
* folder-name collisions with numeric suffixes.
*/
export function createNewAgentGroup(name: string): AgentGroup {
let folder = toFolder(name);
const baseFolder = folder;
let suffix = 2;
while (getAgentGroupByFolder(folder)) {
folder = `${baseFolder}-${suffix}`;
suffix++;
}
const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createAgentGroup({
id: agId,
name,
folder,
agent_provider: null,
created_at: new Date().toISOString(),
});
const ag = getAgentGroup(agId)!;
initGroupFilesystem(ag);
return ag;
}
@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
return row !== undefined;
}
export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void {
getDb()
.prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?')
.run(title, optionsJson, messagingGroupId);
}
export function deletePendingChannelApproval(messagingGroupId: string): void {
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
}
+262 -38
View File
@@ -16,27 +16,53 @@
* access gate is not registered and core defaults to allow-all.
*/
import { recordDroppedMessage } from '../../db/dropped-messages.js';
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import {
routeInbound,
setAccessGate,
setChannelRequestGate,
setMessageInterceptor,
setSenderResolver,
setSenderScopeGate,
type AccessGateResult,
} from '../../router.js';
import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { canAccessAgentGroup } from './access.js';
import { requestChannelApproval } from './channel-approval.js';
import {
buildAgentSelectionOptions,
CHOOSE_EXISTING_VALUE,
CONNECT_PREFIX,
createNewAgentGroup,
NEW_AGENT_VALUE,
REJECT_VALUE,
requestChannelApproval,
} from './channel-approval.js';
import { addMember } from './db/agent-group-members.js';
import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js';
import {
deletePendingChannelApproval,
getPendingChannelApproval,
updatePendingChannelApprovalCard,
} from './db/pending-channel-approvals.js';
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
import { getUser, upsertUser } from './db/users.js';
import { requestSenderApproval } from './sender-approval.js';
import { ensureUserDm } from './user-dm.js';
// ── Free-text name input state ──
// Tracks approvers waiting for a text reply with the agent name. Keyed by
// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart.
interface PendingNameInput {
channelMgId: string;
dmChannelType: string;
dmPlatformId: string;
}
const awaitingNameInput = new Map<string, PendingNameInput>();
function extractAndUpsertUser(event: InboundEvent): string | null {
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
* handlers get a shot.
*
* Approve: create the wiring with MVP defaults (mention-sticky for
* groups / pattern='.' for DMs; sender_scope='known';
* ignored_message_policy='accumulate'), add the triggering sender as a
* member so sender_scope doesn't immediately bounce them into a
* sender-approval card, then replay the original event.
*
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
* this channel drop silently until an admin explicitly wires it.
* Value dispatch:
* connect:<id> wire to an existing agent group, replay the message
* choose_existing send a follow-up card listing all agents
* new_agent prompt for a free-text agent name (interceptor
* captures the reply and creates immediately)
* reject set denied_at, delete pending row
*/
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
// Click-auth: same pattern as sender-approval (see commit 68058cb).
// Raw platform userId → namespace with channelType → must match the
// designated approver OR have admin privilege over the target agent.
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
clickerId,
expectedApprover: row.approver_user_id,
});
return true; // claim but take no action
return true;
}
const approverId = clickerId;
const approved = payload.value === 'approve';
if (!approved) {
// ── Reject / Cancel ──
if (payload.value === REJECT_VALUE) {
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
deletePendingChannelApproval(row.messaging_group_id);
log.info('Channel registration denied', {
messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id,
approverId,
});
return true;
}
// Rehydrate the original event to know (a) whether it was a DM or group
// (chooses engage_mode default), and (b) who the triggering sender was
// (auto-member-add so sender_scope='known' doesn't bounce the replay).
// ── Choose existing agent — send agent-selection follow-up card ──
if (payload.value === CHOOSE_EXISTING_VALUE) {
const approverDm = await ensureUserDm(row.approver_user_id);
if (!approverDm) {
log.error('Channel registration: no DM channel for approver', {
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
}
const adapter = getDeliveryAdapter();
if (!adapter) return true;
const agentGroups = getAllAgentGroups();
const options = buildAgentSelectionOptions(agentGroups);
const title = '📋 Choose an agent';
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
try {
await adapter.deliver(
approverDm.channel_type,
approverDm.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: row.messaging_group_id,
title,
question: 'Which agent should handle this channel?',
options,
}),
);
} catch (err) {
log.error('Channel registration: agent-selection card delivery failed', {
messagingGroupId: row.messaging_group_id,
err,
});
}
return true;
}
// ── Create new agent — prompt for free-text name ──
if (payload.value === NEW_AGENT_VALUE) {
const approverDm = await ensureUserDm(row.approver_user_id);
if (!approverDm) {
log.error('Channel registration: no DM channel for approver', {
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
}
const adapter = getDeliveryAdapter();
if (!adapter) {
log.error('Channel registration: no delivery adapter for name prompt', {
messagingGroupId: row.messaging_group_id,
});
return true;
}
awaitingNameInput.set(row.approver_user_id, {
channelMgId: row.messaging_group_id,
dmChannelType: approverDm.channel_type,
dmPlatformId: approverDm.platform_id,
});
try {
await adapter.deliver(
approverDm.channel_type,
approverDm.platform_id,
null,
'chat-sdk',
JSON.stringify({ text: 'Reply with the name for your new agent:' }),
);
} catch (err) {
log.error('Channel registration: name prompt delivery failed', {
messagingGroupId: row.messaging_group_id,
err,
});
awaitingNameInput.delete(row.approver_user_id);
}
return true;
}
// ── Resolve target agent group (connect to existing or create new) ──
let targetAgentGroupId: string;
if (payload.value.startsWith(CONNECT_PREFIX)) {
targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length);
const ag = getAgentGroup(targetAgentGroupId);
if (!ag) {
log.error('Channel registration: target agent group no longer exists', {
messagingGroupId: row.messaging_group_id,
targetAgentGroupId,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
} else {
log.warn('Channel registration: unknown response value', {
messagingGroupId: row.messaging_group_id,
value: payload.value,
});
return true;
}
// ── Wire + replay (shared path for connect and create) ──
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
return true;
}
// Decide engage_mode from the original event. DMs (`isMention=true` &
// not in a group) get `pattern='.'` (always respond). Group mentions
// get `mention-sticky` (respond now + follow the thread).
//
// We can't read `mg.is_group` reliably here because we only auto-create
// the mg with `is_group=0` on first sight — the adapter hasn't told us
// yet whether it's actually a group. Fall back to the InboundEvent's
// `threadId`: a non-null threadId implies a threaded platform (Slack
// channel thread, Discord thread), which we treat as a group.
const isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: row.agent_group_id,
agent_group_id: targetAgentGroupId,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id,
agentGroupId: targetAgentGroupId,
mgaId,
engageMode,
approverId,
});
// Auto-admit the triggering sender. Without this, the replay below
// would bounce through sender-approval (sender_scope='known' +
// sender-is-not-a-member).
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: row.agent_group_id,
agent_group_id: targetAgentGroupId,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
// Clear the pending row BEFORE replay so the gate check on the second
// attempt sees a wired channel (agentCount > 0) and takes the fan-out
// path normally.
deletePendingChannelApproval(row.messaging_group_id);
try {
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
}
registerResponseHandler(handleChannelApprovalResponse);
// ── Free-text name interceptor ──
// Captures the next DM from an approver who clicked "Create new agent",
// creates the agent immediately, wires the channel, and replays.
setMessageInterceptor(async (event: InboundEvent): Promise<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
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 };
});
+32 -3
View File
@@ -27,7 +27,7 @@ import {
getMessagingGroupWithAgentCount,
} from './db/messaging-groups.js';
import { findSessionForAgent } from './db/sessions.js';
import { startTypingRefresh } from './modules/typing/index.js';
import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js';
import { log } from './log.js';
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
import { wakeContainer } from './container-runner.js';
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
senderScopeGate = fn;
}
/**
* Message-interceptor hook. Runs at the very top of routeInbound, before
* messaging-group resolution. When the interceptor returns true the message
* is consumed and routing stops. Used by the permissions module to capture
* free-text replies during multi-step approval flows (e.g. agent naming).
*/
export type MessageInterceptorFn = (event: InboundEvent) => Promise<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
* messaging group that has no wirings AND hasn't been denied. The hook is
@@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
* Creates messaging group + session if they don't exist yet.
*/
export async function routeInbound(event: InboundEvent): Promise<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,
// WhatsApp, iMessage, email) collapse threads to the channel.
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 });
});
}
} else if (agent.ignored_message_policy === 'accumulate') {
} else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) {
// Accumulate stores the message as silent context. We allow it when
// engagement simply didn't fire, but NOT when engagement fired and
// the access/scope gate refused — those refusals are security
// decisions about an untrusted sender, and silently storing their
// message (which also stages their attachments to disk via
// writeSessionMessage → extractAttachmentFiles) is exactly what the
// gate is meant to prevent.
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
accumulatedCount++;
} else {
@@ -450,7 +475,11 @@ async function deliverToAgent(
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
const freshSession = getSession(session.id);
if (freshSession) {
await wakeContainer(freshSession);
const woke = await wakeContainer(freshSession);
// wakeContainer never throws — it returns false on transient spawn
// failure (host-sweep retries). Stop the typing indicator we just
// started so it doesn't leak; the inbound row stays pending.
if (!woke) stopTypingRefresh(freshSession.id);
}
}
}
+141 -15
View File
@@ -14,12 +14,13 @@ import type Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { deriveAttachmentName } from './attachment-naming.js';
import { isSafeAttachmentName } from './attachment-safety.js';
import type { OutboundFile } from './channels/adapter.js';
import { DATA_DIR } from './config.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import {
createSession,
findSession,
findSessionByAgentGroup,
findSessionForAgent,
getSession,
@@ -36,6 +37,11 @@ import {
import { log } from './log.js';
import type { Session } from './types.js';
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/** Root directory for all session data. */
export function sessionsBaseDir(): string {
return path.join(DATA_DIR, 'v2-sessions');
@@ -232,6 +238,20 @@ export function writeSessionMessage(
/**
* If message content has attachments with base64 `data`, save them to
* the session's inbox directory and replace with `localPath`.
*
* Both `messageId` and `att.name` originate in untrusted input. WhatsApp
* passes `msg.key.id` through raw (and that field is client generated, so a
* peer can craft it), and other adapters may follow. The session dir is
* mounted writable into the container, so a compromised agent can also
* pre-place a symlink at `inbox/<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(
agentGroupId: string,
@@ -249,19 +269,75 @@ function extractAttachmentFiles(
const attachments = parsed.attachments as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(attachments)) return contentStr;
if (!isSafeAttachmentName(messageId)) {
log.warn('Rejecting unsafe inbound message id', { messageId });
return contentStr;
}
let changed = false;
for (const att of attachments) {
if (typeof att.data === 'string') {
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
fs.mkdirSync(inboxDir, { recursive: true });
const filename = (att.name as string) || `attachment-${Date.now()}`;
const filePath = path.join(inboxDir, filename);
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'));
att.localPath = `inbox/${messageId}/${filename}`;
delete att.data;
changed = true;
log.debug('Saved attachment to inbox', { messageId, filename, size: att.size });
if (typeof att.data !== 'string') continue;
const rawName = deriveAttachmentName(att);
const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
if (filename !== rawName) {
log.warn('Refused unsafe attachment filename, would escape inbox', {
messageId,
rawName,
replacement: filename,
});
}
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
// Refuse to mkdir through a symlink that the container may have pre placed
// at inboxDir. With recursive:true, mkdirSync would silently no op on a
// pre existing symlink and the subsequent writeFileSync would follow it.
if (fs.existsSync(inboxDir)) {
const stat = fs.lstatSync(inboxDir);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir });
continue;
}
}
fs.mkdirSync(inboxDir, { recursive: true });
let realInboxDir: string;
try {
realInboxDir = fs.realpathSync(inboxDir);
} catch (err) {
log.warn('Failed to resolve inbox directory', { messageId, err });
continue;
}
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) {
log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir });
continue;
}
const filePath = path.join(inboxDir, filename);
try {
// wx = exclusive create. Refuses to follow a pre existing symlink or
// overwrite any existing file. The host expects to be the sole writer
// of these attachments.
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'), { flag: 'wx' });
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EEXIST') {
log.warn('Inbox attachment target already exists, refusing to overwrite', {
messageId,
filename,
});
continue;
}
throw err;
}
att.name = filename;
att.localPath = `inbox/${messageId}/${filename}`;
delete att.data;
changed = true;
log.debug('Saved attachment to inbox', { messageId, filename, size: att.size });
}
return changed ? JSON.stringify(parsed) : contentStr;
@@ -352,14 +428,48 @@ export function readOutboxFiles(
messageId: string,
filenames: string[],
): OutboundFile[] | undefined {
if (!isSafeAttachmentName(messageId)) {
log.warn('Rejecting unsafe outbox message id', { messageId });
return undefined;
}
const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId);
if (!fs.existsSync(outboxDir)) return undefined;
let realOutboxDir: string;
try {
const stat = fs.lstatSync(outboxDir);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
log.warn('Rejecting unsafe outbox directory', { messageId, outboxDir });
return undefined;
}
realOutboxDir = fs.realpathSync(outboxDir);
} catch (err) {
log.warn('Failed to inspect outbox directory', { messageId, err });
return undefined;
}
const files: OutboundFile[] = [];
for (const filename of filenames) {
if (!isSafeAttachmentName(filename)) {
log.warn('Refused unsafe outbox filename, would escape outbox', { messageId, filename });
continue;
}
const filePath = path.join(outboxDir, filename);
if (fs.existsSync(filePath)) {
files.push({ filename, data: fs.readFileSync(filePath) });
} else {
try {
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink()) {
log.warn('Rejecting unsafe outbox file', { messageId, filename });
continue;
}
const realFilePath = fs.realpathSync(filePath);
if (!isPathInside(realOutboxDir, realFilePath)) {
log.warn('Rejecting outbox file outside message directory', { messageId, filename });
continue;
}
files.push({ filename, data: fs.readFileSync(realFilePath) });
} catch {
log.warn('Outbox file not found', { messageId, filename });
}
}
@@ -373,10 +483,26 @@ export function readOutboxFiles(
* thrown error would trigger the delivery retry path and deliver twice.
*/
export function clearOutbox(agentGroupId: string, sessionId: string, messageId: string): void {
if (!isSafeAttachmentName(messageId)) {
log.warn('Rejecting unsafe outbox cleanup message id', { messageId });
return;
}
const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId);
if (!fs.existsSync(outboxDir)) return;
try {
fs.rmSync(outboxDir, { recursive: true, force: true });
const stat = fs.lstatSync(outboxDir);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
log.warn('Rejecting unsafe outbox cleanup directory', { messageId, outboxDir });
return;
}
const realOutboxBase = fs.realpathSync(path.join(sessionDir(agentGroupId, sessionId), 'outbox'));
const realOutboxDir = fs.realpathSync(outboxDir);
if (!isPathInside(realOutboxBase, realOutboxDir)) {
log.warn('Rejecting outbox cleanup outside session outbox', { messageId, outboxDir });
return;
}
fs.rmSync(realOutboxDir, { recursive: true, force: true });
} catch (err) {
log.warn('Outbox cleanup failed (message already delivered)', { messageId, err });
}