diff --git a/.claude/skills/add-imessage/SKILL.md b/.claude/skills/add-imessage/SKILL.md index 7ee87aa0f..13c6247f0 100644 --- a/.claude/skills/add-imessage/SKILL.md +++ b/.claude/skills/add-imessage/SKILL.md @@ -75,7 +75,7 @@ Stop and wait for the user to confirm before continuing. ### Remote Mode (Photon API) -1. Set up a [Photon](https://photon.im) account +1. Set up a [Photon](https://photon.codes) account 2. Get your server URL and API key ### Configure environment diff --git a/.claude/skills/add-rtk/SKILL.md b/.claude/skills/add-rtk/SKILL.md new file mode 100644 index 000000000..9eeb80e18 --- /dev/null +++ b/.claude/skills/add-rtk/SKILL.md @@ -0,0 +1,140 @@ +--- +name: add-rtk +description: Install rtk token-compression proxy into agent containers. Routes Bash tool calls through rtk for 60–90% token savings on dev commands (git, cargo, pytest, docker, kubectl, etc.). +--- + +# Add rtk + +Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90% token savings on common dev commands (git, cargo, pytest, docker, kubectl, etc.) — and wire it transparently into agent containers via the Claude Code `PreToolUse` hook. + +## What this sets up + +- `rtk` binary at `~/.local/bin/rtk` on the host +- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers +- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed + +## Step 1 — Install rtk on the host + +```bash +curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh +``` + +If the script put the binary elsewhere, move it: + +```bash +find ~/.local ~/.cargo/bin ~/bin -name rtk 2>/dev/null +mv "$(which rtk 2>/dev/null)" ~/.local/bin/rtk +``` + +Verify: + +```bash +~/.local/bin/rtk --version +chmod +x ~/.local/bin/rtk # if needed +``` + +## Step 2 — Identify the target agent group + +```bash +ncl groups list +``` + +Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each group. + +## Step 3 — Mount rtk into the container config + +`additional_mounts` is a JSON column not exposed via `ncl config update`. Update it directly via the DB helper, merging with any existing mounts. + +Read current mounts first: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db \ + "SELECT additional_mounts FROM container_configs WHERE agent_group_id = ''" +``` + +Then write the merged array (include all existing entries plus the rtk entry): + +```bash +pnpm exec tsx scripts/q.ts data/v2.db \ + "UPDATE container_configs SET additional_mounts = '' WHERE agent_group_id = ''" +``` + +The rtk entry to append: `{"hostPath":"/home//.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}` + +Verify: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db \ + "SELECT additional_mounts FROM container_configs WHERE agent_group_id = ''" +``` + +## Step 4 — Add the PreToolUse hook to settings.json + +Each agent group has a `settings.json` at: + +``` +data/v2-sessions//.claude-shared/settings.json +``` + +This file is mounted at `/home/node/.claude/settings.json` inside the container and is read by Claude Code for hooks, env, and model config. + +Add the `PreToolUse` entry using `jq` to merge safely: + +```bash +SETTINGS="data/v2-sessions//.claude-shared/settings.json" + +jq '.hooks.PreToolUse = [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \ + "$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS" +``` + +If `PreToolUse` already exists, append instead of overwriting: + +```bash +jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \ + "$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS" +``` + +## Step 5 — Restart the container + +```bash +ncl groups restart --id +``` + +No `--message` needed — the hook is transparent and requires no agent awareness. + +## Verify + +Ask the agent to run `git status` or any other supported command. rtk intercepts it silently. Check savings with: + +```bash +~/.local/bin/rtk gain +``` + +## Troubleshooting + +### `rtk: command not found` inside the container + +Mount wasn't applied or container wasn't restarted: + +```bash +pnpm exec tsx scripts/q.ts data/v2.db \ + "SELECT additional_mounts FROM container_configs WHERE agent_group_id = ''" +# Look for entry with /usr/local/bin/rtk +ncl groups restart --id +``` + +### Hook not firing + +Verify the hook is in `settings.json`: + +```bash +jq '.hooks.PreToolUse' data/v2-sessions//.claude-shared/settings.json +``` + +If missing, re-run Step 4. + +### Binary won't execute — permission denied + +```bash +chmod +x ~/.local/bin/rtk +``` diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index f6eeaf977..68d70d5ad 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -55,6 +55,47 @@ pnpm run build ## Credentials +Two paths — manual (Azure Portal) or auto (Teams CLI). + +### Auto: Teams CLI + +Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar). + +1. Install the CLI: + + ```bash + npm install -g @microsoft/teams.cli@preview + ``` + +2. Sign in and verify: + + ```bash + teams login + teams status + ``` + +3. Create the Entra app, client secret, and bot registration: + + ```bash + teams app create \ + --name "NanoClaw" \ + --endpoint "https://your-domain/api/webhooks/teams" + ``` + + The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys: + + - `CLIENT_ID` → `TEAMS_APP_ID` + - `CLIENT_SECRET` → `TEAMS_APP_PASSWORD` + - `TENANT_ID` → `TEAMS_APP_TENANT_ID` + +4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog. + +Continue to [Configure environment](#configure-environment). + +--- + +The steps below describe the **manual Azure Portal path**. + ### Step 1: Create an Azure AD App Registration 1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration** diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e688eab5f..ca081f065 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -58,6 +58,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise { // a Codex thread id never gets handed to Claude or vice versa. let continuation: string | undefined = migrateLegacyContinuation(config.providerName); + // Before resuming, drop a session whose on-disk transcript has grown too + // large/old to cold-resume within the host's idle ceiling. Without this a + // long-lived hub keeps trying to reload an ever-growing .jsonl, hangs the + // first turn, and gets killed before it can reply (then repeats forever). + if (continuation) { + const rotateReason = config.provider.maybeRotateContinuation?.(continuation, config.cwd); + if (rotateReason) { + log(`Rotating session — ${rotateReason}; starting fresh`); + clearContinuation(config.providerName); + continuation = undefined; + } + } + if (continuation) { log(`Resuming agent session ${continuation}`); } diff --git a/container/agent-runner/src/providers/claude.rotate.test.ts b/container/agent-runner/src/providers/claude.rotate.test.ts new file mode 100644 index 000000000..2d22f243e --- /dev/null +++ b/container/agent-runner/src/providers/claude.rotate.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { ClaudeProvider } from './claude.js'; + +// maybeRotateContinuation guards the cold-resume failure mode: a long-lived +// session whose on-disk transcript has grown so large (or old) that the SDK +// can't reload it before the host's idle ceiling kills the container. + +let tmp: string; +let prevHome: string | undefined; +let prevConv: string | undefined; +let prevBytes: string | undefined; +let prevDays: string | undefined; + +const PROJECT_DIR = '-workspace-agent'; +const CWD = '/workspace/agent'; + +function writeTranscript(sessionId: string, bytes: number, firstTs?: string): string { + const dir = path.join(tmp, '.claude', 'projects', PROJECT_DIR); + fs.mkdirSync(dir, { recursive: true }); + const p = path.join(dir, `${sessionId}.jsonl`); + const first = + JSON.stringify({ + type: 'user', + timestamp: firstTs ?? new Date().toISOString(), + message: { role: 'user', content: 'hello' }, + }) + '\n'; + const filler = 'x'.repeat(Math.max(0, bytes - first.length)); + fs.writeFileSync(p, first + filler); + return p; +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-rotate-')); + prevHome = process.env.HOME; + prevConv = process.env.NANOCLAW_CONVERSATIONS_DIR; + prevBytes = process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES; + prevDays = process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS; + process.env.HOME = tmp; + delete process.env.CLAUDE_CONFIG_DIR; + process.env.NANOCLAW_CONVERSATIONS_DIR = path.join(tmp, 'conversations'); +}); + +afterEach(() => { + const restore = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v)); + restore('HOME', prevHome); + restore('NANOCLAW_CONVERSATIONS_DIR', prevConv); + restore('CLAUDE_TRANSCRIPT_ROTATE_BYTES', prevBytes); + restore('CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS', prevDays); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('ClaudeProvider.maybeRotateContinuation', () => { + it('keeps a small, recent transcript (returns null, leaves file in place)', () => { + process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024); + const p = writeTranscript('sess-small', 4096); + const provider = new ClaudeProvider(); + expect(provider.maybeRotateContinuation('sess-small', CWD)).toBeNull(); + expect(fs.existsSync(p)).toBe(true); + }); + + it('rotates an oversized transcript (returns reason, moves the .jsonl aside)', () => { + process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(64 * 1024); + const p = writeTranscript('sess-big', 200 * 1024); + const provider = new ClaudeProvider(); + const reason = provider.maybeRotateContinuation('sess-big', CWD); + expect(reason).toContain('MB'); + expect(fs.existsSync(p)).toBe(false); // original moved out of the resume path + const dir = path.dirname(p); + expect(fs.readdirSync(dir).some((f) => f.startsWith('sess-big.jsonl.rotated-'))).toBe(true); + }); + + it('rotates an aged transcript even when small', () => { + process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024); + process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS = '7'; + const old = new Date(Date.now() - 10 * 86400_000).toISOString(); + writeTranscript('sess-old', 2048, old); + const provider = new ClaudeProvider(); + expect(provider.maybeRotateContinuation('sess-old', CWD)).toContain('d'); + }); + + it('returns null for an unknown session id', () => { + const provider = new ClaudeProvider(); + expect(provider.maybeRotateContinuation('does-not-exist', CWD)).toBeNull(); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 31be51ae4..f764c7c84 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; @@ -188,49 +189,122 @@ const postToolUseHook: HookCallback = async () => { return { continue: true }; }; +/** + * Read a Claude transcript .jsonl, render a markdown summary, and drop it into + * the agent's `conversations/` folder so context survives a compaction or a + * session rotation. Best-effort: returns false (and logs) on any failure. + */ +function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: string | undefined, assistantName?: string): boolean { + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return false; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + if (messages.length === 0) return false; + + // Try to get summary from sessions index + let summary: string | undefined; + const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json'); + if (fs.existsSync(indexPath)) { + try { + const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary; + } catch { + /* ignore */ + } + } + + const name = summary + ? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) + : `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`; + + const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations'; + fs.mkdirSync(conversationsDir, { recursive: true }); + const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; + fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); + log(`Archived conversation to ${filename}`); + return true; + } catch (err) { + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + return false; + } +} + function createPreCompactHook(assistantName?: string): HookCallback { return async (input) => { const preCompact = input as PreCompactHookInput; - const { transcript_path: transcriptPath, session_id: sessionId } = preCompact; - - if (!transcriptPath || !fs.existsSync(transcriptPath)) { - log('No transcript found for archiving'); - return {}; - } - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const messages = parseTranscript(content); - if (messages.length === 0) return {}; - - // Try to get summary from sessions index - let summary: string | undefined; - const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json'); - if (fs.existsSync(indexPath)) { - try { - const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); - summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary; - } catch { - /* ignore */ - } - } - - const name = summary - ? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) - : `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`; - - const conversationsDir = '/workspace/agent/conversations'; - fs.mkdirSync(conversationsDir, { recursive: true }); - const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; - fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); - log(`Archived conversation to ${filename}`); - } catch (err) { - log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); - } + archiveTranscriptFile(preCompact.transcript_path, preCompact.session_id, assistantName); return {}; }; } +// ── Continuation rotation (cold-resume guard) ── + +/** + * Resume cost is dominated by transcript size. Past this many bytes a fresh + * cold container can't reload the .jsonl before the host's 30-min idle ceiling + * fires, so the session is dropped and started clean. Operator-overridable. + */ +function transcriptRotateBytes(): number { + return Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES) || 12 * 1024 * 1024; +} + +/** + * Secondary age trigger, measured from the transcript's first entry. 0 (or a + * non-positive value) disables the age check; size alone then governs. + */ +function transcriptRotateAgeMs(): number { + const days = Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS); + return Number.isFinite(days) && days > 0 ? days * 86_400_000 : 14 * 86_400_000; +} + +function claudeProjectsDir(): string { + const base = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || os.homedir(), '.claude'); + return path.join(base, 'projects'); +} + +/** + * Locate the .jsonl backing a session id. The SDK names project dirs by a + * mangled cwd; rather than reproduce that convention we scan project dirs for + * `.jsonl` (session ids are UUIDs, so this is unambiguous). + */ +function findTranscriptPath(sessionId: string): string | null { + const projects = claudeProjectsDir(); + let dirs: string[]; + try { + dirs = fs.readdirSync(projects); + } catch { + return null; + } + for (const dir of dirs) { + const candidate = path.join(projects, dir, `${sessionId}.jsonl`); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** Epoch-ms of the first transcript entry, or null if unreadable. */ +function transcriptStartMs(transcriptPath: string): number | null { + try { + const fd = fs.openSync(transcriptPath, 'r'); + try { + const buf = Buffer.alloc(4096); + const n = fs.readSync(fd, buf, 0, buf.length, 0); + const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0]; + const ts = JSON.parse(firstLine)?.timestamp; + const ms = ts ? Date.parse(ts) : NaN; + return Number.isNaN(ms) ? null : ms; + } finally { + fs.closeSync(fd); + } + } catch { + return null; + } +} + // ── Provider ── /** @@ -277,6 +351,41 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } + maybeRotateContinuation(continuation: string): string | null { + const transcriptPath = findTranscriptPath(continuation); + if (!transcriptPath) return null; + + let size: number; + try { + size = fs.statSync(transcriptPath).size; + } catch { + return null; + } + + const maxBytes = transcriptRotateBytes(); + const startMs = transcriptStartMs(transcriptPath); + const ageMs = startMs === null ? 0 : Date.now() - startMs; + const maxAgeMs = transcriptRotateAgeMs(); + + let reason: string | null = null; + if (size > maxBytes) { + reason = `transcript ${(size / 1_048_576).toFixed(1)}MB > ${(maxBytes / 1_048_576).toFixed(0)}MB cap`; + } else if (startMs !== null && ageMs > maxAgeMs) { + reason = `transcript ${(ageMs / 86_400_000).toFixed(1)}d old > ${(maxAgeMs / 86_400_000).toFixed(0)}d cap`; + } + if (!reason) return null; + + // Preserve a readable summary, then move the heavy .jsonl out of the + // resume path so the SDK starts a fresh session and the disk is reclaimed. + archiveTranscriptFile(transcriptPath, continuation, this.assistantName); + try { + fs.renameSync(transcriptPath, `${transcriptPath}.rotated-${Date.now()}`); + } catch (err) { + log(`Failed to move rotated transcript aside: ${err instanceof Error ? err.message : String(err)}`); + } + return reason; + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index a6722a128..d906a8ce9 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,6 +14,21 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; + + /** + * Optional pre-resume maintenance. Given the stored continuation token, + * decide whether its backing transcript has grown too large or too old to + * resume cheaply. Return a non-null reason string to tell the caller to drop + * the continuation and start a fresh session (the provider archives any + * recoverable summary first); return null to keep resuming. + * + * Guards the cold-resume failure mode: a long-lived hub session accumulates + * days of history — including base64 image blocks the agent Read — and the + * SDK reloads the whole .jsonl on every resume. Past a threshold the first + * turn alone can exceed the host's idle ceiling, so the container is killed + * before it ever replies. Providers without an on-disk transcript omit this. + */ + maybeRotateContinuation?(continuation: string, cwd: string): string | null; } /** diff --git a/package.json b/package.json index d7c1d0b80..abfb1d3bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.64", + "version": "2.0.65", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 9d8883091..b20141557 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 174k tokens, 87% of context window + + 176k tokens, 88% of context window @@ -15,8 +15,8 @@ tokens - - 174k + + 176k diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index c7c2b77df..c1b92ffe4 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -247,7 +247,7 @@ async function collectRemoteCreds(): Promise { "Photon is a separate service that owns an iMessage account and", "exposes it over HTTP. NanoClaw will talk to it via its API.", '', - ' 1. Set up a Photon server: https://photon.im', + ' 1. Set up a Photon server: https://photon.codes', ' 2. Copy the server URL and API key from your Photon dashboard', ].join('\n'), 'Remote iMessage via Photon', diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index b8eb65439..5028486dc 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -70,8 +70,8 @@ export const CONFIG: Entry[] = [ surface: 'flag+ui', group: 'OneCLI', type: 'url', - default: 'https://app.onecli.sh', - placeholder: 'https://app.onecli.sh', + default: 'https://api.onecli.sh', + placeholder: 'https://api.onecli.sh', validate: httpUrl, }, { diff --git a/setup/register.ts b/setup/register.ts index 7bd5ae3da..154229b81 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -194,7 +194,12 @@ export async function run(args: string[]): Promise { // 4. Send onboarding message — only on first wiring, not re-registration if (newlyWired) { - const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared'); + const { session } = resolveSession( + agentGroup.id, + messagingGroup.id, + null, + parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared', + ); writeSessionMessage(agentGroup.id, session.id, { id: generateId('onboard'), kind: 'task', @@ -208,40 +213,38 @@ export async function run(args: string[]): Promise { log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel }); } - // 5. Update assistant name in CLAUDE.md files if different from default + // 5. Apply assistant name to JUST the group being registered. + // + // Earlier behavior did a project-wide find-replace of "Andy" across every + // `groups/*/CLAUDE.md` and overwrote `.env`'s `ASSISTANT_NAME`, which + // caused two real-world problems: + // - registering a second agent (e.g. "Homie") clobbered the unrelated + // primary agent's CLAUDE.md (replacing "Andy" with "Homie" in + // groups/diddyclaw/CLAUDE.md when Diddyclaw was already in place); + // - the global `.env` ASSISTANT_NAME flipped to the most recently- + // registered agent, which then became the install-wide default + // trigger for any *new* group registered without an explicit + // `--assistant-name`. + // Both were unintentional global side-effects of a per-agent operation. + // Scope is now strictly: only the freshly-registered agent's own + // `groups//CLAUDE.md`. let nameUpdated = false; if (parsed.assistantName !== 'Andy') { - log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName }); - - const groupsDir = path.join(projectRoot, 'groups'); - const mdFiles = fs - .readdirSync(groupsDir) - .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) - .filter((f) => fs.existsSync(f)); - - for (const mdFile of mdFiles) { - let content = fs.readFileSync(mdFile, 'utf-8'); - content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`); - content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`); - fs.writeFileSync(mdFile, content); - log.info('Updated CLAUDE.md', { file: mdFile }); - } - - // Update .env - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - let envContent = fs.readFileSync(envFile, 'utf-8'); - if (envContent.includes('ASSISTANT_NAME=')) { - envContent = envContent.replace(/^ASSISTANT_NAME=.*$/m, `ASSISTANT_NAME="${parsed.assistantName}"`); - } else { - envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`; + const mdFile = path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'); + if (fs.existsSync(mdFile)) { + const before = fs.readFileSync(mdFile, 'utf-8'); + const after = before + .replace(/^# Andy$/m, `# ${parsed.assistantName}`) + .replace(/You are Andy/g, `You are ${parsed.assistantName}`); + if (after !== before) { + fs.writeFileSync(mdFile, after); + log.info('Updated assistant name in registered group only', { + file: mdFile, + to: parsed.assistantName, + }); + nameUpdated = true; } - fs.writeFileSync(envFile, envContent); - } else { - fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`); } - log.info('Set ASSISTANT_NAME in .env'); - nameUpdated = true; } emitStatus('REGISTER_CHANNEL', { diff --git a/setup/signal-auth.ts b/setup/signal-auth.ts index ce289db28..2d71610d4 100644 --- a/setup/signal-auth.ts +++ b/setup/signal-auth.ts @@ -36,6 +36,7 @@ const LINK_TIMEOUT_MS = 180_000; const DEFAULT_DEVICE_NAME = 'NanoClaw'; interface SignalAccount { + number?: string; account?: string; registered?: boolean; } @@ -59,7 +60,7 @@ function listAccounts(): string[] { const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[]; return parsed .filter((a) => a.registered !== false) - .map((a) => a.account ?? '') + .map((a) => a.number ?? a.account ?? '') .filter(Boolean); } catch { return []; diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index a2e66900b..02e87d373 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -357,6 +357,87 @@ describe('unknown-channel registration flow', () => { .c; expect(stillPending).toBe(1); }); + + it('does not let a scoped admin connect an unknown channel to another agent group', async () => { + const { routeInbound } = await import('../../router.js'); + const { getResponseHandlers } = await import('../../response-registry.js'); + const { getDb } = await import('../../db/connection.js'); + + createAgentGroup({ id: 'ag-2', name: 'Betty', folder: 'betty', agent_provider: null, created_at: now() }); + upsertUser({ id: 'telegram:scoped-admin', kind: 'telegram', display_name: 'Scoped Admin', created_at: now() }); + grantRole({ + user_id: 'telegram:scoped-admin', + role: 'admin', + agent_group_id: 'ag-1', + granted_by: 'telegram:owner', + granted_at: now(), + }); + createMessagingGroup({ + id: 'mg-dm-scoped-admin', + channel_type: 'telegram', + platform_id: 'dm-scoped-admin', + name: 'Scoped Admin DM', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + getDb() + .prepare( + `INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at) + VALUES (?, ?, ?, ?)`, + ) + .run('telegram:scoped-admin', 'telegram', 'mg-dm-scoped-admin', now()); + + await routeInbound(groupMention('chat-scoped-cross-group')); + await new Promise((r) => setTimeout(r, 10)); + + const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as { + messaging_group_id: string; + }; + expect(pending).toBeDefined(); + expect(deliverMock).toHaveBeenCalledTimes(1); + expect(deliverMock.mock.calls[0][1]).toBe('dm-scoped-admin'); + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'choose_existing', + userId: 'scoped-admin', + channelType: 'telegram', + platformId: 'dm-scoped-admin', + threadId: null, + }); + if (claimed) break; + } + + const followupPayload = JSON.parse(deliverMock.mock.calls[1][4] as string) as { + options: Array<{ label: string; value: string }>; + }; + expect(followupPayload.options.map((option) => option.value)).toContain('connect:ag-1'); + expect(followupPayload.options.map((option) => option.value)).not.toContain('connect:ag-2'); + + for (const handler of getResponseHandlers()) { + const claimed = await handler({ + questionId: pending.messaging_group_id, + value: 'connect:ag-2', + userId: 'scoped-admin', + channelType: 'telegram', + platformId: 'dm-scoped-admin', + threadId: null, + }); + if (claimed) break; + } + + const mgaCount = ( + getDb() + .prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?') + .get(pending.messaging_group_id) as { c: number } + ).c; + expect(mgaCount).toBe(0); + const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number }) + .c; + expect(stillPending).toBe(1); + }); }); describe('no-owner / no-agent failure modes', () => { diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 6127cea2f..762fc5c4a 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -55,6 +55,7 @@ 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'; +import { hasAdminPrivilege } from './db/user-roles.js'; // ── Value constants (response handler in index.ts parses these) ── @@ -76,15 +77,24 @@ function toFolder(name: string): string { // ── Card builders ── -function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] { +function visibleAgentGroupsForApprover( + agentGroups: AgentGroup[], + approverUserId: string | null | undefined, +): AgentGroup[] { + if (!approverUserId) return agentGroups; + return agentGroups.filter((agentGroup) => hasAdminPrivilege(approverUserId, agentGroup.id)); +} + +function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string | null): RawOption[] { + const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId); const options: RawOption[] = []; - if (agentGroups.length === 1) { + if (visibleAgentGroups.length === 1) { options.push({ - label: `Connect to ${agentGroups[0].name}`, - selectedLabel: `✅ Connected to ${agentGroups[0].name}`, - value: `${CONNECT_PREFIX}${agentGroups[0].id}`, + label: `Connect to ${visibleAgentGroups[0].name}`, + selectedLabel: `✅ Connected to ${visibleAgentGroups[0].name}`, + value: `${CONNECT_PREFIX}${visibleAgentGroups[0].id}`, }); - } else { + } else if (visibleAgentGroups.length > 1) { options.push({ label: 'Choose existing agent', selectedLabel: '📋 Choosing…', @@ -194,7 +204,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) 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)); + const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId)); createPendingChannelApproval({ messaging_group_id: messagingGroupId, @@ -241,8 +251,12 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) /** * Build normalized options for the agent-selection follow-up card. */ -export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] { - const options: RawOption[] = agentGroups.map((ag) => ({ +export function buildAgentSelectionOptions( + agentGroups: AgentGroup[], + approverUserId?: string | null, +): NormalizedOption[] { + const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId); + const options: RawOption[] = visibleAgentGroups.map((ag) => ({ label: ag.name, selectedLabel: `✅ Connected to ${ag.name}`, value: `${CONNECT_PREFIX}${ag.id}`, diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 2b51d63dd..67adfe771 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -354,7 +354,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< if (!adapter) return true; const agentGroups = getAllAgentGroups(); - const options = buildAgentSelectionOptions(agentGroups); + const options = buildAgentSelectionOptions(agentGroups, approverId); const title = '📋 Choose an agent'; updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options)); @@ -438,6 +438,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< deletePendingChannelApproval(row.messaging_group_id); return true; } + if (!hasAdminPrivilege(approverId, targetAgentGroupId)) { + log.warn('Channel registration: target agent group rejected for unauthorized approver', { + messagingGroupId: row.messaging_group_id, + targetAgentGroupId, + approverId, + }); + return true; + } } else { log.warn('Channel registration: unknown response value', { messagingGroupId: row.messaging_group_id,