diff --git a/CLAUDE.md b/CLAUDE.md
index 6565e8f..1376a9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -157,6 +157,17 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, `SKILL.md` format rules, and the pre-submission checklist.
+## 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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7a7816a..413e542 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
-3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
+3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
+4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
| Checkbox | Label |
|----------|-------|
diff --git a/assets/setup-splash.txt b/assets/setup-splash.txt
new file mode 100644
index 0000000..e4b77ec
--- /dev/null
+++ b/assets/setup-splash.txt
@@ -0,0 +1,30 @@
+
+ [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
diff --git a/container/Dockerfile b/container/Dockerfile
index 4b4cf22..efa58b6 100644
--- a/container/Dockerfile
+++ b/container/Dockerfile
@@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
-ARG VERCEL_VERSION=latest
+ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
# ---- System dependencies -----------------------------------------------------
diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts
index 3f0e73b..3ca44a8 100644
--- a/container/agent-runner/src/db/connection.ts
+++ b/container/agent-runner/src/db/connection.ts
@@ -28,11 +28,37 @@ let _inbound: Database | null = null;
let _outbound: Database | null = null;
let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
-/** Inbound DB — container opens read-only (host is the sole writer). */
+/**
+ * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled.
+ *
+ * Use this (not getInboundDb) for readers that need to see host-written rows
+ * promptly — e.g. messages_in polling. Caller must .close() the returned
+ * connection (try/finally).
+ *
+ * Needed for mounts where host writes don't reliably invalidate
+ * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple
+ * Container), NFS.
+ *
+ * Cost is microseconds per query, so safe for universal use.
+ */
+export function openInboundDb(): Database {
+ const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
+ db.exec('PRAGMA busy_timeout = 5000');
+ db.exec('PRAGMA mmap_size = 0');
+ return db;
+}
+
+/**
+ * Inbound DB — long-lived singleton, OK for tables the host writes once
+ * at spawn and never again (destinations, session_routing). For
+ * messages_in polling — where the host writes continuously and a stale
+ * view causes the pollHandle hang — use `openInboundDb()` instead.
+ */
export function getInboundDb(): Database {
if (!_inbound) {
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
_inbound.exec('PRAGMA busy_timeout = 5000');
+ _inbound.exec('PRAGMA mmap_size = 0');
}
return _inbound;
}
diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts
index 4ecf818..88906ed 100644
--- a/container/agent-runner/src/db/messages-in.ts
+++ b/container/agent-runner/src/db/messages-in.ts
@@ -8,7 +8,7 @@
* processing_ack. The host reads processing_ack to sync message lifecycle.
*/
import { getConfig } from '../config.js';
-import { getInboundDb, getOutboundDb } from './connection.js';
+import { openInboundDb, getOutboundDb } from './connection.js';
export interface MessageInRow {
id: string;
@@ -50,31 +50,35 @@ function getMaxMessagesPerPrompt(): number {
* trigger=1 separately (see src/db/session-db.ts).
*/
export function getPendingMessages(): MessageInRow[] {
- const inbound = getInboundDb();
+ const inbound = openInboundDb();
const outbound = getOutboundDb();
- const pending = inbound
- .prepare(
- `SELECT * FROM messages_in
- WHERE status = 'pending'
- AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
- ORDER BY seq DESC
- LIMIT ?`,
- )
- .all(getMaxMessagesPerPrompt()) as MessageInRow[];
+ try {
+ const pending = inbound
+ .prepare(
+ `SELECT * FROM messages_in
+ WHERE status = 'pending'
+ AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
+ ORDER BY seq DESC
+ LIMIT ?`,
+ )
+ .all(getMaxMessagesPerPrompt()) as MessageInRow[];
- if (pending.length === 0) return [];
+ if (pending.length === 0) return [];
- // Filter out messages already acknowledged in outbound.db
- const ackedIds = new Set(
- (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
- (r) => r.message_id,
- ),
- );
+ // Filter out messages already acknowledged in outbound.db
+ const ackedIds = new Set(
+ (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map(
+ (r) => r.message_id,
+ ),
+ );
- // Reverse: we fetched DESC to take the most recent N, but the agent
- // should see them in chronological order (oldest first).
- return pending.filter((m) => !ackedIds.has(m.id)).reverse();
+ // Reverse: we fetched DESC to take the most recent N, but the agent
+ // should see them in chronological order (oldest first).
+ return pending.filter((m) => !ackedIds.has(m.id)).reverse();
+ } finally {
+ inbound.close();
+ }
}
/** Mark messages as processing — writes to processing_ack in outbound.db. */
@@ -112,7 +116,12 @@ export function markFailed(id: string): void {
/** Get a message by ID (read from inbound.db). */
export function getMessageIn(id: string): MessageInRow | undefined {
- return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
+ const inbound = openInboundDb();
+ try {
+ return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
+ } finally {
+ inbound.close();
+ }
}
/**
@@ -120,19 +129,23 @@ export function getMessageIn(id: string): MessageInRow | undefined {
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
*/
export function findQuestionResponse(questionId: string): MessageInRow | undefined {
- const inbound = getInboundDb();
+ const inbound = openInboundDb();
const outbound = getOutboundDb();
- const response = inbound
- .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
- .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
+ try {
+ const response = inbound
+ .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
+ .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
- if (!response) return undefined;
+ if (!response) return undefined;
- // Check it hasn't been acked already
- const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
- if (acked) return undefined;
+ // Check it hasn't been acked already
+ const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
+ if (acked) return undefined;
- return response;
+ return response;
+ } finally {
+ inbound.close();
+ }
}
diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts
index 00e41bb..9b8451d 100644
--- a/container/agent-runner/src/mcp-tools/scheduling.ts
+++ b/container/agent-runner/src/mcp-tools/scheduling.ts
@@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = {
script,
processAfter,
recurrence,
+ platformId: r.platform_id,
+ channelType: r.channel_type,
+ threadId: r.thread_id,
}),
});
diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts
index bd48db2..986489f 100644
--- a/container/agent-runner/src/poll-loop.ts
+++ b/container/agent-runner/src/poll-loop.ts
@@ -260,31 +260,69 @@ async function processQuery(
// Stream liveness is decided host-side via the heartbeat file + processing
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
// will kill the container and messages get reset to pending.
+ let pollInFlight = false;
const pollHandle = setInterval(() => {
- if (done) return;
+ if (done || pollInFlight) return;
+ pollInFlight = true;
- // Skip system messages (MCP tool responses) and /clear (needs fresh query).
- // Thread routing is the router's concern — if a message landed in this
- // session, the agent should see it. Per-thread sessions already isolate
- // threads into separate containers; shared sessions intentionally merge
- // everything. Filtering on thread_id here caused deadlocks when the
- // initial batch and follow-ups had mismatched thread_ids (e.g. a
- // host-generated welcome trigger with null thread vs a Discord DM reply).
- const newMessages = getPendingMessages().filter((m) => {
- if (m.kind === 'system') return false;
- if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
- return true;
- });
- if (newMessages.length > 0) {
- const newIds = newMessages.map((m) => m.id);
- markProcessing(newIds);
+ void (async () => {
+ try {
+ // Skip system messages (MCP tool responses) and /clear (needs fresh query).
+ // Thread routing is the router's concern — if a message landed in this
+ // session, the agent should see it. Per-thread sessions already isolate
+ // threads into separate containers; shared sessions intentionally merge
+ // everything. Filtering on thread_id here caused deadlocks when the
+ // initial batch and follow-ups had mismatched thread_ids (e.g. a
+ // host-generated welcome trigger with null thread vs a Discord DM reply).
+ const newMessages = getPendingMessages().filter((m) => {
+ if (m.kind === 'system') return false;
+ if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
+ return true;
+ });
+ if (newMessages.length === 0) return;
- const prompt = formatMessages(newMessages);
- log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
- query.push(prompt);
+ const newIds = newMessages.map((m) => m.id);
+ markProcessing(newIds);
- markCompleted(newIds);
- }
+ // Run pre-task scripts on follow-ups too — without this, a task that
+ // arrives during an active query (e.g. a */10 monitoring cron) bypasses
+ // its script gate and always wakes the agent, defeating the gate.
+ // Mirrors the initial-batch hook above.
+ let keep = newMessages;
+ let skipped: string[] = [];
+ // MODULE-HOOK:scheduling-pre-task-followup:start
+ const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
+ const preTask = await applyPreTaskScripts(newMessages);
+ keep = preTask.keep;
+ skipped = preTask.skipped;
+ if (skipped.length > 0) {
+ markCompleted(skipped);
+ log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
+ }
+ // MODULE-HOOK:scheduling-pre-task-followup:end
+
+ if (keep.length === 0) return;
+ // Re-check done — the outer query may have finished while the script
+ // was awaited. Pushing into a closed stream is wasted work; the
+ // claimed messages get released by the host's processing-claim sweep.
+ if (done) return;
+
+ const keptIds = keep.map((m) => m.id);
+ const prompt = formatMessages(keep);
+ log(`Pushing ${keep.length} follow-up message(s) into active query`);
+ query.push(prompt);
+ markCompleted(keptIds);
+ } catch (err) {
+ // Without this catch the rejection escapes the void IIFE and Node
+ // terminates the container on unhandled-rejection. The initial-batch
+ // path is wrapped by processQuery's outer try/catch; the follow-up
+ // path is not, so it needs its own.
+ const errMsg = err instanceof Error ? err.message : String(err);
+ log(`Follow-up poll error: ${errMsg}`);
+ } finally {
+ pollInFlight = false;
+ }
+ })();
}, ACTIVE_POLL_INTERVAL_MS);
try {
diff --git a/nanoclaw.sh b/nanoclaw.sh
index 058dbbf..82d445a 100755
--- a/nanoclaw.sh
+++ b/nanoclaw.sh
@@ -129,10 +129,46 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header
-# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
-# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
-# skips re-printing the wordmark, keeping the flow visually continuous.
-printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
+# 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
-
135k tokens, 68% of context window
+
+ 139k tokens, 70% of context window
@@ -10,13 +10,13 @@
-
+
tokens
tokens
- 135k
- 135k
+ 139k
+ 139k
diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts
new file mode 100644
index 0000000..c85679f
--- /dev/null
+++ b/scripts/delete-cli-agent.ts
@@ -0,0 +1,75 @@
+/**
+ * Delete the scratch CLI agent created during setup's ping-pong test.
+ *
+ * Dynamically finds and removes all rows referencing the agent group
+ * (any table with an agent_group_id column), deletes the agent group
+ * itself, and removes the groups// directory. Leaves the CLI
+ * messaging group intact so it can be reused for a new agent.
+ *
+ * Usage:
+ * pnpm exec tsx scripts/delete-cli-agent.ts --folder
+ */
+import fs from 'fs';
+import path from 'path';
+
+import { DATA_DIR } from '../src/config.js';
+import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js';
+import { initDb } from '../src/db/connection.js';
+import { runMigrations } from '../src/db/migrations/index.js';
+
+interface Args {
+ folder: string;
+}
+
+function parseArgs(): Args {
+ const argv = process.argv.slice(2);
+ let folder = '';
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i];
+ }
+ if (!folder) {
+ console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder ');
+ process.exit(1);
+ }
+ return { folder };
+}
+
+const args = parseArgs();
+
+const db = initDb(path.join(DATA_DIR, 'v2.db'));
+runMigrations(db);
+
+const ag = getAgentGroupByFolder(args.folder);
+if (!ag) {
+ console.log(`No agent group with folder "${args.folder}" — nothing to delete.`);
+ process.exit(0);
+}
+
+const cleanup = db.transaction(() => {
+ const tables = db
+ .prepare(
+ `SELECT DISTINCT m.name FROM sqlite_master m
+ JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id'
+ WHERE m.type = 'table' AND m.name != 'agent_groups'`,
+ )
+ .all() as { name: string }[];
+ for (const { name } of tables) {
+ db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id);
+ }
+ deleteAgentGroup(ag.id);
+});
+cleanup();
+
+// Remove the groups// directory.
+const groupDir = path.join(process.cwd(), 'groups', args.folder);
+if (fs.existsSync(groupDir)) {
+ fs.rmSync(groupDir, { recursive: true });
+}
+
+// Remove session data on disk.
+const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id);
+if (fs.existsSync(sessionsDir)) {
+ fs.rmSync(sessionsDir, { recursive: true });
+}
+
+console.log(`Deleted agent group ${ag.id} (${args.folder}).`);
diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts
index 4a56827..73fb9d1 100644
--- a/scripts/init-cli-agent.ts
+++ b/scripts/init-cli-agent.ts
@@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
interface Args {
displayName: string;
agentName: string;
+ folder?: string;
}
function parseArgs(argv: string[]): Args {
let displayName: string | undefined;
let agentName: string | undefined;
+ let folder: string | undefined;
for (let i = 0; i < argv.length; i++) {
const key = argv[i];
const val = argv[i + 1];
@@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args {
} else if (key === '--agent-name') {
agentName = val;
i++;
+ } else if (key === '--folder') {
+ folder = val;
+ i++;
}
}
@@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args {
return {
displayName,
agentName: agentName?.trim() || displayName,
+ folder,
};
}
@@ -95,7 +101,7 @@ async function main(): Promise {
const promotedToOwner = false;
// 2. Agent group + filesystem.
- const folder = `cli-with-${normalizeName(args.displayName)}`;
+ const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
if (!ag) {
const agId = generateId('ag');
diff --git a/setup/auto.ts b/setup/auto.ts
index 392bc13..f977571 100644
--- a/setup/auto.ts
+++ b/setup/auto.ts
@@ -23,6 +23,7 @@
*/
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';
@@ -51,9 +52,9 @@ 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 { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, 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';
@@ -62,6 +63,13 @@ const RUN_START = Date.now();
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
async function main(): Promise {
+ // Make sure ~/.local/bin is on PATH for every child process we spawn.
+ // Installers we run mid-setup (OneCLI, claude) drop binaries there and
+ // append a PATH line to the user's shell rc, but rc updates don't reach
+ // an already-running Node process — so without this patch a freshly
+ // installed `onecli` is invisible to a subsequent `runInheritScript`.
+ ensureLocalBinOnPath();
+
// Parse CLI flags first — `--help` short-circuits before we render anything,
// and flag values get folded into process.env so existing step code reading
// NANOCLAW_* sees them unchanged.
@@ -85,17 +93,21 @@ async function main(): Promise {
// Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path.
- const 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);
+ // 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);
@@ -122,39 +134,6 @@ async function main(): Promise {
}
}
- // Detect existing .env and offer to reuse it so the user doesn't have to
- // paste credentials again on a re-run.
- const existingEnv = detectExistingEnv();
- if (existingEnv) {
- const lines = Object.values(existingEnv.groups).map(
- (g) => ` ${k.green('✓')} ${g.label}`,
- );
- note(lines.join('\n'), 'Found existing configuration');
-
- const reuseChoice = ensureAnswer(
- await brightSelect({
- message: 'Use this existing environment?',
- options: [
- { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' },
- { value: 'fresh', label: 'No, start fresh' },
- ],
- initialValue: 'reuse',
- }),
- ) as 'reuse' | 'fresh';
- setupLog.userInput('existing_env_choice', reuseChoice);
-
- if (reuseChoice === 'reuse') {
- for (const [key, value] of Object.entries(existingEnv.raw)) {
- if (!process.env[key]) process.env[key] = value;
- }
- if (existingEnv.groups.onecli) skip.add('onecli');
- if (detectRegisteredGroups(process.cwd())) {
- skip.add('cli-agent');
- skip.add('first-chat');
- }
- }
- }
-
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(
@@ -344,6 +323,11 @@ async function main(): Promise {
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(
@@ -352,7 +336,7 @@ async function main(): Promise {
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(
@@ -373,6 +357,27 @@ async function main(): Promise {
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 brightSelect<'continue' | 'chat'>({
message: 'What next?',
@@ -390,7 +395,23 @@ async function main(): Promise {
}),
) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next);
- if (next === 'chat') await runFirstChat();
+ if (next === 'chat') {
+ const terminalAgentName = `${displayName!}'s Terminal`;
+ const createRes = await runQuietChild(
+ 'create-terminal-agent',
+ 'pnpm',
+ ['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
+ { running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` },
+ );
+ if (!createRes.ok) {
+ await fail(
+ 'create-terminal-agent',
+ `Couldn't create ${terminalAgentName}.`,
+ 'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
+ );
+ }
+ await runFirstChat();
+ }
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
@@ -579,18 +600,16 @@ async function confirmAssistantResponds(): Promise {
const s = p.spinner();
const start = Date.now();
const label = 'Waking your assistant…';
- s.start(fitToWidth(label, ' (999s)'));
+ s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
- const elapsed = Math.round((Date.now() - start) / 1000);
- const suffix = ` (${elapsed}s)`;
+ const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await pingCliAgent();
clearInterval(tick);
- const elapsed = Math.round((Date.now() - start) / 1000);
- const suffix = ` (${elapsed}s)`;
+ const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else {
@@ -1063,54 +1082,12 @@ async function askChannelChoice(): Promise {
// ─── interactive / env helpers ─────────────────────────────────────────
-interface ExistingEnvGroup {
- label: string;
- keys: string[];
-}
-
-const ENV_KEY_GROUPS: Record = {
- onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] },
- telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] },
- discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] },
- slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] },
- signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] },
- teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] },
- whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] },
- imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] },
-};
-
-function detectExistingEnv(): { groups: Record; raw: Record } | null {
- const envPath = path.join(process.cwd(), '.env');
- if (!fs.existsSync(envPath)) return null;
-
- let content: string;
- try {
- content = fs.readFileSync(envPath, 'utf-8');
- } catch {
- return null;
- }
-
- const raw: Record = {};
- for (const line of content.split('\n')) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('#')) continue;
- const eq = trimmed.indexOf('=');
- if (eq < 1) continue;
- raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
- }
-
- if (Object.keys(raw).length === 0) return null;
-
- const groups: Record = {};
- for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) {
- const found = def.keys.filter((key) => raw[key] !== undefined);
- if (found.length > 0) {
- groups[id] = { label: def.label, keys: found };
- }
- }
-
- if (Object.keys(groups).length === 0) return null;
- return { groups, raw };
+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 {
@@ -1190,9 +1167,11 @@ function maybeReexecUnderSg(): void {
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
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);
}
diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts
index 3638e4e..28c0254 100644
--- a/setup/channels/discord.ts
+++ b/setup/channels/discord.ts
@@ -28,10 +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 { accentGreen, brandBody, note } from '../lib/theme.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';
@@ -164,9 +165,8 @@ async function walkThroughBotCreation(): Promise {
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
- '',
- k.dim(url),
- ].join('\n'),
+ formatNoteLink(url),
+ ].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
@@ -224,9 +224,8 @@ async function walkThroughServerCreation(): Promise {
' 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');
@@ -240,7 +239,7 @@ async function walkThroughServerCreation(): Promise {
}
async function collectDiscordToken(): Promise {
- const existing = process.env.DISCORD_BOT_TOKEN?.trim();
+ 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?`,
@@ -289,9 +288,8 @@ async function validateDiscordToken(token: string): Promise {
username?: string;
message?: string;
};
- const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
- s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
+ s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
@@ -309,8 +307,7 @@ async function validateDiscordToken(token: string): Promise {
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
- const elapsedS = Math.round((Date.now() - start) / 1000);
- s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
+ s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -338,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise {
team?: unknown;
message?: string;
};
- const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
@@ -351,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise {
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
- s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
+ s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
@@ -369,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise {
owner,
};
} catch (err) {
- const elapsedS = Math.round((Date.now() - start) / 1000);
- s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
+ s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
@@ -450,9 +445,8 @@ async function promptInviteBot(
'',
' 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');
@@ -479,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise {
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
- const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
@@ -492,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise {
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
- s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
+ s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
- const elapsedS = Math.round((Date.now() - start) / 1000);
- s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
+ s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts
index a2654c0..8c0b78d 100644
--- a/setup/channels/imessage.ts
+++ b/setup/channels/imessage.ts
@@ -37,6 +37,7 @@ import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
+import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -222,8 +223,8 @@ async function walkThroughFullDiskAccess(): Promise {
}
async function collectRemoteCreds(): Promise {
- const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim();
- const existingKey = process.env.IMESSAGE_API_KEY?.trim();
+ 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?`,
diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts
index 0c5718e..8462a56 100644
--- a/setup/channels/signal.ts
+++ b/setup/channels/signal.ts
@@ -44,7 +44,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
-import { accentGreen, note } from '../lib/theme.js';
+import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -324,8 +324,7 @@ async function restartService(): Promise {
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
- const elapsed = Math.round((Date.now() - start) / 1000);
- s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
+ s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts
index 167fa72..0e3f052 100644
--- a/setup/channels/slack.ts
+++ b/setup/channels/slack.ts
@@ -25,10 +25,11 @@ 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 { accentGreen, note, 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';
@@ -135,9 +136,8 @@ async function walkThroughAppCreation(): Promise {
' 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');
@@ -151,7 +151,7 @@ async function walkThroughAppCreation(): Promise {
}
async function collectBotToken(): Promise {
- const existing = process.env.SLACK_BOT_TOKEN?.trim();
+ 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?`,
@@ -185,7 +185,7 @@ async function collectBotToken(): Promise {
}
async function collectSigningSecret(): Promise {
- const existing = process.env.SLACK_SIGNING_SECRET?.trim();
+ 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?',
@@ -241,10 +241,9 @@ async function validateSlackToken(token: string): Promise {
user_id?: string;
error?: string;
};
- const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
- `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
+ `Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
@@ -273,8 +272,7 @@ async function validateSlackToken(token: string): Promise {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
- const elapsedS = Math.round((Date.now() - start) / 1000);
- s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
+ s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -334,9 +332,8 @@ async function openDmChannel(token: string, userId: string): Promise {
channel?: { id?: string };
error?: string;
};
- const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.channel?.id) {
- s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
+ 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,
});
@@ -360,8 +357,7 @@ async function openDmChannel(token: string, userId: string): Promise {
`Slack said "${reason}". Check the member ID and app permissions, 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-open-dm', 'failed', Date.now() - start, {
ERROR: message,
diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts
index 01839c4..41e2070 100644
--- a/setup/channels/teams.ts
+++ b/setup/channels/teams.ts
@@ -42,6 +42,7 @@ 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');
@@ -60,8 +61,8 @@ export async function runTeamsChannel(_displayName: string): Promise {
const collected: Collected = {};
const completed: string[] = [];
- const existingAppId = process.env.TEAMS_APP_ID?.trim();
- const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim();
+ 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?`,
@@ -70,9 +71,9 @@ export async function runTeamsChannel(_displayName: string): Promise {
if (reuse) {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
- collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
+ collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
- collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim();
+ collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts
index 5681fd1..41ee407 100644
--- a/setup/channels/telegram.ts
+++ b/setup/channels/telegram.ts
@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
-import { confirmThenOpen } from '../lib/browser.js';
+import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
@@ -33,7 +33,8 @@ import {
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
-import { accentGreen, brandBold, note } from '../lib/theme.js';
+import { readEnvKey } from '../environment.js';
+import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -50,9 +51,8 @@ export async function runTelegramChannel(displayName: string): Promise {
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,7 @@ export async function runTelegramChannel(displayName: string): Promise {
}
async function collectTelegramToken(): Promise {
- const existing = process.env.TELEGRAM_BOT_TOKEN?.trim();
+ 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?`,
@@ -191,10 +191,9 @@ async function validateTelegramToken(token: string): Promise {
result?: { username?: string; id?: number };
description?: string;
};
- const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) {
const username = data.result.username;
- s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
+ s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
@@ -212,8 +211,7 @@ async function validateTelegramToken(token: string): Promise {
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
- const elapsedS = Math.round((Date.now() - start) / 1000);
- s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
+ s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -254,11 +252,11 @@ async function runPairTelegram(): Promise<
stopSpinner("Old code expired. Here's a fresh one.");
}
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
- s.start('Waiting for you to send the code from Telegram…');
+ s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true;
} 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') {
diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts
index 96d23d5..922c985 100644
--- a/setup/channels/whatsapp.ts
+++ b/setup/channels/whatsapp.ts
@@ -46,7 +46,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
-import { accentGreen, brandBody, brandBold, note } 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');
@@ -379,8 +379,7 @@ async function restartService(): Promise {
// Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
- const elapsed = Math.round((Date.now() - start) / 1000);
- s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
+ s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts
index d9a90c5..73b8557 100644
--- a/setup/cli-agent.ts
+++ b/setup/cli-agent.ts
@@ -8,6 +8,7 @@
* Args:
* --display-name (required) operator's display name
* --agent-name (optional) agent persona name, defaults to display-name
+ * --folder (optional) explicit folder name, defaults to cli-with-
*/
import { execFileSync } from 'child_process';
import path from 'path';
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
function parseArgs(args: string[]): {
displayName: string;
agentName?: string;
+ folder?: string;
} {
let displayName: string | undefined;
let agentName: string | undefined;
+ let folder: string | undefined;
for (let i = 0; i < args.length; i++) {
const key = args[i];
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
agentName = val;
i++;
break;
+ case '--folder':
+ folder = val;
+ i++;
+ break;
}
}
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
process.exit(2);
}
- return { displayName, agentName };
+ return { displayName, agentName, folder };
}
export async function run(args: string[]): Promise {
- const { displayName, agentName } = parseArgs(args);
+ const { displayName, agentName, folder } = parseArgs(args);
const projectRoot = process.cwd();
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
if (agentName) scriptArgs.push('--agent-name', agentName);
+ if (folder) scriptArgs.push('--folder', folder);
log.info('Invoking init-cli-agent', { displayName, agentName });
diff --git a/setup/container.ts b/setup/container.ts
index 6ecd032..18de61a 100644
--- a/setup/container.ts
+++ b/setup/container.ts
@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise {
}
// Socket is unreachable due to group perms — current shell's supplementary
- // groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
- // or a prior install) doesn't affect us until next login. Re-exec this
- // step under `sg docker` so the child picks up docker as its primary
- // group and can talk to /var/run/docker.sock without a logout.
+ // groups are fixed at login, so `usermod -aG docker` doesn't affect us
+ // until next login. Ensure the user is in the docker group (install-docker.sh
+ // does this on fresh installs, but skips when Docker is already present),
+ // then re-exec under `sg docker` so the child picks up docker as its
+ // primary group and can talk to /var/run/docker.sock without a logout.
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
+ // Ensure the current user is in the docker group — without this,
+ // sg will ask for the (typically unset) group password and fail.
+ const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
+ if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
+ log.info('Adding current user to docker group');
+ spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
+ stdio: 'inherit',
+ });
+ }
+
log.info('Re-executing container step under `sg docker`');
const res = spawnSync(
'sg',
diff --git a/setup/environment.ts b/setup/environment.ts
index c351023..5960b0e 100644
--- a/setup/environment.ts
+++ b/setup/environment.ts
@@ -11,6 +11,30 @@ 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;
diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts
index 9d801fa..7c5c970 100644
--- a/setup/lib/browser.ts
+++ b/setup/lib/browser.ts
@@ -9,12 +9,19 @@
* `confirmThenOpen` pauses for the operator before triggering the open —
* the browser tends to steal focus when it pops, and a split-second
* "wait what just happened" moment is worse than letting the user hit
- * Enter when they're ready.
+ * Enter when they're ready. On headless devices (no graphical session
+ * available) it skips both the prompt and the open: there's no browser
+ * to launch, the surrounding `note(...)` already shows the URL for
+ * copy-paste on another device, and the next prompt in the channel
+ * flow ("Got your bot token?" etc.) provides the natural completion
+ * confirmation.
*/
import { spawn } from 'child_process';
import * as p from '@clack/prompts';
+import k from 'kleur';
+import { isHeadless } from '../platform.js';
import { ensureAnswer } from './runner.js';
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
}
}
+/**
+ * Format a URL for inclusion in a setup `note(...)` card. On
+ * headless devices we surface the URL inside the card with a
+ * "Get started:" label at full strength — copy-pasting onto
+ * another device is the actual action, not an incidental
+ * reference. The leading `\n` acts as a visual separator from
+ * the body steps above; callers `.filter(line => line !== null)`
+ * before joining, so on GUI we drop the line entirely (and the
+ * URL ends up below the next-step confirm prompt as a "if
+ * browser does not appear, please visit" fallback — see
+ * `confirmThenOpen`).
+ */
+export function formatNoteLink(url: string): string | null {
+ if (isHeadless()) return `\nGet started: ${url}`;
+ return null;
+}
+
/**
* Gate a browser-open on a confirm so the user is ready for their browser
- * to take focus. Proceeds on cancel as well — the user can always copy the
- * URL from the note that precedes the prompt.
+ * to take focus. Proceeds on cancel as well. On headless devices both the
+ * prompt and the open are skipped — the URL is already surfaced inside
+ * the surrounding note (via `formatNoteLink`).
+ *
+ * On GUI devices the confirm message includes the fallback URL on the
+ * lines below the action ("If browser does not appear, please visit:
+ * " in dim) so the user has a copy-paste path right next to the
+ * action button without needing to scroll back up to the card.
*/
export async function confirmThenOpen(
url: string,
message = 'Press Enter to open your browser',
): Promise {
+ if (isHeadless()) return;
+ const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer(
await p.confirm({
- message,
+ message: `${message}${fallback}`,
initialValue: true,
}),
);
diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts
index e76a4fc..187377e 100644
--- a/setup/lib/claude-assist.ts
+++ b/setup/lib/claude-assist.ts
@@ -28,7 +28,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { ensureAnswer } from './runner.js';
-import { brandBody, fitToWidth, note } from './theme.js';
+import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
export interface AssistContext {
stepName: string;
@@ -295,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`);
@@ -355,8 +354,7 @@ 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(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
resolve(payload);
diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts
index cf7a86d..6ffffed 100644
--- a/setup/lib/runner.ts
+++ b/setup/lib/runner.ts
@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
-import { brandBody, fitToWidth } from './theme.js';
+import { brandBody, fitToWidth, fmtDuration } from './theme.js';
export type Fields = Record;
export type Block = { type: string; fields: Fields };
@@ -307,18 +307,16 @@ async function runUnderSpinner<
): Promise {
const s = p.spinner();
const start = Date.now();
- s.start(fitToWidth(labels.running, ' (999s)'));
+ s.start(fitToWidth(labels.running, ' (99m 59s)'));
const tick = setInterval(() => {
- const elapsed = Math.round((Date.now() - start) / 1000);
- const suffix = ` (${elapsed}s)`;
+ const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await work();
clearInterval(tick);
- const elapsed = Math.round((Date.now() - start) / 1000);
- const suffix = ` (${elapsed}s)`;
+ const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts
index 0dfa53f..2c80c8a 100644
--- a/setup/lib/theme.ts
+++ b/setup/lib/theme.ts
@@ -51,6 +51,22 @@ export function accentGreen(s: string): string {
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
diff --git a/setup/lib/tz-from-claude.ts b/setup/lib/tz-from-claude.ts
index 5486fbb..f861f64 100644
--- a/setup/lib/tz-from-claude.ts
+++ b/setup/lib/tz-from-claude.ts
@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { isValidTimezone } from '../../src/timezone.js';
-import { fitToWidth } from './theme.js';
+import { fitToWidth, fmtDuration } from './theme.js';
export function claudeCliAvailable(): boolean {
try {
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
const s = p.spinner();
const start = Date.now();
const label = 'Looking up that timezone…';
- s.start(fitToWidth(label, ' (999s)'));
+ s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
- const elapsed = Math.round((Date.now() - start) / 1000);
- const suffix = ` (${elapsed}s)`;
+ const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const reply = await queryClaude(prompt);
clearInterval(tick);
- const elapsed = Math.round((Date.now() - start) / 1000);
- const suffix = ` (${elapsed}s)`;
+ const suffix = ` (${fmtDuration(Date.now() - start)})`;
const resolved = reply ? extractTimezone(reply) : null;
if (resolved) {
diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts
index 82247a1..a2a7069 100644
--- a/src/channels/adapter.ts
+++ b/src/channels/adapter.ts
@@ -135,6 +135,7 @@ export interface ChannelAdapter {
// Optional
setTyping?(platformId: string, threadId: string | null): Promise;
syncConversations?(): Promise;
+ resolveChannelName?(platformId: string): Promise;
/**
* Subscribe the bot to a thread so follow-up messages route via the
diff --git a/src/host-core.test.ts b/src/host-core.test.ts
index 2bb72d4..043b6b1 100644
--- a/src/host-core.test.ts
+++ b/src/host-core.test.ts
@@ -23,6 +23,8 @@ import {
sessionDir,
inboundDbPath,
outboundDbPath,
+ readOutboxFiles,
+ clearOutbox,
} from './session-manager.js';
import { getSession, findSession } from './db/sessions.js';
import type { InboundEvent } from './channels/adapter.js';
@@ -108,6 +110,147 @@ describe('session manager', () => {
outDb.close();
});
+ it('should reject outbound attachment filenames that escape the message outbox', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const dir = sessionDir('ag-1', 'sess-test');
+ const msgOutbox = path.join(dir, 'outbox', 'msg-1');
+ fs.mkdirSync(msgOutbox, { recursive: true });
+
+ const outside = path.join(TEST_DIR, 'outside.txt');
+ fs.writeFileSync(outside, 'outside secret');
+
+ expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined();
+ });
+
+ it('should reject outbound attachment symlinks that escape the message outbox', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const dir = sessionDir('ag-1', 'sess-test');
+ const msgOutbox = path.join(dir, 'outbox', 'msg-1');
+ fs.mkdirSync(msgOutbox, { recursive: true });
+
+ const outside = path.join(TEST_DIR, 'outside.txt');
+ fs.writeFileSync(outside, 'outside secret');
+ fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt'));
+
+ expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined();
+ });
+
+ it('should not recursively delete outside the outbox for unsafe message ids', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const victimDir = path.join(TEST_DIR, 'victim-dir');
+ fs.mkdirSync(victimDir, { recursive: true });
+ fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete');
+
+ clearOutbox('ag-1', 'sess-test', '../../../../victim-dir');
+
+ expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true);
+ });
+
+ it('should still read and clear normal basename outbox files', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const dir = sessionDir('ag-1', 'sess-test');
+ const msgOutbox = path.join(dir, 'outbox', 'msg-1');
+ fs.mkdirSync(msgOutbox, { recursive: true });
+ fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok');
+
+ const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']);
+ expect(files).toHaveLength(1);
+ expect(files?.[0]?.filename).toBe('result.txt');
+ expect(files?.[0]?.data.toString()).toBe('ok');
+
+ clearOutbox('ag-1', 'sess-test', 'msg-1');
+ expect(fs.existsSync(msgOutbox)).toBe(false);
+ });
+
+ it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
+
+ // The container has /workspace write access, so it can pre create
+ // inbox/ as a symlink to escape.
+ const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
+ fs.mkdirSync(inboxRoot, { recursive: true });
+ const evilTarget = path.join(TEST_DIR, 'evil-target');
+ fs.mkdirSync(evilTarget, { recursive: true });
+ fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil'));
+
+ writeSessionMessage('ag-1', session.id, {
+ id: 'msg-evil',
+ kind: 'chat',
+ timestamp: now(),
+ content: JSON.stringify({
+ text: 'evil',
+ attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
+ }),
+ });
+
+ expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false);
+ });
+
+ it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
+
+ // The container pre creates inbox//photo.png as a symlink to a
+ // host file. Without the wx flag, writeFileSync would follow it.
+ const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym');
+ fs.mkdirSync(inboxDir, { recursive: true });
+ const outside = path.join(TEST_DIR, 'outside.txt');
+ fs.writeFileSync(outside, 'ORIGINAL');
+ fs.symlinkSync(outside, path.join(inboxDir, 'photo.png'));
+
+ writeSessionMessage('ag-1', session.id, {
+ id: 'msg-sym',
+ kind: 'chat',
+ timestamp: now(),
+ content: JSON.stringify({
+ text: 'sym',
+ attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
+ }),
+ });
+
+ expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL');
+ });
+
+ it('should reject inbound attachments when messageId is unsafe', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
+
+ writeSessionMessage('ag-1', session.id, {
+ id: '../../escape',
+ kind: 'chat',
+ timestamp: now(),
+ content: JSON.stringify({
+ text: 'msgid',
+ attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
+ }),
+ });
+
+ const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
+ if (fs.existsSync(inboxRoot)) {
+ expect(fs.readdirSync(inboxRoot)).toEqual([]);
+ }
+ });
+
+ it('should still save inbound attachments with safe basenames', () => {
+ initSessionFolder('ag-1', 'sess-test');
+ const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
+
+ writeSessionMessage('ag-1', session.id, {
+ id: 'msg-ok',
+ kind: 'chat',
+ timestamp: now(),
+ content: JSON.stringify({
+ text: 'ok',
+ attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
+ }),
+ });
+
+ const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png');
+ expect(fs.existsSync(expected)).toBe(true);
+ expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES');
+ });
+
it('should resolve to existing session (shared mode)', () => {
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
expect(c1).toBe(true);
diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts
index da992d2..a2e6690 100644
--- a/src/modules/permissions/channel-approval.test.ts
+++ b/src/modules/permissions/channel-approval.test.ts
@@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => {
expect(kind).toBe('chat-sdk');
const payload = JSON.parse(content as string);
expect(payload.type).toBe('ask_question');
- // Card names the target agent so the owner knows what they're wiring to.
- expect(payload.question).toContain('Andy');
+ // Single-agent card offers a direct "Connect to " button.
+ const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
+ expect(connectOption).toBeDefined();
+ expect(connectOption.label).toContain('Andy');
const { getDb } = await import('../../db/connection.js');
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
@@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => {
};
expect(pending).toBeDefined();
- // Owner clicks approve.
+ // Owner clicks "Connect to Andy" (single-agent card).
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
- value: 'approve',
+ value: 'connect:ag-1',
userId: 'owner', // raw platform id — handler namespaces it
channelType: 'telegram',
platformId: 'dm-owner',
@@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => {
if (claimed) break;
}
- // Wiring created with MVP defaults.
+ // Wiring created with defaults.
const mga = getDb()
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
.get(pending.messaging_group_id) as {
@@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => {
for (const handler of getResponseHandlers()) {
const claimed = await handler({
questionId: pending.messaging_group_id,
- value: 'approve',
+ value: 'connect:ag-1',
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts
index 8ab41bc..6127cea 100644
--- a/src/modules/permissions/channel-approval.ts
+++ b/src/modules/permissions/channel-approval.ts
@@ -5,24 +5,32 @@
* addressed to the bot (SDK-confirmed mention or DM), it calls
* `requestChannelApproval` instead of silently dropping. The flow:
*
- * 1. Pick the target agent group we'd wire to (MVP: first by name).
- * Multi-agent picker is a follow-up — see ACTION-ITEMS.
+ * 1. Gather all existing agent groups.
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
* them, reusing the same primitives the sender-approval flow uses.
- * 3. Deliver an Approve / Ignore card that names the target agent
- * explicitly so the owner knows what they're wiring to.
+ * 3. Deliver a card with three action families:
+ * a. Connect to [agent] — one button per existing agent group.
+ * Single-agent installs get a one-click connect.
+ * b. Connect new agent — prompts for a free-text name, creates
+ * the agent immediately on reply.
+ * c. Reject — deny the channel.
* 4. Record a `pending_channel_approvals` row holding the original event
- * so it can be re-routed on approve.
+ * so it can be re-routed on connect/create.
*
- * On approve (handler in index.ts):
- * - Create `messaging_group_agents` with MVP defaults
+ * On connect (handler in index.ts):
+ * - Create `messaging_group_agents` with defaults
* (mention-sticky for groups / pattern='.' for DMs,
* sender_scope='known', ignored_message_policy='accumulate')
* - Add the triggering sender to `agent_group_members` so sender_scope
* doesn't bounce the replayed message into a sender-approval cascade
* - Delete the pending row, replay the original event
*
- * On ignore:
+ * On connect new agent (handler in index.ts):
+ * - Prompt for a free-text agent name via DM
+ * - On reply: create the agent group + filesystem, then wire
+ * and replay as above
+ *
+ * On reject:
* - Set `messaging_groups.denied_at = now()` so the router stops
* escalating on this channel until an admin explicitly re-wires
* - Delete the pending row
@@ -36,19 +44,81 @@
* - Approver has no reachable DM.
* - Delivery adapter missing.
*/
-import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
-import { getAllAgentGroups } from '../../db/agent-groups.js';
-import { getMessagingGroup } from '../../db/messaging-groups.js';
+import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
+import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
+import { getChannelAdapter } from '../../channels/channel-registry.js';
+import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
import { getDeliveryAdapter } from '../../delivery.js';
+import { initGroupFilesystem } from '../../group-init.js';
import { log } from '../../log.js';
import type { InboundEvent } from '../../channels/adapter.js';
+import type { AgentGroup } from '../../types.js';
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
-const APPROVAL_OPTIONS: RawOption[] = [
- { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
- { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' },
-];
+// ── Value constants (response handler in index.ts parses these) ──
+
+export const CONNECT_PREFIX = 'connect:';
+export const NEW_AGENT_VALUE = 'new_agent';
+export const CHOOSE_EXISTING_VALUE = 'choose_existing';
+export const REJECT_VALUE = 'reject';
+
+// ── Utilities ──
+
+function toFolder(name: string): string {
+ return (
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'unnamed'
+ );
+}
+
+// ── Card builders ──
+
+function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
+ const options: RawOption[] = [];
+ if (agentGroups.length === 1) {
+ options.push({
+ label: `Connect to ${agentGroups[0].name}`,
+ selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
+ value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
+ });
+ } else {
+ options.push({
+ label: 'Choose existing agent',
+ selectedLabel: '📋 Choosing…',
+ value: CHOOSE_EXISTING_VALUE,
+ });
+ }
+ options.push({
+ label: 'Connect new agent',
+ selectedLabel: '🆕 Connecting new agent…',
+ value: NEW_AGENT_VALUE,
+ });
+ options.push({
+ label: 'Reject',
+ selectedLabel: '🙅 Rejected',
+ value: REJECT_VALUE,
+ });
+ return options;
+}
+
+function buildQuestionText(
+ isGroup: boolean,
+ senderName: string | undefined,
+ channelName: string | null,
+ channelType: string,
+): string {
+ const who = senderName ?? 'Someone';
+ if (isGroup) {
+ const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
+ return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
+ }
+ return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
+}
+
+// ── Main flow ──
export interface RequestChannelApprovalInput {
messagingGroupId: string;
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise {
const { messagingGroupId, event } = input;
- // In-flight dedup: don't spam the owner if the same unwired channel
- // gets more mentions / DMs while a card is already pending.
if (hasInFlightChannelApproval(messagingGroupId)) {
- log.debug('Channel registration already in flight — dropping retry', {
- messagingGroupId,
- });
+ log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
return;
}
- // MVP: pick the first agent group by name. Multi-agent systems will get
- // a richer card later (user picks the target from a list).
const agentGroups = getAllAgentGroups();
if (agentGroups.length === 0) {
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
});
return;
}
- const target = agentGroups[0];
+ // Use first agent group for approver resolution — owners and global admins
+ // are returned regardless of which group we pass.
+ const referenceGroup = agentGroups[0];
- // pickApprover takes the target agent group's id — gets scoped admins +
- // global admins + owners. For fresh installs with only an owner, the
- // owner is returned.
- const approvers = pickApprover(target.id);
+ const approvers = pickApprover(referenceGroup.id);
if (approvers.length === 0) {
log.warn('Channel registration skipped — no owner or admin configured', {
messagingGroupId,
- targetAgentGroupId: target.id,
+ targetAgentGroupId: referenceGroup.id,
});
return;
}
const originMg = getMessagingGroup(messagingGroupId);
const originChannelType = originMg?.channel_type ?? '';
+
+ // Resolve channel name if not yet persisted.
+ if (originMg && !originMg.name) {
+ const channelAdapter = getChannelAdapter(originChannelType);
+ if (channelAdapter?.resolveChannelName) {
+ try {
+ const name = await channelAdapter.resolveChannelName(originMg.platform_id);
+ if (name) {
+ updateMessagingGroup(originMg.id, { name });
+ originMg.name = name;
+ }
+ } catch {
+ /* non-critical */
+ }
+ }
+ }
+
const delivery = await pickApprovalDelivery(approvers, originChannelType);
if (!delivery) {
log.warn('Channel registration skipped — no DM channel for any approver', {
messagingGroupId,
- targetAgentGroupId: target.id,
+ targetAgentGroupId: referenceGroup.id,
});
return;
}
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
- // Extract sender name from the event content for a human-readable card.
let senderName: string | undefined;
try {
const parsed = JSON.parse(event.message.content) as Record;
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
} catch {
- // non-critical — fall through to generic wording
+ // non-critical
}
- const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
- const question = isGroup
- ? senderName
- ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
- : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
- : senderName
- ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
- : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
- const options = normalizeOptions(APPROVAL_OPTIONS);
+ const channelName = originMg?.name ?? null;
+ const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
+ const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
+ const options = normalizeOptions(buildApprovalOptions(agentGroups));
createPendingChannelApproval({
messaging_group_id: messagingGroupId,
- agent_group_id: target.id,
+ agent_group_id: referenceGroup.id,
original_message: JSON.stringify(event),
approver_user_id: delivery.userId,
created_at: new Date().toISOString(),
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
const adapter = getDeliveryAdapter();
if (!adapter) {
- log.error('Channel registration row created but no delivery adapter is wired', {
- messagingGroupId,
- });
+ log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
return;
}
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
'chat-sdk',
JSON.stringify({
type: 'ask_question',
- // Use messaging_group_id as the questionId — it's unique per card
- // (PK on pending table dedups) and lets the response handler look
- // up the pending row directly without another index.
questionId: messagingGroupId,
title,
question,
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
);
log.info('Channel registration card delivered', {
messagingGroupId,
- targetAgentGroupId: target.id,
+ agentGroupCount: agentGroups.length,
approver: delivery.userId,
});
} catch (err) {
- log.error('Channel registration card delivery failed', {
- messagingGroupId,
- err,
- });
+ log.error('Channel registration card delivery failed', { messagingGroupId, err });
}
}
-export const APPROVE_VALUE = 'approve';
-export const REJECT_VALUE = 'reject';
+// ── Helpers for the response handler (index.ts) ──
+
+/**
+ * Build normalized options for the agent-selection follow-up card.
+ */
+export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
+ const options: RawOption[] = agentGroups.map((ag) => ({
+ label: ag.name,
+ selectedLabel: `✅ Connected to ${ag.name}`,
+ value: `${CONNECT_PREFIX}${ag.id}`,
+ }));
+ options.push({
+ label: 'Cancel',
+ selectedLabel: '🙅 Cancelled',
+ value: REJECT_VALUE,
+ });
+ return normalizeOptions(options);
+}
+
+/**
+ * Create a new agent group and initialize its filesystem. Handles
+ * folder-name collisions with numeric suffixes.
+ */
+export function createNewAgentGroup(name: string): AgentGroup {
+ let folder = toFolder(name);
+ const baseFolder = folder;
+ let suffix = 2;
+ while (getAgentGroupByFolder(folder)) {
+ folder = `${baseFolder}-${suffix}`;
+ suffix++;
+ }
+
+ const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ createAgentGroup({
+ id: agId,
+ name,
+ folder,
+ agent_provider: null,
+ created_at: new Date().toISOString(),
+ });
+
+ const ag = getAgentGroup(agId)!;
+ initGroupFilesystem(ag);
+ return ag;
+}
diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts
index d402074..24f7209 100644
--- a/src/modules/permissions/db/pending-channel-approvals.ts
+++ b/src/modules/permissions/db/pending-channel-approvals.ts
@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
return row !== undefined;
}
+export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void {
+ getDb()
+ .prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?')
+ .run(title, optionsJson, messagingGroupId);
+}
+
export function deletePendingChannelApproval(messagingGroupId: string): void {
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
}
diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts
index 83390d8..98a9463 100644
--- a/src/modules/permissions/index.ts
+++ b/src/modules/permissions/index.ts
@@ -16,27 +16,53 @@
* access gate is not registered and core defaults to allow-all.
*/
import { recordDroppedMessage } from '../../db/dropped-messages.js';
+import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
import {
routeInbound,
setAccessGate,
setChannelRequestGate,
+ setMessageInterceptor,
setSenderResolver,
setSenderScopeGate,
type AccessGateResult,
} from '../../router.js';
import type { InboundEvent } from '../../channels/adapter.js';
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
+import { getDeliveryAdapter } from '../../delivery.js';
import { log } from '../../log.js';
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
import { canAccessAgentGroup } from './access.js';
-import { requestChannelApproval } from './channel-approval.js';
+import {
+ buildAgentSelectionOptions,
+ CHOOSE_EXISTING_VALUE,
+ CONNECT_PREFIX,
+ createNewAgentGroup,
+ NEW_AGENT_VALUE,
+ REJECT_VALUE,
+ requestChannelApproval,
+} from './channel-approval.js';
import { addMember } from './db/agent-group-members.js';
-import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js';
+import {
+ deletePendingChannelApproval,
+ getPendingChannelApproval,
+ updatePendingChannelApprovalCard,
+} from './db/pending-channel-approvals.js';
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
import { hasAdminPrivilege } from './db/user-roles.js';
import { getUser, upsertUser } from './db/users.js';
import { requestSenderApproval } from './sender-approval.js';
+import { ensureUserDm } from './user-dm.js';
+
+// ── Free-text name input state ──
+// Tracks approvers waiting for a text reply with the agent name. Keyed by
+// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart.
+interface PendingNameInput {
+ channelMgId: string;
+ dmChannelType: string;
+ dmPlatformId: string;
+}
+const awaitingNameInput = new Map();
function extractAndUpsertUser(event: InboundEvent): string | null {
let content: Record;
@@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => {
* by messaging_group_id). If no such row, return false so downstream
* handlers get a shot.
*
- * Approve: create the wiring with MVP defaults (mention-sticky for
- * groups / pattern='.' for DMs; sender_scope='known';
- * ignored_message_policy='accumulate'), add the triggering sender as a
- * member so sender_scope doesn't immediately bounce them into a
- * sender-approval card, then replay the original event.
- *
- * Deny: set `messaging_groups.denied_at = now()` so future mentions on
- * this channel drop silently until an admin explicitly wires it.
+ * Value dispatch:
+ * connect: — wire to an existing agent group, replay the message
+ * choose_existing — send a follow-up card listing all agents
+ * new_agent — prompt for a free-text agent name (interceptor
+ * captures the reply and creates immediately)
+ * reject — set denied_at, delete pending row
*/
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise {
const row = getPendingChannelApproval(payload.questionId);
if (!row) return false;
- // Click-auth: same pattern as sender-approval (see commit 68058cb).
- // Raw platform userId → namespace with channelType → must match the
- // designated approver OR have admin privilege over the target agent.
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
const isAuthorized =
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
clickerId,
expectedApprover: row.approver_user_id,
});
- return true; // claim but take no action
+ return true;
}
const approverId = clickerId;
- const approved = payload.value === 'approve';
- if (!approved) {
+ // ── Reject / Cancel ──
+ if (payload.value === REJECT_VALUE) {
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
deletePendingChannelApproval(row.messaging_group_id);
log.info('Channel registration denied', {
messagingGroupId: row.messaging_group_id,
- agentGroupId: row.agent_group_id,
approverId,
});
return true;
}
- // Rehydrate the original event to know (a) whether it was a DM or group
- // (chooses engage_mode default), and (b) who the triggering sender was
- // (auto-member-add so sender_scope='known' doesn't bounce the replay).
+ // ── Choose existing agent — send agent-selection follow-up card ──
+ if (payload.value === CHOOSE_EXISTING_VALUE) {
+ const approverDm = await ensureUserDm(row.approver_user_id);
+ if (!approverDm) {
+ log.error('Channel registration: no DM channel for approver', {
+ messagingGroupId: row.messaging_group_id,
+ approverUserId: row.approver_user_id,
+ });
+ return true;
+ }
+
+ const adapter = getDeliveryAdapter();
+ if (!adapter) return true;
+
+ const agentGroups = getAllAgentGroups();
+ const options = buildAgentSelectionOptions(agentGroups);
+ const title = '📋 Choose an agent';
+ updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
+
+ try {
+ await adapter.deliver(
+ approverDm.channel_type,
+ approverDm.platform_id,
+ null,
+ 'chat-sdk',
+ JSON.stringify({
+ type: 'ask_question',
+ questionId: row.messaging_group_id,
+ title,
+ question: 'Which agent should handle this channel?',
+ options,
+ }),
+ );
+ } catch (err) {
+ log.error('Channel registration: agent-selection card delivery failed', {
+ messagingGroupId: row.messaging_group_id,
+ err,
+ });
+ }
+ return true;
+ }
+
+ // ── Create new agent — prompt for free-text name ──
+ if (payload.value === NEW_AGENT_VALUE) {
+ const approverDm = await ensureUserDm(row.approver_user_id);
+ if (!approverDm) {
+ log.error('Channel registration: no DM channel for approver', {
+ messagingGroupId: row.messaging_group_id,
+ approverUserId: row.approver_user_id,
+ });
+ return true;
+ }
+
+ const adapter = getDeliveryAdapter();
+ if (!adapter) {
+ log.error('Channel registration: no delivery adapter for name prompt', {
+ messagingGroupId: row.messaging_group_id,
+ });
+ return true;
+ }
+
+ awaitingNameInput.set(row.approver_user_id, {
+ channelMgId: row.messaging_group_id,
+ dmChannelType: approverDm.channel_type,
+ dmPlatformId: approverDm.platform_id,
+ });
+
+ try {
+ await adapter.deliver(
+ approverDm.channel_type,
+ approverDm.platform_id,
+ null,
+ 'chat-sdk',
+ JSON.stringify({ text: 'Reply with the name for your new agent:' }),
+ );
+ } catch (err) {
+ log.error('Channel registration: name prompt delivery failed', {
+ messagingGroupId: row.messaging_group_id,
+ err,
+ });
+ awaitingNameInput.delete(row.approver_user_id);
+ }
+ return true;
+ }
+
+ // ── Resolve target agent group (connect to existing or create new) ──
+ let targetAgentGroupId: string;
+
+ if (payload.value.startsWith(CONNECT_PREFIX)) {
+ targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length);
+ const ag = getAgentGroup(targetAgentGroupId);
+ if (!ag) {
+ log.error('Channel registration: target agent group no longer exists', {
+ messagingGroupId: row.messaging_group_id,
+ targetAgentGroupId,
+ });
+ deletePendingChannelApproval(row.messaging_group_id);
+ return true;
+ }
+ } else {
+ log.warn('Channel registration: unknown response value', {
+ messagingGroupId: row.messaging_group_id,
+ value: payload.value,
+ });
+ return true;
+ }
+
+ // ── Wire + replay (shared path for connect and create) ──
let event: InboundEvent;
try {
event = JSON.parse(row.original_message) as InboundEvent;
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
return true;
}
- // Decide engage_mode from the original event. DMs (`isMention=true` &
- // not in a group) get `pattern='.'` (always respond). Group mentions
- // get `mention-sticky` (respond now + follow the thread).
- //
- // We can't read `mg.is_group` reliably here because we only auto-create
- // the mg with `is_group=0` on first sight — the adapter hasn't told us
- // yet whether it's actually a group. Fall back to the InboundEvent's
- // `threadId`: a non-null threadId implies a threaded platform (Slack
- // channel thread, Discord thread), which we treat as a group.
const isGroup = event.threadId !== null;
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
const engagePattern = isGroup ? null : '.';
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: row.messaging_group_id,
- agent_group_id: row.agent_group_id,
+ agent_group_id: targetAgentGroupId,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'known',
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
});
log.info('Channel registration approved — wiring created', {
messagingGroupId: row.messaging_group_id,
- agentGroupId: row.agent_group_id,
+ agentGroupId: targetAgentGroupId,
mgaId,
engageMode,
approverId,
});
- // Auto-admit the triggering sender. Without this, the replay below
- // would bounce through sender-approval (sender_scope='known' +
- // sender-is-not-a-member).
const senderUserId = extractAndUpsertUser(event);
if (senderUserId) {
addMember({
user_id: senderUserId,
- agent_group_id: row.agent_group_id,
+ agent_group_id: targetAgentGroupId,
added_by: approverId,
added_at: new Date().toISOString(),
});
}
- // Clear the pending row BEFORE replay so the gate check on the second
- // attempt sees a wired channel (agentCount > 0) and takes the fan-out
- // path normally.
deletePendingChannelApproval(row.messaging_group_id);
try {
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
}
registerResponseHandler(handleChannelApprovalResponse);
+
+// ── Free-text name interceptor ──
+// Captures the next DM from an approver who clicked "Create new agent",
+// creates the agent immediately, wires the channel, and replays.
+
+setMessageInterceptor(async (event: InboundEvent): Promise => {
+ const userId = extractAndUpsertUser(event);
+ if (!userId) return false;
+
+ const pending = awaitingNameInput.get(userId);
+ if (!pending) return false;
+ if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
+
+ awaitingNameInput.delete(userId);
+
+ let text: string | undefined;
+ try {
+ const parsed = JSON.parse(event.message.content) as Record;
+ text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim();
+ } catch {
+ /* fall through */
+ }
+
+ if (!text) {
+ log.warn('Channel registration: empty name reply, ignoring', { userId });
+ return true;
+ }
+
+ const row = getPendingChannelApproval(pending.channelMgId);
+ if (!row) return true;
+
+ const ag = createNewAgentGroup(text);
+ log.info('Channel registration: new agent group created', {
+ messagingGroupId: row.messaging_group_id,
+ agentGroupId: ag.id,
+ agentName: ag.name,
+ folder: ag.folder,
+ });
+
+ let originalEvent: InboundEvent;
+ try {
+ originalEvent = JSON.parse(row.original_message) as InboundEvent;
+ } catch (err) {
+ log.error('Channel registration: failed to parse stored event', {
+ messagingGroupId: row.messaging_group_id,
+ err,
+ });
+ deletePendingChannelApproval(row.messaging_group_id);
+ return true;
+ }
+
+ const isGroup = originalEvent.threadId !== null;
+ const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
+ const engagePattern = isGroup ? null : '.';
+
+ const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ createMessagingGroupAgent({
+ id: mgaId,
+ messaging_group_id: row.messaging_group_id,
+ agent_group_id: ag.id,
+ engage_mode: engageMode,
+ engage_pattern: engagePattern,
+ sender_scope: 'known',
+ ignored_message_policy: 'accumulate',
+ session_mode: 'shared',
+ priority: 0,
+ created_at: new Date().toISOString(),
+ });
+ log.info('Channel registration approved — wiring created', {
+ messagingGroupId: row.messaging_group_id,
+ agentGroupId: ag.id,
+ mgaId,
+ engageMode,
+ approverId: userId,
+ });
+
+ const senderUserId = extractAndUpsertUser(originalEvent);
+ if (senderUserId) {
+ addMember({
+ user_id: senderUserId,
+ agent_group_id: ag.id,
+ added_by: userId,
+ added_at: new Date().toISOString(),
+ });
+ }
+
+ deletePendingChannelApproval(row.messaging_group_id);
+
+ try {
+ await routeInbound(originalEvent);
+ } catch (err) {
+ log.error('Failed to replay message after channel approval', {
+ messagingGroupId: row.messaging_group_id,
+ err,
+ });
+ }
+
+ const adapter = getDeliveryAdapter();
+ if (adapter) {
+ const dm = await ensureUserDm(row.approver_user_id);
+ if (dm) {
+ adapter
+ .deliver(
+ dm.channel_type,
+ dm.platform_id,
+ null,
+ 'chat-sdk',
+ JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
+ )
+ .catch(() => {});
+ }
+ }
+ return true;
+});
diff --git a/src/router.ts b/src/router.ts
index ad4cd56..9d4765b 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
senderScopeGate = fn;
}
+/**
+ * Message-interceptor hook. Runs at the very top of routeInbound, before
+ * messaging-group resolution. When the interceptor returns true the message
+ * is consumed and routing stops. Used by the permissions module to capture
+ * free-text replies during multi-step approval flows (e.g. agent naming).
+ */
+export type MessageInterceptorFn = (event: InboundEvent) => Promise;
+
+let messageInterceptor: MessageInterceptorFn | null = null;
+
+export function setMessageInterceptor(fn: MessageInterceptorFn): void {
+ messageInterceptor = fn;
+}
+
/**
* Channel-registration hook. Runs when the router sees a mention/DM on a
* messaging group that has no wirings AND hasn't been denied. The hook is
@@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
* Creates messaging group + session if they don't exist yet.
*/
export async function routeInbound(event: InboundEvent): Promise {
+ // Pre-route interceptor — lets modules consume messages before any routing
+ // (e.g. free-text replies during multi-step approval flows).
+ if (messageInterceptor && (await messageInterceptor(event))) return;
+
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
// WhatsApp, iMessage, email) collapse threads to the channel.
const adapter = getChannelAdapter(event.channelType);
diff --git a/src/session-manager.ts b/src/session-manager.ts
index 96bca96..6b00655 100644
--- a/src/session-manager.ts
+++ b/src/session-manager.ts
@@ -21,7 +21,6 @@ import { DATA_DIR } from './config.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import {
createSession,
- findSession,
findSessionByAgentGroup,
findSessionForAgent,
getSession,
@@ -38,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');
@@ -234,6 +238,20 @@ export function writeSessionMessage(
/**
* If message content has attachments with base64 `data`, save them to
* the session's inbox directory and replace with `localPath`.
+ *
+ * Both `messageId` and `att.name` originate in untrusted input. WhatsApp
+ * passes `msg.key.id` through raw (and that field is client generated, so a
+ * peer can craft it), and other adapters may follow. The session dir is
+ * mounted writable into the container, so a compromised agent can also
+ * pre-place a symlink at `inbox//` and wait for a chat message
+ * with a matching id to redirect the host's write.
+ *
+ * Defenses, mirrored from the outbound side:
+ * 1. basename check on `messageId` and `filename`.
+ * 2. lstat of the inbox dir to refuse pre-placed symlinks.
+ * 3. realpath-based containment under the session inbox root.
+ * 4. `wx` flag on writeFileSync to refuse following a pre-existing symlink
+ * at the target file path or overwriting any existing file.
*/
function extractAttachmentFiles(
agentGroupId: string,
@@ -251,34 +269,75 @@ function extractAttachmentFiles(
const attachments = parsed.attachments as Array> | undefined;
if (!Array.isArray(attachments)) return contentStr;
+ if (!isSafeAttachmentName(messageId)) {
+ log.warn('Rejecting unsafe inbound message id', { messageId });
+ return contentStr;
+ }
+
let changed = false;
for (const att of attachments) {
- if (typeof att.data === 'string') {
- // The name field is attacker-controlled: chat platforms with E2E
- // attachment encryption (WhatsApp, Matrix) cannot sanitize filename
- // server-side, and other adapters pass att.name through raw. Without
- // this guard, `path.join(inboxDir, '../../...')` writes anywhere the
- // host process has fs permission — see Signal Desktop's Nov 2025
- // attachment-fileName advisory for the same archetype.
- const rawName = deriveAttachmentName(att);
- const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
- if (filename !== rawName) {
- log.warn('Refused unsafe attachment filename — would escape inbox', {
- messageId,
- rawName,
- replacement: filename,
- });
- }
- const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
- fs.mkdirSync(inboxDir, { recursive: true });
- const filePath = path.join(inboxDir, filename);
- fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'));
- att.name = filename;
- 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;
@@ -369,19 +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) {
- // Reject any name that isn't a bare basename before touching the filesystem.
if (!isSafeAttachmentName(filename)) {
- log.warn('Refused unsafe outbox filename — would escape outbox', { messageId, filename });
+ 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 });
}
}
@@ -395,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 });
}