Merge branch 'main' into feat/migrate-from-v1

This commit is contained in:
Gabi Simons
2026-04-26 12:26:04 +03:00
committed by GitHub
47 changed files with 2496 additions and 189 deletions

95
setup/add-signal.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Install the Signal adapter in an already-running NanoClaw checkout.
# Non-interactive — the operator-facing "install signal-cli" + QR scan
# live in setup/channels/signal.ts. This script only:
#
# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels
# branch.
# 2. Appends the self-registration import to src/channels/index.ts.
# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has
# no npm deps).
# 4. Builds.
#
# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli
# link has produced a number; that keeps this script idempotent and
# re-runnable without re-auth.
#
# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All
# chatty progress goes to stderr so setup:auto's raw-log capture sees
# the full story without cluttering the final block for the parser.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-signal/SKILL.md.
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
# shellcheck source=setup/lib/channels-remote.sh
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
CHANNELS_REMOTE=$(resolve_channels_remote)
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
emit_status() {
local status=$1 error=${2:-}
local already=${ADAPTER_ALREADY_INSTALLED:-false}
echo "=== NANOCLAW SETUP: ADD_SIGNAL ==="
echo "STATUS: ${status}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-signal] $*" >&2; }
need_install() {
[ ! -f src/channels/signal.ts ] && return 0
! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0
return 1
}
ADAPTER_ALREADY_INSTALLED=true
if need_install; then
ADAPTER_ALREADY_INSTALLED=false
log "Fetching channels branch…"
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
exit 1
}
log "Copying adapter files from ${CHANNELS_BRANCH}"
for f in \
src/channels/signal.ts \
src/channels/signal.test.ts
do
git show "${CHANNELS_BRANCH}:$f" > "$f" || {
emit_status failed "git show ${CHANNELS_BRANCH}:$f failed"
exit 1
}
done
if ! grep -q "^import './signal.js';" src/channels/index.ts; then
echo "import './signal.js';" >> src/channels/index.ts
fi
fi
# qrcode is needed by setup/signal-auth.ts to render the linking URL as a
# terminal QR. Install idempotently — if it's already present (e.g. from a
# prior WhatsApp install) pnpm is a no-op.
if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then
log "Installing ${QRCODE_VERSION}"
pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${QRCODE_VERSION} failed"
exit 1
}
fi
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
emit_status success

View File

@@ -30,6 +30,7 @@ import k from 'kleur';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
import { runSlackChannel } from './channels/slack.js';
import { runTeamsChannel } from './channels/teams.js';
import { runTelegramChannel } from './channels/telegram.js';
@@ -57,6 +58,7 @@ type ChannelChoice =
| 'telegram'
| 'discord'
| 'whatsapp'
| 'signal'
| 'teams'
| 'slack'
| 'imessage'
@@ -327,6 +329,8 @@ async function main(): Promise<void> {
await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
@@ -454,6 +458,8 @@ function channelDmLabel(choice: ChannelChoice): string | null {
return 'Discord DMs';
case 'whatsapp':
return 'WhatsApp';
case 'signal':
return 'Signal';
case 'teams':
return 'Teams';
case 'imessage':
@@ -847,6 +853,11 @@ async function askChannelChoice(): Promise<ChannelChoice> {
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
{ value: 'discord', label: 'Yes, connect Discord' },
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
{
value: 'signal',
label: 'Yes, connect Signal',
hint: 'needs signal-cli installed',
},
{
value: 'imessage',
label: 'Yes, connect iMessage (experimental)',

357
setup/channels/signal.ts Normal file
View File

@@ -0,0 +1,357 @@
/**
* Signal channel flow for setup:auto.
*
* `runSignalChannel(displayName)` owns the full branch from signal-cli
* presence check through the welcome DM:
*
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
* offer `brew install signal-cli` inline. On Linux, surface the
* GitHub releases URL and bail with an actionable error.
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
* a terminal QR the operator scans from Signal → Linked Devices.
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
* 5. Kick the service so the adapter picks up the new credentials.
* 6. Ask operator role + agent name.
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
* DM path delivers the greeting through the adapter.
*
* Signal's `link` flow creates a *secondary* device. The phone number
* comes from the primary (the phone that scanned the QR); this host then
* sends/receives as that primary number. No registration of new numbers.
*
* Output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. See docs/setup-flow.md.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
type StepResult,
dumpTranscriptOnFailure,
ensureAnswer,
fail,
runQuietChild,
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runSignalChannel(displayName: string): Promise<void> {
await ensureSignalCli();
const install = await runQuietChild(
'signal-install',
'bash',
['setup/add-signal.sh'],
{
running: 'Installing the Signal adapter…',
done: 'Signal adapter installed.',
skipped: 'Signal adapter already installed.',
},
);
if (!install.ok) {
await fail(
'signal-install',
"Couldn't install the Signal adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runSignalAuth();
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
await fail(
'signal-auth',
`Signal link failed (${reason}).`,
reason === 'qr_timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const account = auth.terminal?.fields.ACCOUNT;
if (!account) {
await fail(
'signal-auth',
'Linked with Signal but couldn\'t read the phone number back.',
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
);
}
writeSignalAccount(account!);
await restartService();
const role = await askOperatorRole('Signal');
setupLog.userInput('signal_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'signal',
'--user-id', account!,
'--platform-id', account!,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to Signal…`,
done: `${agentName} is ready. Check Signal for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'signal',
AGENT_NAME: agentName,
PLATFORM_ID: account!,
ROLE: role,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function ensureSignalCli(): Promise<void> {
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
const probe = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!probe.error && probe.status === 0) return;
if (process.platform === 'darwin') {
p.note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'The quickest way on macOS is Homebrew:',
'',
k.cyan(' brew install signal-cli'),
'',
"Install it in another terminal, then re-run setup.",
].join('\n'),
'signal-cli not found',
);
} else {
p.note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'Grab the latest release from GitHub:',
'',
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
'',
"Install it, make sure `signal-cli --version` works, then re-run setup.",
].join('\n'),
'signal-cli not found',
);
}
await fail(
'signal-install',
'signal-cli is required but not installed.',
'Install it and re-run setup.',
);
}
async function runSignalAuth(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('signal-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting Signal link…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number): void => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks how many lines the QR block occupies so we can wipe it in-place
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
// but we still want to erase the QR from screen once it's served).
let qrLinesPrinted = 0;
const result = await spawnStep(
'signal-auth',
[],
(block: Block) => {
if (block.type === 'SIGNAL_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
void renderQr(qr).then((lines) => {
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
s.start('Waiting for you to scan…');
spinnerActive = true;
});
} else if (block.type === 'SIGNAL_AUTH') {
const status = block.fields.STATUS;
// Wipe the QR block regardless of outcome — it's either scanned
// and useless, or expired and misleading.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const account = block.fields.ACCOUNT;
if (status === 'skipped') {
stopSpinner(
account
? `Signal already linked as ${k.cyan(account)}.`
: 'Signal already linked.',
);
} else if (status === 'success') {
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
} else if (status === 'failed') {
const err = block.fields.ERROR ?? 'unknown';
stopSpinner(`Signal link failed: ${err}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('signal-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw linking URL as a block-art QR, returned line-by-line so
* the caller can count lines for in-place cleanup. Uses small-mode so the
* code stays scannable on 24-row terminals. If qrcode isn't installed
* (add-signal.sh should have handled it, but we're defensive), fall back
* to the raw URL and ask the user to paste it into an external renderer.
*/
async function renderQr(url: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
const caption = k.dim(
' Signal → Settings → Linked Devices → Link New Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return [
'Linking URL (render at https://qr.io or similar):',
'',
url,
'',
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
];
}
}
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
function writeSignalAccount(account: string): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
contents = contents.replace(
/^SIGNAL_ACCOUNT=.*$/m,
`SIGNAL_ACCOUNT=${account}`,
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += `SIGNAL_ACCOUNT=${account}\n`;
}
fs.writeFileSync(envPath, contents);
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
setupLog.userInput('signal_account', account);
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your Signal account…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
stdio: 'ignore',
});
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
}
}
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('signal-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
@@ -17,58 +19,63 @@ describe('environment detection', () => {
});
});
describe('registered groups DB query', () => {
let db: Database.Database;
describe('detectRegisteredGroups', () => {
let tempDir: string;
beforeEach(() => {
db = new Database(':memory:');
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
});
it('returns 0 for empty table', () => {
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(0);
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('returns correct count after inserts', () => {
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
'123@g.us',
'Group 1',
'group-1',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
it('returns false when no registration state exists', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
expect(detectRegisteredGroups(tempDir)).toBe(false);
});
db.prepare(
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
'456@g.us',
'Group 2',
'group-2',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
);
it('detects pre-migration registered_groups.json', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
expect(detectRegisteredGroups(tempDir)).toBe(true);
});
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(2);
it('returns false for an empty v2 central DB', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
db.exec(`
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL,
agent_group_id TEXT NOT NULL
);
`);
db.close();
expect(detectRegisteredGroups(tempDir)).toBe(false);
});
it('detects wired agent groups in the v2 central DB', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
db.exec(`
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
CREATE TABLE messaging_group_agents (
id TEXT PRIMARY KEY,
messaging_group_id TEXT NOT NULL,
agent_group_id TEXT NOT NULL
);
`);
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
db.prepare(
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
).run('mga-1', 'mg-1', 'ag-1');
db.close();
expect(detectRegisteredGroups(tempDir)).toBe(true);
});
});

View File

@@ -7,11 +7,35 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
export function detectRegisteredGroups(projectRoot: string): boolean {
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
return true;
}
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return false;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
)
.get() as { count: number };
return row.count > 0;
} catch {
return false;
} finally {
db?.close();
}
}
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
const authDir = path.join(projectRoot, 'store', 'auth');
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
let hasRegisteredGroups = false;
// Check JSON file first (pre-migration)
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
hasRegisteredGroups = true;
} else {
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
const dbPath = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(dbPath)) {
try {
const db = new Database(dbPath, { readonly: true });
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
if (row.count > 0) hasRegisteredGroups = true;
db.close();
} catch {
// Table might not exist yet
}
}
}
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
// Check for existing OpenClaw installation
const homedir = (await import('os')).homedir();

View File

@@ -16,6 +16,7 @@ const STEPS: Record<
register: () => import('./register.js'),
groups: () => import('./groups.js'),
'whatsapp-auth': () => import('./whatsapp-auth.js'),
'signal-auth': () => import('./signal-auth.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { classifyPingResult } from './agent-ping.js';
describe('classifyPingResult', () => {
it('treats a normal text reply as ok', () => {
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
});
it('detects Anthropic auth errors printed as a chat reply', () => {
expect(
classifyPingResult(
0,
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
),
).toBe('auth_error');
});
it('detects auth errors on stderr too', () => {
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
});
it('preserves socket errors', () => {
expect(classifyPingResult(2, '')).toBe('socket_error');
});
it('treats empty output as no reply', () => {
expect(classifyPingResult(0, '')).toBe('no_reply');
});
});

View File

@@ -13,7 +13,21 @@
*/
import { spawn } from 'child_process';
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
const output = `${stdout}\n${stderr}`;
if (
/Invalid bearer token/i.test(output) ||
/authentication[_ ]error/i.test(output) ||
/Failed to authenticate/i.test(output)
) {
return 'auth_error';
}
if (exitCode === 2) return 'socket_error';
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
return 'no_reply';
}
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
return new Promise((resolve) => {
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf-8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf-8');
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code === 2) resolve('socket_error');
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
else resolve('no_reply');
resolve(classifyPingResult(code, stdout, stderr));
});
child.on('error', () => {
if (settled) return;

View File

@@ -20,6 +20,7 @@ import {
import { isValidGroupFolder } from '../src/group-folder.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { log } from '../src/log.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
import { emitStatus } from './status.js';
@@ -112,12 +113,10 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
// Chat SDK adapters prefix platform IDs with the channel type
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
// the stored ID always matches what the adapter sends at runtime.
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
}
// Normalize platform_id to the same shape the adapter will emit at runtime,
// so the router's (channel_type, platform_id) lookup matches what we store.
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
log.info('Registering channel', parsed);
@@ -167,19 +166,22 @@ export async function run(args: string[]): Promise<void> {
if (!existing) {
newlyWired = true;
const mgaId = generateId('mga');
const triggerRules = parsed.trigger
? JSON.stringify({
pattern: parsed.trigger,
requiresTrigger: parsed.requiresTrigger,
})
: null;
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
// create rows with the same shape. Groups default to 'mention' (bot only
// responds when addressed); DMs default to 'pattern'/'.' (respond to
// every message). An explicit --trigger overrides the pattern regex.
const isGroup = messagingGroup.is_group === 1;
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: messagingGroup.id,
agent_group_id: agentGroup.id,
trigger_rules: triggerRules,
response_scope: 'all',
session_mode: parsed.sessionMode,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
priority: 0,
created_at: new Date().toISOString(),
});

182
setup/signal-auth.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Step: signal-auth — link this host to an existing Signal account via
* signal-cli's QR-code flow.
*
* signal-cli `link` opens a bi-directional handshake with the Signal
* servers: it prints one line containing a linking URL (`sgnl://linkdevice?…`
* or older `tsdevice://linkdevice?…`), then blocks until either the user
* scans it from an existing Signal install, or the code expires. On
* success, a secondary account is created under the user's signal-cli
* data directory, associated with the phone number of the scanner.
*
* Methods:
* (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR
* with the URL, wait for completion.
*
* Block schema (parent parses these):
* SIGNAL_AUTH_QR { QR: "<sgnl:// or tsdevice:// url>" } — one-shot
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } — terminal
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
* { STATUS: failed, ERROR: <reason> }
*
* STATUS values match the runner's vocabulary (success/skipped/failed) so
* spawnStep recognises them and sets `ok` correctly; Signal-specific UI
* lives in setup/channels/signal.ts.
*
* If one or more accounts are already linked (discovered via
* `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH
* STATUS=skipped with the first account so the driver can reuse it.
* Selecting a different existing account is a driver concern.
*/
import { spawn, spawnSync } from 'child_process';
import { emitStatus } from './status.js';
const LINK_TIMEOUT_MS = 180_000;
const DEFAULT_DEVICE_NAME = 'NanoClaw';
interface SignalAccount {
account?: string;
registered?: boolean;
}
function cliPath(): string {
return process.env.SIGNAL_CLI_PATH || 'signal-cli';
}
/**
* Query signal-cli for currently linked accounts. Empty array if none
* configured, no binary, or the call fails for any other reason.
*/
function listAccounts(): string[] {
const cli = cliPath();
try {
const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (res.status !== 0) return [];
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
return parsed
.filter((a) => a.registered !== false)
.map((a) => a.account ?? '')
.filter(Boolean);
} catch {
return [];
}
}
export async function run(_args: string[]): Promise<void> {
const cli = cliPath();
// Verify signal-cli exists before we commit to the long-running link.
// The driver checks too, but this keeps the step honest when run alone.
const probe = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (probe.error || probe.status !== 0) {
emitStatus('SIGNAL_AUTH', {
STATUS: 'failed',
ERROR: 'signal-cli not found. Install signal-cli first.',
});
return;
}
const existing = listAccounts();
if (existing.length > 0) {
emitStatus('SIGNAL_AUTH', {
STATUS: 'skipped',
ACCOUNT: existing[0],
REASON: 'already-authenticated',
});
return;
}
await new Promise<void>((resolve) => {
let settled = false;
let qrEmitted = false;
const finish = (block: Record<string, string | number | boolean>, code: number): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
emitStatus('SIGNAL_AUTH', block);
resolve();
setTimeout(() => process.exit(code), 500);
};
const timer = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch {
/* ignore */
}
finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1);
}, LINK_TIMEOUT_MS);
const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// stdout carries the URL on the first line; subsequent lines may print
// status like "Associated with: +1555…". We don't strictly need to parse
// the number — listAccounts after exit is the source of truth — but the
// URL match drives the QR emit, which is the whole point.
let stdoutBuf = '';
const handleStdout = (chunk: Buffer): void => {
stdoutBuf += chunk.toString('utf-8');
let idx: number;
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, idx).trim();
stdoutBuf = stdoutBuf.slice(idx + 1);
if (!line) continue;
// Match both modern (sgnl://) and legacy (tsdevice://) schemes.
if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) {
qrEmitted = true;
emitStatus('SIGNAL_AUTH_QR', { QR: line });
}
}
};
child.stdout.on('data', handleStdout);
// Capture stderr for the transcript / log — signal-cli writes warnings
// and errors there. We don't emit on partial stderr lines since a
// successful link can still produce noise.
let stderrBuf = '';
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf += chunk.toString('utf-8');
});
child.on('error', (err) => {
finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1);
});
child.on('close', (code) => {
// After a successful link, signal-cli exits 0 and the newly linked
// account shows up in listAccounts. Use that as the source of truth
// rather than scraping stdout — more robust across signal-cli versions.
if (code === 0) {
const post = listAccounts();
if (post.length === 0) {
finish(
{ STATUS: 'failed', ERROR: 'link exited 0 but no account registered' },
1,
);
return;
}
finish({ STATUS: 'success', ACCOUNT: post[0] }, 0);
return;
}
// Non-zero exit. Surface the last non-empty stderr line for context;
// signal-cli's own error messages are usually informative.
const lastErr =
stderrBuf
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.slice(-1)[0] ?? `signal-cli link exited with code ${code}`;
finish({ STATUS: 'failed', ERROR: lastErr }, 1);
});
});
}

55
setup/verify.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { determineVerifyStatus } from './verify.js';
const healthyBase = {
service: 'running' as const,
credentials: 'configured',
anyChannelConfigured: false,
registeredGroups: 1,
agentPing: 'ok' as const,
};
describe('determineVerifyStatus', () => {
it('accepts a working CLI-only install', () => {
expect(determineVerifyStatus(healthyBase)).toBe('success');
});
it('accepts a messaging-channel install when CLI ping is skipped', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'skipped',
}),
).toBe('success');
});
it('fails when neither CLI nor messaging channels are usable', () => {
expect(
determineVerifyStatus({
...healthyBase,
agentPing: 'skipped',
}),
).toBe('failed');
});
it('fails when the CLI agent does not respond', () => {
expect(
determineVerifyStatus({
...healthyBase,
anyChannelConfigured: true,
agentPing: 'no_reply',
}),
).toBe('failed');
});
it('fails when no agent groups are registered', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
}),
).toBe('failed');
});
});

View File

@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js';
import { pingCliAgent } from './lib/agent-ping.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
getPlatform,
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
// everything upstream looks healthy, since a broken socket would just hang.
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
if (service === 'running' && registeredGroups > 0) {
log.info('Pinging CLI agent');
agentPing = await pingCliAgent();
log.info('Agent ping result', { agentPing });
}
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
anyChannelConfigured &&
registeredGroups > 0 &&
(agentPing === 'ok' || agentPing === 'skipped')
? 'success'
: 'failed';
// Determine overall status. A CLI-only install is valid when the local
// agent round-trip succeeds; messaging app credentials are optional.
const status = determineVerifyStatus({
service,
credentials,
anyChannelConfigured,
registeredGroups,
agentPing,
});
log.info('Verification complete', { status, channelAuth });
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
if (status === 'failed') process.exit(1);
}
export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string;
anyChannelConfigured: boolean;
registeredGroups: number;
agentPing: PingResult | 'skipped';
}): 'success' | 'failed' {
const cliAgentResponds = input.agentPing === 'ok';
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
return input.service === 'running' &&
input.credentials !== 'missing' &&
hasUsableChannel &&
input.registeredGroups > 0 &&
(cliAgentResponds || input.agentPing === 'skipped')
? 'success'
: 'failed';
}
/**
* Given a PID, resolve the script path the process is executing (i.e. the
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any