Merge branch 'main' into pr/setup-local-bin-path

This commit is contained in:
Mohamed Khedr
2026-04-30 21:57:55 +01:00
committed by GitHub
47 changed files with 1970 additions and 380 deletions

View File

@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace 2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes: 3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` - `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret** 5. Go to **Basic Information** and copy the **Signing Secret**
@@ -76,7 +76,13 @@ pnpm run build
10. Under **Subscribe to bot events**, add: 10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention` - `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes** 11. Click **Save Changes**
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
### Configure environment ### Configure environment

View File

@@ -157,6 +157,17 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist. Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
## PR Hygiene
Before creating a PR, run these checks:
```bash
git diff upstream/main --stat HEAD
git log upstream/main..HEAD --oneline
```
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
## Development ## Development
Run commands directly — don't tell the user to run them. Run commands directly — don't tell the user to run them.
@@ -186,7 +197,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
systemctl --user start|stop|restart nanoclaw systemctl --user start|stop|restart nanoclaw
``` ```
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here). ## Troubleshooting
Check these first when something goes wrong:
| What | Where |
|------|-------|
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
| Session DBs | `data/v2-sessions/<agent-group>/<session>/``inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) |
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
## Supply Chain Security (pnpm) ## Supply Chain Security (pnpm)

View File

@@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection: 3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
| Checkbox | Label | | Checkbox | Label |
|----------|-------| |----------|-------|

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

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

View File

@@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback {
/** /**
* Claude Code auto-compacts context at this window (tokens). Kept here so * Claude Code auto-compacts context at this window (tokens). Kept here so
* the generic bootstrap doesn't need to know about Claude-specific env vars. * the generic bootstrap doesn't need to know about Claude-specific env vars.
*
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
* raise or lower the threshold without editing source — useful when running
* with a 1M-context model variant or when emergency-tuning a deployment.
*/ */
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
/** /**
* Stale-session detection. Matches Claude Code's error text when a * Stale-session detection. Matches Claude Code's error text when a

View File

@@ -129,10 +129,46 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR" mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header write_header
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing, # NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and # with the figlet wordmark and taglines below. Pre-rendered into
# skips re-printing the wordmark, keeping the flow visually continuous. # assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" # figlet); the bash script just streams the literal frame. clack's intro
# then carries the "let's get you set up" framing — setup:auto sees
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
cat "$PROJECT_ROOT/assets/setup-splash.txt"
# ─── pre-flight: root user warning (Linux) ────────────────────────────
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
printf ' %s\n' \
"$(red 'Warning: you are running as root.')"
printf ' %s\n' \
"$(dim "Running NanoClaw as root is not recommended. It can cause permission")"
printf ' %s\n\n' \
"$(dim "issues with containers, services, and file ownership.")"
printf ' %s\n' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')"
printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')"
read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS </dev/tty
case "${ROOT_ANS:-1}" in
2)
ph_event setup_root_continued
printf '\n'
;;
*)
ph_event setup_root_aborted
printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')"
printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')"
printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')"
printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')"
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
printf ' %s\n' "$(dim '4. Log out: exit')"
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
exit 1
;;
esac
fi
# ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
# setup/install-node.sh and setup/install-docker.sh both require `brew` on # setup/install-node.sh and setup/install-docker.sh both require `brew` on
@@ -188,9 +224,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_LABEL="Installing the basics"
BOOTSTRAP_START=$(date +%s) BOOTSTRAP_START=$(date +%s)
# One-line "why" that teaches a differentiator while the user waits.
printf '%s %s\n' "$(gray '│')" \
"$(dim "Small. Runs on your machine. Yours to modify.")"
spinner_start "$BOOTSTRAP_LABEL" spinner_start "$BOOTSTRAP_LABEL"
# Run in the background so we can tick elapsed time. Capture exit code via # Run in the background so we can tick elapsed time. Capture exit code via

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,75 @@
/**
* Delete the scratch CLI agent created during setup's ping-pong test.
*
* Dynamically finds and removes all rows referencing the agent group
* (any table with an agent_group_id column), deletes the agent group
* itself, and removes the groups/<folder>/ directory. Leaves the CLI
* messaging group intact so it can be reused for a new agent.
*
* Usage:
* pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from '../src/config.js';
import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
interface Args {
folder: string;
}
function parseArgs(): Args {
const argv = process.argv.slice(2);
let folder = '';
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i];
}
if (!folder) {
console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>');
process.exit(1);
}
return { folder };
}
const args = parseArgs();
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db);
const ag = getAgentGroupByFolder(args.folder);
if (!ag) {
console.log(`No agent group with folder "${args.folder}" — nothing to delete.`);
process.exit(0);
}
const cleanup = db.transaction(() => {
const tables = db
.prepare(
`SELECT DISTINCT m.name FROM sqlite_master m
JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id'
WHERE m.type = 'table' AND m.name != 'agent_groups'`,
)
.all() as { name: string }[];
for (const { name } of tables) {
db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id);
}
deleteAgentGroup(ag.id);
});
cleanup();
// Remove the groups/<folder>/ directory.
const groupDir = path.join(process.cwd(), 'groups', args.folder);
if (fs.existsSync(groupDir)) {
fs.rmSync(groupDir, { recursive: true });
}
// Remove session data on disk.
const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id);
if (fs.existsSync(sessionsDir)) {
fs.rmSync(sessionsDir, { recursive: true });
}
console.log(`Deleted agent group ${ag.id} (${args.folder}).`);

View File

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

View File

@@ -47,13 +47,14 @@ import {
} from './lib/setup-config-parse.js'; } from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.js'; import { runWindowedStep } from './lib/windowed-runner.js';
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
import { pollHealth } from './onecli.js'; import { pollHealth } from './onecli.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
import * as setupLog from './logs.js'; import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js'; import { emit as phEmit } from './lib/diagnostics.js';
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js'; import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent'; const CLI_AGENT_NAME = 'Terminal Agent';
@@ -92,17 +93,21 @@ async function main(): Promise<void> {
// Welcome menu — default path or open advanced overrides before any setup // Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path. // work begins. Default lands on standard so Enter is the happy path.
const startChoice = ensureAnswer( // On sg re-exec, the user already chose — skip straight to standard.
await brightSelect<'default' | 'advanced'>({ let startChoice: 'default' | 'advanced' = 'default';
message: 'How would you like to begin?', if (process.env.NANOCLAW_REEXEC_SG !== '1') {
options: [ startChoice = ensureAnswer(
{ value: 'default', label: 'Standard setup' }, await brightSelect<'default' | 'advanced'>({
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' }, message: 'How would you like to begin?',
], options: [
initialValue: 'default', { value: 'default', label: 'Standard setup' },
}), { value: 'advanced', label: 'Advanced', hint: 'override defaults' },
) as 'default' | 'advanced'; ],
setupLog.userInput('start_choice', startChoice); initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
}
if (startChoice === 'advanced') { if (startChoice === 'advanced') {
configValues = await runAdvancedScreen(configValues); configValues = await runAdvancedScreen(configValues);
applyToEnv(configValues); applyToEnv(configValues);
@@ -130,11 +135,13 @@ async function main(): Promise<void> {
} }
if (!skip.has('container')) { if (!skip.has('container')) {
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
p.log.message( p.log.message(
dimWrap( brandBody(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.', dimWrap(
4, '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', { const res = await runWindowedStep('container', {
@@ -169,9 +176,11 @@ async function main(): Promise<void> {
if (!skip.has('onecli')) { if (!skip.has('onecli')) {
p.log.message( p.log.message(
dimWrap( brandBody(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', dimWrap(
4, 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
), ),
); );
@@ -295,29 +304,39 @@ async function main(): Promise<void> {
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') { 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( 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; let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); async function resolveDisplayName(): Promise<string> {
if (needsDisplayName) { if (displayName) return displayName;
const fallback = process.env.USER?.trim() || 'Operator';
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
displayName = preset || (await askDisplayName(fallback)); const existing = detectExistingDisplayName(process.cwd());
const fallback = process.env.USER?.trim() || 'Operator';
displayName = preset || existing || (await askDisplayName(fallback));
return displayName;
}
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
} }
if (!skip.has('cli-agent')) { if (!skip.has('cli-agent')) {
await resolveDisplayName();
const res = await runQuietStep( const res = await runQuietStep(
'cli-agent', 'cli-agent',
{ {
running: 'Bringing your assistant online…', running: 'Bringing your assistant online…',
done: 'Assistant wired up.', done: 'Assistant wired up.',
}, },
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'],
); );
if (!res.ok) { if (!res.ok) {
await fail( await fail(
@@ -328,16 +347,39 @@ async function main(): Promise<void> {
} }
if (!skip.has('first-chat')) { if (!skip.has('first-chat')) {
p.log.message( p.log.message(
dimWrap( brandBody(
"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.", dimWrap(
4, "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(); const ping = await confirmAssistantResponds();
if (ping === 'ok') { if (ping === 'ok') {
phEmit('first_chat_ready'); phEmit('first_chat_ready');
const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent');
const cleanupStart = Date.now();
const cleanup = await spawnQuiet(
'pnpm',
['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'],
cleanupRawLog,
);
setupLog.step(
'cleanup-cli-agent',
cleanup.ok ? 'success' : 'failed',
Date.now() - cleanupStart,
{ exit_code: cleanup.exitCode },
cleanupRawLog,
);
if (!cleanup.ok) {
p.log.warn(
brandBody(
`Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`,
),
);
}
const next = ensureAnswer( const next = ensureAnswer(
await p.select({ await brightSelect<'continue' | 'chat'>({
message: 'What next?', message: 'What next?',
options: [ options: [
{ {
@@ -353,7 +395,23 @@ async function main(): Promise<void> {
}), }),
) as 'continue' | 'chat'; ) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next); setupLog.userInput('first_chat_choice', next);
if (next === 'chat') await runFirstChat(); if (next === 'chat') {
const terminalAgentName = `${displayName!}'s Terminal`;
const createRes = await runQuietChild(
'create-terminal-agent',
'pnpm',
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
{ running: `Creating ${terminalAgentName}`, done: `${terminalAgentName} is ready.` },
);
if (!createRes.ok) {
await fail(
'create-terminal-agent',
`Couldn't create ${terminalAgentName}.`,
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
);
}
await runFirstChat();
}
} else { } else {
phEmit('first_chat_failed', { reason: ping }); phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping); renderPingFailureNote(ping);
@@ -379,6 +437,9 @@ async function main(): Promise<void> {
let channelChoice: ChannelChoice = 'skip'; let channelChoice: ChannelChoice = 'skip';
if (!skip.has('channel')) { if (!skip.has('channel')) {
channelChoice = await askChannelChoice(); channelChoice = await askChannelChoice();
if (channelChoice !== 'skip') {
await resolveDisplayName();
}
if (channelChoice === 'telegram') { if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!); await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') { } else if (channelChoice === 'discord') {
@@ -395,9 +456,11 @@ async function main(): Promise<void> {
await runIMessageChannel(displayName!); await runIMessageChannel(displayName!);
} else { } else {
p.log.info( p.log.info(
wrapForGutter( brandBody(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', wrapForGutter(
4, 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
), ),
); );
} }
@@ -443,7 +506,7 @@ async function main(): Promise<void> {
); );
} }
if (notes.length > 0) { if (notes.length > 0) {
p.note(notes.join('\n'), "What's left"); note(notes.join('\n'), "What's left");
} }
// "What's left" is a soft failure — we don't abort like fail(), but the // "What's left" is a soft failure — we don't abort like fail(), but the
// user is still stuck and a fix is exactly what claude-assist is for. // user is still stuck and a fix is exactly what claude-assist is for.
@@ -475,11 +538,11 @@ async function main(): Promise<void> {
]; ];
const labelWidth = Math.max(...rows.map(([l]) => l.length)); const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n'); const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
p.note(nextSteps, 'Try these'); note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the // Always-on warning goes before the "check your DMs" directive so the
// caveat doesn't land after the user's already looked away at their phone. // caveat doesn't land after the user's already looked away at their phone.
p.note( note(
wrapForGutter( wrapForGutter(
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.", "NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
6, 6,
@@ -496,7 +559,7 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it // that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it // renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro. // as the last thing before outro.
p.note(`${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.")); p.outro(k.green("You're set."));
} else { } else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`.")); p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
@@ -518,10 +581,7 @@ function channelDmLabel(choice: ChannelChoice): string | null {
case 'imessage': case 'imessage':
return 'iMessage'; return 'iMessage';
case 'slack': case 'slack':
// Slack install doesn't wire an agent or send a welcome DM — the return 'Slack DMs';
// driver prints its own "finish in your Slack app" note. Falling
// through to null avoids a misleading "check your Slack DMs" banner.
return null;
default: default:
return null; return null;
} }
@@ -540,18 +600,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const s = p.spinner(); const s = p.spinner();
const start = Date.now(); const start = Date.now();
const label = 'Waking your assistant…'; const label = 'Waking your assistant…';
s.start(fitToWidth(label, ' (999s)')); s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => { const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000); }, 1000);
const result = await pingCliAgent(); const result = await pingCliAgent();
clearInterval(tick); clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
if (result === 'ok') { if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else { } else {
@@ -578,7 +636,7 @@ function renderPingFailureNote(result: PingResult): void {
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
6, 6,
); );
p.note(body, 'Skipping the first chat'); note(body, 'Skipping the first chat');
} }
/** /**
@@ -593,7 +651,7 @@ function renderPingFailureNote(result: PingResult): void {
* clearly optional. * clearly optional.
*/ */
async function runFirstChat(): Promise<void> { async function runFirstChat(): Promise<void> {
p.note( note(
wrapForGutter( wrapForGutter(
[ [
'Your assistant runs in a sandbox on this machine.', 'Your assistant runs in a sandbox on this machine.',
@@ -640,7 +698,7 @@ function sendChatMessage(message: string): Promise<void> {
async function runAuthStep(): Promise<void> { async function runAuthStep(): Promise<void> {
if (anthropicSecretExists()) { if (anthropicSecretExists()) {
p.log.success('Your Claude account is already connected.'); p.log.success(brandBody('Your Claude account is already connected.'));
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
return; return;
} }
@@ -688,7 +746,7 @@ async function runAuthStep(): Promise<void> {
} }
async function runSubscriptionAuth(): Promise<void> { async function runSubscriptionAuth(): Promise<void> {
p.log.step('Opening the Claude sign-in flow…'); p.log.step(brandBody('Opening the Claude sign-in flow…'));
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)')); console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
console.log(); console.log();
const start = Date.now(); const start = Date.now();
@@ -707,7 +765,7 @@ async function runSubscriptionAuth(): Promise<void> {
); );
} }
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
p.log.success('Claude account connected.'); p.log.success(brandBody('Claude account connected.'));
} }
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> { async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
@@ -717,6 +775,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.password({ await p.password({
message: `Paste your ${label}`, message: `Paste your ${label}`,
clearOnError: true,
validate: (v) => { validate: (v) => {
if (!v || !v.trim()) return 'Required'; if (!v || !v.trim()) return 'Required';
if (!v.trim().startsWith(prefix)) { if (!v.trim().startsWith(prefix)) {
@@ -930,9 +989,11 @@ async function runTimezoneStep(): Promise<void> {
tz = await resolveTimezoneViaClaude(raw); tz = await resolveTimezoneViaClaude(raw);
} else { } else {
p.log.warn( p.log.warn(
wrapForGutter( brandBody(
"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`.", wrapForGutter(
4, "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,
),
), ),
); );
} }
@@ -975,7 +1036,7 @@ async function runTimezoneStep(): Promise<void> {
async function askDisplayName(fallback: string): Promise<string> { async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer( const answer = ensureAnswer(
await p.text({ await p.text({
message: 'What should your assistant call you?', message: `What should your assistant call ${accentGreen('you')}?`,
placeholder: fallback, placeholder: fallback,
defaultValue: fallback, defaultValue: fallback,
}), }),
@@ -1105,10 +1166,12 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return; if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.'); p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit', stdio: 'inherit',
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) },
}); });
process.exit(res.status ?? 1); process.exit(res.status ?? 1);
} }

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,23 @@
/** /**
* Slack channel flow for setup:auto. * Slack channel flow for setup:auto.
* *
* `runSlackChannel(displayName)` walks the operator from a bare Slack * `runSlackChannel(displayName)` owns the full branch from creating a
* workspace through a running bot, then stops before wiring an agent: * Slack app through the welcome DM:
* *
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
* event subscriptions, and signing secret * event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts) * 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test → resolves workspace + bot identity * 3. Validate via auth.test → resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive) * 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Print the post-install checklist: set the public webhook URL in * 5. Ask for the operator's Slack user ID
* Slack's Event Subscriptions, DM the bot to bootstrap the channel, * 6. conversations.open to get the DM channel ID
* then `/manage-channels` to wire an agent. * 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
* *
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll), * The welcome DM is sent via outbound delivery (chat.postMessage), which
* Slack needs a public Event Subscriptions URL for inbound events, and * works without Event Subscriptions being configured. The user sees the
* opening an unsolicited DM would need `im:write` scope we don't force * greeting in Slack immediately; inbound replies require webhooks, so the
* the SKILL.md to require. Shipping a honest "here's what's left" note * post-install note covers that.
* is better than a welcome DM the user won't receive until they
* configure the webhook anyway.
* *
* All output obeys the three-level contract. See docs/setup-flow.md. * All output obeys the three-level contract. See docs/setup-flow.md.
*/ */
@@ -26,12 +25,15 @@ import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import * as setupLog from '../logs.js'; import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { wrapForGutter } from '../lib/theme.js'; import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api'; const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps'; const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo { interface WorkspaceInfo {
teamName: string; teamName: string;
@@ -40,10 +42,7 @@ interface WorkspaceInfo {
botUserId: string; botUserId: string;
} }
// displayName is reserved for when we start wiring the first agent here. export async function runSlackChannel(displayName: string): Promise<void> {
// Kept to match the `run<X>Channel(displayName)` signature every other
// channel driver uses, so auto.ts can dispatch without a branch.
export async function runSlackChannel(_displayName: string): Promise<void> {
await walkThroughAppCreation(); await walkThroughAppCreation();
const token = await collectBotToken(); const token = await collectBotToken();
@@ -78,26 +77,67 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
); );
} }
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info); showPostInstallChecklist(info);
} }
async function walkThroughAppCreation(): Promise<void> { async function walkThroughAppCreation(): Promise<void> {
p.note( note(
[ [
"You'll create a Slack app that the assistant talks through.", "You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.", "Free and stays inside the workspaces you pick.",
'', '',
' 1. Create a new app "From scratch", name it, pick a workspace', ' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:', ' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, channels:history, groups:history, im:history,', ' chat:write, im:write, channels:history, groups:history,',
' channels:read, groups:read, users:read, reactions:write', ' im:history, channels:read, groups:read, users:read,',
' reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send', ' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"', ' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"', ' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'', formatNoteLink(SLACK_APPS_URL),
k.dim(SLACK_APPS_URL), ].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Create a Slack app', 'Create a Slack app',
); );
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
@@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise<void> {
} }
async function collectBotToken(): Promise<string> { async function collectBotToken(): Promise<string> {
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste your Slack bot token', message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => { validate: (v) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
if (!t) return 'Token is required'; if (!t) return 'Token is required';
@@ -132,9 +185,22 @@ async function collectBotToken(): Promise<string> {
} }
async function collectSigningSecret(): Promise<string> { async function collectSigningSecret(): Promise<string> {
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer( const answer = ensureAnswer(
await p.password({ await p.password({
message: 'Paste your Slack signing secret', message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => { validate: (v) => {
const t = (v ?? '').trim(); const t = (v ?? '').trim();
if (!t) return 'Signing secret is required'; if (!t) return 'Signing secret is required';
@@ -175,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
user_id?: string; user_id?: string;
error?: string; error?: string;
}; };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) { if (data.ok && data.team && data.user) {
s.stop( s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, `Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
); );
const info: WorkspaceInfo = { const info: WorkspaceInfo = {
teamName: data.team, teamName: data.team,
@@ -207,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
); );
} catch (err) { } catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000); s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, { setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message, ERROR: message,
@@ -221,26 +285,133 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
} }
} }
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (top right)',
' 2. Click "Profile"',
' 3. Click the three dots (⋯) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
function showPostInstallChecklist(info: WorkspaceInfo): void { function showPostInstallChecklist(info: WorkspaceInfo): void {
p.note( note(
wrapForGutter( wrapForGutter(
[ [
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`, `Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'', '',
' 1. A public URL so Slack can deliver events.', ' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' NanoClaw serves a webhook on port 3000 by default — expose it', ' Cloudflare Tunnel, or a reverse proxy on a VPS.',
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
'', '',
' 2. In your Slack app → Event Subscriptions:', ' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on', ' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`, ` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,', ' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention', ' message.im, app_mention',
' • Save, then reinstall the app when Slack prompts', ' • Save Changes',
'', '',
` 3. DM @${info.botName} from Slack once — that bootstraps the`, ' 3. In your Slack app → Interactivity & Shortcuts:',
' messaging group. Then run `/manage-channels` in `claude` to', ' • Toggle "Interactivity" on',
' wire an agent to it.', ` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'), ].join('\n'),
6, 6,
), ),

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise<void> {
} }
// Socket is unreachable due to group perms — current shell's supplementary // Socket is unreachable due to group perms — current shell's supplementary
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh // groups are fixed at login, so `usermod -aG docker` doesn't affect us
// or a prior install) doesn't affect us until next login. Re-exec this // until next login. Ensure the user is in the docker group (install-docker.sh
// step under `sg docker` so the child picks up docker as its primary // does this on fresh installs, but skips when Docker is already present),
// group and can talk to /var/run/docker.sock without a logout. // then re-exec under `sg docker` so the child picks up docker as its
// primary group and can talk to /var/run/docker.sock without a logout.
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) { if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
// Ensure the current user is in the docker group — without this,
// sg will ask for the (typically unset) group password and fail.
const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
log.info('Adding current user to docker group');
spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
stdio: 'inherit',
});
}
log.info('Re-executing container step under `sg docker`'); log.info('Re-executing container step under `sg docker`');
const res = spawnSync( const res = spawnSync(
'sg', 'sg',

View File

@@ -11,6 +11,48 @@ import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
/**
* Read a single key from `.env` on disk (not process.env).
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
*/
export function readEnvKey(key: string, projectRoot?: string): string | null {
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return null;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 1) continue;
if (trimmed.slice(0, eq) === key) {
return trimmed.slice(eq + 1).trim() || null;
}
}
return null;
}
export function detectExistingDisplayName(projectRoot: string): string | null {
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return null;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
.get() as { display_name: string } | undefined;
return row?.display_name?.trim() || null;
} catch {
return null;
} finally {
db?.close();
}
}
export function detectRegisteredGroups(projectRoot: string): boolean { export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true; return true;

View File

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

View File

@@ -9,12 +9,19 @@
* `confirmThenOpen` pauses for the operator before triggering the open — * `confirmThenOpen` pauses for the operator before triggering the open —
* the browser tends to steal focus when it pops, and a split-second * the browser tends to steal focus when it pops, and a split-second
* "wait what just happened" moment is worse than letting the user hit * "wait what just happened" moment is worse than letting the user hit
* Enter when they're ready. * Enter when they're ready. On headless devices (no graphical session
* available) it skips both the prompt and the open: there's no browser
* to launch, the surrounding `note(...)` already shows the URL for
* copy-paste on another device, and the next prompt in the channel
* flow ("Got your bot token?" etc.) provides the natural completion
* confirmation.
*/ */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import k from 'kleur';
import { isHeadless } from '../platform.js';
import { ensureAnswer } from './runner.js'; import { ensureAnswer } from './runner.js';
/** Best-effort open of a URL in the user's default browser. Silent on failure. */ /** Best-effort open of a URL in the user's default browser. Silent on failure. */
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
} }
} }
/**
* Format a URL for inclusion in a setup `note(...)` card. On
* headless devices we surface the URL inside the card with a
* "Get started:" label at full strength — copy-pasting onto
* another device is the actual action, not an incidental
* reference. The leading `\n` acts as a visual separator from
* the body steps above; callers `.filter(line => line !== null)`
* before joining, so on GUI we drop the line entirely (and the
* URL ends up below the next-step confirm prompt as a "if
* browser does not appear, please visit" fallback — see
* `confirmThenOpen`).
*/
export function formatNoteLink(url: string): string | null {
if (isHeadless()) return `\nGet started: ${url}`;
return null;
}
/** /**
* Gate a browser-open on a confirm so the user is ready for their browser * Gate a browser-open on a confirm so the user is ready for their browser
* to take focus. Proceeds on cancel as well — the user can always copy the * to take focus. Proceeds on cancel as well. On headless devices both the
* URL from the note that precedes the prompt. * prompt and the open are skipped — the URL is already surfaced inside
* the surrounding note (via `formatNoteLink`).
*
* On GUI devices the confirm message includes the fallback URL on the
* lines below the action ("If browser does not appear, please visit:
* <url>" in dim) so the user has a copy-paste path right next to the
* action button without needing to scroll back up to the card.
*/ */
export async function confirmThenOpen( export async function confirmThenOpen(
url: string, url: string,
message = 'Press Enter to open your browser', message = 'Press Enter to open your browser',
): Promise<void> { ): Promise<void> {
if (isHeadless()) return;
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer( ensureAnswer(
await p.confirm({ await p.confirm({
message, message: `${message}${fallback}`,
initialValue: true, initialValue: true,
}), }),
); );

View File

@@ -2,8 +2,11 @@
* Offer Claude-assisted debugging when a setup step fails. * Offer Claude-assisted debugging when a setup step fails.
* *
* Flow: * Flow:
* 1. Check `claude` is on PATH and has a working credential. If not, * 1. Check `claude` is on PATH — if not, offer to install it via
* silently skip — pre-auth failures can't use this path. * setup/install-claude.sh. Then check auth via `claude auth status`
* — if not signed in, offer to run `claude setup-token` (browser
* OAuth with code-paste fallback for headless/remote systems).
* If either is declined or fails, silently skip.
* 2. Ask the user for consent ("Want me to ask Claude for a fix?"). * 2. Ask the user for consent ("Want me to ask Claude for a fix?").
* 3. Build a minimal prompt: the one-paragraph situation, the failing * 3. Build a minimal prompt: the one-paragraph situation, the failing
* step's name/message/hint, and a short list of *file references* * step's name/message/hint, and a short list of *file references*
@@ -16,15 +19,16 @@
* *
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs. * Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
*/ */
import { execSync, spawn } from 'child_process'; import { execSync, spawn, spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
import { ensureAnswer } from './runner.js'; import { ensureAnswer } from './runner.js';
import { fitToWidth } from './theme.js'; import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
export interface AssistContext { export interface AssistContext {
stepName: string; stepName: string;
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
projectRoot: string = process.cwd(), projectRoot: string = process.cwd(),
): Promise<boolean> { ): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!isClaudeUsable()) return false; if (!(await ensureClaudeReady(projectRoot))) return false;
const want = ensureAnswer( const want = ensureAnswer(
await p.confirm({ await p.confirm({
@@ -106,12 +110,12 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response); const parsed = parseResponse(response);
if (!parsed) { if (!parsed) {
p.log.warn("Claude responded but I couldn't parse a command out of it."); p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
p.log.message(k.dim(response.trim().slice(0, 500))); p.log.message(k.dim(response.trim().slice(0, 500)));
return false; return false;
} }
p.note( note(
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
"Claude's suggestion", "Claude's suggestion",
); );
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
return true; return true;
} }
function isClaudeUsable(): boolean { function isClaudeInstalled(): boolean {
try { try {
execSync('command -v claude', { stdio: 'ignore' }); execSync('command -v claude', { stdio: 'ignore' });
return true;
} catch { } catch {
return false; return false;
} }
// Availability without auth is half the story; a real query will still }
// fail if the token isn't registered. We try first and surface the error
// rather than pre-checking auth with a separate round trip. function isClaudeAuthenticated(): boolean {
try {
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
return true;
} catch {
return false;
}
}
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
if (!isClaudeInstalled()) {
const install = ensureAnswer(
await p.confirm({
message:
'Claude CLI is needed to diagnose this. Install it now?',
initialValue: true,
}),
);
if (!install) return false;
const code = spawnSync('bash', ['setup/install-claude.sh'], {
cwd: projectRoot,
stdio: 'inherit',
}).status;
if (code !== 0 || !isClaudeInstalled()) {
p.log.error("Couldn't install the Claude CLI.");
return false;
}
p.log.success('Claude CLI installed.');
}
if (!isClaudeAuthenticated()) {
const auth = ensureAnswer(
await p.confirm({
message:
"Claude CLI isn't signed in. Sign in now? (a browser will open)",
initialValue: true,
}),
);
if (!auth) return false;
// setup-token has an interactive TUI; reset terminal to cooked mode
// so its prompts render correctly after clack's raw-mode prompts.
spawnSync('stty', ['sane'], { stdio: 'inherit' });
// Run under script(1) to capture the OAuth token from PTY output
// while preserving interactive TTY for the browser OAuth flow.
// Same approach as register-claude-token.sh, but we set the env var
// instead of writing to OneCLI.
const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`);
try {
const isUtilLinux = (() => {
try {
return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux');
} catch { return false; }
})();
const scriptArgs = isUtilLinux
? ['-q', '-c', 'claude setup-token', tmpfile]
: ['-q', tmpfile, 'claude', 'setup-token'];
spawnSync('script', scriptArgs, {
cwd: projectRoot,
stdio: 'inherit',
});
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
const raw = fs.readFileSync(tmpfile, 'utf-8');
const stripped = raw
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\n\r]/g, '');
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
if (matches) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
}
}
} finally {
try { fs.unlinkSync(tmpfile); } catch {}
}
if (!isClaudeAuthenticated()) {
p.log.error("Couldn't complete Claude sign-in.");
return false;
}
p.log.success('Claude CLI signed in.');
}
return true; return true;
} }
@@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner(
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window). // Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
out.write(`\x1b[${WINDOW_SIZE + 1}A`); out.write(`\x1b[${WINDOW_SIZE + 1}A`);
const elapsed = Math.round((Date.now() - start) / 1000);
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
const suffix = ` (${elapsed}s)`; const suffix = ` (${fmtDuration(Date.now() - start)})`;
const header = fitToWidth('Asking Claude to diagnose…', suffix); const header = fitToWidth('Asking Claude to diagnose…', suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
@@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner(
clearBlock(); clearBlock();
out.write(SHOW_CURSOR); out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit); process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${fmtDuration(Date.now() - start)})`;
const suffix = ` (${elapsed}s)`;
if (kind === 'ok') { if (kind === 'ok') {
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`); p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
resolve(payload); resolve(payload);
} else { } else {
p.log.error( p.log.error(

View File

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

View File

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

View File

@@ -115,7 +115,7 @@ async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
}; };
const ans = ensureAnswer( const ans = ensureAnswer(
e.secret e.secret
? await p.password({ message: e.label, validate }) ? await p.password({ message: e.label, clearOnError: true, validate })
: await p.text({ : await p.text({
message: e.label, message: e.label,
placeholder: e.placeholder ?? e.default, placeholder: e.placeholder ?? e.default,

View File

@@ -11,6 +11,7 @@
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
* - Otherwise → kleur's 16-color cyan (closest fallback) * - Otherwise → kleur's 16-color cyan (closest fallback)
*/ */
import * as p from '@clack/prompts';
import k from 'kleur'; import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -38,6 +39,57 @@ export function brandChip(s: string): string {
return k.bgCyan(k.black(k.bold(s))); return k.bgCyan(k.black(k.bold(s)));
} }
/**
* Accent green (#3fba50) for emphasizing a single word inside prompt
* messages — currently the "you" in "What should your assistant call
* you?" so the operator parses at a glance who the question is about.
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
*/
export function accentGreen(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
return k.green(s);
}
/**
* Format an elapsed-time duration (in milliseconds) for the spinner
* suffixes setup writes everywhere. Sub-minute durations stay in plain
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
* so live spinner output doesn't change shape at every whole minute.
*/
export function fmtDuration(ms: number): string {
const totalSec = Math.round(ms / 1000);
if (totalSec < 60) return `${totalSec}s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m ${s}s`;
}
/**
* Brand body color for setup-flow prose. Used for card bodies (via the
* `note()` formatter) and `p.log.*` body arguments — anywhere the
* previous "dim" treatment was making prose hard to read or washing
* out embedded brand emphasis.
*
* Multi-line input is colored line-by-line so embedded line breaks
* don't bleed the SGR sequence across clack's gutter prefix.
*/
export function brandBody(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return s
.split('\n')
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
.join('\n');
}
return s
.split('\n')
.map((line) => (line.length > 0 ? k.cyan(line) : line))
.join('\n');
}
/** /**
* Wrap text so it fits inside clack's gutter without the terminal's soft * Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with * wrap breaking the `│ …` bar on long lines. Works on a single string with
@@ -68,6 +120,16 @@ export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter); return wrapForGutter(text, gutter);
} }
/**
* Wrap clack's `p.note` so card bodies render in the brand body color
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
* on each line individually, so `brandBody` colors each line cleanly
* without bleeding across the gutter prefix.
*/
export function note(message: string, title?: string): void {
p.note(message, title, { format: brandBody });
}
const ANSI_RE = /\x1b\[[0-9;]*m/g; const ANSI_RE = /\x1b\[[0-9;]*m/g;
function visibleLength(s: string): number { function visibleLength(s: string): number {

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { deriveAttachmentName, extForMime } from './attachment-naming.js';
describe('extForMime', () => {
it('returns empty for undefined / non-string / empty', () => {
expect(extForMime(undefined)).toBe('');
expect(extForMime('')).toBe('');
expect(extForMime({})).toBe('');
expect(extForMime(null)).toBe('');
expect(extForMime(42)).toBe('');
});
it('maps common MIME types to canonical extensions', () => {
expect(extForMime('image/jpeg')).toBe('jpg');
expect(extForMime('application/pdf')).toBe('pdf');
expect(extForMime('audio/ogg')).toBe('ogg');
});
it('strips parameters and is case-insensitive', () => {
expect(extForMime('image/JPEG; foo=bar')).toBe('jpg');
expect(extForMime(' Application/PDF ')).toBe('pdf');
expect(extForMime('text/plain; charset=utf-8')).toBe('txt');
});
it('returns empty for unknown MIMEs', () => {
expect(extForMime('application/octet-stream')).toBe('');
expect(extForMime('application/x-totally-made-up')).toBe('');
});
});
describe('deriveAttachmentName', () => {
it('returns explicit name when set, no derivation', () => {
expect(deriveAttachmentName({ name: 'photo.jpg', mimeType: 'application/pdf' })).toBe('photo.jpg');
});
it('ignores empty / non-string explicit name and falls through to derivation', () => {
const out = deriveAttachmentName({ name: '', mimeType: 'application/pdf' });
expect(out).toMatch(/^attachment-\d+\.pdf$/);
const out2 = deriveAttachmentName({ name: 42, mimeType: 'application/pdf' });
expect(out2).toMatch(/^attachment-\d+\.pdf$/);
});
it('derives extension from mimeType when no name', () => {
expect(deriveAttachmentName({ mimeType: 'application/pdf' })).toMatch(/^attachment-\d+\.pdf$/);
expect(deriveAttachmentName({ mimeType: 'image/jpeg' })).toMatch(/^attachment-\d+\.jpg$/);
});
it('falls back to att.type when mimeType is missing (Telegram photos/stickers)', () => {
expect(deriveAttachmentName({ type: 'photo' })).toMatch(/^attachment-\d+\.jpg$/);
expect(deriveAttachmentName({ type: 'sticker' })).toMatch(/^attachment-\d+\.webp$/);
expect(deriveAttachmentName({ type: 'voice' })).toMatch(/^attachment-\d+\.ogg$/);
expect(deriveAttachmentName({ type: 'animation' })).toMatch(/^attachment-\d+\.mp4$/);
});
it('case-insensitive att.type lookup', () => {
expect(deriveAttachmentName({ type: 'PHOTO' })).toMatch(/^attachment-\d+\.jpg$/);
});
it('returns bare timestamp when nothing matches', () => {
expect(deriveAttachmentName({})).toMatch(/^attachment-\d+$/);
expect(deriveAttachmentName({ mimeType: 'application/octet-stream' })).toMatch(/^attachment-\d+$/);
expect(deriveAttachmentName({ type: 'mystery-class' })).toMatch(/^attachment-\d+$/);
});
it('does not crash on non-string mimeType (defensive against buggy bridges)', () => {
expect(() => deriveAttachmentName({ mimeType: { foo: 'bar' } })).not.toThrow();
expect(deriveAttachmentName({ mimeType: { foo: 'bar' } })).toMatch(/^attachment-\d+$/);
});
});

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

@@ -0,0 +1,69 @@
/**
* Derive a safe, extensioned filename for inbound attachments when the
* channel bridge passes data without an explicit `name`.
*
* Two-step lookup:
* 1. `mimeType` → extension (Discord/Slack documents, Telegram document
* uploads — channels that set the MIME but not a filename).
* 2. `att.type` → extension (Telegram photos/stickers/voice/animations —
* coarse media-class set by the chat-sdk bridge with no MIME).
*
* Output is still passed through `isSafeAttachmentName` at the call site.
* The maps emit static values, so no derivation path can construct a
* traversal payload — only an attacker-controlled `att.name` can, and that
* goes through the safety guard unchanged.
*/
// Map common MIME types to canonical file extensions. Without an extension,
// agents (and humans) can't tell what kind of file landed in the inbox, and
// tools keyed on extension (image viewers, exiftool, etc.) misbehave.
const MIME_TO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/heic': 'heic',
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/mp4': 'm4a',
'video/mp4': 'mp4',
'video/webm': 'webm',
'video/quicktime': 'mov',
'application/pdf': 'pdf',
'text/plain': 'txt',
'application/json': 'json',
'application/zip': 'zip',
};
// Fallback when `mimeType` is missing — Telegram photos and stickers arrive
// without an explicit MIME on the attachment object. The channel bridge sets
// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.)
// which is reliable enough to derive a canonical extension. Telegram's GIFs
// are actually MP4, hence `animation: 'mp4'`.
const TYPE_TO_EXT: Record<string, string> = {
image: 'jpg',
photo: 'jpg',
sticker: 'webp',
voice: 'ogg',
audio: 'mp3',
video: 'mp4',
animation: 'mp4',
};
export function extForMime(mime: unknown): string {
if (typeof mime !== 'string' || !mime) return '';
const clean = mime.split(';')[0].trim().toLowerCase();
return MIME_TO_EXT[clean] ?? '';
}
export function deriveAttachmentName(att: Record<string, unknown>): string {
const explicit = att.name;
if (typeof explicit === 'string' && explicit) return explicit;
let ext = extForMime(att.mimeType);
if (!ext && typeof att.type === 'string') {
ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? '';
}
const ts = Date.now();
return ext ? `attachment-${ts}.${ext}` : `attachment-${ts}`;
}

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

@@ -0,0 +1,23 @@
import path from 'path';
/**
* Is `name` safe to use as the last segment of a path inside an
* attachment-staging directory? Filenames originate from untrusted sources —
* channel messages from any chat participant, agent-to-agent forwards from
* a possibly-compromised peer agent — and land in `path.join(dir, name)`
* sinks on the host. Without this guard, a `..`-laden name escapes the
* inbox and writes anywhere the host process has filesystem permission.
*
* Rejects:
* - non-string / empty
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
* - anything containing a path separator (`/` or `\`) or NUL
* - any value where `path.basename(name) !== name`, catching OS-specific
* separators and covering drives/prefixes on Windows runtimes
*/
export function isSafeAttachmentName(name: string): boolean {
if (typeof name !== 'string' || name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (/[\\/\0]/.test(name)) return false;
return path.basename(name) === name;
}

View File

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

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

@@ -0,0 +1,197 @@
/**
* Unit tests for the startup circuit breaker.
*
* Covers state transitions, the documented backoff schedule, and the
* fresh-install case where DATA_DIR doesn't exist yet (the breaker runs
* before initDb, so it has to create the dir itself).
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// vi.mock factories are hoisted above imports, so they can't close over local
// consts. vi.hoisted is hoisted alongside the mock and runs before any
// `import` — so it can only use globals (no path/os modules). Use require()
// inside the callback to compute the test dir.
const { TEST_DIR } = vi.hoisted(() => {
const nodePath = require('path') as typeof import('path');
const nodeOs = require('os') as typeof import('os');
return { TEST_DIR: nodePath.join(nodeOs.tmpdir(), 'nanoclaw-cb-test') };
});
const CB_PATH = path.join(TEST_DIR, 'circuit-breaker.json');
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: TEST_DIR };
});
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
function readState(): { attempt: number; timestamp: string } {
return JSON.parse(fs.readFileSync(CB_PATH, 'utf-8'));
}
function seedState(attempt: number, timestamp = new Date().toISOString()): void {
fs.writeFileSync(CB_PATH, JSON.stringify({ attempt, timestamp }));
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
vi.useRealTimers();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
describe('resetCircuitBreaker', () => {
it('deletes the state file', () => {
seedState(3);
expect(fs.existsSync(CB_PATH)).toBe(true);
resetCircuitBreaker();
expect(fs.existsSync(CB_PATH)).toBe(false);
});
it('is a no-op when the file does not exist', () => {
expect(fs.existsSync(CB_PATH)).toBe(false);
expect(() => resetCircuitBreaker()).not.toThrow();
});
});
describe('enforceStartupBackoff — state transitions', () => {
it('first run writes attempt=1 and does not delay', async () => {
vi.useFakeTimers();
const start = Date.now();
await enforceStartupBackoff();
// No timers should have been queued — clean first start is 0s.
expect(Date.now() - start).toBe(0);
expect(readState().attempt).toBe(1);
});
it('within reset window, attempt is incremented', async () => {
seedState(1);
vi.useFakeTimers();
const promise = enforceStartupBackoff();
await vi.runAllTimersAsync();
await promise;
expect(readState().attempt).toBe(2);
});
it('outside reset window (>1h), attempt resets to 1', async () => {
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
seedState(5, longAgo);
await enforceStartupBackoff();
expect(readState().attempt).toBe(1);
});
it('exactly at the reset window boundary still counts as "within"', async () => {
// RESET_WINDOW_MS = 60min. Use 59min59s to stay inside even if the test
// takes a few ms to execute.
const justInside = new Date(Date.now() - (60 * 60 * 1000 - 1000)).toISOString();
seedState(2, justInside);
vi.useFakeTimers();
const promise = enforceStartupBackoff();
await vi.runAllTimersAsync();
await promise;
expect(readState().attempt).toBe(3);
});
it('treats a malformed state file as no prior state', async () => {
fs.writeFileSync(CB_PATH, '{ this is not json');
await enforceStartupBackoff();
expect(readState().attempt).toBe(1);
});
it('resetCircuitBreaker after a startup actually clears the counter for the next startup', async () => {
// Simulate: crash, restart (attempt=2), graceful shutdown, restart again.
seedState(1);
vi.useFakeTimers();
const p1 = enforceStartupBackoff();
await vi.runAllTimersAsync();
await p1;
expect(readState().attempt).toBe(2);
resetCircuitBreaker();
expect(fs.existsSync(CB_PATH)).toBe(false);
await enforceStartupBackoff();
expect(readState().attempt).toBe(1);
});
});
describe('enforceStartupBackoff — backoff schedule', () => {
/**
* Documented schedule:
*
* clean start → 1 crash → 2 crash → 3 crash → 4 crash → 5 crash → 6+ crash
* 0s → 0s → 10s → 30s → 2min → 5min → 15min cap
*
* Each row is [priorAttempt seeded in the file, expected delay this run
* produces in seconds]. priorAttempt=null = no file = very first start.
*
* To assert the *requested* delay (not just observed elapsed real time),
* we spy on global.setTimeout and look at the longest call. runAllTimersAsync
* lets the function complete so we can move on.
*/
const cases: Array<{ label: string; priorAttempt: number | null; expectedDelaySec: number }> = [
{ label: 'clean first start (no file)', priorAttempt: null, expectedDelaySec: 0 },
{ label: 'first crash (attempt=2)', priorAttempt: 1, expectedDelaySec: 0 },
{ label: 'second crash (attempt=3)', priorAttempt: 2, expectedDelaySec: 10 },
{ label: 'third crash (attempt=4)', priorAttempt: 3, expectedDelaySec: 30 },
{ label: 'fourth crash (attempt=5)', priorAttempt: 4, expectedDelaySec: 120 },
{ label: 'fifth crash (attempt=6)', priorAttempt: 5, expectedDelaySec: 300 },
{ label: 'sixth crash (attempt=7) — cap', priorAttempt: 6, expectedDelaySec: 900 },
{ label: 'far past cap (attempt=20)', priorAttempt: 19, expectedDelaySec: 900 },
];
for (const { label, priorAttempt, expectedDelaySec } of cases) {
it(`${label}: delays ${expectedDelaySec}s`, async () => {
if (priorAttempt !== null) seedState(priorAttempt);
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const promise = enforceStartupBackoff();
await vi.runAllTimersAsync();
await promise;
// enforceStartupBackoff only calls setTimeout when delaySec > 0. Pick
// the longest delay it requested (vitest may queue small internal
// timers we don't care about).
const requestedDelays = setTimeoutSpy.mock.calls.map((c) => c[1] ?? 0);
const maxDelayMs = requestedDelays.length ? Math.max(...requestedDelays) : 0;
expect(maxDelayMs).toBe(expectedDelaySec * 1000);
});
}
});
describe('enforceStartupBackoff — fresh install (DATA_DIR missing)', () => {
/**
* The breaker runs before initDb (which is what creates DATA_DIR). On a
* fresh checkout the dir doesn't exist yet, so write() must create it
* before writing the state file — otherwise the host crashes on its very
* first start.
*/
it('creates DATA_DIR on demand and does not throw', async () => {
fs.rmSync(TEST_DIR, { recursive: true });
expect(fs.existsSync(TEST_DIR)).toBe(false);
await expect(enforceStartupBackoff()).resolves.toBeUndefined();
expect(fs.existsSync(TEST_DIR)).toBe(true);
expect(fs.existsSync(CB_PATH)).toBe(true);
expect(readState().attempt).toBe(1);
});
});

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

@@ -0,0 +1,84 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { log } from './log.js';
const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json');
const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
// Index = number of consecutive crashes (0 = clean start, attempt 1).
// 6+ crashes capped at 15min.
const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900];
interface CircuitBreakerState {
attempt: number;
timestamp: string;
}
function read(): CircuitBreakerState | null {
try {
const raw = fs.readFileSync(CB_PATH, 'utf-8');
return JSON.parse(raw) as CircuitBreakerState;
} catch {
return null;
}
}
function write(state: CircuitBreakerState): void {
// The breaker runs before initDb (which is what creates DATA_DIR), so on a
// fresh checkout the dir may not exist yet.
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n');
}
function getDelay(attempt: number): number {
const idx = Math.min(attempt - 1, BACKOFF_SCHEDULE_S.length - 1);
return BACKOFF_SCHEDULE_S[idx];
}
export function resetCircuitBreaker(): void {
try {
fs.unlinkSync(CB_PATH);
log.info('Circuit breaker reset on clean shutdown');
} catch {}
}
export async function enforceStartupBackoff(): Promise<void> {
const now = new Date();
const prev = read();
let attempt: number;
if (!prev) {
attempt = 1;
} else {
const elapsedMs = now.getTime() - new Date(prev.timestamp).getTime();
if (elapsedMs < RESET_WINDOW_MS) {
attempt = prev.attempt + 1;
log.warn('Previous startup was not a clean shutdown', {
previousAttempt: prev.attempt,
previousTimestamp: prev.timestamp,
elapsedSec: Math.round(elapsedMs / 1000),
});
} else {
attempt = 1;
log.info('Circuit breaker reset — last startup was over 1h ago', {
previousAttempt: prev.attempt,
previousTimestamp: prev.timestamp,
});
}
}
write({ attempt, timestamp: now.toISOString() });
const delaySec = getDelay(attempt);
if (delaySec > 0) {
const resumeAt = new Date(now.getTime() + delaySec * 1000).toISOString();
log.warn('Circuit breaker: delaying startup due to repeated crashes', {
attempt,
delaySec,
resumeAt,
});
await new Promise((resolve) => setTimeout(resolve, delaySec * 1000));
log.info('Circuit breaker: backoff complete, resuming startup', { attempt });
}
}

View File

@@ -58,7 +58,7 @@ const activeContainers = new Map<string, { process: ChildProcess; containerName:
* a duplicate container against the same session directory, producing * a duplicate container against the same session directory, producing
* racy double-replies. * racy double-replies.
*/ */
const wakePromises = new Map<string, Promise<void>>(); const wakePromises = new Map<string, Promise<boolean>>();
export function getActiveContainerCount(): number { export function getActiveContainerCount(): number {
return activeContainers.size; return activeContainers.size;
@@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean {
* (the in-flight wake promise is reused). * (the in-flight wake promise is reused).
* *
* The container runs the v2 agent-runner which polls the session DB. * The container runs the v2 agent-runner which polls the session DB.
*
* Contract: never throws. Returns `true` on successful spawn, `false` on
* transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't
* need to wrap — the inbound row stays pending and host-sweep retries on
* its next tick. Callers that care (e.g. the router's typing indicator)
* can branch on the boolean.
*/ */
export function wakeContainer(session: Session): Promise<void> { export function wakeContainer(session: Session): Promise<boolean> {
if (activeContainers.has(session.id)) { if (activeContainers.has(session.id)) {
log.debug('Container already running', { sessionId: session.id }); log.debug('Container already running', { sessionId: session.id });
return Promise.resolve(); return Promise.resolve(true);
} }
const existing = wakePromises.get(session.id); const existing = wakePromises.get(session.id);
if (existing) { if (existing) {
log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id });
return existing; return existing;
} }
const promise = spawnContainer(session).finally(() => { const promise = spawnContainer(session)
wakePromises.delete(session.id); .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); wakePromises.set(session.id, promise);
return promise; return promise;
} }
@@ -435,20 +447,18 @@ async function buildContainerArgs(
} }
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection. // are routed through the agent vault for credential injection. Treated as
try { // a transient hard failure: if we can't wire the gateway, we don't spawn.
if (agentIdentifier) { // The caller (router or host-sweep) catches the throw, leaves the inbound
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); // message pending, and the next sweep tick retries.
} if (agentIdentifier) {
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); await onecli.ensureAgent({ name: agentGroup.name, identifier: 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 });
} }
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 // Host gateway
args.push(...hostGatewayArgs()); args.push(...hostGatewayArgs());

View File

@@ -173,6 +173,43 @@ describe('session manager', () => {
expect(getSession(session.id)!.last_active).not.toBeNull(); expect(getSession(session.id)!.last_active).not.toBeNull();
}); });
it('should refuse path-traversal in attachment filenames', () => {
// Regression: attachment.name comes from untrusted senders (E2EE-protected
// chat platforms can't sanitize it server-side). Without the guard, a
// `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere
// the host process can reach.
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox');
const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary');
if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget);
writeSessionMessage('ag-1', session.id, {
id: 'msg-attack',
kind: 'chat',
timestamp: now(),
content: JSON.stringify({
text: 'pwn',
attachments: [
{
type: 'document',
name: '../../../../../../../../tmp/nanoclaw-traversal-canary',
data: Buffer.from('owned').toString('base64'),
},
],
}),
});
expect(fs.existsSync(escapeTarget)).toBe(false);
// The bytes should still land — under a synthesized safe name inside the
// inbox — so the agent doesn't lose data on a malicious filename.
const inboxDir = path.join(inboxBase, 'msg-attack');
expect(fs.existsSync(inboxDir)).toBe(true);
const written = fs.readdirSync(inboxDir);
expect(written).toHaveLength(1);
expect(written[0]).not.toContain('/');
expect(written[0]).not.toContain('..');
});
}); });
describe('router', () => { describe('router', () => {

View File

@@ -168,6 +168,8 @@ async function sweepSession(session: Session): Promise<void> {
const dueCount = countDueMessages(inDb); const dueCount = countDueMessages(inDb);
if (dueCount > 0 && !isContainerRunning(session.id)) { if (dueCount > 0 && !isContainerRunning(session.id)) {
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
// wakeContainer never throws — transient spawn failures (OneCLI down,
// etc.) return false and leave messages pending for the next tick.
await wakeContainer(session); await wakeContainer(session);
} }

View File

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

View File

@@ -21,6 +21,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { isSafeAttachmentName } from '../../attachment-safety.js';
import { getAgentGroup } from '../../db/agent-groups.js'; import { getAgentGroup } from '../../db/agent-groups.js';
import { getSession } from '../../db/sessions.js'; import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js'; import { wakeContainer } from '../../container-runner.js';
@@ -29,6 +30,8 @@ import { resolveSession, sessionDir, writeSessionMessage } from '../../session-m
import type { Session } from '../../types.js'; import type { Session } from '../../types.js';
import { hasDestination } from './db/agent-destinations.js'; import { hasDestination } from './db/agent-destinations.js';
export { isSafeAttachmentName };
export interface ForwardedAttachment { export interface ForwardedAttachment {
name: string; name: string;
filename: string; filename: string;
@@ -36,26 +39,6 @@ export interface ForwardedAttachment {
localPath: string; localPath: string;
} }
/**
* Is `name` safe to use as the last segment of a path inside the target
* agent's inbox directory? Filenames arrive in messages_out content from
* the source agent — under a multi-agent setup with heterogenous providers
* (or a compromised / hallucinating sub-agent) they can't be trusted.
*
* Rejects:
* - empty string
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
* - anything containing a path separator (`/` or `\`) or NUL
* - any value where `path.basename(name) !== name`, catching OS-specific
* separators and covering drives/prefixes on Windows runtimes
*/
export function isSafeAttachmentName(name: string): boolean {
if (typeof name !== 'string' || name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (/[\\/\0]/.test(name)) return false;
return path.basename(name) === name;
}
/** /**
* Copy file attachments from the source agent's outbox into the target * Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing * agent's inbox. Returns attachments using the formatter's existing

View File

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

View File

@@ -5,24 +5,32 @@
* addressed to the bot (SDK-confirmed mention or DM), it calls * addressed to the bot (SDK-confirmed mention or DM), it calls
* `requestChannelApproval` instead of silently dropping. The flow: * `requestChannelApproval` instead of silently dropping. The flow:
* *
* 1. Pick the target agent group we'd wire to (MVP: first by name). * 1. Gather all existing agent groups.
* Multi-agent picker is a follow-up — see ACTION-ITEMS.
* 2. Pick an eligible approver (owner / admin) and a reachable DM for * 2. Pick an eligible approver (owner / admin) and a reachable DM for
* them, reusing the same primitives the sender-approval flow uses. * them, reusing the same primitives the sender-approval flow uses.
* 3. Deliver an Approve / Ignore card that names the target agent * 3. Deliver a card with three action families:
* explicitly so the owner knows what they're wiring to. * a. Connect to [agent] — one button per existing agent group.
* Single-agent installs get a one-click connect.
* b. Connect new agent — prompts for a free-text name, creates
* the agent immediately on reply.
* c. Reject — deny the channel.
* 4. Record a `pending_channel_approvals` row holding the original event * 4. Record a `pending_channel_approvals` row holding the original event
* so it can be re-routed on approve. * so it can be re-routed on connect/create.
* *
* On approve (handler in index.ts): * On connect (handler in index.ts):
* - Create `messaging_group_agents` with MVP defaults * - Create `messaging_group_agents` with defaults
* (mention-sticky for groups / pattern='.' for DMs, * (mention-sticky for groups / pattern='.' for DMs,
* sender_scope='known', ignored_message_policy='accumulate') * sender_scope='known', ignored_message_policy='accumulate')
* - Add the triggering sender to `agent_group_members` so sender_scope * - Add the triggering sender to `agent_group_members` so sender_scope
* doesn't bounce the replayed message into a sender-approval cascade * doesn't bounce the replayed message into a sender-approval cascade
* - Delete the pending row, replay the original event * - Delete the pending row, replay the original event
* *
* On ignore: * On connect new agent (handler in index.ts):
* - Prompt for a free-text agent name via DM
* - On reply: create the agent group + filesystem, then wire
* and replay as above
*
* On reject:
* - Set `messaging_groups.denied_at = now()` so the router stops * - Set `messaging_groups.denied_at = now()` so the router stops
* escalating on this channel until an admin explicitly re-wires * escalating on this channel until an admin explicitly re-wires
* - Delete the pending row * - Delete the pending row
@@ -36,19 +44,81 @@
* - Approver has no reachable DM. * - Approver has no reachable DM.
* - Delivery adapter missing. * - Delivery adapter missing.
*/ */
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
import { getAllAgentGroups } from '../../db/agent-groups.js'; import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
import { getMessagingGroup } from '../../db/messaging-groups.js'; import { getChannelAdapter } from '../../channels/channel-registry.js';
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js'; import { getDeliveryAdapter } from '../../delivery.js';
import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { InboundEvent } from '../../channels/adapter.js'; import type { InboundEvent } from '../../channels/adapter.js';
import type { AgentGroup } from '../../types.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
const APPROVAL_OPTIONS: RawOption[] = [ // ── Value constants (response handler in index.ts parses these) ──
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, export const CONNECT_PREFIX = 'connect:';
]; export const NEW_AGENT_VALUE = 'new_agent';
export const CHOOSE_EXISTING_VALUE = 'choose_existing';
export const REJECT_VALUE = 'reject';
// ── Utilities ──
function toFolder(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unnamed'
);
}
// ── Card builders ──
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
const options: RawOption[] = [];
if (agentGroups.length === 1) {
options.push({
label: `Connect to ${agentGroups[0].name}`,
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
});
} else {
options.push({
label: 'Choose existing agent',
selectedLabel: '📋 Choosing…',
value: CHOOSE_EXISTING_VALUE,
});
}
options.push({
label: 'Connect new agent',
selectedLabel: '🆕 Connecting new agent…',
value: NEW_AGENT_VALUE,
});
options.push({
label: 'Reject',
selectedLabel: '🙅 Rejected',
value: REJECT_VALUE,
});
return options;
}
function buildQuestionText(
isGroup: boolean,
senderName: string | undefined,
channelName: string | null,
channelType: string,
): string {
const who = senderName ?? 'Someone';
if (isGroup) {
const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
}
return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
}
// ── Main flow ──
export interface RequestChannelApprovalInput { export interface RequestChannelApprovalInput {
messagingGroupId: string; messagingGroupId: string;
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> { export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
const { messagingGroupId, event } = input; const { messagingGroupId, event } = input;
// In-flight dedup: don't spam the owner if the same unwired channel
// gets more mentions / DMs while a card is already pending.
if (hasInFlightChannelApproval(messagingGroupId)) { if (hasInFlightChannelApproval(messagingGroupId)) {
log.debug('Channel registration already in flight — dropping retry', { log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
messagingGroupId,
});
return; return;
} }
// MVP: pick the first agent group by name. Multi-agent systems will get
// a richer card later (user picks the target from a list).
const agentGroups = getAllAgentGroups(); const agentGroups = getAllAgentGroups();
if (agentGroups.length === 0) { if (agentGroups.length === 0) {
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', { log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
}); });
return; return;
} }
const target = agentGroups[0]; // Use first agent group for approver resolution — owners and global admins
// are returned regardless of which group we pass.
const referenceGroup = agentGroups[0];
// pickApprover takes the target agent group's id — gets scoped admins + const approvers = pickApprover(referenceGroup.id);
// global admins + owners. For fresh installs with only an owner, the
// owner is returned.
const approvers = pickApprover(target.id);
if (approvers.length === 0) { if (approvers.length === 0) {
log.warn('Channel registration skipped — no owner or admin configured', { log.warn('Channel registration skipped — no owner or admin configured', {
messagingGroupId, messagingGroupId,
targetAgentGroupId: target.id, targetAgentGroupId: referenceGroup.id,
}); });
return; return;
} }
const originMg = getMessagingGroup(messagingGroupId); const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? ''; const originChannelType = originMg?.channel_type ?? '';
// Resolve channel name if not yet persisted.
if (originMg && !originMg.name) {
const channelAdapter = getChannelAdapter(originChannelType);
if (channelAdapter?.resolveChannelName) {
try {
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
if (name) {
updateMessagingGroup(originMg.id, { name });
originMg.name = name;
}
} catch {
/* non-critical */
}
}
}
const delivery = await pickApprovalDelivery(approvers, originChannelType); const delivery = await pickApprovalDelivery(approvers, originChannelType);
if (!delivery) { if (!delivery) {
log.warn('Channel registration skipped — no DM channel for any approver', { log.warn('Channel registration skipped — no DM channel for any approver', {
messagingGroupId, messagingGroupId,
targetAgentGroupId: target.id, targetAgentGroupId: referenceGroup.id,
}); });
return; return;
} }
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
// Extract sender name from the event content for a human-readable card.
let senderName: string | undefined; let senderName: string | undefined;
try { try {
const parsed = JSON.parse(event.message.content) as Record<string, unknown>; const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
senderName = (parsed.senderName ?? parsed.sender) as string | undefined; senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
} catch { } catch {
// non-critical — fall through to generic wording // non-critical
} }
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; const channelName = originMg?.name ?? null;
const question = isGroup const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
? senderName const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` const options = normalizeOptions(buildApprovalOptions(agentGroups));
: `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
: senderName
? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
: `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
const options = normalizeOptions(APPROVAL_OPTIONS);
createPendingChannelApproval({ createPendingChannelApproval({
messaging_group_id: messagingGroupId, messaging_group_id: messagingGroupId,
agent_group_id: target.id, agent_group_id: referenceGroup.id,
original_message: JSON.stringify(event), original_message: JSON.stringify(event),
approver_user_id: delivery.userId, approver_user_id: delivery.userId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const adapter = getDeliveryAdapter(); const adapter = getDeliveryAdapter();
if (!adapter) { if (!adapter) {
log.error('Channel registration row created but no delivery adapter is wired', { log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
messagingGroupId,
});
return; return;
} }
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
'chat-sdk', 'chat-sdk',
JSON.stringify({ JSON.stringify({
type: 'ask_question', type: 'ask_question',
// Use messaging_group_id as the questionId — it's unique per card
// (PK on pending table dedups) and lets the response handler look
// up the pending row directly without another index.
questionId: messagingGroupId, questionId: messagingGroupId,
title, title,
question, question,
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
); );
log.info('Channel registration card delivered', { log.info('Channel registration card delivered', {
messagingGroupId, messagingGroupId,
targetAgentGroupId: target.id, agentGroupCount: agentGroups.length,
approver: delivery.userId, approver: delivery.userId,
}); });
} catch (err) { } catch (err) {
log.error('Channel registration card delivery failed', { log.error('Channel registration card delivery failed', { messagingGroupId, err });
messagingGroupId,
err,
});
} }
} }
export const APPROVE_VALUE = 'approve'; // ── Helpers for the response handler (index.ts) ──
export const REJECT_VALUE = 'reject';
/**
* Build normalized options for the agent-selection follow-up card.
*/
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
const options: RawOption[] = agentGroups.map((ag) => ({
label: ag.name,
selectedLabel: `✅ Connected to ${ag.name}`,
value: `${CONNECT_PREFIX}${ag.id}`,
}));
options.push({
label: 'Cancel',
selectedLabel: '🙅 Cancelled',
value: REJECT_VALUE,
});
return normalizeOptions(options);
}
/**
* Create a new agent group and initialize its filesystem. Handles
* folder-name collisions with numeric suffixes.
*/
export function createNewAgentGroup(name: string): AgentGroup {
let folder = toFolder(name);
const baseFolder = folder;
let suffix = 2;
while (getAgentGroupByFolder(folder)) {
folder = `${baseFolder}-${suffix}`;
suffix++;
}
const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createAgentGroup({
id: agId,
name,
folder,
agent_provider: null,
created_at: new Date().toISOString(),
});
const ag = getAgentGroup(agId)!;
initGroupFilesystem(ag);
return ag;
}

View File

@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
return row !== undefined; return row !== undefined;
} }
export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void {
getDb()
.prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?')
.run(title, optionsJson, messagingGroupId);
}
export function deletePendingChannelApproval(messagingGroupId: string): void { export function deletePendingChannelApproval(messagingGroupId: string): void {
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId); getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
} }

View File

@@ -16,27 +16,53 @@
* access gate is not registered and core defaults to allow-all. * access gate is not registered and core defaults to allow-all.
*/ */
import { recordDroppedMessage } from '../../db/dropped-messages.js'; import { recordDroppedMessage } from '../../db/dropped-messages.js';
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import { import {
routeInbound, routeInbound,
setAccessGate, setAccessGate,
setChannelRequestGate, setChannelRequestGate,
setMessageInterceptor,
setSenderResolver, setSenderResolver,
setSenderScopeGate, setSenderScopeGate,
type AccessGateResult, type AccessGateResult,
} from '../../router.js'; } from '../../router.js';
import type { InboundEvent } from '../../channels/adapter.js'; import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js'; import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { canAccessAgentGroup } from './access.js'; import { canAccessAgentGroup } from './access.js';
import { requestChannelApproval } from './channel-approval.js'; import {
buildAgentSelectionOptions,
CHOOSE_EXISTING_VALUE,
CONNECT_PREFIX,
createNewAgentGroup,
NEW_AGENT_VALUE,
REJECT_VALUE,
requestChannelApproval,
} from './channel-approval.js';
import { addMember } from './db/agent-group-members.js'; import { addMember } from './db/agent-group-members.js';
import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js'; import {
deletePendingChannelApproval,
getPendingChannelApproval,
updatePendingChannelApprovalCard,
} from './db/pending-channel-approvals.js';
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js'; import { hasAdminPrivilege } from './db/user-roles.js';
import { getUser, upsertUser } from './db/users.js'; import { getUser, upsertUser } from './db/users.js';
import { requestSenderApproval } from './sender-approval.js'; import { requestSenderApproval } from './sender-approval.js';
import { ensureUserDm } from './user-dm.js';
// ── Free-text name input state ──
// Tracks approvers waiting for a text reply with the agent name. Keyed by
// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart.
interface PendingNameInput {
channelMgId: string;
dmChannelType: string;
dmPlatformId: string;
}
const awaitingNameInput = new Map<string, PendingNameInput>();
function extractAndUpsertUser(event: InboundEvent): string | null { function extractAndUpsertUser(event: InboundEvent): string | null {
let content: Record<string, unknown>; let content: Record<string, unknown>;
@@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => {
* by messaging_group_id). If no such row, return false so downstream * by messaging_group_id). If no such row, return false so downstream
* handlers get a shot. * handlers get a shot.
* *
* Approve: create the wiring with MVP defaults (mention-sticky for * Value dispatch:
* groups / pattern='.' for DMs; sender_scope='known'; * connect:<id> — wire to an existing agent group, replay the message
* ignored_message_policy='accumulate'), add the triggering sender as a * choose_existing — send a follow-up card listing all agents
* member so sender_scope doesn't immediately bounce them into a * new_agent — prompt for a free-text agent name (interceptor
* sender-approval card, then replay the original event. * captures the reply and creates immediately)
* * reject — set denied_at, delete pending row
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
* this channel drop silently until an admin explicitly wires it.
*/ */
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> { async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
const row = getPendingChannelApproval(payload.questionId); const row = getPendingChannelApproval(payload.questionId);
if (!row) return false; if (!row) return false;
// Click-auth: same pattern as sender-approval (see commit 68058cb).
// Raw platform userId → namespace with channelType → must match the
// designated approver OR have admin privilege over the target agent.
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null; const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
const isAuthorized = const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
clickerId, clickerId,
expectedApprover: row.approver_user_id, expectedApprover: row.approver_user_id,
}); });
return true; // claim but take no action return true;
} }
const approverId = clickerId; const approverId = clickerId;
const approved = payload.value === 'approve';
if (!approved) { // ── Reject / Cancel ──
if (payload.value === REJECT_VALUE) {
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString()); setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
deletePendingChannelApproval(row.messaging_group_id); deletePendingChannelApproval(row.messaging_group_id);
log.info('Channel registration denied', { log.info('Channel registration denied', {
messagingGroupId: row.messaging_group_id, messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id,
approverId, approverId,
}); });
return true; return true;
} }
// Rehydrate the original event to know (a) whether it was a DM or group // ── Choose existing agent — send agent-selection follow-up card ──
// (chooses engage_mode default), and (b) who the triggering sender was if (payload.value === CHOOSE_EXISTING_VALUE) {
// (auto-member-add so sender_scope='known' doesn't bounce the replay). const approverDm = await ensureUserDm(row.approver_user_id);
if (!approverDm) {
log.error('Channel registration: no DM channel for approver', {
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
}
const adapter = getDeliveryAdapter();
if (!adapter) return true;
const agentGroups = getAllAgentGroups();
const options = buildAgentSelectionOptions(agentGroups);
const title = '📋 Choose an agent';
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
try {
await adapter.deliver(
approverDm.channel_type,
approverDm.platform_id,
null,
'chat-sdk',
JSON.stringify({
type: 'ask_question',
questionId: row.messaging_group_id,
title,
question: 'Which agent should handle this channel?',
options,
}),
);
} catch (err) {
log.error('Channel registration: agent-selection card delivery failed', {
messagingGroupId: row.messaging_group_id,
err,
});
}
return true;
}
// ── Create new agent — prompt for free-text name ──
if (payload.value === NEW_AGENT_VALUE) {
const approverDm = await ensureUserDm(row.approver_user_id);
if (!approverDm) {
log.error('Channel registration: no DM channel for approver', {
messagingGroupId: row.messaging_group_id,
approverUserId: row.approver_user_id,
});
return true;
}
const adapter = getDeliveryAdapter();
if (!adapter) {
log.error('Channel registration: no delivery adapter for name prompt', {
messagingGroupId: row.messaging_group_id,
});
return true;
}
awaitingNameInput.set(row.approver_user_id, {
channelMgId: row.messaging_group_id,
dmChannelType: approverDm.channel_type,
dmPlatformId: approverDm.platform_id,
});
try {
await adapter.deliver(
approverDm.channel_type,
approverDm.platform_id,
null,
'chat-sdk',
JSON.stringify({ text: 'Reply with the name for your new agent:' }),
);
} catch (err) {
log.error('Channel registration: name prompt delivery failed', {
messagingGroupId: row.messaging_group_id,
err,
});
awaitingNameInput.delete(row.approver_user_id);
}
return true;
}
// ── Resolve target agent group (connect to existing or create new) ──
let targetAgentGroupId: string;
if (payload.value.startsWith(CONNECT_PREFIX)) {
targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length);
const ag = getAgentGroup(targetAgentGroupId);
if (!ag) {
log.error('Channel registration: target agent group no longer exists', {
messagingGroupId: row.messaging_group_id,
targetAgentGroupId,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
} else {
log.warn('Channel registration: unknown response value', {
messagingGroupId: row.messaging_group_id,
value: payload.value,
});
return true;
}
// ── Wire + replay (shared path for connect and create) ──
let event: InboundEvent; let event: InboundEvent;
try { try {
event = JSON.parse(row.original_message) as InboundEvent; event = JSON.parse(row.original_message) as InboundEvent;
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
return true; return true;
} }
// Decide engage_mode from the original event. DMs (`isMention=true` &
// not in a group) get `pattern='.'` (always respond). Group mentions
// get `mention-sticky` (respond now + follow the thread).
//
// We can't read `mg.is_group` reliably here because we only auto-create
// the mg with `is_group=0` on first sight — the adapter hasn't told us
// yet whether it's actually a group. Fall back to the InboundEvent's
// `threadId`: a non-null threadId implies a threaded platform (Slack
// channel thread, Discord thread), which we treat as a group.
const isGroup = event.threadId !== null; const isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.'; const engagePattern = isGroup ? null : '.';
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
createMessagingGroupAgent({ createMessagingGroupAgent({
id: mgaId, id: mgaId,
messaging_group_id: row.messaging_group_id, messaging_group_id: row.messaging_group_id,
agent_group_id: row.agent_group_id, agent_group_id: targetAgentGroupId,
engage_mode: engageMode, engage_mode: engageMode,
engage_pattern: engagePattern, engage_pattern: engagePattern,
sender_scope: 'known', sender_scope: 'known',
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
}); });
log.info('Channel registration approved — wiring created', { log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id, messagingGroupId: row.messaging_group_id,
agentGroupId: row.agent_group_id, agentGroupId: targetAgentGroupId,
mgaId, mgaId,
engageMode, engageMode,
approverId, approverId,
}); });
// Auto-admit the triggering sender. Without this, the replay below
// would bounce through sender-approval (sender_scope='known' +
// sender-is-not-a-member).
const senderUserId = extractAndUpsertUser(event); const senderUserId = extractAndUpsertUser(event);
if (senderUserId) { if (senderUserId) {
addMember({ addMember({
user_id: senderUserId, user_id: senderUserId,
agent_group_id: row.agent_group_id, agent_group_id: targetAgentGroupId,
added_by: approverId, added_by: approverId,
added_at: new Date().toISOString(), added_at: new Date().toISOString(),
}); });
} }
// Clear the pending row BEFORE replay so the gate check on the second
// attempt sees a wired channel (agentCount > 0) and takes the fan-out
// path normally.
deletePendingChannelApproval(row.messaging_group_id); deletePendingChannelApproval(row.messaging_group_id);
try { try {
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
} }
registerResponseHandler(handleChannelApprovalResponse); registerResponseHandler(handleChannelApprovalResponse);
// ── Free-text name interceptor ──
// Captures the next DM from an approver who clicked "Create new agent",
// creates the agent immediately, wires the channel, and replays.
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
const userId = extractAndUpsertUser(event);
if (!userId) return false;
const pending = awaitingNameInput.get(userId);
if (!pending) return false;
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
awaitingNameInput.delete(userId);
let text: string | undefined;
try {
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim();
} catch {
/* fall through */
}
if (!text) {
log.warn('Channel registration: empty name reply, ignoring', { userId });
return true;
}
const row = getPendingChannelApproval(pending.channelMgId);
if (!row) return true;
const ag = createNewAgentGroup(text);
log.info('Channel registration: new agent group created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: ag.id,
agentName: ag.name,
folder: ag.folder,
});
let originalEvent: InboundEvent;
try {
originalEvent = JSON.parse(row.original_message) as InboundEvent;
} catch (err) {
log.error('Channel registration: failed to parse stored event', {
messagingGroupId: row.messaging_group_id,
err,
});
deletePendingChannelApproval(row.messaging_group_id);
return true;
}
const isGroup = originalEvent.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
agent_group_id: ag.id,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
ignored_message_policy: 'accumulate',
session_mode: 'shared',
priority: 0,
created_at: new Date().toISOString(),
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
agentGroupId: ag.id,
mgaId,
engageMode,
approverId: userId,
});
const senderUserId = extractAndUpsertUser(originalEvent);
if (senderUserId) {
addMember({
user_id: senderUserId,
agent_group_id: ag.id,
added_by: userId,
added_at: new Date().toISOString(),
});
}
deletePendingChannelApproval(row.messaging_group_id);
try {
await routeInbound(originalEvent);
} catch (err) {
log.error('Failed to replay message after channel approval', {
messagingGroupId: row.messaging_group_id,
err,
});
}
const adapter = getDeliveryAdapter();
if (adapter) {
const dm = await ensureUserDm(row.approver_user_id);
if (dm) {
adapter
.deliver(
dm.channel_type,
dm.platform_id,
null,
'chat-sdk',
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
)
.catch(() => {});
}
}
return true;
});

View File

@@ -27,7 +27,7 @@ import {
getMessagingGroupWithAgentCount, getMessagingGroupWithAgentCount,
} from './db/messaging-groups.js'; } from './db/messaging-groups.js';
import { findSessionForAgent } from './db/sessions.js'; import { findSessionForAgent } from './db/sessions.js';
import { startTypingRefresh } from './modules/typing/index.js'; import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js';
import { log } from './log.js'; import { log } from './log.js';
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
import { wakeContainer } from './container-runner.js'; import { wakeContainer } from './container-runner.js';
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
senderScopeGate = fn; senderScopeGate = fn;
} }
/**
* Message-interceptor hook. Runs at the very top of routeInbound, before
* messaging-group resolution. When the interceptor returns true the message
* is consumed and routing stops. Used by the permissions module to capture
* free-text replies during multi-step approval flows (e.g. agent naming).
*/
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
let messageInterceptor: MessageInterceptorFn | null = null;
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
messageInterceptor = fn;
}
/** /**
* Channel-registration hook. Runs when the router sees a mention/DM on a * Channel-registration hook. Runs when the router sees a mention/DM on a
* messaging group that has no wirings AND hasn't been denied. The hook is * messaging group that has no wirings AND hasn't been denied. The hook is
@@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
* Creates messaging group + session if they don't exist yet. * Creates messaging group + session if they don't exist yet.
*/ */
export async function routeInbound(event: InboundEvent): Promise<void> { export async function routeInbound(event: InboundEvent): Promise<void> {
// Pre-route interceptor — lets modules consume messages before any routing
// (e.g. free-text replies during multi-step approval flows).
if (messageInterceptor && (await messageInterceptor(event))) return;
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram, // 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
// WhatsApp, iMessage, email) collapse threads to the channel. // WhatsApp, iMessage, email) collapse threads to the channel.
const adapter = getChannelAdapter(event.channelType); const adapter = getChannelAdapter(event.channelType);
@@ -289,7 +307,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err }); log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err });
}); });
} }
} else if (agent.ignored_message_policy === 'accumulate') { } else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) {
// Accumulate stores the message as silent context. We allow it when
// engagement simply didn't fire, but NOT when engagement fired and
// the access/scope gate refused — those refusals are security
// decisions about an untrusted sender, and silently storing their
// message (which also stages their attachments to disk via
// writeSessionMessage → extractAttachmentFiles) is exactly what the
// gate is meant to prevent.
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false); await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
accumulatedCount++; accumulatedCount++;
} else { } else {
@@ -450,7 +475,11 @@ async function deliverToAgent(
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
const freshSession = getSession(session.id); const freshSession = getSession(session.id);
if (freshSession) { if (freshSession) {
await wakeContainer(freshSession); const woke = await wakeContainer(freshSession);
// wakeContainer never throws — it returns false on transient spawn
// failure (host-sweep retries). Stop the typing indicator we just
// started so it doesn't leak; the inbound row stays pending.
if (!woke) stopTypingRefresh(freshSession.id);
} }
} }
} }

View File

@@ -14,6 +14,8 @@ import type Database from 'better-sqlite3';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { deriveAttachmentName } from './attachment-naming.js';
import { isSafeAttachmentName } from './attachment-safety.js';
import type { OutboundFile } from './channels/adapter.js'; import type { OutboundFile } from './channels/adapter.js';
import { DATA_DIR } from './config.js'; import { DATA_DIR } from './config.js';
import { getMessagingGroup } from './db/messaging-groups.js'; import { getMessagingGroup } from './db/messaging-groups.js';
@@ -252,11 +254,26 @@ function extractAttachmentFiles(
let changed = false; let changed = false;
for (const att of attachments) { for (const att of attachments) {
if (typeof att.data === 'string') { if (typeof att.data === 'string') {
// The name field is attacker-controlled: chat platforms with E2E
// attachment encryption (WhatsApp, Matrix) cannot sanitize filename
// server-side, and other adapters pass att.name through raw. Without
// this guard, `path.join(inboxDir, '../../...')` writes anywhere the
// host process has fs permission — see Signal Desktop's Nov 2025
// attachment-fileName advisory for the same archetype.
const rawName = deriveAttachmentName(att);
const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
if (filename !== rawName) {
log.warn('Refused unsafe attachment filename — would escape inbox', {
messageId,
rawName,
replacement: filename,
});
}
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
fs.mkdirSync(inboxDir, { recursive: true }); fs.mkdirSync(inboxDir, { recursive: true });
const filename = (att.name as string) || `attachment-${Date.now()}`;
const filePath = path.join(inboxDir, filename); const filePath = path.join(inboxDir, filename);
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'));
att.name = filename;
att.localPath = `inbox/${messageId}/${filename}`; att.localPath = `inbox/${messageId}/${filename}`;
delete att.data; delete att.data;
changed = true; changed = true;
@@ -356,6 +373,11 @@ export function readOutboxFiles(
if (!fs.existsSync(outboxDir)) return undefined; if (!fs.existsSync(outboxDir)) return undefined;
const files: OutboundFile[] = []; const files: OutboundFile[] = [];
for (const filename of filenames) { for (const filename of filenames) {
// Reject any name that isn't a bare basename before touching the filesystem.
if (!isSafeAttachmentName(filename)) {
log.warn('Refused unsafe outbox filename — would escape outbox', { messageId, filename });
continue;
}
const filePath = path.join(outboxDir, filename); const filePath = path.join(outboxDir, filename);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
files.push({ filename, data: fs.readFileSync(filePath) }); files.push({ filename, data: fs.readFileSync(filePath) });