Merge branch 'main' into fix/add-whatsapp-qr-browser-wrapper

This commit is contained in:
gavrielc
2026-05-22 23:02:40 +03:00
committed by GitHub
16 changed files with 601 additions and 87 deletions
+1 -1
View File
@@ -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
+140
View File
@@ -0,0 +1,140 @@
---
name: add-rtk
description: Install rtk token-compression proxy into agent containers. Routes Bash tool calls through rtk for 6090% 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 6090% 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 35 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 = '<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 = '<merged-json>' WHERE agent_group_id = '<group-id>'"
```
The rtk entry to append: `{"hostPath":"/home/<user>/.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 = '<group-id>'"
```
## Step 4 — Add the PreToolUse hook to settings.json
Each agent group has a `settings.json` at:
```
data/v2-sessions/<group-id>/.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/<group-id>/.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 <group-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 = '<group-id>'"
# Look for entry with /usr/local/bin/rtk
ncl groups restart --id <group-id>
```
### Hook not firing
Verify the hook is in `settings.json`:
```bash
jq '.hooks.PreToolUse' data/v2-sessions/<group-id>/.claude-shared/settings.json
```
If missing, re-run Step 4.
### Binary won't execute — permission denied
```bash
chmod +x ~/.local/bin/rtk
```
+41
View File
@@ -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**
+13
View File
@@ -58,6 +58,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// 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}`);
}
@@ -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();
});
});
+145 -36
View File
@@ -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
* `<sessionId>.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);
@@ -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;
}
/**
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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="174k tokens, 87% of context window">
<title>174k tokens, 87% 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="176k tokens, 88% of context window">
<title>176k tokens, 88% 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">174k</text>
<text x="71" y="14">174k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">176k</text>
<text x="71" y="14">176k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -247,7 +247,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
"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',
+2 -2
View File
@@ -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,
},
{
+34 -31
View File
@@ -194,7 +194,12 @@ export async function run(args: string[]): Promise<void> {
// 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<void> {
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/<folder>/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', {
+2 -1
View File
@@ -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 [];
@@ -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', () => {
+23 -9
View File
@@ -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}`,
+9 -1
View File
@@ -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,