mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge branch 'main' into feat/migrate-from-v1
Resolve import conflict in setup/auto.ts — keep runMigrateV1 import, deduplicate runWindowedStep and getLaunchdLabel/getSystemdUnit imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ pnpm run build
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
|
||||
2. Name it (e.g., "NanoClaw") and select your workspace
|
||||
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
|
||||
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
@@ -76,7 +76,13 @@ pnpm run build
|
||||
10. Under **Subscribe to bot events**, add:
|
||||
- `message.channels`, `message.groups`, `message.im`, `app_mention`
|
||||
11. Click **Save Changes**
|
||||
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
|
||||
|
||||
### Interactivity
|
||||
|
||||
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
|
||||
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
|
||||
14. Click **Save Changes**
|
||||
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
|
||||
|
||||
### Configure environment
|
||||
|
||||
|
||||
@@ -158,6 +158,17 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
|
||||
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
|
||||
|
||||
## PR Hygiene
|
||||
|
||||
Before creating a PR, run these checks:
|
||||
|
||||
```bash
|
||||
git diff upstream/main --stat HEAD
|
||||
git log upstream/main..HEAD --oneline
|
||||
```
|
||||
|
||||
Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included.
|
||||
|
||||
## Development
|
||||
|
||||
Run commands directly — don't tell the user to run them.
|
||||
@@ -187,7 +198,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
|
||||
systemctl --user start|stop|restart nanoclaw
|
||||
```
|
||||
|
||||
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
|
||||
## Troubleshooting
|
||||
|
||||
Check these first when something goes wrong:
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
|
||||
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
|
||||
| Session DBs | `data/v2-sessions/<agent-group>/<session>/` — `inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) |
|
||||
|
||||
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
|
||||
|
||||
## Supply Chain Security (pnpm)
|
||||
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
|
||||
|
||||
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
|
||||
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
|
||||
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
|
||||
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
|
||||
| Checkbox | Label |
|
||||
|----------|-------|
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [2m[38;2;43;183;206m°[39m[22m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧[39m
|
||||
[2m[38;2;43;183;206mo[39m[22m [38;2;43;183;206m⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿[39m
|
||||
[38;2;43;183;206m⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇[39m
|
||||
[38;2;43;183;206m⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀[39m [2m[38;2;43;183;206mo[39m[22m
|
||||
[38;2;43;183;206m⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀[39m
|
||||
[2m[38;2;43;183;206m°[39m[22m [38;2;43;183;206m⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [38;2;43;183;206mO[39m
|
||||
[38;2;43;183;206m⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206mo[39m [38;2;43;183;206m⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀[39m
|
||||
|
||||
[1m _ _ [22m[38;2;43;183;206m[1m ___ _ [22m[39m
|
||||
[1m| \| |__ _ _ _ ___ [22m[38;2;43;183;206m[1m / __| |__ ___ __ __[22m[39m
|
||||
[1m| .` / _` | ' \/ _ \[22m[38;2;43;183;206m[1m| (__| / _` \ V V /[22m[39m
|
||||
[1m|_|\_\__,_|_||_\___/[22m[38;2;43;183;206m[1m \___|_\__,_|\_/\_/ [22m[39m
|
||||
|
||||
[2mSmall.[22m
|
||||
[2mRuns on your machine.[22m
|
||||
[2mYours to modify.[22m
|
||||
|
||||
[38;2;5;62;165m════════════════════════════════════════[39m
|
||||
@@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
|
||||
@@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = {
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -260,31 +260,69 @@ async function processQuery(
|
||||
// Stream liveness is decided host-side via the heartbeat file + processing
|
||||
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
|
||||
// will kill the container and messages get reset to pending.
|
||||
let pollInFlight = false;
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done) return;
|
||||
if (done || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length > 0) {
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(newIds);
|
||||
void (async () => {
|
||||
try {
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length === 0) return;
|
||||
|
||||
const prompt = formatMessages(newMessages);
|
||||
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(newIds);
|
||||
|
||||
markCompleted(newIds);
|
||||
}
|
||||
// Run pre-task scripts on follow-ups too — without this, a task that
|
||||
// arrives during an active query (e.g. a */10 monitoring cron) bypasses
|
||||
// its script gate and always wakes the agent, defeating the gate.
|
||||
// Mirrors the initial-batch hook above.
|
||||
let keep = newMessages;
|
||||
let skipped: string[] = [];
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(newMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:end
|
||||
|
||||
if (keep.length === 0) return;
|
||||
// Re-check done — the outer query may have finished while the script
|
||||
// was awaited. Pushing into a closed stream is wasted work; the
|
||||
// claimed messages get released by the host's processing-claim sweep.
|
||||
if (done) return;
|
||||
|
||||
const keptIds = keep.map((m) => m.id);
|
||||
const prompt = formatMessages(keep);
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
// Without this catch the rejection escapes the void IIFE and Node
|
||||
// terminates the container on unhandled-rejection. The initial-batch
|
||||
// path is wrapped by processQuery's outer try/catch; the follow-up
|
||||
// path is not, so it needs its own.
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Follow-up poll error: ${errMsg}`);
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
})();
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
|
||||
@@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
/**
|
||||
* Claude Code auto-compacts context at this window (tokens). Kept here so
|
||||
* the generic bootstrap doesn't need to know about Claude-specific env vars.
|
||||
*
|
||||
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
|
||||
* raise or lower the threshold without editing source — useful when running
|
||||
* with a 1M-context model variant or when emergency-tuning a deployment.
|
||||
*/
|
||||
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
|
||||
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
|
||||
|
||||
/**
|
||||
* Stale-session detection. Matches Claude Code's error text when a
|
||||
|
||||
+43
-9
@@ -129,10 +129,46 @@ rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
||||
# and skip printing these again, so the flow stays visually continuous.
|
||||
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
||||
# NanoClaw splash — under-the-sea lobster mascot in truecolor braille,
|
||||
# with the figlet wordmark and taglines below. Pre-rendered into
|
||||
# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa +
|
||||
# figlet); the bash script just streams the literal frame. clack's intro
|
||||
# then carries the "let's get you set up" framing — setup:auto sees
|
||||
# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark.
|
||||
cat "$PROJECT_ROOT/assets/setup-splash.txt"
|
||||
|
||||
# ─── pre-flight: root user warning (Linux) ────────────────────────────
|
||||
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
|
||||
printf ' %s\n' \
|
||||
"$(red 'Warning: you are running as root.')"
|
||||
printf ' %s\n' \
|
||||
"$(dim "Running NanoClaw as root is not recommended. It can cause permission")"
|
||||
printf ' %s\n\n' \
|
||||
"$(dim "issues with containers, services, and file ownership.")"
|
||||
printf ' %s\n' "$(bold '1)') $(dim 'Show me instructions for creating a new Linux user')"
|
||||
printf ' %s\n\n' "$(bold '2)') $(dim 'Continue setting up NanoClaw as root user (not recommended)')"
|
||||
read -r -p " $(bold 'Choose [1/2]: ')" ROOT_ANS </dev/tty
|
||||
|
||||
case "${ROOT_ANS:-1}" in
|
||||
2)
|
||||
ph_event setup_root_continued
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_root_aborted
|
||||
printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')"
|
||||
printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')"
|
||||
printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')"
|
||||
printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')"
|
||||
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
|
||||
printf ' %s\n' "$(dim '4. Log out: exit')"
|
||||
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
|
||||
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
@@ -188,9 +224,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
||||
BOOTSTRAP_LABEL="Installing the basics"
|
||||
BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
@@ -222,7 +255,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
|
||||
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||
|
||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
||||
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
|
||||
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
@@ -259,4 +292,5 @@ fi
|
||||
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
||||
# preamble so the flow continues visually from "Basics installed" straight
|
||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||
exec pnpm --silent run setup:auto
|
||||
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
|
||||
exec pnpm --silent run setup:auto -- "$@"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.13",
|
||||
"version": "2.0.23",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="132k tokens, 66% of context window">
|
||||
<title>132k tokens, 66% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="139k tokens, 69% of context window">
|
||||
<title>139k tokens, 69% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">132k</text>
|
||||
<text x="71" y="14">132k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">139k</text>
|
||||
<text x="71" y="14">139k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -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}).`);
|
||||
@@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
|
||||
interface Args {
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let folder: string | undefined;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const val = argv[i + 1];
|
||||
@@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args {
|
||||
} else if (key === '--agent-name') {
|
||||
agentName = val;
|
||||
i++;
|
||||
} else if (key === '--folder') {
|
||||
folder = val;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args {
|
||||
return {
|
||||
displayName,
|
||||
agentName: agentName?.trim() || displayName,
|
||||
folder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ async function main(): Promise<void> {
|
||||
const promotedToOwner = false;
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = `cli-with-${normalizeName(args.displayName)}`;
|
||||
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
+387
-165
@@ -24,6 +24,9 @@
|
||||
* headless `claude -p` call for IANA-zone resolution.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
@@ -38,37 +41,81 @@ import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { runMigrateV1 } from './migrate-v1.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
resolveTimezoneViaClaude,
|
||||
} from './lib/tz-from-claude.js';
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
printHelp,
|
||||
readFromEnv,
|
||||
} from './lib/setup-config-parse.js';
|
||||
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js';
|
||||
import { pollHealth } from './onecli.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
type ChannelChoice =
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
| 'whatsapp'
|
||||
| 'signal'
|
||||
| 'teams'
|
||||
| 'slack'
|
||||
| 'imessage'
|
||||
| 'skip';
|
||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Make sure ~/.local/bin is on PATH for every child process we spawn.
|
||||
// Installers we run mid-setup (OneCLI, claude) drop binaries there and
|
||||
// append a PATH line to the user's shell rc, but rc updates don't reach
|
||||
// an already-running Node process — so without this patch a freshly
|
||||
// installed `onecli` is invisible to a subsequent `runInheritScript`.
|
||||
ensureLocalBinOnPath();
|
||||
|
||||
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||||
// and flag values get folded into process.env so existing step code reading
|
||||
// NANOCLAW_* sees them unchanged.
|
||||
const flagResult = parseFlags(process.argv.slice(2));
|
||||
if (flagResult.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
if (flagResult.errors.length > 0) {
|
||||
for (const err of flagResult.errors) console.error(`error: ${err}`);
|
||||
console.error('');
|
||||
console.error('Run with --help for the full list of supported flags.');
|
||||
process.exit(1);
|
||||
}
|
||||
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||||
applyToEnv(configValues);
|
||||
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
phEmit('auto_started');
|
||||
|
||||
// Welcome menu — default path or open advanced overrides before any setup
|
||||
// work begins. Default lands on standard so Enter is the happy path.
|
||||
// On sg re-exec, the user already chose — skip straight to standard.
|
||||
let startChoice: 'default' | 'advanced' = 'default';
|
||||
if (process.env.NANOCLAW_REEXEC_SG !== '1') {
|
||||
startChoice = ensureAnswer(
|
||||
await brightSelect<'default' | 'advanced'>({
|
||||
message: 'How would you like to begin?',
|
||||
options: [
|
||||
{ value: 'default', label: 'Standard setup' },
|
||||
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
|
||||
],
|
||||
initialValue: 'default',
|
||||
}),
|
||||
) as 'default' | 'advanced';
|
||||
setupLog.userInput('start_choice', startChoice);
|
||||
}
|
||||
if (startChoice === 'advanced') {
|
||||
configValues = await runAdvancedScreen(configValues);
|
||||
applyToEnv(configValues);
|
||||
}
|
||||
|
||||
const skip = new Set(
|
||||
(process.env.NANOCLAW_SKIP ?? '')
|
||||
.split(',')
|
||||
@@ -91,16 +138,13 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const res = await runWindowedStep('container', {
|
||||
@@ -135,63 +179,103 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
if (remoteHost) {
|
||||
// Advanced-settings override: user has already named a remote vault,
|
||||
// so skip the local-vs-fresh prompt entirely. Health-check it here
|
||||
// rather than letting the step fail silently — a typo in the URL is a
|
||||
// common mistake and the answer is human-fixable.
|
||||
const s = p.spinner();
|
||||
s.start(`Checking remote OneCLI at ${remoteHost}…`);
|
||||
const healthy = await pollHealth(remoteHost, 5000);
|
||||
if (!healthy) {
|
||||
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
`Couldn't reach OneCLI at ${remoteHost}.`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
s.stop('Remote OneCLI is reachable.');
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
{
|
||||
running: `Connecting to remote OneCLI at ${remoteHost}…`,
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
['--remote-url', remoteHost],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Respect an existing OneCLI install. Re-running the installer would
|
||||
// rebind the listener and knock any other app using that gateway
|
||||
// offline — confirm with the user before doing that.
|
||||
const existing = detectExistingOnecli();
|
||||
let reuse = false;
|
||||
if (existing) {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||
options: [
|
||||
{
|
||||
value: 'reuse',
|
||||
label: 'Use the existing instance',
|
||||
hint: 'recommended — keeps other apps bound to this vault working',
|
||||
},
|
||||
{
|
||||
value: 'fresh',
|
||||
label: 'Install a fresh instance for NanoClaw',
|
||||
hint: 'reinstalls onecli; other apps may need to reconnect',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('onecli_choice', choice);
|
||||
reuse = choice === 'reuse';
|
||||
}
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
{
|
||||
running: reuse
|
||||
? 'Hooking up to your existing OneCLI…'
|
||||
: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
reuse ? ['--reuse'] : [],
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,39 +304,42 @@ async function main(): Promise<void> {
|
||||
done: 'NanoClaw is running.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'service',
|
||||
"Couldn't start NanoClaw.",
|
||||
'See logs/nanoclaw.error.log for details.',
|
||||
);
|
||||
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn(
|
||||
"NanoClaw's permissions need a tweak before it can reach Docker.",
|
||||
);
|
||||
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
|
||||
p.log.message(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||
` systemctl --user restart ${getSystemdUnit()}`,
|
||||
brandBody(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let displayName: string | undefined;
|
||||
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
||||
if (needsDisplayName) {
|
||||
const fallback = process.env.USER?.trim() || 'Operator';
|
||||
async function resolveDisplayName(): Promise<string> {
|
||||
if (displayName) return displayName;
|
||||
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
||||
displayName = preset || (await askDisplayName(fallback));
|
||||
const existing = detectExistingDisplayName(process.cwd());
|
||||
const fallback = process.env.USER?.trim() || 'Operator';
|
||||
displayName = preset || existing || (await askDisplayName(fallback));
|
||||
return displayName;
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
|
||||
skip.add('cli-agent');
|
||||
skip.add('first-chat');
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent')) {
|
||||
await resolveDisplayName();
|
||||
const res = await runQuietStep(
|
||||
'cli-agent',
|
||||
{
|
||||
running: 'Bringing your assistant online…',
|
||||
done: 'Assistant wired up.',
|
||||
},
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'],
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
@@ -263,16 +350,39 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent');
|
||||
const cleanupStart = Date.now();
|
||||
const cleanup = await spawnQuiet(
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'],
|
||||
cleanupRawLog,
|
||||
);
|
||||
setupLog.step(
|
||||
'cleanup-cli-agent',
|
||||
cleanup.ok ? 'success' : 'failed',
|
||||
Date.now() - cleanupStart,
|
||||
{ exit_code: cleanup.exitCode },
|
||||
cleanupRawLog,
|
||||
);
|
||||
if (!cleanup.ok) {
|
||||
p.log.warn(
|
||||
brandBody(
|
||||
`Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const next = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect<'continue' | 'chat'>({
|
||||
message: 'What next?',
|
||||
options: [
|
||||
{
|
||||
@@ -288,7 +398,23 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
) as 'continue' | 'chat';
|
||||
setupLog.userInput('first_chat_choice', next);
|
||||
if (next === 'chat') await runFirstChat();
|
||||
if (next === 'chat') {
|
||||
const terminalAgentName = `${displayName!}'s Terminal`;
|
||||
const createRes = await runQuietChild(
|
||||
'create-terminal-agent',
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
|
||||
{ running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` },
|
||||
);
|
||||
if (!createRes.ok) {
|
||||
await fail(
|
||||
'create-terminal-agent',
|
||||
`Couldn't create ${terminalAgentName}.`,
|
||||
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
|
||||
);
|
||||
}
|
||||
await runFirstChat();
|
||||
}
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
@@ -297,7 +423,7 @@ async function main(): Promise<void> {
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
? "NanoClaw service isn't listening on its CLI socket."
|
||||
: "No reply from the assistant within 30 seconds.",
|
||||
: 'No reply from the assistant within 30 seconds.',
|
||||
hint:
|
||||
ping === 'socket_error'
|
||||
? 'Socket at data/cli.sock did not accept a connection.'
|
||||
@@ -323,6 +449,9 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice !== 'skip') {
|
||||
await resolveDisplayName();
|
||||
}
|
||||
if (channelChoice === 'telegram') {
|
||||
await runTelegramChannel(displayName!);
|
||||
} else if (channelChoice === 'discord') {
|
||||
@@ -339,9 +468,11 @@ async function main(): Promise<void> {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -356,7 +487,7 @@ async function main(): Promise<void> {
|
||||
if (!res.ok) {
|
||||
const notes: string[] = [];
|
||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||
notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
|
||||
}
|
||||
const service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
@@ -382,10 +513,12 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
|
||||
notes.push(
|
||||
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
|
||||
);
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
note(notes.join('\n'), "What's left");
|
||||
}
|
||||
// "What's left" is a soft failure — we don't abort like fail(), but the
|
||||
// user is still stuck and a fix is exactly what claude-assist is for.
|
||||
@@ -416,14 +549,12 @@ async function main(): Promise<void> {
|
||||
['Open Claude Code:', 'claude'],
|
||||
];
|
||||
const labelWidth = Math.max(...rows.map(([l]) => l.length));
|
||||
const nextSteps = rows
|
||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||
.join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
|
||||
note(nextSteps, 'Try these');
|
||||
|
||||
// Always-on warning goes before the "check your DMs" directive so the
|
||||
// caveat doesn't land after the user's already looked away at their phone.
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
|
||||
6,
|
||||
@@ -440,10 +571,7 @@ async function main(): Promise<void> {
|
||||
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||
// renders with a visible box, cyan-bold the directive line, and put it
|
||||
// as the last thing before outro.
|
||||
p.note(
|
||||
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
|
||||
'Go say hi',
|
||||
);
|
||||
note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
|
||||
p.outro(k.green("You're set."));
|
||||
} else {
|
||||
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
|
||||
@@ -465,10 +593,7 @@ function channelDmLabel(choice: ChannelChoice): string | null {
|
||||
case 'imessage':
|
||||
return 'iMessage';
|
||||
case 'slack':
|
||||
// Slack install doesn't wire an agent or send a welcome DM — the
|
||||
// driver prints its own "finish in your Slack app" note. Falling
|
||||
// through to null avoids a misleading "check your Slack DMs" banner.
|
||||
return null;
|
||||
return 'Slack DMs';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -487,25 +612,21 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(fitToWidth(label, ' (999s)'));
|
||||
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
|
||||
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
|
||||
}
|
||||
return result;
|
||||
@@ -527,7 +648,7 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
6,
|
||||
);
|
||||
p.note(body, 'Skipping the first chat');
|
||||
note(body, 'Skipping the first chat');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,7 +663,7 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
* clearly optional.
|
||||
*/
|
||||
async function runFirstChat(): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
'Your assistant runs in a sandbox on this machine.',
|
||||
@@ -561,9 +682,7 @@ async function runFirstChat(): Promise<void> {
|
||||
message: first
|
||||
? 'Try a quick hello — or press Enter to continue setup'
|
||||
: 'Another message? Press Enter to continue setup',
|
||||
placeholder: first
|
||||
? 'e.g. "hi, what can you do?"'
|
||||
: 'press Enter to continue',
|
||||
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
|
||||
}),
|
||||
);
|
||||
first = false;
|
||||
@@ -579,11 +698,9 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||||
// with spaces on the far side.
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['--silent', 'run', 'chat', ...message.split(/\s+/)],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
@@ -593,11 +710,21 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success('Your Claude account is already connected.');
|
||||
p.log.success(brandBody('Your Claude account is already connected.'));
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
|
||||
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
|
||||
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
|
||||
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
|
||||
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
|
||||
if (customBaseUrl && customAuthToken) {
|
||||
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'How would you like to connect to Claude?',
|
||||
@@ -631,15 +758,11 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step("Opening the Claude sign-in flow…");
|
||||
console.log(
|
||||
k.dim(' (a browser will open for sign-in; this part is interactive)'),
|
||||
);
|
||||
p.log.step(brandBody('Opening the Claude sign-in flow…'));
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
const code = await runInheritScript('bash', [
|
||||
'setup/register-claude-token.sh',
|
||||
]);
|
||||
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
|
||||
const durationMs = Date.now() - start;
|
||||
console.log();
|
||||
if (code !== 0) {
|
||||
@@ -654,7 +777,7 @@ async function runSubscriptionAuth(): Promise<void> {
|
||||
);
|
||||
}
|
||||
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
|
||||
p.log.success('Claude account connected.');
|
||||
p.log.success(brandBody('Claude account connected.'));
|
||||
}
|
||||
|
||||
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
@@ -664,6 +787,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: `Paste your ${label}`,
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return 'Required';
|
||||
if (!v.trim().startsWith(prefix)) {
|
||||
@@ -679,11 +803,16 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets', 'create',
|
||||
'--name', 'Anthropic',
|
||||
'--type', 'anthropic',
|
||||
'--value', token,
|
||||
'--host-pattern', 'api.anthropic.com',
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'anthropic',
|
||||
'--value',
|
||||
token,
|
||||
'--host-pattern',
|
||||
'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
running: `Saving your ${label} to your OneCLI vault…`,
|
||||
@@ -702,6 +831,92 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Anthropic auth for a custom endpoint. The token is stored as a
|
||||
* OneCLI generic secret with header injection so the proxy rewrites the
|
||||
* Authorization header on the wire — the container only ever sees
|
||||
* ANTHROPIC_BASE_URL + a placeholder bearer.
|
||||
*/
|
||||
async function runCustomEndpointAuth(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
await fail(
|
||||
'auth',
|
||||
`Invalid Anthropic base URL: ${baseUrl}`,
|
||||
'Check --anthropic-base-url and retry.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await runQuietChild(
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'generic',
|
||||
'--value',
|
||||
token,
|
||||
'--host-pattern',
|
||||
host,
|
||||
'--header-name',
|
||||
'Authorization',
|
||||
'--value-format',
|
||||
'Bearer {value}',
|
||||
],
|
||||
{
|
||||
running: `Saving your Anthropic auth token to your OneCLI vault…`,
|
||||
done: 'Claude account connected.',
|
||||
},
|
||||
{ extraFields: { METHOD: 'custom-endpoint', HOST: host } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'auth',
|
||||
`Couldn't save your Anthropic auth token to the vault.`,
|
||||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
// ANTHROPIC_BASE_URL has to be in .env so the runtime provider config
|
||||
// reads it when building container env. The token is *not* written —
|
||||
// OneCLI holds it.
|
||||
writeEnvLine('ANTHROPIC_BASE_URL', baseUrl);
|
||||
|
||||
// Register the claude provider so the runtime passes ANTHROPIC_BASE_URL
|
||||
// and the placeholder bearer into the container. Only appended when the
|
||||
// user has configured a custom endpoint; standard installs don't load
|
||||
// the file at all.
|
||||
appendProviderImport('./claude.js');
|
||||
}
|
||||
|
||||
function writeEnvLine(key: string, value: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
const re = new RegExp(`^${key}=.*$`, 'm');
|
||||
const next = re.test(content)
|
||||
? content.replace(re, `${key}=${value}`)
|
||||
: content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`;
|
||||
fs.writeFileSync(envFile, next);
|
||||
}
|
||||
|
||||
function appendProviderImport(modulePath: string): void {
|
||||
const file = path.join(process.cwd(), 'src', 'providers', 'index.ts');
|
||||
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const line = `import '${modulePath}';`;
|
||||
if (content.includes(line)) return;
|
||||
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
||||
fs.writeFileSync(file, content + sep + line + '\n');
|
||||
}
|
||||
|
||||
// ─── timezone step ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -722,10 +937,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
const fields = res.terminal?.fields ?? {};
|
||||
const resolvedTz = fields.RESOLVED_TZ;
|
||||
const needsInput = fields.NEEDS_USER_INPUT === 'true';
|
||||
const isUtc =
|
||||
resolvedTz === 'UTC' ||
|
||||
resolvedTz === 'Etc/UTC' ||
|
||||
resolvedTz === 'Universal';
|
||||
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
|
||||
|
||||
// Three branches:
|
||||
// - no TZ detected: ask where they are (or leave as UTC)
|
||||
@@ -747,8 +959,8 @@ async function runTimezoneStep(): Promise<void> {
|
||||
const message = needsInput
|
||||
? "Your system didn't expose a timezone. Which one are you in?"
|
||||
: !isUtc
|
||||
? "Where are you, then?"
|
||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||
? 'Where are you, then?'
|
||||
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
|
||||
|
||||
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
|
||||
// straight to the free-text prompt — the user already said "not that".
|
||||
@@ -775,7 +987,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: "Where are you? (city, region, or IANA zone)",
|
||||
message: 'Where are you? (city, region, or IANA zone)',
|
||||
placeholder: 'e.g. New York, London, Asia/Tokyo',
|
||||
validate: (v) => (v && v.trim() ? undefined : 'Required'),
|
||||
}),
|
||||
@@ -789,9 +1001,11 @@ async function runTimezoneStep(): Promise<void> {
|
||||
tz = await resolveTimezoneViaClaude(raw);
|
||||
} else {
|
||||
p.log.warn(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -834,7 +1048,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
async function askDisplayName(fallback: string): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant call you?',
|
||||
message: `What should your assistant call ${accentGreen('you')}?`,
|
||||
placeholder: fallback,
|
||||
defaultValue: fallback,
|
||||
}),
|
||||
@@ -880,6 +1094,14 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
function ensureLocalBinOnPath(): void {
|
||||
const localBin = path.join(os.homedir(), '.local', 'bin');
|
||||
const current = process.env.PATH ?? '';
|
||||
const segments = current.split(path.delimiter).filter(Boolean);
|
||||
if (segments.includes(localBin)) return;
|
||||
process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin;
|
||||
}
|
||||
|
||||
function anthropicSecretExists(): boolean {
|
||||
try {
|
||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||
@@ -956,10 +1178,12 @@ function maybeReexecUnderSg(): void {
|
||||
if (!/permission denied/i.test(err)) return;
|
||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||
|
||||
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
|
||||
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
|
||||
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
|
||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) },
|
||||
});
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
@@ -971,17 +1195,15 @@ function printIntro(): void {
|
||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
p.intro(
|
||||
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
|
||||
);
|
||||
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always include the wordmark inside the clack intro line. When bash ran
|
||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||
// above us; the small repeat is worth it to keep the brand anchored at
|
||||
// the visible top of the clack session once the bash output scrolls away.
|
||||
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
|
||||
// bash already printed the wordmark above us; the clack intro carries the
|
||||
// welcome framing alone so the two don't double up. Standalone runs of
|
||||
// setup:auto still see this as the first line — fine without the wordmark
|
||||
// since the line itself signals the start of the flow.
|
||||
p.intro("Let's get you set up.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+35
-29
@@ -28,9 +28,11 @@ import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
@@ -155,7 +157,7 @@ async function askHasBotToken(): Promise<boolean> {
|
||||
|
||||
async function walkThroughBotCreation(): Promise<void> {
|
||||
const url = 'https://discord.com/developers/applications';
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
|
||||
'',
|
||||
@@ -163,9 +165,8 @@ async function walkThroughBotCreation(): Promise<void> {
|
||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||
' 3. On the same tab, enable "Message Content Intent"',
|
||||
' (under Privileged Gateway Intents)',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord bot',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||
@@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void {
|
||||
// to find it — tokens in the Dev Portal aren't visible after first reveal,
|
||||
// and "Reset Token" issues a new one.
|
||||
if (hasExistingBot) {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"Where to find your bot token:",
|
||||
'',
|
||||
@@ -216,16 +217,15 @@ async function walkThroughServerCreation(): Promise<void> {
|
||||
// the web client and rely on the + button being visible. The steps below
|
||||
// are the same whether they're in the desktop app or the browser.
|
||||
const url = 'https://discord.com/channels/@me';
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
|
||||
'',
|
||||
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||
' 3. Give it any name (e.g. "NanoClaw")',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||
@@ -239,9 +239,22 @@ async function walkThroughServerCreation(): Promise<void> {
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
const existing = readEnvKey('DISCORD_BOT_TOKEN');
|
||||
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('discord_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
@@ -275,9 +288,8 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
username?: string;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (res.ok && data.username) {
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: data.username,
|
||||
BOT_ID: data.id ?? '',
|
||||
@@ -295,8 +307,7 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -324,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
team?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id || !data.verify_key) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||
@@ -337,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
);
|
||||
}
|
||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
// owner is populated for solo applications; team-owned apps return a
|
||||
// team object instead and we'll fall back to a manual user-id prompt.
|
||||
const owner =
|
||||
@@ -355,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
owner,
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -385,14 +394,14 @@ async function resolveOwnerUserId(
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
|
||||
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
|
||||
);
|
||||
}
|
||||
return await promptForUserIdWithDevMode();
|
||||
}
|
||||
|
||||
async function promptForUserIdWithDevMode(): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"To get your Discord user ID:",
|
||||
'',
|
||||
@@ -430,15 +439,14 @@ async function promptInviteBot(
|
||||
`&scope=bot` +
|
||||
`&permissions=${INVITE_PERMISSIONS}`;
|
||||
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`@${botUsername} needs to share a server with you before it can DM you.`,
|
||||
'',
|
||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||
' 2. Click "Authorize"',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Add bot to a server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||
@@ -465,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
body: JSON.stringify({ recipient_id: userId }),
|
||||
});
|
||||
const data = (await res.json()) as { id?: string; message?: string };
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
@@ -478,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
);
|
||||
}
|
||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.id,
|
||||
});
|
||||
return data.id;
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -506,7 +512,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
@@ -36,7 +36,8 @@ import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
@@ -189,7 +190,7 @@ async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
@@ -222,7 +223,20 @@ async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
p.note(
|
||||
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
|
||||
const existingKey = readEnvKey('IMESSAGE_API_KEY');
|
||||
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('imessage_remote_creds', 'reused-existing');
|
||||
return { serverUrl: existingUrl, apiKey: existingKey };
|
||||
}
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
@@ -250,6 +264,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
clearOnError: true,
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
@@ -264,7 +279,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
@@ -303,7 +318,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
@@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise<void> {
|
||||
if (!probe.error && probe.status === 0) return;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
@@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise<void> {
|
||||
'signal-cli not found',
|
||||
);
|
||||
} else {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
@@ -323,8 +324,7 @@ async function restartService(): Promise<void> {
|
||||
// Give the adapter a moment to connect to signal-cli before
|
||||
// init-first-agent's welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
@@ -346,7 +346,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+207
-36
@@ -1,24 +1,23 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
* `runSlackChannel(displayName)` owns the full branch from creating a
|
||||
* Slack app through the welcome DM:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Print the post-install checklist: set the public webhook URL in
|
||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
||||
* then `/manage-channels` to wire an agent.
|
||||
* 5. Ask for the operator's Slack user ID
|
||||
* 6. conversations.open to get the DM channel ID
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
||||
* is better than a welcome DM the user won't receive until they
|
||||
* configure the webhook anyway.
|
||||
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
||||
* works without Event Subscriptions being configured. The user sees the
|
||||
* greeting in Slack immediately; inbound replies require webhooks, so the
|
||||
* post-install note covers that.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
@@ -26,12 +25,15 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
@@ -40,10 +42,7 @@ interface WorkspaceInfo {
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
export async function runSlackChannel(displayName: string): Promise<void> {
|
||||
await walkThroughAppCreation();
|
||||
|
||||
const token = await collectBotToken();
|
||||
@@ -78,26 +77,67 @@ export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const ownerUserId = await collectSlackUserId();
|
||||
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||
const platformId = `slack:${dmChannelId}`;
|
||||
|
||||
const role = await askOperatorRole('Slack');
|
||||
setupLog.userInput('slack_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'slack',
|
||||
'--user-id', `slack:${ownerUserId}`,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Wiring ${agentName} to your Slack DMs…`,
|
||||
done: 'Agent wired.',
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'slack',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/init-first-agent` in Claude Code.',
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' chat:write, channels:history, groups:history, im:history,',
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' chat:write, im:write, channels:history, groups:history,',
|
||||
' im:history, channels:read, groups:read, users:read,',
|
||||
' reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
formatNoteLink(SLACK_APPS_URL),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||
@@ -111,9 +151,22 @@ async function walkThroughAppCreation(): Promise<void> {
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_BOT_TOKEN');
|
||||
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_bot_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack bot token',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
@@ -132,9 +185,22 @@ async function collectBotToken(): Promise<string> {
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
const existing = readEnvKey('SLACK_SIGNING_SECRET');
|
||||
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: 'Found an existing Slack signing secret. Use it?',
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('slack_signing_secret', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack signing secret',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Signing secret is required';
|
||||
@@ -175,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
@@ -207,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -221,26 +285,133 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSlackUserId(): Promise<string> {
|
||||
note(
|
||||
[
|
||||
"To get your Slack member ID:",
|
||||
'',
|
||||
' 1. In Slack, click your profile picture (top right)',
|
||||
' 2. Click "Profile"',
|
||||
' 3. Click the three dots (⋯) → "Copy member ID"',
|
||||
].join('\n'),
|
||||
'Find your Slack user ID',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your Slack member ID',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Member ID is required';
|
||||
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
|
||||
return "That doesn't look like a Slack member ID (starts with U)";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const id = (answer as string).trim();
|
||||
setupLog.userInput('slack_user_id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Opening a DM channel…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/conversations.open`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ users: userId }),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
channel?: { id?: string };
|
||||
error?: string;
|
||||
};
|
||||
if (data.ok && data.channel?.id) {
|
||||
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.channel.id,
|
||||
});
|
||||
return data.channel.id;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
if (reason === 'missing_scope') {
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Your Slack app is missing the im:write scope.",
|
||||
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Couldn't open a DM channel with you.",
|
||||
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'slack-open-dm',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
p.note(
|
||||
note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
|
||||
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
||||
`To receive replies, Slack needs a public URL for delivering events:`,
|
||||
'',
|
||||
' 1. A public URL so Slack can deliver events.',
|
||||
' NanoClaw serves a webhook on port 3000 by default — expose it',
|
||||
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
|
||||
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
'',
|
||||
' 2. In your Slack app → Event Subscriptions:',
|
||||
' • Toggle "Enable Events" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Subscribe to bot events: message.channels, message.groups,',
|
||||
' message.im, app_mention',
|
||||
' • Save, then reinstall the app when Slack prompts',
|
||||
' • Save Changes',
|
||||
'',
|
||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
' 3. In your Slack app → Interactivity & Shortcuts:',
|
||||
' • Toggle "Interactivity" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Save Changes',
|
||||
'',
|
||||
' 4. Slack will prompt you to reinstall the app — do it to apply',
|
||||
' the new settings',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
|
||||
+35
-10
@@ -40,7 +40,9 @@ import {
|
||||
} from '../lib/claude-handoff.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
import { note } from '../lib/theme.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
|
||||
const CHANNEL = 'teams';
|
||||
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
||||
@@ -59,6 +61,28 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
const collected: Collected = {};
|
||||
const completed: string[] = [];
|
||||
|
||||
const existingAppId = readEnvKey('TEAMS_APP_ID');
|
||||
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
|
||||
if (existingAppId && existingPassword) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
collected.appId = existingAppId;
|
||||
collected.appPassword = existingPassword;
|
||||
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
|
||||
if (collected.appType === 'SingleTenant') {
|
||||
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
|
||||
}
|
||||
setupLog.userInput('teams_credentials', 'reused-existing');
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted (reused existing credentials).');
|
||||
await finishWithHandoff(collected, completed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
printIntro();
|
||||
|
||||
await confirmPrereqs({ collected, completed });
|
||||
@@ -79,7 +103,7 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
// ─── step: intro / prereqs ──────────────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'Setting up Teams is more involved than the other channels — about',
|
||||
'7 steps across the Azure portal and Teams admin.',
|
||||
@@ -93,7 +117,7 @@ function printIntro(): void {
|
||||
}
|
||||
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'Before we start, confirm you have:',
|
||||
'',
|
||||
@@ -119,7 +143,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
|
||||
// ─── step: public URL ──────────────────────────────────────────────────
|
||||
|
||||
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"Azure Bot Service delivers messages to an HTTPS endpoint you",
|
||||
"control. The endpoint needs to reach this machine's webhook",
|
||||
@@ -175,7 +199,7 @@ async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
||||
'2. Name it (e.g. "NanoClaw")',
|
||||
@@ -259,7 +283,7 @@ async function stepClientSecret(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`1. In your app registration, open "Certificates & secrets"`,
|
||||
'2. Click "New client secret"',
|
||||
@@ -276,6 +300,7 @@ async function stepClientSecret(args: {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste the client secret Value',
|
||||
clearOnError: true,
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
@@ -328,7 +353,7 @@ async function stepAzureBot(args: {
|
||||
` --appid ${args.collected.appId} \\\n` +
|
||||
` ${tenantFlag}--endpoint "${endpoint}"`;
|
||||
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
|
||||
'',
|
||||
@@ -365,7 +390,7 @@ async function stepEnableTeamsChannel(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'1. Open your Azure Bot resource → Channels',
|
||||
'2. Click Microsoft Teams → Accept terms → Apply',
|
||||
@@ -435,7 +460,7 @@ async function stepSideload(args: {
|
||||
completed: string[];
|
||||
zipPath: string;
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'1. Open Microsoft Teams',
|
||||
'2. Go to Apps → Manage your apps → Upload an app',
|
||||
@@ -501,7 +526,7 @@ async function finishWithHandoff(
|
||||
collected: Collected,
|
||||
completed: string[],
|
||||
): Promise<void> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
'The Teams adapter is live and the service is running.',
|
||||
'',
|
||||
@@ -530,7 +555,7 @@ async function finishWithHandoff(
|
||||
);
|
||||
|
||||
if (choice === 'self') {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
' 1. Find your bot in Teams (search by name, or via the sideloaded',
|
||||
' app) and send it a message ("hi" is fine)',
|
||||
|
||||
+26
-15
@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import {
|
||||
type Block,
|
||||
@@ -33,7 +33,8 @@ import {
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
@@ -47,12 +48,11 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
// installed, or the bot's web profile if not. tg://resolve?domain= is
|
||||
// more direct but silently fails when the scheme isn't registered.
|
||||
const botUrl = `https://t.me/${botUsername}`;
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
||||
'',
|
||||
k.dim(botUrl),
|
||||
].join('\n'),
|
||||
formatNoteLink(botUrl),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Open Telegram',
|
||||
);
|
||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
||||
@@ -132,7 +132,19 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function collectTelegramToken(): Promise<string> {
|
||||
p.note(
|
||||
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
|
||||
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
|
||||
const reuse = ensureAnswer(await p.confirm({
|
||||
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
|
||||
initialValue: true,
|
||||
}));
|
||||
if (reuse) {
|
||||
setupLog.userInput('telegram_token', 'reused-existing');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Your assistant talks to you through a Telegram bot you create.",
|
||||
"Here's how:",
|
||||
@@ -150,6 +162,7 @@ async function collectTelegramToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
clearOnError: true,
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return "Token is required";
|
||||
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
|
||||
@@ -178,10 +191,9 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
result?: { username?: string; id?: number };
|
||||
description?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.result?.username) {
|
||||
const username = data.result.username;
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: username,
|
||||
BOT_ID: data.result.id ?? '',
|
||||
@@ -199,8 +211,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
@@ -240,12 +251,12 @@ async function runPairTelegram(): Promise<
|
||||
} else {
|
||||
stopSpinner("Old code expired. Here's a fresh one.");
|
||||
}
|
||||
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start('Waiting for you to send the code from Telegram…');
|
||||
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
||||
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
|
||||
s.start('Waiting for the correct code…');
|
||||
s.start(fitToWidth('Waiting for the correct code…', ''));
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||
if (block.fields.STATUS === 'success') {
|
||||
@@ -291,7 +302,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
@@ -171,7 +171,7 @@ async function askAuthMethod(): Promise<AuthMethod> {
|
||||
}
|
||||
|
||||
async function askPhoneNumber(): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"Enter your phone number the way WhatsApp expects it:",
|
||||
'',
|
||||
@@ -249,7 +249,7 @@ async function runWhatsAppAuth(
|
||||
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
|
||||
const code = block.fields.CODE ?? '????';
|
||||
stopSpinner('Your pairing code is ready.');
|
||||
p.note(formatPairingCard(code), 'Pairing code');
|
||||
note(formatPairingCard(code), 'Pairing code');
|
||||
s.start('Waiting for you to enter the code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'WHATSAPP_AUTH') {
|
||||
@@ -267,7 +267,7 @@ async function runWhatsAppAuth(
|
||||
if (spinnerActive) {
|
||||
stopSpinner('WhatsApp linked.');
|
||||
} else {
|
||||
p.log.success('WhatsApp linked.');
|
||||
p.log.success(brandBody('WhatsApp linked.'));
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (qrLinesPrinted > 0) {
|
||||
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
|
||||
// Give the adapter a moment to reconnect before init-first-agent's
|
||||
// welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
@@ -395,7 +394,7 @@ async function restartService(): Promise<void> {
|
||||
}
|
||||
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
@@ -462,7 +461,7 @@ async function resolveAgentName(): Promise<string> {
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
message: `What should your ${accentGreen('assistant')} be called?`,
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
|
||||
+10
-2
@@ -8,6 +8,7 @@
|
||||
* Args:
|
||||
* --display-name <name> (required) operator's display name
|
||||
* --agent-name <name> (optional) agent persona name, defaults to display-name
|
||||
* --folder <name> (optional) explicit folder name, defaults to cli-with-<normalized-display-name>
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
|
||||
function parseArgs(args: string[]): {
|
||||
displayName: string;
|
||||
agentName?: string;
|
||||
folder?: string;
|
||||
} {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let folder: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
|
||||
agentName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--folder':
|
||||
folder = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { displayName, agentName };
|
||||
return { displayName, agentName, folder };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { displayName, agentName } = parseArgs(args);
|
||||
const { displayName, agentName, folder } = parseArgs(args);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
|
||||
|
||||
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
|
||||
if (agentName) scriptArgs.push('--agent-name', agentName);
|
||||
if (folder) scriptArgs.push('--folder', folder);
|
||||
|
||||
log.info('Invoking init-cli-agent', { displayName, agentName });
|
||||
|
||||
|
||||
+15
-4
@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// Socket is unreachable due to group perms — current shell's supplementary
|
||||
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
|
||||
// or a prior install) doesn't affect us until next login. Re-exec this
|
||||
// step under `sg docker` so the child picks up docker as its primary
|
||||
// group and can talk to /var/run/docker.sock without a logout.
|
||||
// groups are fixed at login, so `usermod -aG docker` doesn't affect us
|
||||
// until next login. Ensure the user is in the docker group (install-docker.sh
|
||||
// does this on fresh installs, but skips when Docker is already present),
|
||||
// then re-exec under `sg docker` so the child picks up docker as its
|
||||
// primary group and can talk to /var/run/docker.sock without a logout.
|
||||
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
|
||||
// Ensure the current user is in the docker group — without this,
|
||||
// sg will ask for the (typically unset) group password and fail.
|
||||
const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
|
||||
if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
|
||||
log.info('Adding current user to docker group');
|
||||
spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
log.info('Re-executing container step under `sg docker`');
|
||||
const res = spawnSync(
|
||||
'sg',
|
||||
|
||||
@@ -11,6 +11,48 @@ import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* Read a single key from `.env` on disk (not process.env).
|
||||
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
|
||||
*/
|
||||
export function readEnvKey(key: string, projectRoot?: string): string | null {
|
||||
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 1) continue;
|
||||
if (trimmed.slice(0, eq) === key) {
|
||||
return trimmed.slice(eq + 1).trim() || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectExistingDisplayName(projectRoot: string): string | null {
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return null;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
|
||||
.get() as { display_name: string } | undefined;
|
||||
return row?.display_name?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
return true;
|
||||
|
||||
@@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core';
|
||||
import { isCancel } from '@clack/prompts';
|
||||
import { styleText } from 'node:util';
|
||||
|
||||
import { brandBody } from './theme.js';
|
||||
|
||||
const BULLET_ACTIVE = '●';
|
||||
const BULLET_INACTIVE = '○';
|
||||
const BAR = '│';
|
||||
@@ -95,7 +97,7 @@ export function brightSelect<T>(
|
||||
const shown =
|
||||
st === 'cancel'
|
||||
? styleText(['strikethrough', 'dim'], selected)
|
||||
: styleText('dim', selected);
|
||||
: styleText('dim', brandBody(selected));
|
||||
lines.push(`${grayBar} ${shown}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -104,11 +106,12 @@ export function brightSelect<T>(
|
||||
options.forEach((opt, idx) => {
|
||||
const label = opt.label ?? String(opt.value);
|
||||
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
|
||||
const marker =
|
||||
idx === cursor
|
||||
? styleText('green', BULLET_ACTIVE)
|
||||
: styleText('dim', BULLET_INACTIVE);
|
||||
lines.push(`${bar} ${marker} ${label}${hint}`);
|
||||
const isActive = idx === cursor;
|
||||
const marker = isActive
|
||||
? styleText('green', BULLET_ACTIVE)
|
||||
: styleText('dim', BULLET_INACTIVE);
|
||||
const shownLabel = isActive ? brandBody(label) : label;
|
||||
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
|
||||
});
|
||||
lines.push(styleText(color, CAP_BOT));
|
||||
return lines.join('\n');
|
||||
|
||||
+36
-4
@@ -9,12 +9,19 @@
|
||||
* `confirmThenOpen` pauses for the operator before triggering the open —
|
||||
* the browser tends to steal focus when it pops, and a split-second
|
||||
* "wait what just happened" moment is worse than letting the user hit
|
||||
* Enter when they're ready.
|
||||
* Enter when they're ready. On headless devices (no graphical session
|
||||
* available) it skips both the prompt and the open: there's no browser
|
||||
* to launch, the surrounding `note(...)` already shows the URL for
|
||||
* copy-paste on another device, and the next prompt in the channel
|
||||
* flow ("Got your bot token?" etc.) provides the natural completion
|
||||
* confirmation.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
|
||||
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
|
||||
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a URL for inclusion in a setup `note(...)` card. On
|
||||
* headless devices we surface the URL inside the card with a
|
||||
* "Get started:" label at full strength — copy-pasting onto
|
||||
* another device is the actual action, not an incidental
|
||||
* reference. The leading `\n` acts as a visual separator from
|
||||
* the body steps above; callers `.filter(line => line !== null)`
|
||||
* before joining, so on GUI we drop the line entirely (and the
|
||||
* URL ends up below the next-step confirm prompt as a "if
|
||||
* browser does not appear, please visit" fallback — see
|
||||
* `confirmThenOpen`).
|
||||
*/
|
||||
export function formatNoteLink(url: string): string | null {
|
||||
if (isHeadless()) return `\nGet started: ${url}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a browser-open on a confirm so the user is ready for their browser
|
||||
* to take focus. Proceeds on cancel as well — the user can always copy the
|
||||
* URL from the note that precedes the prompt.
|
||||
* to take focus. Proceeds on cancel as well. On headless devices both the
|
||||
* prompt and the open are skipped — the URL is already surfaced inside
|
||||
* the surrounding note (via `formatNoteLink`).
|
||||
*
|
||||
* On GUI devices the confirm message includes the fallback URL on the
|
||||
* lines below the action ("If browser does not appear, please visit:
|
||||
* <url>" in dim) so the user has a copy-paste path right next to the
|
||||
* action button without needing to scroll back up to the card.
|
||||
*/
|
||||
export async function confirmThenOpen(
|
||||
url: string,
|
||||
message = 'Press Enter to open your browser',
|
||||
): Promise<void> {
|
||||
if (isHeadless()) return;
|
||||
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message,
|
||||
message: `${message}${fallback}`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
|
||||
+104
-16
@@ -2,8 +2,11 @@
|
||||
* Offer Claude-assisted debugging when a setup step fails.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check `claude` is on PATH and has a working credential. If not,
|
||||
* silently skip — pre-auth failures can't use this path.
|
||||
* 1. Check `claude` is on PATH — if not, offer to install it via
|
||||
* setup/install-claude.sh. Then check auth via `claude auth status`
|
||||
* — if not signed in, offer to run `claude setup-token` (browser
|
||||
* OAuth with code-paste fallback for headless/remote systems).
|
||||
* If either is declined or fails, silently skip.
|
||||
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
|
||||
* 3. Build a minimal prompt: the one-paragraph situation, the failing
|
||||
* step's name/message/hint, and a short list of *file references*
|
||||
@@ -16,15 +19,16 @@
|
||||
*
|
||||
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync, spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
|
||||
|
||||
export interface AssistContext {
|
||||
stepName: string;
|
||||
@@ -90,7 +94,7 @@ export async function offerClaudeAssist(
|
||||
projectRoot: string = process.cwd(),
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||
if (!isClaudeUsable()) return false;
|
||||
if (!(await ensureClaudeReady(projectRoot))) return false;
|
||||
|
||||
const want = ensureAnswer(
|
||||
await p.confirm({
|
||||
@@ -106,12 +110,12 @@ export async function offerClaudeAssist(
|
||||
|
||||
const parsed = parseResponse(response);
|
||||
if (!parsed) {
|
||||
p.log.warn("Claude responded but I couldn't parse a command out of it.");
|
||||
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
|
||||
p.log.message(k.dim(response.trim().slice(0, 500)));
|
||||
return false;
|
||||
}
|
||||
|
||||
p.note(
|
||||
note(
|
||||
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
|
||||
"Claude's suggestion",
|
||||
);
|
||||
@@ -128,15 +132,101 @@ export async function offerClaudeAssist(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isClaudeUsable(): boolean {
|
||||
function isClaudeInstalled(): boolean {
|
||||
try {
|
||||
execSync('command -v claude', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Availability without auth is half the story; a real query will still
|
||||
// fail if the token isn't registered. We try first and surface the error
|
||||
// rather than pre-checking auth with a separate round trip.
|
||||
}
|
||||
|
||||
function isClaudeAuthenticated(): boolean {
|
||||
try {
|
||||
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||
if (!isClaudeInstalled()) {
|
||||
const install = ensureAnswer(
|
||||
await p.confirm({
|
||||
message:
|
||||
'Claude CLI is needed to diagnose this. Install it now?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!install) return false;
|
||||
|
||||
const code = spawnSync('bash', ['setup/install-claude.sh'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
}).status;
|
||||
if (code !== 0 || !isClaudeInstalled()) {
|
||||
p.log.error("Couldn't install the Claude CLI.");
|
||||
return false;
|
||||
}
|
||||
p.log.success('Claude CLI installed.');
|
||||
}
|
||||
|
||||
if (!isClaudeAuthenticated()) {
|
||||
const auth = ensureAnswer(
|
||||
await p.confirm({
|
||||
message:
|
||||
"Claude CLI isn't signed in. Sign in now? (a browser will open)",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!auth) return false;
|
||||
|
||||
// setup-token has an interactive TUI; reset terminal to cooked mode
|
||||
// so its prompts render correctly after clack's raw-mode prompts.
|
||||
spawnSync('stty', ['sane'], { stdio: 'inherit' });
|
||||
|
||||
// Run under script(1) to capture the OAuth token from PTY output
|
||||
// while preserving interactive TTY for the browser OAuth flow.
|
||||
// Same approach as register-claude-token.sh, but we set the env var
|
||||
// instead of writing to OneCLI.
|
||||
const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`);
|
||||
try {
|
||||
const isUtilLinux = (() => {
|
||||
try {
|
||||
return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux');
|
||||
} catch { return false; }
|
||||
})();
|
||||
const scriptArgs = isUtilLinux
|
||||
? ['-q', '-c', 'claude setup-token', tmpfile]
|
||||
: ['-q', tmpfile, 'claude', 'setup-token'];
|
||||
|
||||
spawnSync('script', scriptArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
|
||||
const raw = fs.readFileSync(tmpfile, 'utf-8');
|
||||
const stripped = raw
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/[\n\r]/g, '');
|
||||
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
|
||||
if (matches) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpfile); } catch {}
|
||||
}
|
||||
|
||||
if (!isClaudeAuthenticated()) {
|
||||
p.log.error("Couldn't complete Claude sign-in.");
|
||||
return false;
|
||||
}
|
||||
p.log.success('Claude CLI signed in.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -205,9 +295,8 @@ async function queryClaudeUnderSpinner(
|
||||
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
||||
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||
|
||||
@@ -265,10 +354,9 @@ async function queryClaudeUnderSpinner(
|
||||
clearBlock();
|
||||
out.write(SHOW_CURSOR);
|
||||
process.off('exit', restoreCursorOnExit);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (kind === 'ok') {
|
||||
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
|
||||
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
|
||||
resolve(payload);
|
||||
} else {
|
||||
p.log.error(
|
||||
|
||||
@@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process';
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { brandBody, note } from './theme.js';
|
||||
|
||||
export interface HandoffContext {
|
||||
/** Channel this handoff is happening in (e.g., 'teams'). */
|
||||
channel: string;
|
||||
@@ -62,14 +64,14 @@ export interface HandoffContext {
|
||||
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
|
||||
if (!isClaudeUsable()) {
|
||||
p.log.warn(
|
||||
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
|
||||
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const systemPrompt = buildSystemPrompt(ctx);
|
||||
|
||||
p.note(
|
||||
note(
|
||||
[
|
||||
"I'm handing you off to Claude in interactive mode.",
|
||||
"It has the context of where you are in setup.",
|
||||
@@ -91,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success("Back from Claude. Let's continue.");
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
|
||||
+5
-7
@@ -20,7 +20,7 @@ import k from 'kleur';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { offerClaudeAssist } from './claude-assist.js';
|
||||
import { emit as phEmit } from './diagnostics.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||
|
||||
export type Fields = Record<string, string>;
|
||||
export type Block = { type: string; fields: Fields };
|
||||
@@ -307,18 +307,16 @@ async function runUnderSpinner<
|
||||
): Promise<T> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start(fitToWidth(labels.running, ' (999s)'));
|
||||
s.start(fitToWidth(labels.running, ' (99m 59s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await work();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
@@ -390,7 +388,7 @@ export async function fail(
|
||||
const skipList = [
|
||||
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
|
||||
].join(',');
|
||||
p.log.step(`Retrying from ${stepName}…`);
|
||||
p.log.step(brandBody(`Retrying from ${stepName}…`));
|
||||
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_SKIP: skipList },
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Parser/reader/writer for the advanced-config registry (setup-config.ts).
|
||||
*
|
||||
* readFromEnv() → values found in process.env
|
||||
* parseFlags() → values from argv, plus --help and any pass-through args
|
||||
* applyToEnv() → write resolved values back to process.env so existing
|
||||
* step code keeps reading env vars unchanged
|
||||
* printHelp() → render --help from the registry
|
||||
*
|
||||
* Flag parsing supports:
|
||||
* --key value space form
|
||||
* --key=value equals form
|
||||
* --key booleans only (sets true)
|
||||
* --no-key booleans only (sets false)
|
||||
*/
|
||||
import {
|
||||
CONFIG,
|
||||
envVarFor,
|
||||
flagFor,
|
||||
findByFlag,
|
||||
type Entry,
|
||||
} from './setup-config.js';
|
||||
|
||||
export type ConfigValues = Record<string, string | boolean | number>;
|
||||
|
||||
function coerce(e: Entry, raw: string): string | number | boolean | undefined {
|
||||
switch (e.type) {
|
||||
case 'boolean': {
|
||||
const v = raw.toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(v)) return true;
|
||||
if (['false', '0', 'no'].includes(v)) return false;
|
||||
return undefined;
|
||||
}
|
||||
case 'integer': {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues {
|
||||
const out: ConfigValues = {};
|
||||
for (const e of CONFIG) {
|
||||
const raw = env[envVarFor(e)];
|
||||
if (raw === undefined || raw === '') continue;
|
||||
const v = coerce(e, raw);
|
||||
if (v !== undefined) out[e.key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type FlagParseResult = {
|
||||
values: ConfigValues;
|
||||
rest: string[];
|
||||
help: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export function parseFlags(argv: string[]): FlagParseResult {
|
||||
const values: ConfigValues = {};
|
||||
const rest: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let help = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
help = true;
|
||||
continue;
|
||||
}
|
||||
// POSIX end-of-options. pnpm passes a bare `--` through when invoked as
|
||||
// `pnpm run script --` with nothing after it; treat the rest as
|
||||
// pass-through positional args.
|
||||
if (arg === '--') {
|
||||
rest.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
if (!arg.startsWith('--')) {
|
||||
rest.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = arg.indexOf('=');
|
||||
let name = eq === -1 ? arg : arg.slice(0, eq);
|
||||
const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1);
|
||||
|
||||
let negated = false;
|
||||
if (name.startsWith('--no-')) {
|
||||
negated = true;
|
||||
name = `--${name.slice(5)}`;
|
||||
}
|
||||
|
||||
const entry = findByFlag(name);
|
||||
if (!entry) {
|
||||
errors.push(`Unknown flag: ${arg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'boolean') {
|
||||
if (negated) values[entry.key] = false;
|
||||
else if (inline !== undefined) {
|
||||
const v = coerce(entry, inline);
|
||||
if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`);
|
||||
else values[entry.key] = v;
|
||||
} else values[entry.key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = inline !== undefined ? inline : argv[++i];
|
||||
if (raw === undefined) {
|
||||
errors.push(`Missing value for ${name}`);
|
||||
continue;
|
||||
}
|
||||
const v = coerce(entry, raw);
|
||||
if (v === undefined) {
|
||||
errors.push(`Invalid ${entry.type} for ${name}: ${raw}`);
|
||||
continue;
|
||||
}
|
||||
if (entry.type === 'string' || entry.type === 'url') {
|
||||
const err = entry.validate?.(raw);
|
||||
if (err) {
|
||||
errors.push(`${name}: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
values[entry.key] = v;
|
||||
}
|
||||
|
||||
return { values, rest, help, errors };
|
||||
}
|
||||
|
||||
export function applyToEnv(
|
||||
values: ConfigValues,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
for (const e of CONFIG) {
|
||||
if (!(e.key in values)) continue;
|
||||
const v = values[e.key];
|
||||
env[envVarFor(e)] =
|
||||
typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
export function printHelp(stream: NodeJS.WritableStream = process.stdout): void {
|
||||
const lines: string[] = [];
|
||||
lines.push('Usage: bash nanoclaw.sh [flags...]');
|
||||
lines.push('');
|
||||
lines.push('Flags:');
|
||||
const width = Math.max(...CONFIG.map((e) => flagFor(e).length));
|
||||
for (const e of CONFIG) {
|
||||
const flag = flagFor(e).padEnd(width + 2);
|
||||
lines.push(` ${flag}${e.help}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Each flag also reads from its corresponding NANOCLAW_<KEY> env var.');
|
||||
lines.push('Run without flags for the default interactive flow.');
|
||||
stream.write(lines.join('\n') + '\n');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Advanced-settings screen — menu of UI-visible entries from the config
|
||||
* registry. The user picks one entry, edits it, returns to the menu, and
|
||||
* exits via "Done". Returns a fresh values object; the caller passes it to
|
||||
* applyToEnv() so downstream step code reads them via env vars.
|
||||
*
|
||||
* Per-entry edit contract:
|
||||
* - Blank input on text/password/integer = leave current value unchanged.
|
||||
* - Enums get a synthetic "leave unchanged" first option.
|
||||
* - Booleans use confirm with the current value as initialValue.
|
||||
* - Secret entries mask the current value as bullets in hints/labels.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { brightSelect } from './bright-select.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { CONFIG, type Entry } from './setup-config.js';
|
||||
import type { ConfigValues } from './setup-config-parse.js';
|
||||
|
||||
const SKIP_SENTINEL = '__leave_unchanged__';
|
||||
const DONE_SENTINEL = '__done__';
|
||||
const MASK = '••••••••';
|
||||
|
||||
export async function runAdvancedScreen(
|
||||
initial: ConfigValues,
|
||||
): Promise<ConfigValues> {
|
||||
const result: ConfigValues = { ...initial };
|
||||
const visible = CONFIG.filter((e) => e.surface === 'flag+ui');
|
||||
|
||||
while (true) {
|
||||
const options = [
|
||||
...visible.map((e) => ({
|
||||
value: e.key,
|
||||
label: e.label,
|
||||
hint: hintFor(e, result),
|
||||
})),
|
||||
{ value: DONE_SENTINEL, label: 'Done — continue with setup' },
|
||||
];
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: 'Pick a setting to override',
|
||||
options,
|
||||
initialValue: DONE_SENTINEL,
|
||||
}),
|
||||
) as string;
|
||||
|
||||
if (choice === DONE_SENTINEL) return result;
|
||||
const entry = visible.find((e) => e.key === choice);
|
||||
if (entry) await promptOne(entry, result);
|
||||
}
|
||||
}
|
||||
|
||||
function hintFor(e: Entry, values: ConfigValues): string {
|
||||
const v = values[e.key];
|
||||
if (v === undefined) return 'not set';
|
||||
if (e.secret) return MASK;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
|
||||
if (e.type === 'boolean') {
|
||||
const init =
|
||||
typeof values[e.key] === 'boolean'
|
||||
? (values[e.key] as boolean)
|
||||
: (e.default ?? false);
|
||||
const ans = ensureAnswer(
|
||||
await p.confirm({ message: e.label, initialValue: init }),
|
||||
);
|
||||
values[e.key] = ans as boolean;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.type === 'enum') {
|
||||
const ans = ensureAnswer(
|
||||
await brightSelect<string>({
|
||||
message: e.label,
|
||||
options: [
|
||||
{ value: SKIP_SENTINEL, label: 'Leave unchanged' },
|
||||
...e.options,
|
||||
],
|
||||
initialValue: SKIP_SENTINEL,
|
||||
}),
|
||||
);
|
||||
if (ans !== SKIP_SENTINEL) values[e.key] = ans as string;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.type === 'integer') {
|
||||
const ans = ensureAnswer(
|
||||
await p.text({
|
||||
message: e.label,
|
||||
placeholder: e.default !== undefined ? String(e.default) : undefined,
|
||||
validate: (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) return 'Must be a number';
|
||||
if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`;
|
||||
if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`;
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const trimmed = ((ans as string) ?? '').trim();
|
||||
if (trimmed) values[e.key] = Number(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
// string | url
|
||||
const validate = (v: string | undefined): string | undefined => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
return e.validate?.(s);
|
||||
};
|
||||
const ans = ensureAnswer(
|
||||
e.secret
|
||||
? await p.password({ message: e.label, clearOnError: true, validate })
|
||||
: await p.text({
|
||||
message: e.label,
|
||||
placeholder: e.placeholder ?? e.default,
|
||||
validate,
|
||||
}),
|
||||
);
|
||||
const trimmed = ((ans as string) ?? '').trim();
|
||||
if (trimmed) values[e.key] = trimmed;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Setup-time advanced-config registry.
|
||||
*
|
||||
* One source of truth for: CLI flags, env-var names, the advanced-settings
|
||||
* screen, and `--help` output. The flag parser, env reader, and UI screen
|
||||
* all consume this list and write resolved values back to `process.env` so
|
||||
* existing step code keeps reading env vars unchanged.
|
||||
*
|
||||
* Default name conventions (overridable per entry):
|
||||
* key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar'
|
||||
*
|
||||
* Surface levels:
|
||||
* 'flag' — CLI flag + env var only (debug/internal knobs)
|
||||
* 'flag+ui' — also shown in the advanced-settings screen
|
||||
*/
|
||||
|
||||
export type EntrySurface = 'flag' | 'flag+ui';
|
||||
|
||||
interface BaseEntry {
|
||||
/** Canonical camelCase key. */
|
||||
key: string;
|
||||
/** Override of the auto-derived NANOCLAW_<UPPER_SNAKE> env var. */
|
||||
envVar?: string;
|
||||
/** Override of the auto-derived --kebab-case flag. */
|
||||
flag?: string;
|
||||
label: string;
|
||||
help: string;
|
||||
surface: EntrySurface;
|
||||
/** UI section header. Entries without a group land in 'Other'. */
|
||||
group?: string;
|
||||
/** Mask in UI, redact in logs. */
|
||||
secret?: boolean;
|
||||
}
|
||||
|
||||
interface StringEntry extends BaseEntry {
|
||||
type: 'string' | 'url';
|
||||
default?: string;
|
||||
placeholder?: string;
|
||||
validate?: (v: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface EnumEntry extends BaseEntry {
|
||||
type: 'enum';
|
||||
options: { value: string; label: string; hint?: string }[];
|
||||
default?: string;
|
||||
}
|
||||
|
||||
interface BoolEntry extends BaseEntry {
|
||||
type: 'boolean';
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
interface IntEntry extends BaseEntry {
|
||||
type: 'integer';
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry;
|
||||
|
||||
const httpUrl = (v: string): string | undefined =>
|
||||
/^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…';
|
||||
|
||||
export const CONFIG: Entry[] = [
|
||||
{
|
||||
key: 'onecliApiHost',
|
||||
label: 'OneCLI vault URL',
|
||||
help: 'Use a remote OneCLI vault instead of installing one locally.',
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'url',
|
||||
default: 'https://app.onecli.sh',
|
||||
placeholder: 'https://app.onecli.sh',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
key: 'onecliApiToken',
|
||||
label: 'OneCLI access token',
|
||||
help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.',
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'string',
|
||||
secret: true,
|
||||
placeholder: 'oc_…',
|
||||
validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'),
|
||||
},
|
||||
{
|
||||
key: 'anthropicBaseUrl',
|
||||
label: 'Anthropic API base URL',
|
||||
help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.',
|
||||
surface: 'flag+ui',
|
||||
group: 'Anthropic',
|
||||
type: 'url',
|
||||
placeholder: 'https://api.anthropic.com',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
key: 'anthropicAuthToken',
|
||||
label: 'Anthropic auth token',
|
||||
help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.',
|
||||
surface: 'flag+ui',
|
||||
group: 'Anthropic',
|
||||
type: 'string',
|
||||
secret: true,
|
||||
validate: (v) => (v.trim() ? undefined : 'Required'),
|
||||
},
|
||||
|
||||
// Existing env-var knobs — flag-only so they don't clutter the UI screen.
|
||||
{
|
||||
key: 'skip',
|
||||
envVar: 'NANOCLAW_SKIP',
|
||||
label: 'Skip steps',
|
||||
help: 'Comma-separated step names to skip (debugging only).',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'displayName',
|
||||
envVar: 'NANOCLAW_DISPLAY_NAME',
|
||||
label: 'Display name',
|
||||
help: 'Skip the "what should your assistant call you?" prompt.',
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── name derivation ───────────────────────────────────────────────────
|
||||
|
||||
export function envVarFor(e: Entry): string {
|
||||
if (e.envVar) return e.envVar;
|
||||
return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function flagFor(e: Entry): string {
|
||||
if (e.flag) return e.flag;
|
||||
return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
export function findByFlag(flag: string): Entry | null {
|
||||
return CONFIG.find((e) => flagFor(e) === flag) ?? null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
* - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan)
|
||||
* - Otherwise → kleur's 16-color cyan (closest fallback)
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
||||
@@ -38,6 +39,57 @@ export function brandChip(s: string): string {
|
||||
return k.bgCyan(k.black(k.bold(s)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Accent green (#3fba50) for emphasizing a single word inside prompt
|
||||
* messages — currently the "you" in "What should your assistant call
|
||||
* you?" so the operator parses at a glance who the question is about.
|
||||
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
|
||||
*/
|
||||
export function accentGreen(s: string): string {
|
||||
if (!USE_ANSI) return s;
|
||||
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
|
||||
return k.green(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an elapsed-time duration (in milliseconds) for the spinner
|
||||
* suffixes setup writes everywhere. Sub-minute durations stay in plain
|
||||
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
|
||||
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
|
||||
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
|
||||
* so live spinner output doesn't change shape at every whole minute.
|
||||
*/
|
||||
export function fmtDuration(ms: number): string {
|
||||
const totalSec = Math.round(ms / 1000);
|
||||
if (totalSec < 60) return `${totalSec}s`;
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand body color for setup-flow prose. Used for card bodies (via the
|
||||
* `note()` formatter) and `p.log.*` body arguments — anywhere the
|
||||
* previous "dim" treatment was making prose hard to read or washing
|
||||
* out embedded brand emphasis.
|
||||
*
|
||||
* Multi-line input is colored line-by-line so embedded line breaks
|
||||
* don't bleed the SGR sequence across clack's gutter prefix.
|
||||
*/
|
||||
export function brandBody(s: string): string {
|
||||
if (!USE_ANSI) return s;
|
||||
if (TRUECOLOR) {
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
|
||||
.join('\n');
|
||||
}
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => (line.length > 0 ? k.cyan(line) : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text so it fits inside clack's gutter without the terminal's soft
|
||||
* wrap breaking the `│ …` bar on long lines. Works on a single string with
|
||||
@@ -68,6 +120,16 @@ export function dimWrap(text: string, gutter: number): string {
|
||||
return wrapForGutter(text, gutter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap clack's `p.note` so card bodies render in the brand body color
|
||||
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
|
||||
* on each line individually, so `brandBody` colors each line cleanly
|
||||
* without bleeding across the gutter prefix.
|
||||
*/
|
||||
export function note(message: string, title?: string): void {
|
||||
p.note(message, title, { format: brandBody });
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
function visibleLength(s: string): number {
|
||||
|
||||
@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { isValidTimezone } from '../../src/timezone.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { fitToWidth, fmtDuration } from './theme.js';
|
||||
|
||||
export function claudeCliAvailable(): boolean {
|
||||
try {
|
||||
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Looking up that timezone…';
|
||||
s.start(fitToWidth(label, ' (999s)'));
|
||||
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const reply = await queryClaude(prompt);
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
|
||||
const resolved = reply ? extractTimezone(reply) : null;
|
||||
if (resolved) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js';
|
||||
import type { StepResult, SpinnerLabels } from './runner.js';
|
||||
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
import { brandBody, fitToWidth } from './theme.js';
|
||||
|
||||
const WINDOW_SIZE = 3;
|
||||
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
||||
@@ -169,7 +169,7 @@ async function runUnderWindow(
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
|
||||
@@ -185,7 +185,7 @@ async function handleStall(
|
||||
): Promise<void> {
|
||||
render.pauseRender();
|
||||
p.log.warn(
|
||||
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
|
||||
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
|
||||
);
|
||||
phEmit('step_stalled', { step: stepName });
|
||||
|
||||
|
||||
+78
-13
@@ -86,23 +86,35 @@ function ensureShellProfilePath(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
function writeEnvVar(name: string, value: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
if (/^ONECLI_URL=/m.test(content)) {
|
||||
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
|
||||
const re = new RegExp(`^${name}=.*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, `${name}=${value}`);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
|
||||
}
|
||||
fs.writeFileSync(envFile, content);
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
writeEnvVar('ONECLI_URL', url);
|
||||
}
|
||||
|
||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||
// and the redirect-based version probe fail. Bump deliberately when a
|
||||
// new CLI release ships.
|
||||
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
|
||||
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
|
||||
|
||||
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
|
||||
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
|
||||
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
|
||||
const fallback = installOnecliCliDirect();
|
||||
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
|
||||
}
|
||||
|
||||
function installOnecli(): { stdout: string; ok: boolean } {
|
||||
let stdout = '';
|
||||
|
||||
@@ -163,14 +175,12 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
lines.push(s);
|
||||
};
|
||||
|
||||
const osName =
|
||||
process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
|
||||
const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
|
||||
if (!osName) {
|
||||
append(`Unsupported platform: ${process.platform}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
}
|
||||
const arch =
|
||||
process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
|
||||
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
|
||||
if (!arch) {
|
||||
append(`Unsupported arch: ${process.arch}`);
|
||||
return { stdout: lines.join('\n'), ok: false };
|
||||
@@ -201,10 +211,9 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
|
||||
try {
|
||||
append(`Downloading ${url}`);
|
||||
execSync(
|
||||
`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`,
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
@@ -231,7 +240,7 @@ function installOnecliCliDirect(): { stdout: string; ok: boolean } {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -248,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const reuse = args.includes('--reuse');
|
||||
const remoteUrlIdx = args.indexOf('--remote-url');
|
||||
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
|
||||
ensureShellProfilePath();
|
||||
|
||||
if (remoteUrl) {
|
||||
// Remote-mode: install only the CLI, point it at the remote gateway, and
|
||||
// record the URL in .env. No local gateway is started.
|
||||
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
||||
const res = installOnecliCliOnly();
|
||||
if (!res.ok || !onecliVersion()) {
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'cli_install_failed',
|
||||
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli config set api-host failed', { err });
|
||||
}
|
||||
writeEnvOnecliUrl(remoteUrl);
|
||||
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
|
||||
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
|
||||
if (remoteToken) {
|
||||
// Two auth surfaces: `onecli auth login` persists the key for CLI
|
||||
// calls during setup itself (e.g. detecting an existing Anthropic
|
||||
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
|
||||
// read by the runtime SDK at request time. Both are needed.
|
||||
try {
|
||||
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
|
||||
stdio: 'ignore',
|
||||
env: childEnv(),
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('onecli auth login failed', { err });
|
||||
}
|
||||
writeEnvVar('ONECLI_API_KEY', remoteToken);
|
||||
log.info('Wrote ONECLI_API_KEY to .env');
|
||||
}
|
||||
const healthy = await pollHealth(remoteUrl, 5000);
|
||||
emitStatus('ONECLI', {
|
||||
INSTALLED: true,
|
||||
REMOTE: true,
|
||||
ONECLI_URL: remoteUrl,
|
||||
HEALTHY: healthy,
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (reuse) {
|
||||
// Reuse-mode: don't touch the running gateway at all. Just verify it
|
||||
// exists, read its api-host, write ONECLI_URL to .env, and move on.
|
||||
|
||||
@@ -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+$/);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export interface ChannelAdapter {
|
||||
// Optional
|
||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||
syncConversations?(): Promise<ConversationInfo[]>;
|
||||
resolveChannelName?(platformId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Subscribe the bot to a thread so follow-up messages route via the
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json');
|
||||
const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
// Index = number of consecutive crashes (0 = clean start, attempt 1).
|
||||
// 6+ crashes capped at 15min.
|
||||
const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900];
|
||||
|
||||
interface CircuitBreakerState {
|
||||
attempt: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function read(): CircuitBreakerState | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(CB_PATH, 'utf-8');
|
||||
return JSON.parse(raw) as CircuitBreakerState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(state: CircuitBreakerState): void {
|
||||
// The breaker runs before initDb (which is what creates DATA_DIR), so on a
|
||||
// fresh checkout the dir may not exist yet.
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function getDelay(attempt: number): number {
|
||||
const idx = Math.min(attempt - 1, BACKOFF_SCHEDULE_S.length - 1);
|
||||
return BACKOFF_SCHEDULE_S[idx];
|
||||
}
|
||||
|
||||
export function resetCircuitBreaker(): void {
|
||||
try {
|
||||
fs.unlinkSync(CB_PATH);
|
||||
log.info('Circuit breaker reset on clean shutdown');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function enforceStartupBackoff(): Promise<void> {
|
||||
const now = new Date();
|
||||
const prev = read();
|
||||
|
||||
let attempt: number;
|
||||
if (!prev) {
|
||||
attempt = 1;
|
||||
} else {
|
||||
const elapsedMs = now.getTime() - new Date(prev.timestamp).getTime();
|
||||
if (elapsedMs < RESET_WINDOW_MS) {
|
||||
attempt = prev.attempt + 1;
|
||||
log.warn('Previous startup was not a clean shutdown', {
|
||||
previousAttempt: prev.attempt,
|
||||
previousTimestamp: prev.timestamp,
|
||||
elapsedSec: Math.round(elapsedMs / 1000),
|
||||
});
|
||||
} else {
|
||||
attempt = 1;
|
||||
log.info('Circuit breaker reset — last startup was over 1h ago', {
|
||||
previousAttempt: prev.attempt,
|
||||
previousTimestamp: prev.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
write({ attempt, timestamp: now.toISOString() });
|
||||
|
||||
const delaySec = getDelay(attempt);
|
||||
if (delaySec > 0) {
|
||||
const resumeAt = new Date(now.getTime() + delaySec * 1000).toISOString();
|
||||
log.warn('Circuit breaker: delaying startup due to repeated crashes', {
|
||||
attempt,
|
||||
delaySec,
|
||||
resumeAt,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, delaySec * 1000));
|
||||
log.info('Circuit breaker: backoff complete, resuming startup', { attempt });
|
||||
}
|
||||
}
|
||||
+29
-19
@@ -58,7 +58,7 @@ const activeContainers = new Map<string, { process: ChildProcess; containerName:
|
||||
* a duplicate container against the same session directory, producing
|
||||
* racy double-replies.
|
||||
*/
|
||||
const wakePromises = new Map<string, Promise<void>>();
|
||||
const wakePromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
export function getActiveContainerCount(): number {
|
||||
return activeContainers.size;
|
||||
@@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean {
|
||||
* (the in-flight wake promise is reused).
|
||||
*
|
||||
* The container runs the v2 agent-runner which polls the session DB.
|
||||
*
|
||||
* Contract: never throws. Returns `true` on successful spawn, `false` on
|
||||
* transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't
|
||||
* need to wrap — the inbound row stays pending and host-sweep retries on
|
||||
* its next tick. Callers that care (e.g. the router's typing indicator)
|
||||
* can branch on the boolean.
|
||||
*/
|
||||
export function wakeContainer(session: Session): Promise<void> {
|
||||
export function wakeContainer(session: Session): Promise<boolean> {
|
||||
if (activeContainers.has(session.id)) {
|
||||
log.debug('Container already running', { sessionId: session.id });
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const existing = wakePromises.get(session.id);
|
||||
if (existing) {
|
||||
log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id });
|
||||
return existing;
|
||||
}
|
||||
const promise = spawnContainer(session).finally(() => {
|
||||
wakePromises.delete(session.id);
|
||||
});
|
||||
const promise = spawnContainer(session)
|
||||
.then(() => true)
|
||||
.catch((err) => {
|
||||
log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err });
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
wakePromises.delete(session.id);
|
||||
});
|
||||
wakePromises.set(session.id, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -435,20 +447,18 @@ async function buildContainerArgs(
|
||||
}
|
||||
|
||||
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
|
||||
// are routed through the agent vault for credential injection.
|
||||
try {
|
||||
if (agentIdentifier) {
|
||||
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
|
||||
}
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
|
||||
if (onecliApplied) {
|
||||
log.info('OneCLI gateway applied', { containerName });
|
||||
} else {
|
||||
log.warn('OneCLI gateway not applied — container will have no credentials', { containerName });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('OneCLI gateway error — container will have no credentials', { containerName, err });
|
||||
// are routed through the agent vault for credential injection. Treated as
|
||||
// a transient hard failure: if we can't wire the gateway, we don't spawn.
|
||||
// The caller (router or host-sweep) catches the throw, leaves the inbound
|
||||
// message pending, and the next sweep tick retries.
|
||||
if (agentIdentifier) {
|
||||
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
|
||||
}
|
||||
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
|
||||
if (!onecliApplied) {
|
||||
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
|
||||
}
|
||||
log.info('OneCLI gateway applied', { containerName });
|
||||
|
||||
// Host gateway
|
||||
args.push(...hostGatewayArgs());
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
sessionDir,
|
||||
inboundDbPath,
|
||||
outboundDbPath,
|
||||
readOutboxFiles,
|
||||
clearOutbox,
|
||||
} from './session-manager.js';
|
||||
import { getSession, findSession } from './db/sessions.js';
|
||||
import type { InboundEvent } from './channels/adapter.js';
|
||||
@@ -108,6 +110,147 @@ describe('session manager', () => {
|
||||
outDb.close();
|
||||
});
|
||||
|
||||
it('should reject outbound attachment filenames that escape the message outbox', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const dir = sessionDir('ag-1', 'sess-test');
|
||||
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
|
||||
fs.mkdirSync(msgOutbox, { recursive: true });
|
||||
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'outside secret');
|
||||
|
||||
expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject outbound attachment symlinks that escape the message outbox', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const dir = sessionDir('ag-1', 'sess-test');
|
||||
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
|
||||
fs.mkdirSync(msgOutbox, { recursive: true });
|
||||
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'outside secret');
|
||||
fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt'));
|
||||
|
||||
expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not recursively delete outside the outbox for unsafe message ids', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const victimDir = path.join(TEST_DIR, 'victim-dir');
|
||||
fs.mkdirSync(victimDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete');
|
||||
|
||||
clearOutbox('ag-1', 'sess-test', '../../../../victim-dir');
|
||||
|
||||
expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should still read and clear normal basename outbox files', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const dir = sessionDir('ag-1', 'sess-test');
|
||||
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
|
||||
fs.mkdirSync(msgOutbox, { recursive: true });
|
||||
fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok');
|
||||
|
||||
const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']);
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files?.[0]?.filename).toBe('result.txt');
|
||||
expect(files?.[0]?.data.toString()).toBe('ok');
|
||||
|
||||
clearOutbox('ag-1', 'sess-test', 'msg-1');
|
||||
expect(fs.existsSync(msgOutbox)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// The container has /workspace write access, so it can pre create
|
||||
// inbox/<msgId> as a symlink to escape.
|
||||
const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
fs.mkdirSync(inboxRoot, { recursive: true });
|
||||
const evilTarget = path.join(TEST_DIR, 'evil-target');
|
||||
fs.mkdirSync(evilTarget, { recursive: true });
|
||||
fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil'));
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-evil',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'evil',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// The container pre creates inbox/<msgId>/photo.png as a symlink to a
|
||||
// host file. Without the wx flag, writeFileSync would follow it.
|
||||
const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'ORIGINAL');
|
||||
fs.symlinkSync(outside, path.join(inboxDir, 'photo.png'));
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-sym',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'sym',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL');
|
||||
});
|
||||
|
||||
it('should reject inbound attachments when messageId is unsafe', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: '../../escape',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'msgid',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
if (fs.existsSync(inboxRoot)) {
|
||||
expect(fs.readdirSync(inboxRoot)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should still save inbound attachments with safe basenames', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-ok',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'ok',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png');
|
||||
expect(fs.existsSync(expected)).toBe(true);
|
||||
expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES');
|
||||
});
|
||||
|
||||
it('should resolve to existing session (shared mode)', () => {
|
||||
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
expect(c1).toBe(true);
|
||||
@@ -173,6 +316,43 @@ describe('session manager', () => {
|
||||
|
||||
expect(getSession(session.id)!.last_active).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should refuse path-traversal in attachment filenames', () => {
|
||||
// Regression: attachment.name comes from untrusted senders (E2EE-protected
|
||||
// chat platforms can't sanitize it server-side). Without the guard, a
|
||||
// `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere
|
||||
// the host process can reach.
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary');
|
||||
if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget);
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-attack',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'pwn',
|
||||
attachments: [
|
||||
{
|
||||
type: 'document',
|
||||
name: '../../../../../../../../tmp/nanoclaw-traversal-canary',
|
||||
data: Buffer.from('owned').toString('base64'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(escapeTarget)).toBe(false);
|
||||
// The bytes should still land — under a synthesized safe name inside the
|
||||
// inbox — so the agent doesn't lose data on a malicious filename.
|
||||
const inboxDir = path.join(inboxBase, 'msg-attack');
|
||||
expect(fs.existsSync(inboxDir)).toBe(true);
|
||||
const written = fs.readdirSync(inboxDir);
|
||||
expect(written).toHaveLength(1);
|
||||
expect(written[0]).not.toContain('/');
|
||||
expect(written[0]).not.toContain('..');
|
||||
});
|
||||
});
|
||||
|
||||
describe('router', () => {
|
||||
|
||||
@@ -168,6 +168,8 @@ async function sweepSession(session: Session): Promise<void> {
|
||||
const dueCount = countDueMessages(inDb);
|
||||
if (dueCount > 0 && !isContainerRunning(session.id)) {
|
||||
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
|
||||
// wakeContainer never throws — transient spawn failures (OneCLI down,
|
||||
// etc.) return false and leave messages pending for the next tick.
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -7,6 +7,7 @@
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
|
||||
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
|
||||
import { initDb } from './db/connection.js';
|
||||
import { runMigrations } from './db/migrations/index.js';
|
||||
@@ -58,6 +59,9 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from
|
||||
async function main(): Promise<void> {
|
||||
log.info('NanoClaw starting');
|
||||
|
||||
// 0. Circuit breaker — backoff on rapid restarts
|
||||
await enforceStartupBackoff();
|
||||
|
||||
// 1. Init central DB
|
||||
const dbPath = path.join(DATA_DIR, 'v2.db');
|
||||
const db = initDb(dbPath);
|
||||
@@ -174,8 +178,15 @@ async function shutdown(signal: string): Promise<void> {
|
||||
}
|
||||
stopDeliveryPolls();
|
||||
stopHostSweep();
|
||||
await teardownChannelAdapters();
|
||||
process.exit(0);
|
||||
try {
|
||||
await teardownChannelAdapters();
|
||||
} finally {
|
||||
// Always reset on graceful shutdown — even if teardown threw, we got here
|
||||
// via SIGTERM/SIGINT, not a crash, so the next start shouldn't be counted
|
||||
// as one.
|
||||
resetCircuitBreaker();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { isSafeAttachmentName } from '../../attachment-safety.js';
|
||||
import { getAgentGroup } from '../../db/agent-groups.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { wakeContainer } from '../../container-runner.js';
|
||||
@@ -29,6 +30,8 @@ import { resolveSession, sessionDir, writeSessionMessage } from '../../session-m
|
||||
import type { Session } from '../../types.js';
|
||||
import { hasDestination } from './db/agent-destinations.js';
|
||||
|
||||
export { isSafeAttachmentName };
|
||||
|
||||
export interface ForwardedAttachment {
|
||||
name: string;
|
||||
filename: string;
|
||||
@@ -36,26 +39,6 @@ export interface ForwardedAttachment {
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `name` safe to use as the last segment of a path inside the target
|
||||
* agent's inbox directory? Filenames arrive in messages_out content from
|
||||
* the source agent — under a multi-agent setup with heterogenous providers
|
||||
* (or a compromised / hallucinating sub-agent) they can't be trusted.
|
||||
*
|
||||
* Rejects:
|
||||
* - empty string
|
||||
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
|
||||
* - anything containing a path separator (`/` or `\`) or NUL
|
||||
* - any value where `path.basename(name) !== name`, catching OS-specific
|
||||
* separators and covering drives/prefixes on Windows runtimes
|
||||
*/
|
||||
export function isSafeAttachmentName(name: string): boolean {
|
||||
if (typeof name !== 'string' || name.length === 0) return false;
|
||||
if (name === '.' || name === '..') return false;
|
||||
if (/[\\/\0]/.test(name)) return false;
|
||||
return path.basename(name) === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file attachments from the source agent's outbox into the target
|
||||
* agent's inbox. Returns attachments using the formatter's existing
|
||||
|
||||
@@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => {
|
||||
expect(kind).toBe('chat-sdk');
|
||||
const payload = JSON.parse(content as string);
|
||||
expect(payload.type).toBe('ask_question');
|
||||
// Card names the target agent so the owner knows what they're wiring to.
|
||||
expect(payload.question).toContain('Andy');
|
||||
// Single-agent card offers a direct "Connect to <name>" button.
|
||||
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
|
||||
expect(connectOption).toBeDefined();
|
||||
expect(connectOption.label).toContain('Andy');
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
|
||||
@@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => {
|
||||
};
|
||||
expect(pending).toBeDefined();
|
||||
|
||||
// Owner clicks approve.
|
||||
// Owner clicks "Connect to Andy" (single-agent card).
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
value: 'connect:ag-1',
|
||||
userId: 'owner', // raw platform id — handler namespaces it
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
@@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => {
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// Wiring created with MVP defaults.
|
||||
// Wiring created with defaults.
|
||||
const mga = getDb()
|
||||
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as {
|
||||
@@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => {
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
value: 'connect:ag-1',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
|
||||
@@ -5,24 +5,32 @@
|
||||
* addressed to the bot (SDK-confirmed mention or DM), it calls
|
||||
* `requestChannelApproval` instead of silently dropping. The flow:
|
||||
*
|
||||
* 1. Pick the target agent group we'd wire to (MVP: first by name).
|
||||
* Multi-agent picker is a follow-up — see ACTION-ITEMS.
|
||||
* 1. Gather all existing agent groups.
|
||||
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
|
||||
* them, reusing the same primitives the sender-approval flow uses.
|
||||
* 3. Deliver an Approve / Ignore card that names the target agent
|
||||
* explicitly so the owner knows what they're wiring to.
|
||||
* 3. Deliver a card with three action families:
|
||||
* a. Connect to [agent] — one button per existing agent group.
|
||||
* Single-agent installs get a one-click connect.
|
||||
* b. Connect new agent — prompts for a free-text name, creates
|
||||
* the agent immediately on reply.
|
||||
* c. Reject — deny the channel.
|
||||
* 4. Record a `pending_channel_approvals` row holding the original event
|
||||
* so it can be re-routed on approve.
|
||||
* so it can be re-routed on connect/create.
|
||||
*
|
||||
* On approve (handler in index.ts):
|
||||
* - Create `messaging_group_agents` with MVP defaults
|
||||
* On connect (handler in index.ts):
|
||||
* - Create `messaging_group_agents` with defaults
|
||||
* (mention-sticky for groups / pattern='.' for DMs,
|
||||
* sender_scope='known', ignored_message_policy='accumulate')
|
||||
* - Add the triggering sender to `agent_group_members` so sender_scope
|
||||
* doesn't bounce the replayed message into a sender-approval cascade
|
||||
* - Delete the pending row, replay the original event
|
||||
*
|
||||
* On ignore:
|
||||
* On connect new agent (handler in index.ts):
|
||||
* - Prompt for a free-text agent name via DM
|
||||
* - On reply: create the agent group + filesystem, then wire
|
||||
* and replay as above
|
||||
*
|
||||
* On reject:
|
||||
* - Set `messaging_groups.denied_at = now()` so the router stops
|
||||
* escalating on this channel until an admin explicitly re-wires
|
||||
* - Delete the pending row
|
||||
@@ -36,19 +44,81 @@
|
||||
* - Approver has no reachable DM.
|
||||
* - Delivery adapter missing.
|
||||
*/
|
||||
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
|
||||
import { getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { getMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { getChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import type { AgentGroup } from '../../types.js';
|
||||
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
||||
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
||||
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
|
||||
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' },
|
||||
];
|
||||
// ── Value constants (response handler in index.ts parses these) ──
|
||||
|
||||
export const CONNECT_PREFIX = 'connect:';
|
||||
export const NEW_AGENT_VALUE = 'new_agent';
|
||||
export const CHOOSE_EXISTING_VALUE = 'choose_existing';
|
||||
export const REJECT_VALUE = 'reject';
|
||||
|
||||
// ── Utilities ──
|
||||
|
||||
function toFolder(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unnamed'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card builders ──
|
||||
|
||||
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
|
||||
const options: RawOption[] = [];
|
||||
if (agentGroups.length === 1) {
|
||||
options.push({
|
||||
label: `Connect to ${agentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
|
||||
});
|
||||
} else {
|
||||
options.push({
|
||||
label: 'Choose existing agent',
|
||||
selectedLabel: '📋 Choosing…',
|
||||
value: CHOOSE_EXISTING_VALUE,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'Connect new agent',
|
||||
selectedLabel: '🆕 Connecting new agent…',
|
||||
value: NEW_AGENT_VALUE,
|
||||
});
|
||||
options.push({
|
||||
label: 'Reject',
|
||||
selectedLabel: '🙅 Rejected',
|
||||
value: REJECT_VALUE,
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildQuestionText(
|
||||
isGroup: boolean,
|
||||
senderName: string | undefined,
|
||||
channelName: string | null,
|
||||
channelType: string,
|
||||
): string {
|
||||
const who = senderName ?? 'Someone';
|
||||
if (isGroup) {
|
||||
const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
|
||||
return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
|
||||
}
|
||||
return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
|
||||
}
|
||||
|
||||
// ── Main flow ──
|
||||
|
||||
export interface RequestChannelApprovalInput {
|
||||
messagingGroupId: string;
|
||||
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
|
||||
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
|
||||
const { messagingGroupId, event } = input;
|
||||
|
||||
// In-flight dedup: don't spam the owner if the same unwired channel
|
||||
// gets more mentions / DMs while a card is already pending.
|
||||
if (hasInFlightChannelApproval(messagingGroupId)) {
|
||||
log.debug('Channel registration already in flight — dropping retry', {
|
||||
messagingGroupId,
|
||||
});
|
||||
log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
|
||||
return;
|
||||
}
|
||||
|
||||
// MVP: pick the first agent group by name. Multi-agent systems will get
|
||||
// a richer card later (user picks the target from a list).
|
||||
const agentGroups = getAllAgentGroups();
|
||||
if (agentGroups.length === 0) {
|
||||
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
|
||||
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
});
|
||||
return;
|
||||
}
|
||||
const target = agentGroups[0];
|
||||
// Use first agent group for approver resolution — owners and global admins
|
||||
// are returned regardless of which group we pass.
|
||||
const referenceGroup = agentGroups[0];
|
||||
|
||||
// pickApprover takes the target agent group's id — gets scoped admins +
|
||||
// global admins + owners. For fresh installs with only an owner, the
|
||||
// owner is returned.
|
||||
const approvers = pickApprover(target.id);
|
||||
const approvers = pickApprover(referenceGroup.id);
|
||||
if (approvers.length === 0) {
|
||||
log.warn('Channel registration skipped — no owner or admin configured', {
|
||||
messagingGroupId,
|
||||
targetAgentGroupId: target.id,
|
||||
targetAgentGroupId: referenceGroup.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const originMg = getMessagingGroup(messagingGroupId);
|
||||
const originChannelType = originMg?.channel_type ?? '';
|
||||
|
||||
// Resolve channel name if not yet persisted.
|
||||
if (originMg && !originMg.name) {
|
||||
const channelAdapter = getChannelAdapter(originChannelType);
|
||||
if (channelAdapter?.resolveChannelName) {
|
||||
try {
|
||||
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
|
||||
if (name) {
|
||||
updateMessagingGroup(originMg.id, { name });
|
||||
originMg.name = name;
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const delivery = await pickApprovalDelivery(approvers, originChannelType);
|
||||
if (!delivery) {
|
||||
log.warn('Channel registration skipped — no DM channel for any approver', {
|
||||
messagingGroupId,
|
||||
targetAgentGroupId: target.id,
|
||||
targetAgentGroupId: referenceGroup.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
|
||||
|
||||
// Extract sender name from the event content for a human-readable card.
|
||||
let senderName: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
|
||||
} catch {
|
||||
// non-critical — fall through to generic wording
|
||||
// non-critical
|
||||
}
|
||||
|
||||
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
|
||||
const question = isGroup
|
||||
? senderName
|
||||
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
|
||||
: `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
|
||||
: senderName
|
||||
? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
|
||||
: `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
|
||||
const options = normalizeOptions(APPROVAL_OPTIONS);
|
||||
const channelName = originMg?.name ?? null;
|
||||
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups));
|
||||
|
||||
createPendingChannelApproval({
|
||||
messaging_group_id: messagingGroupId,
|
||||
agent_group_id: target.id,
|
||||
agent_group_id: referenceGroup.id,
|
||||
original_message: JSON.stringify(event),
|
||||
approver_user_id: delivery.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) {
|
||||
log.error('Channel registration row created but no delivery adapter is wired', {
|
||||
messagingGroupId,
|
||||
});
|
||||
log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
// Use messaging_group_id as the questionId — it's unique per card
|
||||
// (PK on pending table dedups) and lets the response handler look
|
||||
// up the pending row directly without another index.
|
||||
questionId: messagingGroupId,
|
||||
title,
|
||||
question,
|
||||
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
);
|
||||
log.info('Channel registration card delivered', {
|
||||
messagingGroupId,
|
||||
targetAgentGroupId: target.id,
|
||||
agentGroupCount: agentGroups.length,
|
||||
approver: delivery.userId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Channel registration card delivery failed', {
|
||||
messagingGroupId,
|
||||
err,
|
||||
});
|
||||
log.error('Channel registration card delivery failed', { messagingGroupId, err });
|
||||
}
|
||||
}
|
||||
|
||||
export const APPROVE_VALUE = 'approve';
|
||||
export const REJECT_VALUE = 'reject';
|
||||
// ── Helpers for the response handler (index.ts) ──
|
||||
|
||||
/**
|
||||
* Build normalized options for the agent-selection follow-up card.
|
||||
*/
|
||||
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
|
||||
const options: RawOption[] = agentGroups.map((ag) => ({
|
||||
label: ag.name,
|
||||
selectedLabel: `✅ Connected to ${ag.name}`,
|
||||
value: `${CONNECT_PREFIX}${ag.id}`,
|
||||
}));
|
||||
options.push({
|
||||
label: 'Cancel',
|
||||
selectedLabel: '🙅 Cancelled',
|
||||
value: REJECT_VALUE,
|
||||
});
|
||||
return normalizeOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent group and initialize its filesystem. Handles
|
||||
* folder-name collisions with numeric suffixes.
|
||||
*/
|
||||
export function createNewAgentGroup(name: string): AgentGroup {
|
||||
let folder = toFolder(name);
|
||||
const baseFolder = folder;
|
||||
let suffix = 2;
|
||||
while (getAgentGroupByFolder(folder)) {
|
||||
folder = `${baseFolder}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createAgentGroup({
|
||||
id: agId,
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const ag = getAgentGroup(agId)!;
|
||||
initGroupFilesystem(ag);
|
||||
return ag;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void {
|
||||
getDb()
|
||||
.prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?')
|
||||
.run(title, optionsJson, messagingGroupId);
|
||||
}
|
||||
|
||||
export function deletePendingChannelApproval(messagingGroupId: string): void {
|
||||
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
|
||||
}
|
||||
|
||||
@@ -16,27 +16,53 @@
|
||||
* access gate is not registered and core defaults to allow-all.
|
||||
*/
|
||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
||||
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
|
||||
import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
setChannelRequestGate,
|
||||
setMessageInterceptor,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
} from '../../router.js';
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { requestChannelApproval } from './channel-approval.js';
|
||||
import {
|
||||
buildAgentSelectionOptions,
|
||||
CHOOSE_EXISTING_VALUE,
|
||||
CONNECT_PREFIX,
|
||||
createNewAgentGroup,
|
||||
NEW_AGENT_VALUE,
|
||||
REJECT_VALUE,
|
||||
requestChannelApproval,
|
||||
} from './channel-approval.js';
|
||||
import { addMember } from './db/agent-group-members.js';
|
||||
import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js';
|
||||
import {
|
||||
deletePendingChannelApproval,
|
||||
getPendingChannelApproval,
|
||||
updatePendingChannelApprovalCard,
|
||||
} from './db/pending-channel-approvals.js';
|
||||
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
|
||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||
import { getUser, upsertUser } from './db/users.js';
|
||||
import { requestSenderApproval } from './sender-approval.js';
|
||||
import { ensureUserDm } from './user-dm.js';
|
||||
|
||||
// ── Free-text name input state ──
|
||||
// Tracks approvers waiting for a text reply with the agent name. Keyed by
|
||||
// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart.
|
||||
interface PendingNameInput {
|
||||
channelMgId: string;
|
||||
dmChannelType: string;
|
||||
dmPlatformId: string;
|
||||
}
|
||||
const awaitingNameInput = new Map<string, PendingNameInput>();
|
||||
|
||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||
let content: Record<string, unknown>;
|
||||
@@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => {
|
||||
* by messaging_group_id). If no such row, return false so downstream
|
||||
* handlers get a shot.
|
||||
*
|
||||
* Approve: create the wiring with MVP defaults (mention-sticky for
|
||||
* groups / pattern='.' for DMs; sender_scope='known';
|
||||
* ignored_message_policy='accumulate'), add the triggering sender as a
|
||||
* member so sender_scope doesn't immediately bounce them into a
|
||||
* sender-approval card, then replay the original event.
|
||||
*
|
||||
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
|
||||
* this channel drop silently until an admin explicitly wires it.
|
||||
* Value dispatch:
|
||||
* connect:<id> — wire to an existing agent group, replay the message
|
||||
* choose_existing — send a follow-up card listing all agents
|
||||
* new_agent — prompt for a free-text agent name (interceptor
|
||||
* captures the reply and creates immediately)
|
||||
* reject — set denied_at, delete pending row
|
||||
*/
|
||||
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const row = getPendingChannelApproval(payload.questionId);
|
||||
if (!row) return false;
|
||||
|
||||
// Click-auth: same pattern as sender-approval (see commit 68058cb).
|
||||
// Raw platform userId → namespace with channelType → must match the
|
||||
// designated approver OR have admin privilege over the target agent.
|
||||
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
clickerId,
|
||||
expectedApprover: row.approver_user_id,
|
||||
});
|
||||
return true; // claim but take no action
|
||||
return true;
|
||||
}
|
||||
const approverId = clickerId;
|
||||
const approved = payload.value === 'approve';
|
||||
|
||||
if (!approved) {
|
||||
// ── Reject / Cancel ──
|
||||
if (payload.value === REJECT_VALUE) {
|
||||
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
log.info('Channel registration denied', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: row.agent_group_id,
|
||||
approverId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rehydrate the original event to know (a) whether it was a DM or group
|
||||
// (chooses engage_mode default), and (b) who the triggering sender was
|
||||
// (auto-member-add so sender_scope='known' doesn't bounce the replay).
|
||||
// ── Choose existing agent — send agent-selection follow-up card ──
|
||||
if (payload.value === CHOOSE_EXISTING_VALUE) {
|
||||
const approverDm = await ensureUserDm(row.approver_user_id);
|
||||
if (!approverDm) {
|
||||
log.error('Channel registration: no DM channel for approver', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverUserId: row.approver_user_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) return true;
|
||||
|
||||
const agentGroups = getAllAgentGroups();
|
||||
const options = buildAgentSelectionOptions(agentGroups);
|
||||
const title = '📋 Choose an agent';
|
||||
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
||||
|
||||
try {
|
||||
await adapter.deliver(
|
||||
approverDm.channel_type,
|
||||
approverDm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: row.messaging_group_id,
|
||||
title,
|
||||
question: 'Which agent should handle this channel?',
|
||||
options,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('Channel registration: agent-selection card delivery failed', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Create new agent — prompt for free-text name ──
|
||||
if (payload.value === NEW_AGENT_VALUE) {
|
||||
const approverDm = await ensureUserDm(row.approver_user_id);
|
||||
if (!approverDm) {
|
||||
log.error('Channel registration: no DM channel for approver', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverUserId: row.approver_user_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) {
|
||||
log.error('Channel registration: no delivery adapter for name prompt', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
awaitingNameInput.set(row.approver_user_id, {
|
||||
channelMgId: row.messaging_group_id,
|
||||
dmChannelType: approverDm.channel_type,
|
||||
dmPlatformId: approverDm.platform_id,
|
||||
});
|
||||
|
||||
try {
|
||||
await adapter.deliver(
|
||||
approverDm.channel_type,
|
||||
approverDm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({ text: 'Reply with the name for your new agent:' }),
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('Channel registration: name prompt delivery failed', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
awaitingNameInput.delete(row.approver_user_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resolve target agent group (connect to existing or create new) ──
|
||||
let targetAgentGroupId: string;
|
||||
|
||||
if (payload.value.startsWith(CONNECT_PREFIX)) {
|
||||
targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length);
|
||||
const ag = getAgentGroup(targetAgentGroupId);
|
||||
if (!ag) {
|
||||
log.error('Channel registration: target agent group no longer exists', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
targetAgentGroupId,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
log.warn('Channel registration: unknown response value', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
value: payload.value,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Wire + replay (shared path for connect and create) ──
|
||||
let event: InboundEvent;
|
||||
try {
|
||||
event = JSON.parse(row.original_message) as InboundEvent;
|
||||
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decide engage_mode from the original event. DMs (`isMention=true` &
|
||||
// not in a group) get `pattern='.'` (always respond). Group mentions
|
||||
// get `mention-sticky` (respond now + follow the thread).
|
||||
//
|
||||
// We can't read `mg.is_group` reliably here because we only auto-create
|
||||
// the mg with `is_group=0` on first sight — the adapter hasn't told us
|
||||
// yet whether it's actually a group. Fall back to the InboundEvent's
|
||||
// `threadId`: a non-null threadId implies a threaded platform (Slack
|
||||
// channel thread, Discord thread), which we treat as a group.
|
||||
const isGroup = event.threadId !== null;
|
||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||
const engagePattern = isGroup ? null : '.';
|
||||
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: row.agent_group_id,
|
||||
agent_group_id: targetAgentGroupId,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'known',
|
||||
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: row.agent_group_id,
|
||||
agentGroupId: targetAgentGroupId,
|
||||
mgaId,
|
||||
engageMode,
|
||||
approverId,
|
||||
});
|
||||
|
||||
// Auto-admit the triggering sender. Without this, the replay below
|
||||
// would bounce through sender-approval (sender_scope='known' +
|
||||
// sender-is-not-a-member).
|
||||
const senderUserId = extractAndUpsertUser(event);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: row.agent_group_id,
|
||||
agent_group_id: targetAgentGroupId,
|
||||
added_by: approverId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the pending row BEFORE replay so the gate check on the second
|
||||
// attempt sees a wired channel (agentCount > 0) and takes the fan-out
|
||||
// path normally.
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
}
|
||||
|
||||
registerResponseHandler(handleChannelApprovalResponse);
|
||||
|
||||
// ── Free-text name interceptor ──
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
|
||||
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return false;
|
||||
|
||||
const pending = awaitingNameInput.get(userId);
|
||||
if (!pending) return false;
|
||||
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
|
||||
|
||||
awaitingNameInput.delete(userId);
|
||||
|
||||
let text: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
log.warn('Channel registration: empty name reply, ignoring', { userId });
|
||||
return true;
|
||||
}
|
||||
|
||||
const row = getPendingChannelApproval(pending.channelMgId);
|
||||
if (!row) return true;
|
||||
|
||||
const ag = createNewAgentGroup(text);
|
||||
log.info('Channel registration: new agent group created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: ag.id,
|
||||
agentName: ag.name,
|
||||
folder: ag.folder,
|
||||
});
|
||||
|
||||
let originalEvent: InboundEvent;
|
||||
try {
|
||||
originalEvent = JSON.parse(row.original_message) as InboundEvent;
|
||||
} catch (err) {
|
||||
log.error('Channel registration: failed to parse stored event', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = originalEvent.threadId !== null;
|
||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||
const engagePattern = isGroup ? null : '.';
|
||||
|
||||
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: ag.id,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'known',
|
||||
ignored_message_policy: 'accumulate',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: ag.id,
|
||||
mgaId,
|
||||
engageMode,
|
||||
approverId: userId,
|
||||
});
|
||||
|
||||
const senderUserId = extractAndUpsertUser(originalEvent);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: ag.id,
|
||||
added_by: userId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
await routeInbound(originalEvent);
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after channel approval', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (adapter) {
|
||||
const dm = await ensureUserDm(row.approver_user_id);
|
||||
if (dm) {
|
||||
adapter
|
||||
.deliver(
|
||||
dm.channel_type,
|
||||
dm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Claude provider container config — only registered when the user has
|
||||
* configured a custom Anthropic-compatible endpoint via setup. Setup
|
||||
* appends `import './claude.js'` to providers/index.ts at that point;
|
||||
* standard installs hitting api.anthropic.com don't need this file
|
||||
* loaded.
|
||||
*
|
||||
* The real auth token never enters the container. Setup creates an
|
||||
* OneCLI generic secret (host-pattern = base URL hostname, header-name
|
||||
* = Authorization, value-format = "Bearer {value}") so the proxy
|
||||
* rewrites the Authorization header on the wire. The container only
|
||||
* needs:
|
||||
* - ANTHROPIC_BASE_URL — so the SDK knows where to call
|
||||
* - ANTHROPIC_AUTH_TOKEN=placeholder — so the SDK adds an
|
||||
* Authorization: Bearer header for OneCLI to overwrite
|
||||
*/
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { registerProviderContainerConfig } from './provider-container-registry.js';
|
||||
|
||||
registerProviderContainerConfig('claude', () => {
|
||||
const dotenv = readEnvFile(['ANTHROPIC_BASE_URL']);
|
||||
const env: Record<string, string> = {};
|
||||
if (dotenv.ANTHROPIC_BASE_URL) {
|
||||
env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL;
|
||||
env.ANTHROPIC_AUTH_TOKEN = 'placeholder';
|
||||
}
|
||||
return { env };
|
||||
});
|
||||
+32
-3
@@ -27,7 +27,7 @@ import {
|
||||
getMessagingGroupWithAgentCount,
|
||||
} from './db/messaging-groups.js';
|
||||
import { findSessionForAgent } from './db/sessions.js';
|
||||
import { startTypingRefresh } from './modules/typing/index.js';
|
||||
import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js';
|
||||
import { log } from './log.js';
|
||||
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
|
||||
import { wakeContainer } from './container-runner.js';
|
||||
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
senderScopeGate = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-interceptor hook. Runs at the very top of routeInbound, before
|
||||
* messaging-group resolution. When the interceptor returns true the message
|
||||
* is consumed and routing stops. Used by the permissions module to capture
|
||||
* free-text replies during multi-step approval flows (e.g. agent naming).
|
||||
*/
|
||||
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||
|
||||
let messageInterceptor: MessageInterceptorFn | null = null;
|
||||
|
||||
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptor = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel-registration hook. Runs when the router sees a mention/DM on a
|
||||
* messaging group that has no wirings AND hasn't been denied. The hook is
|
||||
@@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
|
||||
* Creates messaging group + session if they don't exist yet.
|
||||
*/
|
||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// Pre-route interceptor — lets modules consume messages before any routing
|
||||
// (e.g. free-text replies during multi-step approval flows).
|
||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel.
|
||||
const adapter = getChannelAdapter(event.channelType);
|
||||
@@ -289,7 +307,14 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err });
|
||||
});
|
||||
}
|
||||
} else if (agent.ignored_message_policy === 'accumulate') {
|
||||
} else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) {
|
||||
// Accumulate stores the message as silent context. We allow it when
|
||||
// engagement simply didn't fire, but NOT when engagement fired and
|
||||
// the access/scope gate refused — those refusals are security
|
||||
// decisions about an untrusted sender, and silently storing their
|
||||
// message (which also stages their attachments to disk via
|
||||
// writeSessionMessage → extractAttachmentFiles) is exactly what the
|
||||
// gate is meant to prevent.
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
|
||||
accumulatedCount++;
|
||||
} else {
|
||||
@@ -450,7 +475,11 @@ async function deliverToAgent(
|
||||
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
|
||||
const freshSession = getSession(session.id);
|
||||
if (freshSession) {
|
||||
await wakeContainer(freshSession);
|
||||
const woke = await wakeContainer(freshSession);
|
||||
// wakeContainer never throws — it returns false on transient spawn
|
||||
// failure (host-sweep retries). Stop the typing indicator we just
|
||||
// started so it doesn't leak; the inbound row stays pending.
|
||||
if (!woke) stopTypingRefresh(freshSession.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+141
-15
@@ -14,12 +14,13 @@ import type Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { deriveAttachmentName } from './attachment-naming.js';
|
||||
import { isSafeAttachmentName } from './attachment-safety.js';
|
||||
import type { OutboundFile } from './channels/adapter.js';
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import {
|
||||
createSession,
|
||||
findSession,
|
||||
findSessionByAgentGroup,
|
||||
findSessionForAgent,
|
||||
getSession,
|
||||
@@ -36,6 +37,11 @@ import {
|
||||
import { log } from './log.js';
|
||||
import type { Session } from './types.js';
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/** Root directory for all session data. */
|
||||
export function sessionsBaseDir(): string {
|
||||
return path.join(DATA_DIR, 'v2-sessions');
|
||||
@@ -232,6 +238,20 @@ export function writeSessionMessage(
|
||||
/**
|
||||
* If message content has attachments with base64 `data`, save them to
|
||||
* the session's inbox directory and replace with `localPath`.
|
||||
*
|
||||
* Both `messageId` and `att.name` originate in untrusted input. WhatsApp
|
||||
* passes `msg.key.id` through raw (and that field is client generated, so a
|
||||
* peer can craft it), and other adapters may follow. The session dir is
|
||||
* mounted writable into the container, so a compromised agent can also
|
||||
* pre-place a symlink at `inbox/<future msgId>/` and wait for a chat message
|
||||
* with a matching id to redirect the host's write.
|
||||
*
|
||||
* Defenses, mirrored from the outbound side:
|
||||
* 1. basename check on `messageId` and `filename`.
|
||||
* 2. lstat of the inbox dir to refuse pre-placed symlinks.
|
||||
* 3. realpath-based containment under the session inbox root.
|
||||
* 4. `wx` flag on writeFileSync to refuse following a pre-existing symlink
|
||||
* at the target file path or overwriting any existing file.
|
||||
*/
|
||||
function extractAttachmentFiles(
|
||||
agentGroupId: string,
|
||||
@@ -249,19 +269,75 @@ function extractAttachmentFiles(
|
||||
const attachments = parsed.attachments as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(attachments)) return contentStr;
|
||||
|
||||
if (!isSafeAttachmentName(messageId)) {
|
||||
log.warn('Rejecting unsafe inbound message id', { messageId });
|
||||
return contentStr;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const att of attachments) {
|
||||
if (typeof att.data === 'string') {
|
||||
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const filename = (att.name as string) || `attachment-${Date.now()}`;
|
||||
const filePath = path.join(inboxDir, filename);
|
||||
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'));
|
||||
att.localPath = `inbox/${messageId}/${filename}`;
|
||||
delete att.data;
|
||||
changed = true;
|
||||
log.debug('Saved attachment to inbox', { messageId, filename, size: att.size });
|
||||
if (typeof att.data !== 'string') continue;
|
||||
|
||||
const rawName = deriveAttachmentName(att);
|
||||
const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
|
||||
if (filename !== rawName) {
|
||||
log.warn('Refused unsafe attachment filename, would escape inbox', {
|
||||
messageId,
|
||||
rawName,
|
||||
replacement: filename,
|
||||
});
|
||||
}
|
||||
|
||||
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
|
||||
|
||||
// Refuse to mkdir through a symlink that the container may have pre placed
|
||||
// at inboxDir. With recursive:true, mkdirSync would silently no op on a
|
||||
// pre existing symlink and the subsequent writeFileSync would follow it.
|
||||
if (fs.existsSync(inboxDir)) {
|
||||
const stat = fs.lstatSync(inboxDir);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
|
||||
let realInboxDir: string;
|
||||
try {
|
||||
realInboxDir = fs.realpathSync(inboxDir);
|
||||
} catch (err) {
|
||||
log.warn('Failed to resolve inbox directory', { messageId, err });
|
||||
continue;
|
||||
}
|
||||
const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox');
|
||||
if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) {
|
||||
log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir });
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(inboxDir, filename);
|
||||
try {
|
||||
// wx = exclusive create. Refuses to follow a pre existing symlink or
|
||||
// overwrite any existing file. The host expects to be the sole writer
|
||||
// of these attachments.
|
||||
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'), { flag: 'wx' });
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'EEXIST') {
|
||||
log.warn('Inbox attachment target already exists, refusing to overwrite', {
|
||||
messageId,
|
||||
filename,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
att.name = filename;
|
||||
att.localPath = `inbox/${messageId}/${filename}`;
|
||||
delete att.data;
|
||||
changed = true;
|
||||
log.debug('Saved attachment to inbox', { messageId, filename, size: att.size });
|
||||
}
|
||||
|
||||
return changed ? JSON.stringify(parsed) : contentStr;
|
||||
@@ -352,14 +428,48 @@ export function readOutboxFiles(
|
||||
messageId: string,
|
||||
filenames: string[],
|
||||
): OutboundFile[] | undefined {
|
||||
if (!isSafeAttachmentName(messageId)) {
|
||||
log.warn('Rejecting unsafe outbox message id', { messageId });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId);
|
||||
if (!fs.existsSync(outboxDir)) return undefined;
|
||||
|
||||
let realOutboxDir: string;
|
||||
try {
|
||||
const stat = fs.lstatSync(outboxDir);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
log.warn('Rejecting unsafe outbox directory', { messageId, outboxDir });
|
||||
return undefined;
|
||||
}
|
||||
realOutboxDir = fs.realpathSync(outboxDir);
|
||||
} catch (err) {
|
||||
log.warn('Failed to inspect outbox directory', { messageId, err });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const files: OutboundFile[] = [];
|
||||
for (const filename of filenames) {
|
||||
if (!isSafeAttachmentName(filename)) {
|
||||
log.warn('Refused unsafe outbox filename, would escape outbox', { messageId, filename });
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(outboxDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
files.push({ filename, data: fs.readFileSync(filePath) });
|
||||
} else {
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
log.warn('Rejecting unsafe outbox file', { messageId, filename });
|
||||
continue;
|
||||
}
|
||||
const realFilePath = fs.realpathSync(filePath);
|
||||
if (!isPathInside(realOutboxDir, realFilePath)) {
|
||||
log.warn('Rejecting outbox file outside message directory', { messageId, filename });
|
||||
continue;
|
||||
}
|
||||
files.push({ filename, data: fs.readFileSync(realFilePath) });
|
||||
} catch {
|
||||
log.warn('Outbox file not found', { messageId, filename });
|
||||
}
|
||||
}
|
||||
@@ -373,10 +483,26 @@ export function readOutboxFiles(
|
||||
* thrown error would trigger the delivery retry path and deliver twice.
|
||||
*/
|
||||
export function clearOutbox(agentGroupId: string, sessionId: string, messageId: string): void {
|
||||
if (!isSafeAttachmentName(messageId)) {
|
||||
log.warn('Rejecting unsafe outbox cleanup message id', { messageId });
|
||||
return;
|
||||
}
|
||||
|
||||
const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId);
|
||||
if (!fs.existsSync(outboxDir)) return;
|
||||
try {
|
||||
fs.rmSync(outboxDir, { recursive: true, force: true });
|
||||
const stat = fs.lstatSync(outboxDir);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
log.warn('Rejecting unsafe outbox cleanup directory', { messageId, outboxDir });
|
||||
return;
|
||||
}
|
||||
const realOutboxBase = fs.realpathSync(path.join(sessionDir(agentGroupId, sessionId), 'outbox'));
|
||||
const realOutboxDir = fs.realpathSync(outboxDir);
|
||||
if (!isPathInside(realOutboxBase, realOutboxDir)) {
|
||||
log.warn('Rejecting outbox cleanup outside session outbox', { messageId, outboxDir });
|
||||
return;
|
||||
}
|
||||
fs.rmSync(realOutboxDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
log.warn('Outbox cleanup failed (message already delivered)', { messageId, err });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user