Merge branch 'main' into pr/inbound-db-fresh-open
This commit is contained in:
11
CLAUDE.md
11
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.
|
||||
|
||||
@@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s
|
||||
|
||||
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
|
||||
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
|
||||
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md).
|
||||
4. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
|
||||
|
||||
| Checkbox | Label |
|
||||
|----------|-------|
|
||||
|
||||
30
assets/setup-splash.txt
Normal file
30
assets/setup-splash.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [2m[38;2;43;183;206m°[39m[22m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧[39m
|
||||
[2m[38;2;43;183;206mo[39m[22m [38;2;43;183;206m⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿[39m
|
||||
[38;2;43;183;206m⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇[39m
|
||||
[38;2;43;183;206m⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀[39m [2m[38;2;43;183;206mo[39m[22m
|
||||
[38;2;43;183;206m⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀[39m
|
||||
[2m[38;2;43;183;206m°[39m[22m [38;2;43;183;206m⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀[39m [38;2;43;183;206mO[39m
|
||||
[38;2;43;183;206m⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206mo[39m [38;2;43;183;206m⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀[39m
|
||||
[38;2;43;183;206m⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀[39m
|
||||
|
||||
[1m _ _ [22m[38;2;43;183;206m[1m ___ _ [22m[39m
|
||||
[1m| \| |__ _ _ _ ___ [22m[38;2;43;183;206m[1m / __| |__ ___ __ __[22m[39m
|
||||
[1m| .` / _` | ' \/ _ \[22m[38;2;43;183;206m[1m| (__| / _` \ V V /[22m[39m
|
||||
[1m|_|\_\__,_|_||_\___/[22m[38;2;43;183;206m[1m \___|_\__,_|\_/\_/ [22m[39m
|
||||
|
||||
[2mSmall.[22m
|
||||
[2mRuns on your machine.[22m
|
||||
[2mYours to modify.[22m
|
||||
|
||||
[38;2;5;62;165m════════════════════════════════════════[39m
|
||||
@@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
# ---- System dependencies -----------------------------------------------------
|
||||
|
||||
@@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = {
|
||||
script,
|
||||
processAfter,
|
||||
recurrence,
|
||||
platformId: r.platform_id,
|
||||
channelType: r.channel_type,
|
||||
threadId: r.thread_id,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -260,31 +260,69 @@ async function processQuery(
|
||||
// Stream liveness is decided host-side via the heartbeat file + processing
|
||||
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
|
||||
// will kill the container and messages get reset to pending.
|
||||
let pollInFlight = false;
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done) return;
|
||||
if (done || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length > 0) {
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(newIds);
|
||||
void (async () => {
|
||||
try {
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length === 0) return;
|
||||
|
||||
const prompt = formatMessages(newMessages);
|
||||
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
const newIds = newMessages.map((m) => m.id);
|
||||
markProcessing(newIds);
|
||||
|
||||
markCompleted(newIds);
|
||||
}
|
||||
// Run pre-task scripts on follow-ups too — without this, a task that
|
||||
// arrives during an active query (e.g. a */10 monitoring cron) bypasses
|
||||
// its script gate and always wakes the agent, defeating the gate.
|
||||
// Mirrors the initial-batch hook above.
|
||||
let keep = newMessages;
|
||||
let skipped: string[] = [];
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:start
|
||||
const { applyPreTaskScripts } = await import('./scheduling/task-script.js');
|
||||
const preTask = await applyPreTaskScripts(newMessages);
|
||||
keep = preTask.keep;
|
||||
skipped = preTask.skipped;
|
||||
if (skipped.length > 0) {
|
||||
markCompleted(skipped);
|
||||
log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`);
|
||||
}
|
||||
// MODULE-HOOK:scheduling-pre-task-followup:end
|
||||
|
||||
if (keep.length === 0) return;
|
||||
// Re-check done — the outer query may have finished while the script
|
||||
// was awaited. Pushing into a closed stream is wasted work; the
|
||||
// claimed messages get released by the host's processing-claim sweep.
|
||||
if (done) return;
|
||||
|
||||
const keptIds = keep.map((m) => m.id);
|
||||
const prompt = formatMessages(keep);
|
||||
log(`Pushing ${keep.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
markCompleted(keptIds);
|
||||
} catch (err) {
|
||||
// Without this catch the rejection escapes the void IIFE and Node
|
||||
// terminates the container on unhandled-rejection. The initial-batch
|
||||
// path is wrapped by processQuery's outer try/catch; the follow-up
|
||||
// path is not, so it needs its own.
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Follow-up poll error: ${errMsg}`);
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
})();
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
|
||||
47
nanoclaw.sh
47
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 </dev/tty
|
||||
|
||||
case "${ROOT_ANS:-1}" in
|
||||
2)
|
||||
ph_event setup_root_continued
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
ph_event setup_root_aborted
|
||||
printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')"
|
||||
printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')"
|
||||
printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')"
|
||||
printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')"
|
||||
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
|
||||
printf ' %s\n' "$(dim '4. Log out: exit')"
|
||||
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
|
||||
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
@@ -188,9 +224,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
||||
BOOTSTRAP_LABEL="Installing the basics"
|
||||
BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.19",
|
||||
"version": "2.0.23",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="135k tokens, 68% of context window">
|
||||
<title>135k tokens, 68% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="139k tokens, 69% of context window">
|
||||
<title>139k tokens, 69% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">135k</text>
|
||||
<text x="71" y="14">135k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">139k</text>
|
||||
<text x="71" y="14">139k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
75
scripts/delete-cli-agent.ts
Normal file
75
scripts/delete-cli-agent.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Delete the scratch CLI agent created during setup's ping-pong test.
|
||||
*
|
||||
* Dynamically finds and removes all rows referencing the agent group
|
||||
* (any table with an agent_group_id column), deletes the agent group
|
||||
* itself, and removes the groups/<folder>/ directory. Leaves the CLI
|
||||
* messaging group intact so it can be reused for a new agent.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
|
||||
interface Args {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const argv = process.argv.slice(2);
|
||||
let folder = '';
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i];
|
||||
}
|
||||
if (!folder) {
|
||||
console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder <folder-name>');
|
||||
process.exit(1);
|
||||
}
|
||||
return { folder };
|
||||
}
|
||||
|
||||
const args = parseArgs();
|
||||
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db);
|
||||
|
||||
const ag = getAgentGroupByFolder(args.folder);
|
||||
if (!ag) {
|
||||
console.log(`No agent group with folder "${args.folder}" — nothing to delete.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cleanup = db.transaction(() => {
|
||||
const tables = db
|
||||
.prepare(
|
||||
`SELECT DISTINCT m.name FROM sqlite_master m
|
||||
JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id'
|
||||
WHERE m.type = 'table' AND m.name != 'agent_groups'`,
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
for (const { name } of tables) {
|
||||
db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id);
|
||||
}
|
||||
deleteAgentGroup(ag.id);
|
||||
});
|
||||
cleanup();
|
||||
|
||||
// Remove the groups/<folder>/ directory.
|
||||
const groupDir = path.join(process.cwd(), 'groups', args.folder);
|
||||
if (fs.existsSync(groupDir)) {
|
||||
fs.rmSync(groupDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Remove session data on disk.
|
||||
const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id);
|
||||
if (fs.existsSync(sessionsDir)) {
|
||||
fs.rmSync(sessionsDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Deleted agent group ${ag.id} (${args.folder}).`);
|
||||
@@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
|
||||
interface Args {
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let folder: string | undefined;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const val = argv[i + 1];
|
||||
@@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args {
|
||||
} else if (key === '--agent-name') {
|
||||
agentName = val;
|
||||
i++;
|
||||
} else if (key === '--folder') {
|
||||
folder = val;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args {
|
||||
return {
|
||||
displayName,
|
||||
agentName: agentName?.trim() || displayName,
|
||||
folder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ async function main(): Promise<void> {
|
||||
const promotedToOwner = false;
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = `cli-with-${normalizeName(args.displayName)}`;
|
||||
const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
|
||||
173
setup/auto.ts
173
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,7 +52,7 @@ 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, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
@@ -62,6 +63,13 @@ const RUN_START = Date.now();
|
||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Make sure ~/.local/bin is on PATH for every child process we spawn.
|
||||
// Installers we run mid-setup (OneCLI, claude) drop binaries there and
|
||||
// append a PATH line to the user's shell rc, but rc updates don't reach
|
||||
// an already-running Node process — so without this patch a freshly
|
||||
// installed `onecli` is invisible to a subsequent `runInheritScript`.
|
||||
ensureLocalBinOnPath();
|
||||
|
||||
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||||
// and flag values get folded into process.env so existing step code reading
|
||||
// NANOCLAW_* sees them unchanged.
|
||||
@@ -85,17 +93,21 @@ async function main(): Promise<void> {
|
||||
|
||||
// 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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}),
|
||||
) as 'continue' | 'chat';
|
||||
setupLog.userInput('first_chat_choice', next);
|
||||
if (next === 'chat') await runFirstChat();
|
||||
if (next === 'chat') {
|
||||
const terminalAgentName = `${displayName!}'s Terminal`;
|
||||
const createRes = await runQuietChild(
|
||||
'create-terminal-agent',
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName],
|
||||
{ running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` },
|
||||
);
|
||||
if (!createRes.ok) {
|
||||
await fail(
|
||||
'create-terminal-agent',
|
||||
`Couldn't create ${terminalAgentName}.`,
|
||||
'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.',
|
||||
);
|
||||
}
|
||||
await runFirstChat();
|
||||
}
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
@@ -1061,54 +1082,12 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
interface ExistingEnvGroup {
|
||||
label: string;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
const ENV_KEY_GROUPS: Record<string, { label: string; keys: string[] }> = {
|
||||
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<string, ExistingEnvGroup>; raw: Record<string, string> } | 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<string, string> = {};
|
||||
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<string, ExistingEnvGroup> = {};
|
||||
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 {
|
||||
@@ -1188,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);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
@@ -164,9 +165,8 @@ async function walkThroughBotCreation(): Promise<void> {
|
||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||
' 3. On the same tab, enable "Message Content Intent"',
|
||||
' (under Privileged Gateway Intents)',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
formatNoteLink(url),
|
||||
].filter((line): line is string => line !== null).join('\n'),
|
||||
'Create a Discord bot',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||
@@ -224,9 +224,8 @@ async function walkThroughServerCreation(): Promise<void> {
|
||||
' 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<void> {
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
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?`,
|
||||
@@ -446,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');
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
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?`,
|
||||
|
||||
@@ -25,9 +25,10 @@ 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 { readEnvKey } from '../environment.js';
|
||||
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
@@ -135,9 +136,8 @@ async function walkThroughAppCreation(): Promise<void> {
|
||||
' 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<void> {
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
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?',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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,6 +33,7 @@ import {
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
async function collectTelegramToken(): Promise<string> {
|
||||
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?`,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Args:
|
||||
* --display-name <name> (required) operator's display name
|
||||
* --agent-name <name> (optional) agent persona name, defaults to display-name
|
||||
* --folder <name> (optional) explicit folder name, defaults to cli-with-<normalized-display-name>
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
@@ -18,9 +19,11 @@ import { emitStatus } from './status.js';
|
||||
function parseArgs(args: string[]): {
|
||||
displayName: string;
|
||||
agentName?: string;
|
||||
folder?: string;
|
||||
} {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let folder: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
@@ -34,6 +37,10 @@ function parseArgs(args: string[]): {
|
||||
agentName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--folder':
|
||||
folder = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,17 +53,18 @@ function parseArgs(args: string[]): {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { displayName, agentName };
|
||||
return { displayName, agentName, folder };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { displayName, agentName } = parseArgs(args);
|
||||
const { displayName, agentName, folder } = parseArgs(args);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
|
||||
|
||||
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
|
||||
if (agentName) scriptArgs.push('--agent-name', agentName);
|
||||
if (folder) scriptArgs.push('--folder', folder);
|
||||
|
||||
log.info('Invoking init-cli-agent', { displayName, agentName });
|
||||
|
||||
|
||||
@@ -127,11 +127,22 @@ export async function run(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// Socket is unreachable due to group perms — current shell's supplementary
|
||||
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
|
||||
// or a prior install) doesn't affect us until next login. Re-exec this
|
||||
// step under `sg docker` so the child picks up docker as its primary
|
||||
// group and can talk to /var/run/docker.sock without a logout.
|
||||
// groups are fixed at login, so `usermod -aG docker` doesn't affect us
|
||||
// until next login. Ensure the user is in the docker group (install-docker.sh
|
||||
// does this on fresh installs, but skips when Docker is already present),
|
||||
// then re-exec under `sg docker` so the child picks up docker as its
|
||||
// primary group and can talk to /var/run/docker.sock without a logout.
|
||||
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
|
||||
// Ensure the current user is in the docker group — without this,
|
||||
// sg will ask for the (typically unset) group password and fail.
|
||||
const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' });
|
||||
if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) {
|
||||
log.info('Adding current user to docker group');
|
||||
spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
log.info('Re-executing container step under `sg docker`');
|
||||
const res = spawnSync(
|
||||
'sg',
|
||||
|
||||
@@ -11,6 +11,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;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
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';
|
||||
@@ -38,21 +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. On headless devices both
|
||||
* the prompt and the open are skipped — there's no browser to time
|
||||
* focus for, and the URL is already visible in the surrounding note.
|
||||
* to take focus. Proceeds on cancel as well. On headless devices both the
|
||||
* prompt and the open are skipped — the URL is already surfaced inside
|
||||
* the surrounding note (via `formatNoteLink`).
|
||||
*
|
||||
* On GUI devices the confirm message includes the fallback URL on the
|
||||
* lines below the action ("If browser does not appear, please visit:
|
||||
* <url>" in dim) so the user has a copy-paste path right next to the
|
||||
* action button without needing to scroll back up to the card.
|
||||
*/
|
||||
export async function confirmThenOpen(
|
||||
url: string,
|
||||
message = 'Press Enter to open your browser',
|
||||
): Promise<void> {
|
||||
if (isHeadless()) return;
|
||||
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message,
|
||||
message: `${message}${fallback}`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -135,6 +135,7 @@ export interface ChannelAdapter {
|
||||
// Optional
|
||||
setTyping?(platformId: string, threadId: string | null): Promise<void>;
|
||||
syncConversations?(): Promise<ConversationInfo[]>;
|
||||
resolveChannelName?(platformId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Subscribe the bot to a thread so follow-up messages route via the
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
sessionDir,
|
||||
inboundDbPath,
|
||||
outboundDbPath,
|
||||
readOutboxFiles,
|
||||
clearOutbox,
|
||||
} from './session-manager.js';
|
||||
import { getSession, findSession } from './db/sessions.js';
|
||||
import type { InboundEvent } from './channels/adapter.js';
|
||||
@@ -108,6 +110,147 @@ describe('session manager', () => {
|
||||
outDb.close();
|
||||
});
|
||||
|
||||
it('should reject outbound attachment filenames that escape the message outbox', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const dir = sessionDir('ag-1', 'sess-test');
|
||||
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
|
||||
fs.mkdirSync(msgOutbox, { recursive: true });
|
||||
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'outside secret');
|
||||
|
||||
expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject outbound attachment symlinks that escape the message outbox', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const dir = sessionDir('ag-1', 'sess-test');
|
||||
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
|
||||
fs.mkdirSync(msgOutbox, { recursive: true });
|
||||
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'outside secret');
|
||||
fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt'));
|
||||
|
||||
expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not recursively delete outside the outbox for unsafe message ids', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const victimDir = path.join(TEST_DIR, 'victim-dir');
|
||||
fs.mkdirSync(victimDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete');
|
||||
|
||||
clearOutbox('ag-1', 'sess-test', '../../../../victim-dir');
|
||||
|
||||
expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should still read and clear normal basename outbox files', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const dir = sessionDir('ag-1', 'sess-test');
|
||||
const msgOutbox = path.join(dir, 'outbox', 'msg-1');
|
||||
fs.mkdirSync(msgOutbox, { recursive: true });
|
||||
fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok');
|
||||
|
||||
const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']);
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files?.[0]?.filename).toBe('result.txt');
|
||||
expect(files?.[0]?.data.toString()).toBe('ok');
|
||||
|
||||
clearOutbox('ag-1', 'sess-test', 'msg-1');
|
||||
expect(fs.existsSync(msgOutbox)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// The container has /workspace write access, so it can pre create
|
||||
// inbox/<msgId> as a symlink to escape.
|
||||
const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
fs.mkdirSync(inboxRoot, { recursive: true });
|
||||
const evilTarget = path.join(TEST_DIR, 'evil-target');
|
||||
fs.mkdirSync(evilTarget, { recursive: true });
|
||||
fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil'));
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-evil',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'evil',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// The container pre creates inbox/<msgId>/photo.png as a symlink to a
|
||||
// host file. Without the wx flag, writeFileSync would follow it.
|
||||
const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'ORIGINAL');
|
||||
fs.symlinkSync(outside, path.join(inboxDir, 'photo.png'));
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-sym',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'sym',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL');
|
||||
});
|
||||
|
||||
it('should reject inbound attachments when messageId is unsafe', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: '../../escape',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'msgid',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
if (fs.existsSync(inboxRoot)) {
|
||||
expect(fs.readdirSync(inboxRoot)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should still save inbound attachments with safe basenames', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-ok',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'ok',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png');
|
||||
expect(fs.existsSync(expected)).toBe(true);
|
||||
expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES');
|
||||
});
|
||||
|
||||
it('should resolve to existing session (shared mode)', () => {
|
||||
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
expect(c1).toBe(true);
|
||||
|
||||
@@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => {
|
||||
expect(kind).toBe('chat-sdk');
|
||||
const payload = JSON.parse(content as string);
|
||||
expect(payload.type).toBe('ask_question');
|
||||
// Card names the target agent so the owner knows what they're wiring to.
|
||||
expect(payload.question).toContain('Andy');
|
||||
// Single-agent card offers a direct "Connect to <name>" button.
|
||||
const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:'));
|
||||
expect(connectOption).toBeDefined();
|
||||
expect(connectOption.label).toContain('Andy');
|
||||
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{
|
||||
@@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => {
|
||||
};
|
||||
expect(pending).toBeDefined();
|
||||
|
||||
// Owner clicks approve.
|
||||
// Owner clicks "Connect to Andy" (single-agent card).
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
value: 'connect:ag-1',
|
||||
userId: 'owner', // raw platform id — handler namespaces it
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
@@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => {
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
// Wiring created with MVP defaults.
|
||||
// Wiring created with defaults.
|
||||
const mga = getDb()
|
||||
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as {
|
||||
@@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => {
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'approve',
|
||||
value: 'connect:ag-1',
|
||||
userId: 'owner',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-owner',
|
||||
|
||||
@@ -5,24 +5,32 @@
|
||||
* addressed to the bot (SDK-confirmed mention or DM), it calls
|
||||
* `requestChannelApproval` instead of silently dropping. The flow:
|
||||
*
|
||||
* 1. Pick the target agent group we'd wire to (MVP: first by name).
|
||||
* Multi-agent picker is a follow-up — see ACTION-ITEMS.
|
||||
* 1. Gather all existing agent groups.
|
||||
* 2. Pick an eligible approver (owner / admin) and a reachable DM for
|
||||
* them, reusing the same primitives the sender-approval flow uses.
|
||||
* 3. Deliver an Approve / Ignore card that names the target agent
|
||||
* explicitly so the owner knows what they're wiring to.
|
||||
* 3. Deliver a card with three action families:
|
||||
* a. Connect to [agent] — one button per existing agent group.
|
||||
* Single-agent installs get a one-click connect.
|
||||
* b. Connect new agent — prompts for a free-text name, creates
|
||||
* the agent immediately on reply.
|
||||
* c. Reject — deny the channel.
|
||||
* 4. Record a `pending_channel_approvals` row holding the original event
|
||||
* so it can be re-routed on approve.
|
||||
* so it can be re-routed on connect/create.
|
||||
*
|
||||
* On approve (handler in index.ts):
|
||||
* - Create `messaging_group_agents` with MVP defaults
|
||||
* On connect (handler in index.ts):
|
||||
* - Create `messaging_group_agents` with defaults
|
||||
* (mention-sticky for groups / pattern='.' for DMs,
|
||||
* sender_scope='known', ignored_message_policy='accumulate')
|
||||
* - Add the triggering sender to `agent_group_members` so sender_scope
|
||||
* doesn't bounce the replayed message into a sender-approval cascade
|
||||
* - Delete the pending row, replay the original event
|
||||
*
|
||||
* On ignore:
|
||||
* On connect new agent (handler in index.ts):
|
||||
* - Prompt for a free-text agent name via DM
|
||||
* - On reply: create the agent group + filesystem, then wire
|
||||
* and replay as above
|
||||
*
|
||||
* On reject:
|
||||
* - Set `messaging_groups.denied_at = now()` so the router stops
|
||||
* escalating on this channel until an admin explicitly re-wires
|
||||
* - Delete the pending row
|
||||
@@ -36,19 +44,81 @@
|
||||
* - Approver has no reachable DM.
|
||||
* - Delivery adapter missing.
|
||||
*/
|
||||
import { normalizeOptions, type RawOption } from '../../channels/ask-question.js';
|
||||
import { getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { getMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js';
|
||||
import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { getChannelAdapter } from '../../channels/channel-registry.js';
|
||||
import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { initGroupFilesystem } from '../../group-init.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import type { AgentGroup } from '../../types.js';
|
||||
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
||||
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
||||
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' },
|
||||
{ label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' },
|
||||
];
|
||||
// ── Value constants (response handler in index.ts parses these) ──
|
||||
|
||||
export const CONNECT_PREFIX = 'connect:';
|
||||
export const NEW_AGENT_VALUE = 'new_agent';
|
||||
export const CHOOSE_EXISTING_VALUE = 'choose_existing';
|
||||
export const REJECT_VALUE = 'reject';
|
||||
|
||||
// ── Utilities ──
|
||||
|
||||
function toFolder(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unnamed'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card builders ──
|
||||
|
||||
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
|
||||
const options: RawOption[] = [];
|
||||
if (agentGroups.length === 1) {
|
||||
options.push({
|
||||
label: `Connect to ${agentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
|
||||
});
|
||||
} else {
|
||||
options.push({
|
||||
label: 'Choose existing agent',
|
||||
selectedLabel: '📋 Choosing…',
|
||||
value: CHOOSE_EXISTING_VALUE,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'Connect new agent',
|
||||
selectedLabel: '🆕 Connecting new agent…',
|
||||
value: NEW_AGENT_VALUE,
|
||||
});
|
||||
options.push({
|
||||
label: 'Reject',
|
||||
selectedLabel: '🙅 Rejected',
|
||||
value: REJECT_VALUE,
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildQuestionText(
|
||||
isGroup: boolean,
|
||||
senderName: string | undefined,
|
||||
channelName: string | null,
|
||||
channelType: string,
|
||||
): string {
|
||||
const who = senderName ?? 'Someone';
|
||||
if (isGroup) {
|
||||
const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`;
|
||||
return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`;
|
||||
}
|
||||
return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`;
|
||||
}
|
||||
|
||||
// ── Main flow ──
|
||||
|
||||
export interface RequestChannelApprovalInput {
|
||||
messagingGroupId: string;
|
||||
@@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput {
|
||||
export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise<void> {
|
||||
const { messagingGroupId, event } = input;
|
||||
|
||||
// In-flight dedup: don't spam the owner if the same unwired channel
|
||||
// gets more mentions / DMs while a card is already pending.
|
||||
if (hasInFlightChannelApproval(messagingGroupId)) {
|
||||
log.debug('Channel registration already in flight — dropping retry', {
|
||||
messagingGroupId,
|
||||
});
|
||||
log.debug('Channel registration already in flight — dropping retry', { messagingGroupId });
|
||||
return;
|
||||
}
|
||||
|
||||
// MVP: pick the first agent group by name. Multi-agent systems will get
|
||||
// a richer card later (user picks the target from a list).
|
||||
const agentGroups = getAllAgentGroups();
|
||||
if (agentGroups.length === 0) {
|
||||
log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', {
|
||||
@@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
});
|
||||
return;
|
||||
}
|
||||
const target = agentGroups[0];
|
||||
// Use first agent group for approver resolution — owners and global admins
|
||||
// are returned regardless of which group we pass.
|
||||
const referenceGroup = agentGroups[0];
|
||||
|
||||
// pickApprover takes the target agent group's id — gets scoped admins +
|
||||
// global admins + owners. For fresh installs with only an owner, the
|
||||
// owner is returned.
|
||||
const approvers = pickApprover(target.id);
|
||||
const approvers = pickApprover(referenceGroup.id);
|
||||
if (approvers.length === 0) {
|
||||
log.warn('Channel registration skipped — no owner or admin configured', {
|
||||
messagingGroupId,
|
||||
targetAgentGroupId: target.id,
|
||||
targetAgentGroupId: referenceGroup.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const originMg = getMessagingGroup(messagingGroupId);
|
||||
const originChannelType = originMg?.channel_type ?? '';
|
||||
|
||||
// Resolve channel name if not yet persisted.
|
||||
if (originMg && !originMg.name) {
|
||||
const channelAdapter = getChannelAdapter(originChannelType);
|
||||
if (channelAdapter?.resolveChannelName) {
|
||||
try {
|
||||
const name = await channelAdapter.resolveChannelName(originMg.platform_id);
|
||||
if (name) {
|
||||
updateMessagingGroup(originMg.id, { name });
|
||||
originMg.name = name;
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const delivery = await pickApprovalDelivery(approvers, originChannelType);
|
||||
if (!delivery) {
|
||||
log.warn('Channel registration skipped — no DM channel for any approver', {
|
||||
messagingGroupId,
|
||||
targetAgentGroupId: target.id,
|
||||
targetAgentGroupId: referenceGroup.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
|
||||
|
||||
// Extract sender name from the event content for a human-readable card.
|
||||
let senderName: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
|
||||
} catch {
|
||||
// non-critical — fall through to generic wording
|
||||
// non-critical
|
||||
}
|
||||
|
||||
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
|
||||
const question = isGroup
|
||||
? senderName
|
||||
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
|
||||
: `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
|
||||
: senderName
|
||||
? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
|
||||
: `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
|
||||
const options = normalizeOptions(APPROVAL_OPTIONS);
|
||||
const channelName = originMg?.name ?? null;
|
||||
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups));
|
||||
|
||||
createPendingChannelApproval({
|
||||
messaging_group_id: messagingGroupId,
|
||||
agent_group_id: target.id,
|
||||
agent_group_id: referenceGroup.id,
|
||||
original_message: JSON.stringify(event),
|
||||
approver_user_id: delivery.userId,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) {
|
||||
log.error('Channel registration row created but no delivery adapter is wired', {
|
||||
messagingGroupId,
|
||||
});
|
||||
log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
// Use messaging_group_id as the questionId — it's unique per card
|
||||
// (PK on pending table dedups) and lets the response handler look
|
||||
// up the pending row directly without another index.
|
||||
questionId: messagingGroupId,
|
||||
title,
|
||||
question,
|
||||
@@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
);
|
||||
log.info('Channel registration card delivered', {
|
||||
messagingGroupId,
|
||||
targetAgentGroupId: target.id,
|
||||
agentGroupCount: agentGroups.length,
|
||||
approver: delivery.userId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Channel registration card delivery failed', {
|
||||
messagingGroupId,
|
||||
err,
|
||||
});
|
||||
log.error('Channel registration card delivery failed', { messagingGroupId, err });
|
||||
}
|
||||
}
|
||||
|
||||
export const APPROVE_VALUE = 'approve';
|
||||
export const REJECT_VALUE = 'reject';
|
||||
// ── Helpers for the response handler (index.ts) ──
|
||||
|
||||
/**
|
||||
* Build normalized options for the agent-selection follow-up card.
|
||||
*/
|
||||
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
|
||||
const options: RawOption[] = agentGroups.map((ag) => ({
|
||||
label: ag.name,
|
||||
selectedLabel: `✅ Connected to ${ag.name}`,
|
||||
value: `${CONNECT_PREFIX}${ag.id}`,
|
||||
}));
|
||||
options.push({
|
||||
label: 'Cancel',
|
||||
selectedLabel: '🙅 Cancelled',
|
||||
value: REJECT_VALUE,
|
||||
});
|
||||
return normalizeOptions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new agent group and initialize its filesystem. Handles
|
||||
* folder-name collisions with numeric suffixes.
|
||||
*/
|
||||
export function createNewAgentGroup(name: string): AgentGroup {
|
||||
let folder = toFolder(name);
|
||||
const baseFolder = folder;
|
||||
let suffix = 2;
|
||||
while (getAgentGroupByFolder(folder)) {
|
||||
folder = `${baseFolder}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createAgentGroup({
|
||||
id: agId,
|
||||
name,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const ag = getAgentGroup(agId)!;
|
||||
initGroupFilesystem(ag);
|
||||
return ag;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean {
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void {
|
||||
getDb()
|
||||
.prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?')
|
||||
.run(title, optionsJson, messagingGroupId);
|
||||
}
|
||||
|
||||
export function deletePendingChannelApproval(messagingGroupId: string): void {
|
||||
getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId);
|
||||
}
|
||||
|
||||
@@ -16,27 +16,53 @@
|
||||
* access gate is not registered and core defaults to allow-all.
|
||||
*/
|
||||
import { recordDroppedMessage } from '../../db/dropped-messages.js';
|
||||
import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js';
|
||||
import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js';
|
||||
import {
|
||||
routeInbound,
|
||||
setAccessGate,
|
||||
setChannelRequestGate,
|
||||
setMessageInterceptor,
|
||||
setSenderResolver,
|
||||
setSenderScopeGate,
|
||||
type AccessGateResult,
|
||||
} from '../../router.js';
|
||||
import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js';
|
||||
import { getDeliveryAdapter } from '../../delivery.js';
|
||||
import { log } from '../../log.js';
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../../types.js';
|
||||
import { canAccessAgentGroup } from './access.js';
|
||||
import { requestChannelApproval } from './channel-approval.js';
|
||||
import {
|
||||
buildAgentSelectionOptions,
|
||||
CHOOSE_EXISTING_VALUE,
|
||||
CONNECT_PREFIX,
|
||||
createNewAgentGroup,
|
||||
NEW_AGENT_VALUE,
|
||||
REJECT_VALUE,
|
||||
requestChannelApproval,
|
||||
} from './channel-approval.js';
|
||||
import { addMember } from './db/agent-group-members.js';
|
||||
import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js';
|
||||
import {
|
||||
deletePendingChannelApproval,
|
||||
getPendingChannelApproval,
|
||||
updatePendingChannelApprovalCard,
|
||||
} from './db/pending-channel-approvals.js';
|
||||
import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js';
|
||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||
import { getUser, upsertUser } from './db/users.js';
|
||||
import { requestSenderApproval } from './sender-approval.js';
|
||||
import { ensureUserDm } from './user-dm.js';
|
||||
|
||||
// ── Free-text name input state ──
|
||||
// Tracks approvers waiting for a text reply with the agent name. Keyed by
|
||||
// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart.
|
||||
interface PendingNameInput {
|
||||
channelMgId: string;
|
||||
dmChannelType: string;
|
||||
dmPlatformId: string;
|
||||
}
|
||||
const awaitingNameInput = new Map<string, PendingNameInput>();
|
||||
|
||||
function extractAndUpsertUser(event: InboundEvent): string | null {
|
||||
let content: Record<string, unknown>;
|
||||
@@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => {
|
||||
* by messaging_group_id). If no such row, return false so downstream
|
||||
* handlers get a shot.
|
||||
*
|
||||
* Approve: create the wiring with MVP defaults (mention-sticky for
|
||||
* groups / pattern='.' for DMs; sender_scope='known';
|
||||
* ignored_message_policy='accumulate'), add the triggering sender as a
|
||||
* member so sender_scope doesn't immediately bounce them into a
|
||||
* sender-approval card, then replay the original event.
|
||||
*
|
||||
* Deny: set `messaging_groups.denied_at = now()` so future mentions on
|
||||
* this channel drop silently until an admin explicitly wires it.
|
||||
* Value dispatch:
|
||||
* connect:<id> — wire to an existing agent group, replay the message
|
||||
* choose_existing — send a follow-up card listing all agents
|
||||
* new_agent — prompt for a free-text agent name (interceptor
|
||||
* captures the reply and creates immediately)
|
||||
* reject — set denied_at, delete pending row
|
||||
*/
|
||||
async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<boolean> {
|
||||
const row = getPendingChannelApproval(payload.questionId);
|
||||
if (!row) return false;
|
||||
|
||||
// Click-auth: same pattern as sender-approval (see commit 68058cb).
|
||||
// Raw platform userId → namespace with channelType → must match the
|
||||
// designated approver OR have admin privilege over the target agent.
|
||||
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
@@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
clickerId,
|
||||
expectedApprover: row.approver_user_id,
|
||||
});
|
||||
return true; // claim but take no action
|
||||
return true;
|
||||
}
|
||||
const approverId = clickerId;
|
||||
const approved = payload.value === 'approve';
|
||||
|
||||
if (!approved) {
|
||||
// ── Reject / Cancel ──
|
||||
if (payload.value === REJECT_VALUE) {
|
||||
setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString());
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
log.info('Channel registration denied', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: row.agent_group_id,
|
||||
approverId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rehydrate the original event to know (a) whether it was a DM or group
|
||||
// (chooses engage_mode default), and (b) who the triggering sender was
|
||||
// (auto-member-add so sender_scope='known' doesn't bounce the replay).
|
||||
// ── Choose existing agent — send agent-selection follow-up card ──
|
||||
if (payload.value === CHOOSE_EXISTING_VALUE) {
|
||||
const approverDm = await ensureUserDm(row.approver_user_id);
|
||||
if (!approverDm) {
|
||||
log.error('Channel registration: no DM channel for approver', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverUserId: row.approver_user_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) return true;
|
||||
|
||||
const agentGroups = getAllAgentGroups();
|
||||
const options = buildAgentSelectionOptions(agentGroups);
|
||||
const title = '📋 Choose an agent';
|
||||
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
||||
|
||||
try {
|
||||
await adapter.deliver(
|
||||
approverDm.channel_type,
|
||||
approverDm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: row.messaging_group_id,
|
||||
title,
|
||||
question: 'Which agent should handle this channel?',
|
||||
options,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('Channel registration: agent-selection card delivery failed', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Create new agent — prompt for free-text name ──
|
||||
if (payload.value === NEW_AGENT_VALUE) {
|
||||
const approverDm = await ensureUserDm(row.approver_user_id);
|
||||
if (!approverDm) {
|
||||
log.error('Channel registration: no DM channel for approver', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
approverUserId: row.approver_user_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (!adapter) {
|
||||
log.error('Channel registration: no delivery adapter for name prompt', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
awaitingNameInput.set(row.approver_user_id, {
|
||||
channelMgId: row.messaging_group_id,
|
||||
dmChannelType: approverDm.channel_type,
|
||||
dmPlatformId: approverDm.platform_id,
|
||||
});
|
||||
|
||||
try {
|
||||
await adapter.deliver(
|
||||
approverDm.channel_type,
|
||||
approverDm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({ text: 'Reply with the name for your new agent:' }),
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('Channel registration: name prompt delivery failed', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
awaitingNameInput.delete(row.approver_user_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resolve target agent group (connect to existing or create new) ──
|
||||
let targetAgentGroupId: string;
|
||||
|
||||
if (payload.value.startsWith(CONNECT_PREFIX)) {
|
||||
targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length);
|
||||
const ag = getAgentGroup(targetAgentGroupId);
|
||||
if (!ag) {
|
||||
log.error('Channel registration: target agent group no longer exists', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
targetAgentGroupId,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
log.warn('Channel registration: unknown response value', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
value: payload.value,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Wire + replay (shared path for connect and create) ──
|
||||
let event: InboundEvent;
|
||||
try {
|
||||
event = JSON.parse(row.original_message) as InboundEvent;
|
||||
@@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decide engage_mode from the original event. DMs (`isMention=true` &
|
||||
// not in a group) get `pattern='.'` (always respond). Group mentions
|
||||
// get `mention-sticky` (respond now + follow the thread).
|
||||
//
|
||||
// We can't read `mg.is_group` reliably here because we only auto-create
|
||||
// the mg with `is_group=0` on first sight — the adapter hasn't told us
|
||||
// yet whether it's actually a group. Fall back to the InboundEvent's
|
||||
// `threadId`: a non-null threadId implies a threaded platform (Slack
|
||||
// channel thread, Discord thread), which we treat as a group.
|
||||
const isGroup = event.threadId !== null;
|
||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||
const engagePattern = isGroup ? null : '.';
|
||||
@@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: row.agent_group_id,
|
||||
agent_group_id: targetAgentGroupId,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'known',
|
||||
@@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: row.agent_group_id,
|
||||
agentGroupId: targetAgentGroupId,
|
||||
mgaId,
|
||||
engageMode,
|
||||
approverId,
|
||||
});
|
||||
|
||||
// Auto-admit the triggering sender. Without this, the replay below
|
||||
// would bounce through sender-approval (sender_scope='known' +
|
||||
// sender-is-not-a-member).
|
||||
const senderUserId = extractAndUpsertUser(event);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: row.agent_group_id,
|
||||
agent_group_id: targetAgentGroupId,
|
||||
added_by: approverId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Clear the pending row BEFORE replay so the gate check on the second
|
||||
// attempt sees a wired channel (agentCount > 0) and takes the fan-out
|
||||
// path normally.
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
@@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
}
|
||||
|
||||
registerResponseHandler(handleChannelApprovalResponse);
|
||||
|
||||
// ── Free-text name interceptor ──
|
||||
// Captures the next DM from an approver who clicked "Create new agent",
|
||||
// creates the agent immediately, wires the channel, and replays.
|
||||
|
||||
setMessageInterceptor(async (event: InboundEvent): Promise<boolean> => {
|
||||
const userId = extractAndUpsertUser(event);
|
||||
if (!userId) return false;
|
||||
|
||||
const pending = awaitingNameInput.get(userId);
|
||||
if (!pending) return false;
|
||||
if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false;
|
||||
|
||||
awaitingNameInput.delete(userId);
|
||||
|
||||
let text: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
|
||||
text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
log.warn('Channel registration: empty name reply, ignoring', { userId });
|
||||
return true;
|
||||
}
|
||||
|
||||
const row = getPendingChannelApproval(pending.channelMgId);
|
||||
if (!row) return true;
|
||||
|
||||
const ag = createNewAgentGroup(text);
|
||||
log.info('Channel registration: new agent group created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: ag.id,
|
||||
agentName: ag.name,
|
||||
folder: ag.folder,
|
||||
});
|
||||
|
||||
let originalEvent: InboundEvent;
|
||||
try {
|
||||
originalEvent = JSON.parse(row.original_message) as InboundEvent;
|
||||
} catch (err) {
|
||||
log.error('Channel registration: failed to parse stored event', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = originalEvent.threadId !== null;
|
||||
const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern';
|
||||
const engagePattern = isGroup ? null : '.';
|
||||
|
||||
const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: row.messaging_group_id,
|
||||
agent_group_id: ag.id,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'known',
|
||||
ignored_message_policy: 'accumulate',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Channel registration approved — wiring created', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
agentGroupId: ag.id,
|
||||
mgaId,
|
||||
engageMode,
|
||||
approverId: userId,
|
||||
});
|
||||
|
||||
const senderUserId = extractAndUpsertUser(originalEvent);
|
||||
if (senderUserId) {
|
||||
addMember({
|
||||
user_id: senderUserId,
|
||||
agent_group_id: ag.id,
|
||||
added_by: userId,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
|
||||
try {
|
||||
await routeInbound(originalEvent);
|
||||
} catch (err) {
|
||||
log.error('Failed to replay message after channel approval', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
|
||||
const adapter = getDeliveryAdapter();
|
||||
if (adapter) {
|
||||
const dm = await ensureUserDm(row.approver_user_id);
|
||||
if (dm) {
|
||||
adapter
|
||||
.deliver(
|
||||
dm.channel_type,
|
||||
dm.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void {
|
||||
senderScopeGate = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-interceptor hook. Runs at the very top of routeInbound, before
|
||||
* messaging-group resolution. When the interceptor returns true the message
|
||||
* is consumed and routing stops. Used by the permissions module to capture
|
||||
* free-text replies during multi-step approval flows (e.g. agent naming).
|
||||
*/
|
||||
export type MessageInterceptorFn = (event: InboundEvent) => Promise<boolean>;
|
||||
|
||||
let messageInterceptor: MessageInterceptorFn | null = null;
|
||||
|
||||
export function setMessageInterceptor(fn: MessageInterceptorFn): void {
|
||||
messageInterceptor = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel-registration hook. Runs when the router sees a mention/DM on a
|
||||
* messaging group that has no wirings AND hasn't been denied. The hook is
|
||||
@@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender
|
||||
* Creates messaging group + session if they don't exist yet.
|
||||
*/
|
||||
export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
// Pre-route interceptor — lets modules consume messages before any routing
|
||||
// (e.g. free-text replies during multi-step approval flows).
|
||||
if (messageInterceptor && (await messageInterceptor(event))) return;
|
||||
|
||||
// 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram,
|
||||
// WhatsApp, iMessage, email) collapse threads to the channel.
|
||||
const adapter = getChannelAdapter(event.channelType);
|
||||
|
||||
@@ -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/<future msgId>/` and wait for a chat message
|
||||
* with a matching id to redirect the host's write.
|
||||
*
|
||||
* Defenses, mirrored from the outbound side:
|
||||
* 1. basename check on `messageId` and `filename`.
|
||||
* 2. lstat of the inbox dir to refuse pre-placed symlinks.
|
||||
* 3. realpath-based containment under the session inbox root.
|
||||
* 4. `wx` flag on writeFileSync to refuse following a pre-existing symlink
|
||||
* at the target file path or overwriting any existing file.
|
||||
*/
|
||||
function extractAttachmentFiles(
|
||||
agentGroupId: string,
|
||||
@@ -251,34 +269,75 @@ function extractAttachmentFiles(
|
||||
const attachments = parsed.attachments as Array<Record<string, unknown>> | undefined;
|
||||
if (!Array.isArray(attachments)) return contentStr;
|
||||
|
||||
if (!isSafeAttachmentName(messageId)) {
|
||||
log.warn('Rejecting unsafe inbound message id', { messageId });
|
||||
return contentStr;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const att of attachments) {
|
||||
if (typeof att.data === 'string') {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user