Compare commits

..

4 Commits

Author SHA1 Message Date
gavrielc 5ae66624eb Merge pull request #1885 from ssimeonov/fix/container-home-permissions
fix(container): v2 -- make /home/node writable for mapped host UIDs
2026-04-23 00:56:40 +03:00
gavrielc 7e86f6c642 fix(setup): fall back to npm install when corepack is missing
Some Node installs (older nvm, node@22 keg-only on brew, minimal distro packages) don't ship corepack, so the bootstrap was dying with "corepack: command not found" before pnpm could land on PATH. Now guards the corepack call and falls back to `npm install -g pnpm@<pinned>`, reading the version from package.json's packageManager field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:19:03 +03:00
gavrielc f97cd4442a refactor(skills): collapse setup skill to one instruction — run bash nanoclaw.sh
Deletes the Claude-orchestrated /setup and /new-setup flows. The scripted installer (bash nanoclaw.sh → setup:auto) now handles bootstrap, container, OneCLI, auth, service, first agent, and optional channel wiring end-to-end with inline Claude-assisted recovery on failure. Keeps /setup as a one-line redirect so the trigger still resolves. Drops the opt-out diagnostics files that belonged to the old flow and updates cross-refs in add-wechat, migrate-nanoclaw, and update-nanoclaw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:13:55 +03:00
Simeon Simeonov d2e264a969 fix(container): make /home/node writable for mapped host UIDs
container-runner.ts maps the host UID into the container (e.g. 501 on
macOS) so bind-mounted files have correct ownership. But /home/node is
owned by UID 1000 (node) with mode 755, so the mapped UID cannot write
.claude.json — causing Claude Code to silently fail without making any
API calls. Widen to 777 so any mapped UID can write config files.

This is safe because the container is ephemeral, single-process, and
isolated — no multi-tenant risk. The only pre-existing files are
.bashrc, .profile, and .bash_logout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 23:32:58 -04:00
101 changed files with 721 additions and 7330 deletions
@@ -1,243 +0,0 @@
---
name: add-atomic-chat-tool
description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API.
---
# Add Atomic Chat Integration
This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible).
Tools exposed:
- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`)
- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`)
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
## Phase 1: Pre-flight
### Check if already applied
Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure).
### Check prerequisites
Verify Atomic Chat is installed and its local API server is running. On the host:
```bash
curl -s http://127.0.0.1:1337/v1/models | head
```
If the request fails:
1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`).
2. Open the app.
3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`.
4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B).
5. Load the model once by sending any message in Atomic Chat's UI to warm it up.
## Phase 2: Apply Code Changes
### Copy the MCP server source
```bash
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
```
### Register the MCP server in the agent-runner
Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
};
```
Add an `atomic_chat` entry alongside `nanoclaw`:
```ts
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
nanoclaw: {
command: 'bun',
args: ['run', mcpServerPath],
env: {},
},
atomic_chat: {
command: 'bun',
args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')],
env: {
...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}),
...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}),
},
},
};
```
### Add the tool glob to the allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
```
### Surface `[ATOMIC]` log lines at info level
In the same file, find the stderr logger:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log.debug(line, { container: agentGroup.folder });
}
});
```
Replace it with:
```ts
container.stderr?.on('data', (data) => {
for (const line of data.toString().trim().split('\n')) {
if (!line) continue;
if (line.includes('[ATOMIC]')) {
log.info(line, { container: agentGroup.folder });
} else {
log.debug(line, { container: agentGroup.folder });
}
}
});
```
### Add env-var stubs to `.env.example`
Append to `.env.example`:
```bash
# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool)
# Override the host where Atomic Chat exposes its OpenAI-compatible API.
# Default: http://host.docker.internal:1337 (with fallback to localhost)
# ATOMIC_CHAT_HOST=http://host.docker.internal:1337
# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth.
# ATOMIC_CHAT_API_KEY=
```
### Validate code changes
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
All three must be clean before proceeding.
## Phase 3: Configure
### Set Atomic Chat host (optional)
By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`:
```bash
ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337
```
### Set API key (optional)
Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth:
```bash
ATOMIC_CHAT_API_KEY=sk-...
```
### Restart the service
```bash
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test inference
Tell the user:
> Send a message like: "use atomic chat to tell me the capital of France"
>
> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log | grep -i atomic
```
Look for:
- `[ATOMIC] Listing models...` — list request started
- `[ATOMIC] Found N models` — models discovered
- `[ATOMIC] >>> Generating with <model>` — generation started
- `[ATOMIC] <<< Done: <model> | Xs | N tokens | M chars` — generation completed
## Troubleshooting
### Agent says "Atomic Chat is not installed" or tries to run a CLI
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models`
2. Confirm the Local API Server is enabled in Atomic Chat's settings
3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models`
4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env`
### `model not found` / 404 on generate
The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list.
### Slow first response
Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast.
### Agent doesn't use Atomic Chat tools
The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..."
### Context window or output size issues
Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI.
@@ -1,229 +0,0 @@
/**
* Atomic Chat MCP Server for NanoClaw
* Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent.
* Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
const ATOMIC_CHAT_HOST =
process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337';
const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || '';
const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json';
function log(msg: string): void {
console.error(`[ATOMIC] ${msg}`);
}
function writeStatus(status: string, detail?: string): void {
try {
const data = { status, detail, timestamp: new Date().toISOString() };
const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`;
fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true });
fs.writeFileSync(tmpPath, JSON.stringify(data));
fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE);
} catch {
/* best-effort */
}
}
async function atomicFetch(
apiPath: string,
options?: RequestInit,
): Promise<Response> {
const url = `${ATOMIC_CHAT_HOST}${apiPath}`;
const headers: Record<string, string> = {
...((options?.headers as Record<string, string>) || {}),
};
if (ATOMIC_CHAT_API_KEY) {
headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`;
}
const finalOptions: RequestInit = { ...options, headers };
try {
return await fetch(url, finalOptions);
} catch (err) {
// Fallback to localhost if host.docker.internal fails
if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) {
const fallbackUrl = url.replace('host.docker.internal', 'localhost');
return await fetch(fallbackUrl, finalOptions);
}
throw err;
}
}
const server = new McpServer({
name: 'atomic_chat',
version: '1.0.0',
});
server.tool(
'atomic_chat_list_models',
'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.',
{},
async () => {
log('Listing models...');
writeStatus('listing', 'Listing available models');
try {
const res = await atomicFetch('/v1/models');
if (!res.ok) {
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat API error: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
data?: Array<{ id: string; owned_by?: string }>;
};
const models = data.data || [];
if (models.length === 0) {
return {
content: [
{
type: 'text' as const,
text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.',
},
],
};
}
const list = models
.map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`)
.join('\n');
log(`Found ${models.length} models`);
return {
content: [
{ type: 'text' as const, text: `Available models:\n${list}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
server.tool(
'atomic_chat_generate',
'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.',
{
model: z
.string()
.describe(
'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")',
),
prompt: z.string().describe('The prompt to send to the model'),
system: z
.string()
.optional()
.describe('Optional system prompt to set model behavior'),
temperature: z
.number()
.optional()
.describe('Sampling temperature (0.02.0). Defaults to model default.'),
max_tokens: z
.number()
.optional()
.describe('Maximum number of tokens to generate in the response.'),
},
async (args) => {
log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
writeStatus('generating', `Generating with ${args.model}`);
try {
const messages: Array<{ role: string; content: string }> = [];
if (args.system) {
messages.push({ role: 'system', content: args.system });
}
messages.push({ role: 'user', content: args.prompt });
const body: Record<string, unknown> = {
model: args.model,
messages,
stream: false,
};
if (args.temperature !== undefined) body.temperature = args.temperature;
if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens;
const startedAt = Date.now();
const res = await atomicFetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const errorText = await res.text();
return {
content: [
{
type: 'text' as const,
text: `Atomic Chat error (${res.status}): ${errorText}`,
},
],
isError: true,
};
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const response = data.choices?.[0]?.message?.content ?? '';
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
const completionTokens = data.usage?.completion_tokens;
const meta = `\n\n[${args.model} | ${elapsedSec}s${
completionTokens !== undefined ? ` | ${completionTokens} tokens` : ''
}]`;
log(
`<<< Done: ${args.model} | ${elapsedSec}s | ${
completionTokens ?? '?'
} tokens | ${response.length} chars`,
);
writeStatus(
'done',
`${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`,
);
return { content: [{ type: 'text' as const, text: response + meta }] };
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
-161
View File
@@ -1,161 +0,0 @@
---
name: add-codex
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
---
# Codex agent provider
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
## Install
### Pre-flight
If all of the following are already present, skip to **Configuration**:
- `src/providers/codex.ts`
- `container/agent-runner/src/providers/codex.ts`
- `container/agent-runner/src/providers/codex-app-server.ts`
- `container/agent-runner/src/providers/codex.factory.test.ts`
- `import './codex.js';` line in `src/providers/index.ts`
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
Missing pieces — continue below. All steps are idempotent; re-running is safe.
### 1. Fetch the providers branch
```bash
git fetch origin providers
```
### 2. Copy the Codex source files
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
```bash
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
```
### 3. Append the self-registration imports
Each barrel gets one line — alphabetical placement keeps diffs small.
`src/providers/index.ts`:
```typescript
import './codex.js';
```
`container/agent-runner/src/providers/index.ts`:
```typescript
import './codex.js';
```
### 4. Add the Codex CLI to the container Dockerfile
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
```dockerfile
ARG CODEX_VERSION=0.124.0
```
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
```
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
### 5. Build
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
```
## Configuration
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
### Option A — ChatGPT subscription (recommended for individuals)
On the host (not inside the container), run Codex's OAuth login:
```bash
codex login
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Verify
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
- `init` event with a stable thread ID as continuation
- One or more `activity` / `progress` events during the turn
- `result` event with the model's reply
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm.
-210
View File
@@ -1,210 +0,0 @@
---
name: add-gcal-tool
description: Add Google Calendar as an MCP tool (list calendars, list/search/create events, free/busy queries) using OneCLI-managed OAuth. Multi-calendar and multi-account supported. Mirrors /add-gmail-tool's stub pattern — no raw credentials ever reach the container; OneCLI injects real tokens at request time.
---
# Add Google Calendar Tool (OneCLI-native)
This skill wires [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `calendar.googleapis.com` / `oauth2.googleapis.com` and swaps the bearer for the real OAuth token from its vault.
**Why this package (and not gongrzhe's):** `@gongrzhe/server-calendar-autoauth-mcp` only supports the `primary` calendar and exposes 5 tools (no `list_calendars`). `@cocal/google-calendar-mcp` explicitly supports multi-calendar and multi-account, and is actively maintained.
Tools exposed (surfaced as `mcp__calendar__<name>`, exact set depends on version — run `tools/list` against the MCP server to enumerate): `list-calendars`, `list-events`, `search-events`, `create-event`, `update-event`, `delete-event`, `get-event`, `list-colors`, `get-freebusy`, `get-current-time`, plus multi-account management tools.
**Why this pattern:** v2's invariant is that containers never receive raw API keys (CHANGELOG 2.0.0). Same stub pattern `/add-gmail-tool` uses. This skill is deliberately a sibling, not a combined "Google Workspace" skill — installs independently and removes cleanly.
## Phase 1: Pre-flight
### Verify OneCLI has Google Calendar connected
```bash
onecli apps get --provider google-calendar
```
Expected: `"connection": { "status": "connected" }` with scopes including `calendar.readonly` and `calendar.events`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Google Calendar, and click Connect. Sign in with the Google account the agent should act as. `calendar.readonly` + `calendar.events` are the minimum useful scopes.
### Verify stub credentials exist
The stub lives at `~/.calendar-mcp/` by convention (shared with `/add-gmail-tool`'s sibling). cocal doesn't default to this path (it uses `~/.config/google-calendar-mcp/tokens.json`) — we override via env vars below so it reads our stubs instead.
```bash
ls -la ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json 2>&1
```
If both exist with `onecli-managed`:
```bash
grep -l onecli-managed ~/.calendar-mcp/gcp-oauth.keys.json ~/.calendar-mcp/credentials.json
```
...skip to Phase 2. If either file has real credentials (no `onecli-managed`), **STOP** — back up and delete before proceeding.
If absent, write them:
```bash
mkdir -p ~/.calendar-mcp
cat > ~/.calendar-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.calendar-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events"
}
EOF
chmod 600 ~/.calendar-mcp/*.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.calendar-mcp` must sit under an `allowedRoots` entry.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject the Google Calendar token:
```bash
onecli agents list
```
`secretMode: all` is sufficient. If `selective`, explicitly assign the Calendar secret.
## Phase 2: Apply Code Changes
### Check if already applied
```bash
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block and add:
```dockerfile
ARG CALENDAR_MCP_VERSION=2.6.1
```
If `/add-gmail-tool` has already been applied, the pnpm global-install block already exists with its `zod-to-json-schema@3.22.5` pin. Just append the calendar package — **the calendar-mcp uses `zod@4.x` and does NOT need that pin**, but it's harmless to share the block:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
If `/add-gmail-tool` hasn't been applied, install Calendar standalone:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
### Rebuild the container image
```bash
./container/build.sh
```
## Phase 3: Wire Per-Agent-Group
For each agent group, merge into `groups/<folder>/container.json`:
```jsonc
{
"mcpServers": {
"calendar": {
"command": "google-calendar-mcp",
"args": [],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.calendar-mcp",
"containerPath": ".calendar-mcp",
"readonly": false
}
]
}
```
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
## Phase 4: Build and Restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
Kill any existing agent containers so they respawn with the new mcpServers config:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## Phase 5: Verify
### Test from a wired agent
> Send: **"list my calendars"** or **"what's on my work calendar next Monday?"**.
>
> First call takes 23s while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log | grep -iE 'calendar|mcp'
```
Common signals:
- `command not found: google-calendar-mcp` → image not rebuilt.
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
## Removal
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
-229
View File
@@ -1,229 +0,0 @@
---
name: add-gmail-tool
description: Add Gmail as an MCP tool (read, search, send, label, draft) using OneCLI-managed OAuth. The agent gets Gmail tools in every enabled group; OneCLI injects real tokens at request time so no raw credentials are ever in the container or on disk in usable form.
---
# Add Gmail Tool (OneCLI-native)
This skill wires the [`@gongrzhe/server-gmail-autoauth-mcp`](https://www.npmjs.com/package/@gongrzhe/server-gmail-autoauth-mcp) stdio MCP server into selected agent groups. The MCP server reads stub credentials containing the `onecli-managed` placeholder; the OneCLI gateway intercepts outbound calls to `gmail.googleapis.com` and injects the real OAuth bearer from its vault.
Tools exposed (from `gmail-mcp@1.1.11`, surfaced to the agent as `mcp__gmail__<name>`): `search_emails`, `read_email`, `send_email`, `draft_email`, `delete_email`, `modify_email`, `batch_modify_emails`, `batch_delete_emails`, `download_attachment`, `list_email_labels`, `create_label`, `update_label`, `delete_label`, `get_or_create_label`, `list_filters`, `get_filter`, `create_filter`, `create_filter_from_template`, `delete_filter`.
**Why this pattern:** v2's invariant is that containers never receive raw API keys — OneCLI is the sole credential path (see CHANGELOG v2.0.0). The stub-file pattern satisfies this: the container sees `"onecli-managed"` placeholders, the gateway swaps them in flight.
## Phase 1: Pre-flight
### Verify OneCLI has Gmail connected
```bash
onecli apps get --provider gmail
```
Expected: `"connection": { "status": "connected" }` with scopes including `gmail.readonly`, `gmail.modify`, `gmail.send`.
If not connected, tell the user:
> Open the OneCLI web UI at http://127.0.0.1:10254, go to Apps → Gmail, and click Connect. Sign in with the Google account you want the agent to act as.
### Verify stub credentials exist
```bash
ls -la ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json 2>&1
```
If both exist and contain `"onecli-managed"`:
```bash
grep -l onecli-managed ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
...skip to Phase 2.
If either file exists but does **not** contain `onecli-managed`, **STOP** and tell the user — these are real OAuth credentials from a previous non-OneCLI install. Back them up, then delete before proceeding. The OneCLI migration normally handles this; if it didn't, something is wrong.
If both files are absent, write them now:
```bash
mkdir -p ~/.gmail-mcp
cat > ~/.gmail-mcp/gcp-oauth.keys.json <<'EOF'
{
"installed": {
"client_id": "onecli-managed.apps.googleusercontent.com",
"client_secret": "onecli-managed",
"redirect_uris": ["http://localhost:3000/oauth2callback"]
}
}
EOF
cat > ~/.gmail-mcp/credentials.json <<'EOF'
{
"access_token": "onecli-managed",
"refresh_token": "onecli-managed",
"token_type": "Bearer",
"expiry_date": 99999999999999,
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send"
}
EOF
chmod 600 ~/.gmail-mcp/gcp-oauth.keys.json ~/.gmail-mcp/credentials.json
```
### Verify mount allowlist covers the path
```bash
cat ~/.config/nanoclaw/mount-allowlist.json
```
`~/.gmail-mcp` must sit under an `allowedRoots` entry (e.g. `/home/<user>`). If it doesn't, tell the user to run `/manage-mounts` first or add their home directory.
### Check agent secret-mode
For each target agent group, confirm OneCLI will inject Gmail secrets into its container. Find the OneCLI agent ID that matches the group's `agentGroupId`:
```bash
onecli agents list
```
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
```bash
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
```
## Phase 2: Apply Code Changes
### Check if already applied
```bash
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG BUN_VERSION=1.3.12
```
Add a new line:
```dockerfile
ARG GMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g \
"@gongrzhe/server-gmail-autoauth-mcp@${GMAIL_MCP_VERSION}" \
"zod-to-json-schema@3.22.5"
```
Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates trunk installs, and CLAUDE.md requires a fixed ARG version for all Node CLIs installed into the image.
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
### Rebuild the container image
```bash
./container/build.sh
```
Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cached on rebuild).
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
Merge these into the group's `container.json`:
```jsonc
{
"mcpServers": {
"gmail": {
"command": "gmail-mcp",
"args": [],
"env": {
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.gmail-mcp",
"containerPath": ".gmail-mcp",
"readonly": false
}
]
}
```
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
## Phase 4: Build and Restart
```bash
pnpm run build
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
## Phase 5: Verify
### Test from the wired agent
Tell the user:
> In your `<agent-name>` chat, send: **"list my gmail labels"** or **"search my inbox for invoices from last month"**.
>
> The agent should use `mcp__gmail__list_labels` / `mcp__gmail__search`. The first call may take a second or two while the MCP server starts and OneCLI does the token exchange.
### Check logs if the tool isn't working
```bash
tail -100 logs/nanoclaw.log logs/nanoclaw.error.log | grep -iE 'gmail|mcp'
# Per-container logs — session-scoped:
ls data/v2-sessions/*/stderr.log | head
```
Common signals:
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
## Removal
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
## Notes
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
- **Scopes are set at OAuth connect time.** If the agent needs scopes beyond what's currently connected (e.g. the user later wants `calendar.readonly` for combined email/calendar workflows), disconnect and reconnect Gmail in the OneCLI web UI with the expanded scope set.
- **This is tool-only.** Inbound email as a channel (emails trigger the agent) is a separate piece of work — it needs a `src/channels/gmail.ts` adapter that polls the inbox and routes to a messaging group. The pre-v2 qwibitai skill had this; it has not been ported to v2's channel architecture as of v2.0.0.
## Credits & references
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
+1 -1
View File
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
### Per group / per session
Set `"provider": "opencode"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session XDG mount, `OPENCODE_*` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host; the runner merges them into the same `mcpServers` object passed to **both** Claude and OpenCode providers.
-13
View File
@@ -1,13 +0,0 @@
# Remove Signal
1. Comment out `import './signal.js'` in `src/channels/index.ts`
2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env`
3. Rebuild and restart
If you also want to unlink the Signal account from `signal-cli`:
```bash
signal-cli -a +1YOURNUMBER removeDevice --deviceId <id>
```
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)
-318
View File
@@ -1,318 +0,0 @@
---
name: add-signal
description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge.
---
# Add Signal Channel
Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge — only Node.js builtins (`node:net`, `node:child_process`, `node:fs`).
Unlike Telegram or Discord, Signal has no bot API. NanoClaw registers as a full Signal account on a dedicated phone number (recommended) or links as a secondary device on your existing number.
## Prerequisites
### Java
signal-cli requires Java 17+:
```bash
java -version
```
If missing:
- **macOS:** `brew install --cask temurin@17`
- **Debian/Ubuntu:** `sudo apt-get install -y default-jre`
- **RHEL/Fedora:** `sudo dnf install -y java-17-openjdk`
Java 1725 all work.
### signal-cli
- **macOS:** `brew install signal-cli`
- **Linux:** download the native binary from [GitHub releases](https://github.com/AsamK/signal-cli/releases):
```bash
SIGNAL_CLI_VERSION=$(curl -fsSL https://api.github.com/repos/AsamK/signal-cli/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'][1:])")
curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar -xz -C ~/.local
ln -sf ~/.local/signal-cli ~/.local/bin/signal-cli
signal-cli --version
```
> The Linux native tarball extracts a single binary directly to `~/.local/signal-cli` (not into a subdirectory). The symlink above puts it on PATH.
## Registration
Two paths. The new-number path is recommended and battle-tested.
### Path A: Register a new number (recommended)
Use a dedicated SIM or VoIP number. NanoClaw owns it entirely.
> **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM.
**Step 1: Solve the CAPTCHA**
Signal requires a CAPTCHA on first registration:
1. Open `https://signalcaptchas.org/registration/generate.html` in a browser
2. Solve the captcha
3. Right-click the **"Open Signal"** button → **Copy Link**
4. The link starts with `signalcaptcha://` — the token is everything after that prefix
**Step 2: Request SMS verification**
```bash
signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE"
```
**Step 3: Voice call fallback (if your number can't receive SMS)**
Wait ~60 seconds after the SMS request, then:
```bash
signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN"
```
Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one.
> You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…`
**Step 4: Verify**
```bash
signal-cli -a +1YOURNUMBER verify CODE
```
No output = success.
**Step 5: Set profile name (optional)**
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
```bash
# macOS
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
# optionally: --avatar /path/to/avatar.jpg
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux
systemctl --user stop nanoclaw
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start nanoclaw
```
### Path B: Link as secondary device
Joins an existing Signal account as a secondary device. Simpler, but NanoClaw shares your personal number.
```bash
signal-cli -a +1YOURNUMBER link --name "NanoClaw"
```
This prints a `tsdevice:` URI. Scan it as a QR code on your phone: **Settings → Linked Devices → Link New Device**. QR codes expire in ~30 seconds — re-run if it expires.
## Install
### Pre-flight (idempotent)
Skip to **Credentials** if all of these are already in place:
- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist
- `src/channels/index.ts` contains `import './signal.js';`
Otherwise continue. Every step below is safe to re-run.
### 1. Fetch the channels branch
```bash
git fetch origin channels
```
### 2. Copy the adapter and tests
```bash
git show origin/channels:src/channels/signal.ts > src/channels/signal.ts
git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts
```
### 3. Append the self-registration import
Append to `src/channels/index.ts` (skip if the line is already present):
```typescript
import './signal.js';
```
### 4. Build
```bash
pnpm run build
```
No npm packages to install — the adapter uses only Node.js builtins.
## Credentials
Add to `.env`:
```bash
SIGNAL_ACCOUNT=+1YOURNUMBER
```
### Optional settings
```bash
# TCP daemon host and port (default: 127.0.0.1:7583)
SIGNAL_TCP_HOST=127.0.0.1
SIGNAL_TCP_PORT=7583
# Path to the signal-cli binary (default: resolved on PATH)
SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# Whether NanoClaw manages the daemon lifecycle (default: true).
# Set to false if you run signal-cli daemon externally.
SIGNAL_MANAGE_DAEMON=true
# signal-cli data directory (default: ~/.local/share/signal-cli)
SIGNAL_DATA_DIR=~/.local/share/signal-cli
```
**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network.
Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
```bash
# macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux
systemctl --user restart nanoclaw
```
## Wiring
### DMs
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
sqlite3 data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group.
### Groups
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sqlite3 data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
"
```
### Grant user access
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sqlite3 data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
"
```
Find the UUID from `messaging_groups.platform_id` or the `users` table.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group.
## Channel Info
- **type**: `signal`
- **terminology**: Signal has "chats" (1:1 DMs) and "groups"
- **supports-threads**: no
- **platform-id-format**:
- DM: `signal:{UUID}` — sender's Signal UUID (ACI), **not** their phone number
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
- **typical-use**: Personal assistant via Signal DMs or small group chats
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
### Features
- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles)
- Quoted replies — `replyTo*` fields populated from Signal quotes
- Typing indicators — DMs only (Signal doesn't support group typing)
- Echo suppression — outbound messages matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops
- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`
- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx
Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions.
## Troubleshooting
### Daemon not reachable
```bash
grep "Signal" logs/nanoclaw.log | tail
```
If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`:
- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`)
- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting
If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`.
### Bot not responding
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
### Lost connection mid-session
If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish.
### Messages dropped with `not_member`
The Signal user hasn't been granted membership. See "Grant user access" above. This affects every new Signal user, including the owner's Signal identity — which is a separate user record from their identity on other channels even if it's the same person.
### Captcha required
Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`.
### `Invalid verification method: Before requesting voice verification…`
You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token.
### Config file in use / daemon lock
signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward.
### Group replies going to DM instead of group
Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`.
### Java not found
Install Java 17+ — see the Prerequisites section above.
### QR code expired (Path B)
QR codes expire in ~30 seconds. Re-run the link command to generate a new one.
-5
View File
@@ -1,5 +0,0 @@
# Verify Signal
Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds.
If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`.
+2 -8
View File
@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
- `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
@@ -76,13 +76,7 @@ pnpm run build
10. Under **Subscribe to bot events**, add:
- `message.channels`, `message.groups`, `message.im`, `app_mention`
11. Click **Save Changes**
### Interactivity
12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on
13. Set the **Request URL** to the same `https://your-domain/webhook/slack`
14. Click **Save Changes**
15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings
12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions
### Configure environment
+1 -6
View File
@@ -1,12 +1,7 @@
name: Label PR
# SECURITY: this workflow runs with write access to the base repo on fork PRs,
# because `pull_request_target` executes in the context of the base branch.
# Keep it metadata-only — do NOT add actions/checkout or any step that
# executes PR-supplied content (install scripts, build commands, etc.).
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on:
pull_request_target:
pull_request:
types: [opened, edited]
jobs:
+1 -11
View File
@@ -186,17 +186,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
systemctl --user start|stop|restart nanoclaw
```
## Troubleshooting
Check these first when something goes wrong:
| What | Where |
|------|-------|
| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain |
| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) |
| Session DBs | `data/v2-sessions/<agent-group>/<session>/``inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) |
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here).
## Supply Chain Security (pnpm)
+1 -3
View File
@@ -26,9 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw && bash nanoclaw.sh
```
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
+103 -63
View File
@@ -8,56 +8,92 @@
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">ドキュメント</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
</p>
> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。
---
<h2 align="center">🐳 Dockerサンドボックスで動作</h2>
<p align="center">各エージェントはマイクロVM内の独立したコンテナで実行されます。<br>ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。</p>
**macOS (Apple Silicon)**
```bash
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
```
**Windows (WSL)**
```bash
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
```
> 現在、macOSApple Silicon)とWindowsx86)に対応しています。Linux対応は近日公開予定。
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">発表記事を読む →</a>&nbsp; · &nbsp;<a href="docs/docker-sandboxes.md">手動セットアップガイド →</a></p>
---
## NanoClawを作った理由
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、自分が理解しきれない複雑なソフトウェアに生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOSレベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
[OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。
## クイックスタート
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
gh repo fork qwibitai/nanoclaw --clone
cd nanoclaw
claude
```
`nanoclaw.sh`は、まっさらなマシンから、メッセージを送れる名前付きエージェントが動く状態までを一気通貫で案内します。NodeやpnpmやDockerが無ければインストールし、AnthropicクレデンシャルをOneCLIに登録し、エージェントコンテナをビルドし、最初のチャネル(Telegram、Discord、WhatsApp、またはローカルCLI)とペアリングします。途中でステップが失敗すれば自動的にClaude Codeが呼び出され、原因を診断して中断箇所から再開します。
<details>
<summary>GitHub CLIなしの場合</summary>
1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック)
2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git`
3. `cd nanoclaw`
4. `claude`
</details>
その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。
> **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。
## 設計思想
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を把握したいなら、Claude Codeに説明を求めれば十分です。
**理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。
**分離によるセキュリティ。** エージェントはLinuxコンテナで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセス安全です。
**分離によるセキュリティ。** エージェントはLinuxコンテナmacOSではApple Container、またはDockerで実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセス安全です。
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドであるよう設計されています。自分のフォークを作、Claude Codeにニーズに合わせて変更させます。
**個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。
**カスタマイズ=コード変更。** 設定の肥大化はありません。動作を変えたいならコードを変える。コードベースは変更しても安全な規模です。
**カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたいコードを変更するだけ。コードベースは変更しても安全な規模です。
**AIネイティブ、設計としてハイブリッド。** インストールとオンボーディングは最適化されたスクリプトのパスで、速く決定的です。判断が必要なところ(インストール失敗、対話的な決定、カスタマイズ)では、制御はシームレスにClaude Codeへ渡されます。セットアップ以降も、監視ダッシュボードやデバッグUIは用意しません。問題をチャットで説明すれば、Claude Codeが処理します
**AIネイティブ。**
- インストールウィザードなし — Claude Codeがセットアップを案内。
- モニタリングダッシュボードなし — Claudeに状況を聞くだけ。
- デバッグツールなし — 問題を説明すればClaudeが修正。
**機能ではなくスキル。** トランクにはレジストリとインフラのみを同梱し、個別のチャネルアダプターや代替プロバイダーは含めません。チャネル(Discord、Slack、Telegram、WhatsAppなど)は長期運用される`channels`ブランチに、代替プロバイダー(OpenCode、Ollama)は`providers`ブランチに置かれます。`/add-telegram``/add-opencode`などを実行すると、スキルが必要なモジュールだけを正確にフォークへコピーします。要求していない機能は一切入りません
**機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入りま
**最高のハーネス、最高のモデル。** NanoClawはAnthropic公式のClaude Agent SDK経由でネイティブにClaude Codeを使用します。最新のClaudeモデルとClaude Codeの全ツールセット(自分のNanoClawフォークを変更・拡張する能力を含む)が手に入ります。他プロバイダーはドロップイン・オプションです。OpenAIのCodex(ChatGPTサブスクリプションまたはAPIキー)向けには`/add-codex`、OpenCode経由のOpenRouter、Google、DeepSeekなどには`/add-opencode`、ローカルのオープンウェイトモデルには`/add-ollama-provider`。プロバイダーはエージェントグループごとに設定可能です。
**最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。
## サポート機能
- **マルチチャネルメッセージング** WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat、Resend経由のメール。`/add-<channel>`スキルでオンデマンドにインストール。1つでも複数でも同時に実行可能。
- **柔軟な分離モデル** — チャネルごとに専用エージェントを割り当てて完全プライバシーを確保することも、複数チャネルで1つのエージェントを共有して会話は分離しつつメモリを統一することも、複数チャネルを1つの共有セッションにまとめて会話を横断させることもできます。`/manage-channels`でチャネル単位に選択。[docs/isolation-model.md](docs/isolation-model.md)参照
- **エージェントごとのワークスペース** — 各エージェントグループは独自の`CLAUDE.md`、独自のメモリ、独自のコンテナ、そしてあなたが許可したマウントのみを持ちます。明示的に配線しない限り、境界を越えるものはありません
- **スケジュールタスク** Claudeを実行し、結果を返信できる定期ジョブ。
- **Webアクセス** Webからの検索とコンテンツ取得。
- **コンテナ分離** エージェントはDockerサンドボックス化されます(macOS/Linux/WSL2)。[Docker Sandboxes](docs/docker-sandboxes.md)によるマイクロVM分離や、macOSネイティブのオプトインとしてApple Containerも選択可能です
- **クレデンシャルのセキュリティ** — エージェントは生のAPIキーを保持しません。アウトバウンドリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、リクエスト時に認証情報を注入して、エージェントごとのポリシーとレート制限を適用します
- **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp``/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。
- **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行
- **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離
- **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。
- **Webアクセス** - Webからのコンテンツ検索・取得。
- **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple ContainermacOS)、またはDockermacOS/Linux)でサンドボックス化
- **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動
- **オプション連携** - Gmail`/add-gmail`)などをスキルで追加。
## 使い方
@@ -69,7 +105,7 @@ bash nanoclaw.sh
@Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って
```
所有または管理しているチャネルから、グループやタスクを管理できます:
メインチャネル(セルフチャット)から、グループやタスクを管理できます:
```
@Andy 全グループのスケジュールタスクを一覧表示して
@Andy 月曜のブリーフィングタスクを一時停止して
@@ -78,14 +114,14 @@ bash nanoclaw.sh
## カスタマイズ
NanoClawは設定ファイルを使いません。変更したいときは、Claude Codeにやりたいことを伝えるだけです:
NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです:
- 「トリガーワードを@Bobに変更して
- 「今後はレスポンスをもっと短く直接的にして」
- 「おはようと言ったらカスタム挨拶を追加して」
- 「会話の要約を毎週保存して」
または`/customize`を実行すればガイド付き変更できます。
または`/customize`を実行してガイド付き変更を行えます。
コードベースは十分に小さいため、Claudeが安全に変更できます。
@@ -93,101 +129,105 @@ NanoClawは設定ファイルを使いません。変更したいときは、Cla
**機能を追加するのではなく、スキルを追加してください。**
新しいチャネルやエージェントプロバイダーを追加したい場合、トランクには追加しないでください。新しいチャネルアダプターは`channels`ブランチに、新しいエージェントプロバイダーは`providers`ブランチに追加します。ユーザーはそれぞれのフォークで`/add-<name>`スキルを実行し、スキルが必要なモジュールを標準パスへコピーし、登録を配線し、依存関係をピン留めします。
Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。
こうすることでトランクは純粋なレジストリ/インフラのまま保たれ、どのフォークもスリムなままです。ユーザーは求めたチャネルとプロバイダーだけを受け取り、それ以外は入りません
ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入りま
### RFS(スキル募集)
私たちが見たいスキル:
私たちが求めているスキル:
**コミュニケーションチャネル**
- `/add-signal` Signalをチャネルとして追加
- `/add-signal` - Signalをチャネルとして追加
**セッション管理**
- `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。
## 必要条件
- macOSまたはLinuxWindowsはWSL2経由)
- Node.js 20以上とpnpm 10以上(インストーラーが未インストールなら両方をインストールします)
- [Docker Desktop](https://docker.com/products/docker-desktop)macOS/Windows)または Docker EngineLinux
- [Claude Code](https://claude.ai/download)`/customize``/debug`、セットアップ時のエラー復旧、全ての`/add-<channel>`スキルで使用
- macOSまたはLinux
- Node.js 20以上
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container)macOS)または[Docker](https://docker.com/products/docker-desktop)macOS/Linux
## アーキテクチャ
```
メッセージングアプリ → ホストプロセス(ルーター) → inbound.db → コンテナ(Bun、Claude Agent SDK → outbound.db → ホストプロセス(配信) → メッセージングアプリ
チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK --> レスポンス
```
単一のNodeホストがセッションごとのエージェントコンテナをオーケストレーションします。メッセージが到着すると、ホストはエンティティモデル(ユーザー → メッセージンググループ → エージェントグループ → セッション)に沿ってルーティングし、セッションの`inbound.db`に書き込み、コンテナを起こします。コンテナ内部のagent-runnerは`inbound.db`をポーリングしてClaudeを実行し、レスポンスを`outbound.db`に書き込みます。ホストは`outbound.db`をポーリングし、チャネルアダプターを通じて配信します
単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC
セッションごとに2つのSQLiteファイル、各ファイルにライターは1つだけ — クロスマウントの競合なし、IPCなし、stdinパイプなし。チャネルと代替プロバイダーは起動時に自己登録します。トランクはレジストリとChat SDKブリッジを同梱し、アダプター本体はフォークごとにスキルでインストールされます
詳しいアーキテクチャ説明は[docs/architecture.md](docs/architecture.md)を、3階層の分離モデルについては[docs/isolation-model.md](docs/isolation-model.md)を参照してください。
詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください
主要ファイル:
- `src/index.ts` — エントリーポイント:DB初期化、チャネルアダプター、配信ポーリング、sweep
- `src/router.ts` — インバウンドルーティング:メッセージンググループ → エージェントグループ → セッション → `inbound.db`
- `src/delivery.ts``outbound.db`をポーリングし、アダプター経由で配信、システムアクションを処理
- `src/host-sweep.ts` — 60秒ごとのsweep:ストール検出、期限到来メッセージの起動、繰り返し
- `src/session-manager.ts` — セッションの解決、`inbound.db``outbound.db`のオープン
- `src/container-runner.ts` — エージェントグループごとのコンテナ起動、OneCLIによるクレデンシャル注入
- `src/db/` — セントラルDB(ユーザー、ロール、エージェントグループ、メッセージンググループ、配線、マイグレーション)
- `src/channels/` — チャネルアダプターのインフラ(アダプターは`/add-<channel>`スキルでインストール
- `src/providers/` — ホスト側プロバイダー設定(`claude`はバンドル、その他はスキル経由)
- `container/agent-runner/` — Bun製agent-runner:ポーリングループ、MCPツール、プロバイダー抽象化
- `groups/<folder>/` — エージェントグループごとのファイルシステム(`CLAUDE.md`、スキル、コンテナ設定)
- `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し
- `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録)
- `src/ipc.ts` - IPCウォッチャーとタスク処理
- `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング
- `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー
- `src/container-runner.ts` - ストリーミングエージェントコンテナ起動
- `src/task-scheduler.ts` - スケジュールタスクの実行
- `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態
- `groups/*/CLAUDE.md` - グループごとのメモリ
## FAQ
**なぜDockerなのか?**
Dockerはクロスプラットフォーム対応(macOS、Linux、WSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使えます。さらに強い分離が必要なら、[Docker Sandboxes](docs/docker-sandboxes.md)が各コンテナをマイクロVM内で動作させます。
Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。
**LinuxやWindowsで実行できますか?**
**Linuxで実行できますか?**
はい。DockerがデフォルトのランタイムでmacOSLinux、Windows(WSL2経由)で動作します。`bash nanoclaw.sh`を実行するだけです。
はい。DockerがデフォルトのランタイムでmacOSLinuxの両方で動作します。`/setup`を実行するだけです。
**セキュリティは大丈夫ですか?**
エージェントはアプリケーションレベルのパーミッションチェックではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセス可能です。クレデンシャルはコンテナに渡されず、アウトバウンドAPIリクエストは[OneCLI Agent Vault](https://github.com/onecli/onecli)を経由し、プロキシレベルで認証を注入し、レートリミットやアクセスポリシーをサポートします。実行するものレビューすべきですが、コードベースは実際にレビュー可能な規模です。完全なセキュリティモデルについては[セキュリティドキュメント](https://docs.nanoclaw.dev/concepts/security)を参照してください。
エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。
**なぜ設定ファイルがないのか?**
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが自分の望み通りに動くようにすべきです。設定ファイルが欲しければClaudeに追加するよう伝えれば実現できます。
設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなくコードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。
**サードパーティやオープンソースモデルを使えますか?**
はい。推奨される方法は`/add-opencode`OpenCode設定経由でOpenRouter、OpenAI、Google、DeepSeekなど)か`/add-ollama-provider`(Ollama経由でローカルのオープンウェイトモデル)です。どちらもエージェントグループごとに設定可能なので、同じインストール内で異なるエージェントが異なるバックエンドで動作できます。
一時的な実験用には、Claude API互換のエンドポイントも`.env`で利用できます:
はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
以下が使用可能です:
- [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル
- [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル
- Anthropic互換APIのカスタムモデルデプロイメント
注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。
**問題のデバッグ方法は?**
Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。
**セットアップがうまくいかない場合は?**
ステップが失敗した場合、`nanoclaw.sh`は診断と再開のためにClaude Codeへ制御を渡します。それでも解決しなければ`claude`を実行して`/debug`呼び出してください。他のユーザーにも影響しそうな問題をClaudeが特定した場合は、該当のセットアップステップまたはスキルにPRを送ってください。
問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は`claude`を実行してから`/debug`実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。
**どのような変更がコードベースに受け入れられますか?**
ベース設定に受け入れられるのは、セキュリティ修正、バグ修正、明確な改善のみす。それだけです。
セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。
それ以外(新機能、OS互換性、ハードウェアサポート、拡張など)は、`channels`または`providers`ブランチのスキルとしてコントリビュートしてください
それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです
これにより、ベースシステムを最小限に保ち、ユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。
これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく自分のインストールをカスタマイズできます。
## コミュニティ
質問やアイデアがありますか[Discordに参加](https://discord.gg/VDdww8qS42)してください。
質問やアイデア[Discordに参加](https://discord.gg/VDdww8qS42)してください。
## 変更履歴
破壊的変更については[CHANGELOG.md](CHANGELOG.md)を、完全なリリース履歴はドキュメントサイトの[full release history](https://docs.nanoclaw.dev/changelog)を参照してください。
破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。
## ライセンス
+88 -78
View File
@@ -3,87 +3,93 @@
</p>
<p align="center">
一个将智能体安全运行在独立容器中的 AI 助手。轻量、易于理解,并根据您的需求完全定制。
NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并根据您的个人需求灵活定制。
</p>
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp;
<a href="https://docs.nanoclaw.dev">文档</a>&nbsp; • &nbsp;
<a href="README.md">English</a>&nbsp; • &nbsp;
<a href="README_ja.md">日本語</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
</p>
---
> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。
## 我为什么创建 NanoClaw
通过 Claude CodeNanoClaw 可以动态重写自身代码,根据您的需求定制功能。
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解、却能访问我个人隐私的复杂软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(白名单、配对码),而非真正的操作系统级隔离。所有东西都在一个共享内存的 Node 进程中运行
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作
NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能:一个进程,少数几个文件。Claude 智能体运行在具有文件系统隔离的独立 Linux 容器中,而不是仅靠权限检查。
## 我为什么创建这个项目
[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
## 快速开始
```bash
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
git clone https://github.com/qwibitai/nanoclaw.git
cd nanoclaw
claude
```
`nanoclaw.sh` 会把您从一台全新机器一直带到一个可以直接发消息的命名智能体。它会在缺失时安装 Node、pnpm 和 Docker,向 OneCLI 注册您的 Anthropic 凭据,构建智能体容器,并配对您的第一个渠道(Telegram、Discord、WhatsApp 或本地 CLI)。如果某一步失败,会自动调用 Claude Code 进行诊断并从中断处继续
然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置
> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。
## 设计哲学
**小到可以理解。** 单一进程,少量源文件无微服务。如果您想了解完整的 NanoClaw 代码库,直接让 Claude Code 给您讲一遍就行
**小巧易懂:** 单一进程,少量源文件无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手
**通过隔离实现安全** 智能体运行在 Linux 容器中,只能看到明确挂载的内容。Bash 访问安全,因为命令在容器内执行,而不是在您的宿主机
**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令在容器内执行,不会直接操作您的宿主机。
**为个人用户打造** NanoClaw 不是一个单体框架,而是能精确匹配每个用户需求的软件。它被设计成量身定制的,而不是臃肿膨胀。您创建自己的 fork,让 Claude Code 按您的需求修改它
**为单一用户打造:** 不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配
**定制 = 修改代码。** 没有配置膨胀。想要不同的行为?改代码。代码库小到改动是安全的。
**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。
**AI 原生,混合式设计。** 安装与上手流程走的是经过优化的脚本路径,快速且确定。当某一步需要判断(安装失败、引导决策、定制化)时,控制权会无缝地交给 Claude Code。安装之后也不提供监控仪表盘或调试 UI:您在聊天中描述问题,Claude Code 来处理
**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)
**技能优于功能。** 主干只发布注册表和基础设施,不包含具体的渠道适配器或替代智能体提供者。各个渠道(Discord、Slack、Telegram、WhatsApp……)放在长期存在的 `channels` 分支上;替代提供者(OpenCode、Ollama)放在 `providers` 分支上。您运行 `/add-telegram``/add-opencode` 等,技能会把您所需要的模块精确地复制到您的 fork 里。不会出现您没要求的功能
**技能Skills)优于功能(Features:** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码
**最强的 harness,最的模型** NanoClaw 通过 Anthropic 官方的 Claude Agent SDK 原生使用 Claude Code,所以您能用上最新的 Claude 模型以及 Claude Code 的完整工具集——包括修改和扩展自己的 NanoClaw fork 的能力。其他提供者是可插拔选项:`/add-codex` 对应 OpenAI 的 CodexChatGPT 订阅或 API key),`/add-opencode` 通过 OpenCode 接入 OpenRouter、Google、DeepSeek 等,`/add-ollama-provider` 用于本地开源权重模型。提供者可按智能体组单独配置
**最好的工具套件,最的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制
## 功能支持
- **多渠道消息** WhatsApp、Telegram、Discord、Slack、Microsoft Teams、iMessage、Matrix、Google Chat、Webex、Linear、GitHub、WeChat,以及通过 Resend 的邮件。按需通过 `/add-<channel>` 技能安装。可同时运行一个或多个。
- **灵活的隔离模式** — 可为每个渠道配一个独立智能体以获得完全隐私,也可让一个智能体在多个渠道上共享、统一记忆但会话独立,或者把多个渠道合并到一个共享会话里,让一场对话横跨多个入口。通过 `/manage-channels` 按渠道选择。详见 [docs/isolation-model.md](docs/isolation-model.md)
- **每个智能体的独立工作区** — 每个智能体组都有自己的 `CLAUDE.md`、自己的记忆、自己的容器,以及您允许的挂载点。除非您明确接线,否则不会有东西越过边界。
- **计划任务** 运行 Claude 的周期性作业,可以给您回发消息
- **网络访问** 搜索和抓取网页内容
- **容器隔离** 智能体在 DockermacOS/Linux/WSL2)中沙箱化运行,可选 [Docker Sandboxes](docs/docker-sandboxes.md) 的微虚拟机隔离,或在 macOS 上选用 Apple Container 作为原生运行时。
- **凭据安全** — 智能体不持有原始 API key。出站请求经由 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli),在请求时注入凭据,并按每个智能体执行策略和速率限制。
- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp``/add-telegram` 技能添加渠道,可同时运行一个或多个。
- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统
- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离
- **计划任务** - 运行 Claude 的周期性作业,可以给您回发消息
- **网络访问** - 搜索和抓取网页内容
- **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行
- **智能体集群(Agent Swarms)** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手)
- **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能
## 使用方法
用触发词(默认为 `@Andy`)与您的助手对话:
使用触发词(默认为 `@Andy`)与您的助手对话:
```
@Andy 每个工作日早上 9 点给我发一份销售渠道概览(可以访问我的 Obsidian vault 文件夹)
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它
@Andy 每周一早上 8 点,从 Hacker News 和 TechCrunch 收集 AI 相关资讯,给我一份简报
@Andy 每周一到周五早上9点,给我发一份销售渠道概览(需要访问我的 Obsidian vault 文件夹)
@Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入就更新它
@Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报
```
您拥有或管理的渠道里,还可以管理群组和任务:
主频道(您的self-chat)中,可以管理群组和任务:
```
@Andy 列出所有群组的计划任务
@Andy 列出所有群组的计划任务
@Andy 暂停周一简报任务
@Andy 加入"家庭聊天"群组
```
## 定制
NanoClaw 不用配置文件。想改就直接告诉 Claude Code
没有需要学习的配置文件。直接告诉 Claude Code 您想要什么
- "把触发词改成 @Bob"
- "以后回答更简短、更直接"
- "我说早上好的时候加一个自定义问候"
- "每周存一次话摘要"
- "记住以后回答更简短直接"
- "我说早上好的时候加一个自定义问候"
- "每周存一次话摘要"
或者运行 `/customize` 进行引导式修改。
@@ -91,103 +97,107 @@ NanoClaw 不用配置文件。想改就直接告诉 Claude Code
## 贡献
**不要加功能,加技能。**
**不要加功能,而是添加技能。**
如果您想添加新的渠道或智能体提供者,不要把它加到主干上。新的渠道适配器进入 `channels` 分支;新的智能体提供者进入 `providers` 分支。用户在自己的 fork 上运行 `/add-<name>` 技能,由技能把相关模块复制到标准路径、接好注册、固定依赖版本
如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram
这样主干始终保持为纯粹的注册表和基础设施,每个 fork 也都保持精简——用户只获得他们要求的渠道和提供者,其它什么也不会混进来
然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统
### RFS技能征集
### RFS (技能征集)
我们希望看到的技能:
**通信渠道**
- `/add-signal` 添加 Signal 作为渠道
- `/add-signal` - 添加 Signal 作为渠道
**会话管理**
- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
## 系统要求
- macOS 或 LinuxWindows 通过 WSL2
- Node.js 20+ 和 pnpm 10+(安装脚本会在缺失时自动安装)
- [Docker Desktop](https://docker.com/products/docker-desktop)macOS/Windows)或 Docker EngineLinux
- [Claude Code](https://claude.ai/download),用于 `/customize``/debug`、安装过程中的错误恢复以及所有 `/add-<channel>` 技能
- macOS 或 Linux
- Node.js 20+
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
## 架构
```
消息应用 → 主机进程(路由器) → inbound.db → 容器(Bun、Claude Agent SDK → outbound.db → 主机进程(投递) → 消息应用
渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
```
单一 Node 主机编排每个会话的智能体容器。当一条消息到来时,主机按实体模型(用户 → 消息组 → 智能体组 → 会话)进行路由,写入该会话的 `inbound.db`,并唤醒容器。容器内部的 agent-runner 轮询 `inbound.db`,调用 Claude,并把响应写入 `outbound.db`。主机轮询 `outbound.db`,通过渠道适配器投递回去
单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC
每个会话两个 SQLite 文件,每个文件只有一个写入者——没有跨挂载的锁争用,没有 IPC,没有 stdin 管道。渠道和替代提供者在启动时自注册;主干提供注册表和 Chat SDK 桥接,而适配器本身在每个 fork 里通过技能安装
完整架构说明见 [docs/architecture.md](docs/architecture.md);三级隔离模型见 [docs/isolation-model.md](docs/isolation-model.md)。
完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)
关键文件:
- `src/index.ts` — 入口:数据库初始化、渠道适配器、投递轮询、sweep
- `src/router.ts` — 入站路由:消息组 → 智能体组 → 会话 → `inbound.db`
- `src/delivery.ts` — 轮询 `outbound.db`,通过适配器投递,处理系统动作
- `src/host-sweep.ts` — 60 秒 sweep:失效检测、到期消息唤醒、循环任务
- `src/session-manager.ts` — 解析会话,打开 `inbound.db` / `outbound.db`
- `src/container-runner.ts` — 为每个智能体组启动容器,OneCLI 凭据注入
- `src/db/` — 中心数据库(用户、角色、智能体组、消息组、接线、迁移)
- `src/channels/` — 渠道适配器基础设施(适配器通过 `/add-<channel>` 技能安装
- `src/providers/` — 主机侧提供者配置(`claude` 内置,其他通过技能安装)
- `container/agent-runner/` — Bun 版 agent-runner:轮询循环、MCP 工具、提供者抽象
- `groups/<folder>/` — 每个智能体组的文件系统(`CLAUDE.md`、技能、容器配置)
- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用
- `src/channels/registry.ts` - 渠道注册表(启动时自注册)
- `src/ipc.ts` - IPC 监听与任务处理
- `src/router.ts` - 消息格式化与出站路由
- `src/group-queue.ts` - 带全局并发限制的群组队列
- `src/container-runner.ts` - 生成流式智能体容器
- `src/task-scheduler.ts` - 运行计划任务
- `src/db.ts` - SQLite 操作(消息、群组、会话、状态
- `groups/*/CLAUDE.md` - 各群组的记忆
## FAQ
**为什么 Docker**
**为什么 Docker**
Docker 提供跨平台支持(macOSLinux、Windows via WSL2)和成熟的生态。在 macOS 上,您可以选择通过 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时。如需更强隔离,[Docker Sandboxes](docs/docker-sandboxes.md) 会把每个容器放到一台微虚拟机里运行
Docker 提供跨平台支持(macOSLinux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量的原生运行时体验
**我可以在 Linux 或 Windows 上运行吗?**
**我可以在 Linux 上运行吗?**
可以。Docker 是默认运行时,在 macOSLinux 以及 Windows(通过 WSL2)上工作。运行 `bash nanoclaw.sh` 就行
可以。Docker 是默认的容器运行时,在 macOSLinux 上都可以使用。只需运行 `/setup`
**这个项目安全吗?**
智能体运行在容器,而不是在应用级权限检查之后。它们只能访问明确挂载的目录。凭据不会进入容器——出站 API 请求通过 [OneCLI 的 Agent Vault](https://github.com/onecli/onecli) 在代理层注入认证,并支持速率限制和访问策略。您仍然应该审查自己要运行的代码,但代码库小到您真的做到。完整的安全模型见 [安全文档](https://docs.nanoclaw.dev/concepts/security)。
智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问明确挂载的目录。您仍然应该审查运行的代码,但这个代码库小到您真的可以做到。完整的安全模型见 [docs/SECURITY.md](docs/SECURITY.md)。
**为什么没有配置文件?**
我们不想让配置泛滥。每用户都应该定制 NanoClaw,让代码精确地做他们想要的事,而不是去配置一个通用系统。如果您喜欢配置文件,可以让 Claude 给您加
我们不希望配置泛滥。每用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用系统。如果您喜欢配置文件,告诉 Claude 让它加上
**我可以使用第三方或开源模型吗?**
可以。推荐做法是 `/add-opencode`(通过 OpenCode 配置接入 OpenRouter、OpenAI、Google、DeepSeek 等)或 `/add-ollama-provider`(通过 Ollama 使用本地开源权重模型)。两者都可以按智能体组单独配置,所以同一套安装里不同的智能体可以运行在不同的后端上。
对于一次性实验,任何 Claude API 兼容的端点也可以通过 `.env` 使用:
可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
ANTHROPIC_AUTH_TOKEN=your-token-here
```
这使您能够使用:
- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型
- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型
- 兼容 Anthropic API 格式的自定义模型部署
注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。
**我该如何调试问题?**
问 Claude Code。"为什么计划任务没运行?""最近的日志里有什么?""为什么这条消息没有得到回"这就是 NanoClaw 底层的 AI 原生方式
问 Claude Code。"为什么计划任务没运行?" "最近的日志里有什么?" "为什么这条消息没有得到回" 这就是 AI 原生的方法
**为什么安装对我不成功?**
**为什么我的安装不成功?**
如果某一步失败,`nanoclaw.sh` 会把控制权交给 Claude Code 进行诊断并从中断处继续。如果还是没解决,运行 `claude`,然后 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请对相关的安装步骤或技能提 PR
如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md
**什么样的更改会被接受进代码库**
**什么样的代码更改会被接受?**
进入基础配置的只会是:安全修复、bug 修复、明显的改进。仅此而已。
安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。
其他一切(新能、操作系统兼容、硬件支持、增强)都应作为技能贡献`channels``providers` 分支
其他一切(新能、操作系统兼容、硬件支持、增强功能)都应作为技能贡献。
基础系统保持最小化,每位用户可以定制自己的安装,而不必继承他们不想要的功能。
使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。
## 社区
问题或想法?欢迎[加入 Discord](https://discord.gg/VDdww8qS42)。
任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流
## 更新日志
破坏性变更见 [CHANGELOG.md](CHANGELOG.md),完整发布历史见文档站的 [full release history](https://docs.nanoclaw.dev/changelog)
破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
## 许可证
@@ -1,100 +0,0 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { getOutboundDb, initTestSessionDb } from './connection.js';
import {
clearContinuation,
getContinuation,
migrateLegacyContinuation,
setContinuation,
} from './session-state.js';
beforeEach(() => {
initTestSessionDb();
});
function seedLegacy(value: string): void {
getOutboundDb()
.prepare('INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.run('sdk_session_id', value, new Date().toISOString());
}
describe('session-state — per-provider continuations', () => {
test('set/get round-trip, case-insensitive provider key', () => {
setContinuation('claude', 'claude-conv-1');
expect(getContinuation('claude')).toBe('claude-conv-1');
expect(getContinuation('Claude')).toBe('claude-conv-1');
expect(getContinuation('CLAUDE')).toBe('claude-conv-1');
});
test('providers are isolated — switching reads the right slot', () => {
setContinuation('claude', 'claude-conv-1');
setContinuation('codex', 'codex-thread-xyz');
expect(getContinuation('claude')).toBe('claude-conv-1');
expect(getContinuation('codex')).toBe('codex-thread-xyz');
});
test('clearContinuation only affects the specified provider', () => {
setContinuation('claude', 'keep-me');
setContinuation('codex', 'drop-me');
clearContinuation('codex');
expect(getContinuation('claude')).toBe('keep-me');
expect(getContinuation('codex')).toBeUndefined();
});
test('unknown provider returns undefined', () => {
expect(getContinuation('never-used')).toBeUndefined();
});
});
describe('session-state — legacy migration', () => {
test('adopts legacy value into current provider when current is empty', () => {
seedLegacy('old-session-id');
const adopted = migrateLegacyContinuation('claude');
expect(adopted).toBe('old-session-id');
expect(getContinuation('claude')).toBe('old-session-id');
});
test('always deletes legacy row regardless of migration outcome', () => {
seedLegacy('old-session-id');
setContinuation('claude', 'existing');
migrateLegacyContinuation('claude');
// After migration the legacy key must be gone, whether or not it was adopted.
// A subsequent migration for a different provider must not see it.
const resultAfterSecondCall = migrateLegacyContinuation('codex');
expect(resultAfterSecondCall).toBeUndefined();
});
test('prefers existing current-provider slot over legacy', () => {
seedLegacy('legacy-value');
setContinuation('claude', 'claude-value');
const result = migrateLegacyContinuation('claude');
expect(result).toBe('claude-value');
expect(getContinuation('claude')).toBe('claude-value');
});
test('no legacy row — returns current provider value (possibly undefined)', () => {
expect(migrateLegacyContinuation('claude')).toBeUndefined();
setContinuation('codex', 'codex-value');
expect(migrateLegacyContinuation('codex')).toBe('codex-value');
});
test('migration is idempotent on a second call (legacy already gone)', () => {
seedLegacy('once');
const first = migrateLegacyContinuation('claude');
expect(first).toBe('once');
const second = migrateLegacyContinuation('claude');
expect(second).toBe('once');
});
});
+12 -50
View File
@@ -2,20 +2,12 @@
* Persistent key/value state for the container. Lives in outbound.db
* (container-owned, already scoped per channel/thread).
*
* Primary use: remember each provider's opaque continuation id so the
* agent's conversation resumes across container restarts. Keyed per
* provider because continuations are provider-private — a Claude
* conversation id means nothing to Codex and vice versa. Switching
* providers is therefore lossless: each provider's last thread stays
* on file and resumes cleanly if the user flips back.
* Primary use: remember the SDK session ID so the agent's conversation
* resumes across container restarts. Cleared by /clear.
*/
import { getOutboundDb } from './connection.js';
const LEGACY_KEY = 'sdk_session_id';
function continuationKey(providerName: string): string {
return `continuation:${providerName.toLowerCase()}`;
}
const SDK_SESSION_KEY = 'sdk_session_id';
function getValue(key: string): string | undefined {
const row = getOutboundDb()
@@ -26,7 +18,9 @@ function getValue(key: string): string | undefined {
function setValue(key: string, value: string): void {
getOutboundDb()
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
.prepare(
'INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)',
)
.run(key, value, new Date().toISOString());
}
@@ -34,46 +28,14 @@ function deleteValue(key: string): void {
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
}
/**
* One-time migration of the pre-per-provider continuation row.
*
* Before this was keyed per provider, continuations lived under the
* single key `sdk_session_id`. On container start, if that legacy row
* exists and the current provider has no continuation of its own, adopt
* the legacy value into the current provider's slot (best-guess — the
* legacy row was written by whatever provider ran last). The legacy row
* is always deleted so future provider flips never re-read a stale id
* through the wrong lens.
*
* Returns the continuation the caller should use at startup (either the
* current provider's existing value, the adopted legacy value, or
* undefined).
*/
export function migrateLegacyContinuation(providerName: string): string | undefined {
const legacy = getValue(LEGACY_KEY);
const currentKey = continuationKey(providerName);
const current = getValue(currentKey);
if (legacy === undefined) return current;
// Always drop the legacy row so no future provider reads it.
deleteValue(LEGACY_KEY);
// Prefer the current provider's own slot if one already exists.
if (current !== undefined) return current;
setValue(currentKey, legacy);
return legacy;
export function getStoredSessionId(): string | undefined {
return getValue(SDK_SESSION_KEY);
}
export function getContinuation(providerName: string): string | undefined {
return getValue(continuationKey(providerName));
export function setStoredSessionId(sessionId: string): void {
setValue(SDK_SESSION_KEY, sessionId);
}
export function setContinuation(providerName: string, id: string): void {
setValue(continuationKey(providerName), id);
}
export function clearContinuation(providerName: string): void {
deleteValue(continuationKey(providerName));
export function clearStoredSessionId(): void {
deleteValue(SDK_SESSION_KEY);
}
-1
View File
@@ -95,7 +95,6 @@ async function main(): Promise<void> {
await runPollLoop({
provider,
providerName,
cwd: CWD,
systemContext: { instructions },
});
@@ -98,7 +98,6 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
return Promise.race([
runPollLoop({
provider,
providerName: 'mock',
cwd: '/tmp',
}),
new Promise<void>((_, reject) => {
+8 -20
View File
@@ -2,11 +2,7 @@ import { findByName, getAllDestinations, type DestinationEntry } from './destina
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import {
clearContinuation,
migrateLegacyContinuation,
setContinuation,
} from './db/session-state.js';
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
@@ -23,12 +19,6 @@ function generateId(): string {
export interface PollLoopConfig {
provider: AgentProvider;
/**
* Name of the provider (e.g. "claude", "codex", "opencode"). Used to key
* the stored continuation per-provider so flipping providers doesn't
* resurrect a stale id from a different backend.
*/
providerName: string;
cwd: string;
systemContext?: {
instructions?: string;
@@ -49,9 +39,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Resume the agent's prior session from a previous container run if one
// was persisted. The continuation is opaque to the poll-loop — the
// provider decides how to use it (Claude resumes a .jsonl transcript,
// other providers may reload a thread ID, etc.). Keyed per-provider so
// a Codex thread id never gets handed to Claude or vice versa.
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
// other providers may reload a thread ID, etc.).
let continuation: string | undefined = getStoredSessionId();
if (continuation) {
log(`Resuming agent session ${continuation}`);
@@ -105,7 +94,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
log('Clearing session (resetting continuation)');
continuation = undefined;
clearContinuation(config.providerName);
clearStoredSessionId();
writeMessageOut({
id: generateId(),
kind: 'chat',
@@ -171,10 +160,10 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
const skippedSet = new Set(skipped);
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
try {
const result = await processQuery(query, routing, processingIds, config.providerName);
const result = await processQuery(query, routing, processingIds);
if (result.continuation && result.continuation !== continuation) {
continuation = result.continuation;
setContinuation(config.providerName, continuation);
setStoredSessionId(continuation);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -186,7 +175,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
if (continuation && config.provider.isSessionInvalid(err)) {
log(`Stale session detected (${continuation}) — clearing for next retry`);
continuation = undefined;
clearContinuation(config.providerName);
clearStoredSessionId();
}
// Write error response so the user knows something went wrong
@@ -249,7 +238,6 @@ async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
providerName: string,
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
@@ -300,7 +288,7 @@ async function processQuery(
// container died between `init` and `result`, the SDK session was
// effectively orphaned and the next message started a blank
// Claude session with no prior context.
setContinuation(providerName, event.continuation);
setStoredSessionId(event.continuation);
} else if (event.type === 'result') {
// A result — with or without text — means the turn is done. Mark
// the initial batch completed now so the host sweep doesn't see
@@ -226,12 +226,8 @@ function createPreCompactHook(assistantName?: string): HookCallback {
/**
* Claude Code auto-compacts context at this window (tokens). Kept here so
* the generic bootstrap doesn't need to know about Claude-specific env vars.
*
* Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to
* raise or lower the threshold without editing source — useful when running
* with a 1M-context model variant or when emergency-tuning a deployment.
*/
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000';
const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000';
/**
* Stale-session detection. Matches Claude Code's error text when a
+1 -7
View File
@@ -9,15 +9,9 @@
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$SCRIPT_DIR"
# Derive the image name from the project root so two NanoClaw installs on the
# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches
# setup/lib/install-slug.sh + src/install-slug.ts.
# shellcheck source=../setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
IMAGE_NAME="$(container_image_base)"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
+7 -19
View File
@@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG"
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
write_header
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
# skips re-printing the wordmark, keeping the flow visually continuous.
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
# and skip printing these again, so the flow stays visually continuous.
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
@@ -190,7 +190,7 @@ BOOTSTRAP_START=$(date +%s)
# One-line "why" that teaches a differentiator while the user waits.
printf '%s %s\n' "$(gray '│')" \
"$(dim "Small. Runs on your machine. Yours to modify.")"
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
spinner_start "$BOOTSTRAP_LABEL"
# Run in the background so we can tick elapsed time. Capture exit code via
@@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
else
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
@@ -245,19 +245,7 @@ fi
# wipe it.
export NANOCLAW_BOOTSTRAPPED=1
# setup.sh may have just installed pnpm via npm into a prefix that's not on
# our PATH (custom `npm config set prefix`, or the default prefix missing
# from the shell's login PATH). Its PATH mutation doesn't propagate back
# to us — so replay the same lookup here before the exec.
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
export PATH="$NPM_PREFIX/bin:$PATH"
fi
fi
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
# preamble so the flow continues visually from "Basics installed" straight
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
exec pnpm --silent run setup:auto -- "$@"
exec pnpm --silent run setup:auto
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.19",
"version": "2.0.0",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
@@ -24,7 +24,6 @@
"test:watch": "vitest"
},
"dependencies": {
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.3.1",
"better-sqlite3": "11.10.0",
-3
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@clack/core':
specifier: ^1.2.0
version: 1.2.0
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0
+7 -7
View File
@@ -1,22 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="135k tokens, 68% of context window">
<title>135k tokens, 68% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="43.8k tokens, 22% of context window">
<title>43.8k tokens, 22% 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"/>
</linearGradient>
<clipPath id="r">
<rect width="90" height="20" rx="3" fill="#fff"/>
<rect width="97" height="20" rx="3" fill="#fff"/>
</clipPath>
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#dfb317"/>
<rect width="90" height="20" fill="url(#s)"/>
<rect x="52" width="45" height="20" fill="#4c1"/>
<rect width="97" height="20" fill="url(#s)"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">135k</text>
<text x="71" y="14">135k</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">43.8k</text>
<text x="74" y="14">43.8k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+10 -1
View File
@@ -48,7 +48,6 @@ import { addMember } from '../src/modules/permissions/db/agent-group-members.js'
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
type Role = 'owner' | 'admin' | 'member';
@@ -138,6 +137,16 @@ function namespacedUserId(channel: string, raw: string): string {
return raw.includes(':') ? raw : `${channel}:${raw}`;
}
function namespacedPlatformId(channel: string, raw: string): string {
if (raw.startsWith(`${channel}:`)) return raw;
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
// <groupId>@g.us) store platform_id without a channel prefix. The '@' is
// the discriminator — telegram/discord platform_ids don't contain it
// except after a channel prefix, which is already handled above.
if (raw.includes('@')) return raw;
return `${channel}:${raw}`;
}
function generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
-14
View File
@@ -120,20 +120,6 @@ install_deps() {
|| true
fi
# `npm install -g` writes to npm's global prefix, which isn't always on the
# shell PATH — common on macOS where the user has `npm config set prefix
# ~/.npm-global` to avoid sudo, or on Linux where /usr/local/bin isn't in
# PATH. Discover the prefix and prepend its bin dir so `command -v pnpm`
# sees the new install.
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
local npm_prefix
npm_prefix=$(npm config get prefix 2>/dev/null)
if [ -n "$npm_prefix" ] && [ -x "$npm_prefix/bin/pnpm" ]; then
export PATH="$npm_prefix/bin:$PATH"
log "Prepended npm prefix bin to PATH: $npm_prefix/bin"
fi
fi
if ! command -v pnpm >/dev/null 2>&1; then
log "pnpm not on PATH after corepack + npm fallback"
return
+6 -14
View File
@@ -16,13 +16,7 @@ cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# 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"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
@@ -60,8 +54,8 @@ 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"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
@@ -110,15 +104,13 @@ mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|| true
;;
esac
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env bash
#
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
# and restart the service. Non-interactive — the Full Disk Access walkthrough
# (local mode) and Photon URL/key prompts (remote mode) live in
# setup/channels/imessage.ts. Creds come in via env vars:
# IMESSAGE_LOCAL 'true' | 'false' (required)
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
#
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# 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}
local mode=${IMESSAGE_LOCAL:-}
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-imessage] $*" >&2; }
# Validate creds based on mode.
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
exit 1
fi
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
exit 1
fi
if [ "$(uname -s)" != "Darwin" ]; then
emit_status failed "local mode requires macOS"
exit 1
fi
else
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
exit 1
fi
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
exit 1
fi
fi
need_install() {
[ ! -f src/channels/imessage.ts ] && return 0
! grep -q "^import './imessage.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 from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
# Append self-registration import if missing.
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
echo "import './imessage.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
remove_env() {
local key=$1
if grep -q "^${key}=" .env 2>/dev/null; then
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
fi
}
# Write the canonical keys for the chosen mode, strip the opposite mode's
# keys so stale values can't confuse the adapter's factory.
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
if [ "$IMESSAGE_LOCAL" = "true" ]; then
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
remove_env IMESSAGE_SERVER_URL
remove_env IMESSAGE_API_KEY
else
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
remove_env IMESSAGE_ENABLED
fi
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the creds…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the adapter a moment to open chat.db (local) or handshake with
# Photon (remote) before emitting success.
sleep 3
emit_status success
-95
View File
@@ -1,95 +0,0 @@
#!/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
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env bash
#
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
# .env + data/env/env, and restart the service. Non-interactive — the
# operator-facing app creation walkthrough + credential paste live in
# setup/channels/slack.ts. Credentials come in via env vars:
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
#
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
# progress messages go 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-slack/SKILL.md.
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# 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_SLACK ==="
echo "STATUS: ${status}"
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
[ -n "$error" ] && echo "ERROR: ${error}"
echo "=== END ==="
}
log() { echo "[add-slack] $*" >&2; }
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
emit_status failed "SLACK_BOT_TOKEN env var not set"
exit 1
fi
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
emit_status failed "SLACK_SIGNING_SECRET env var not set"
exit 1
fi
need_install() {
[ ! -f src/channels/slack.ts ] && return 0
! grep -q "^import './slack.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 from ${CHANNELS_BRANCH}"
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
# Append self-registration import if missing.
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
echo "import './slack.js';" >> src/channels/index.ts
fi
log "Installing ${ADAPTER_VERSION}"
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
exit 1
}
log "Building…"
pnpm run build >&2 2>/dev/null || {
emit_status failed "pnpm run build failed"
exit 1
}
else
log "Adapter files already installed — skipping install phase."
fi
# Persist credentials. auto.ts validates via auth.test before this point, so
# bad values here would be an internal bug rather than operator input.
touch .env
upsert_env() {
local key=$1 value=$2
if grep -q "^${key}=" .env; then
awk -v k="$key" -v v="$value" \
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
.env > .env.tmp && mv .env.tmp .env
else
echo "${key}=${value}" >> .env
fi
}
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
# Container reads from data/env/env (the host mounts it).
mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|| true
;;
esac
# Give the Slack adapter a moment to finish starting the webhook listener
# before emitting success.
sleep 3
emit_status success
+6 -14
View File
@@ -19,13 +19,7 @@ cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-teams/SKILL.md.
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# 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"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
@@ -67,8 +61,8 @@ 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"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
@@ -119,15 +113,13 @@ mkdir -p data/env
cp .env data/env/env
log "Restarting service so the new adapter picks up the credentials…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|| true
;;
esac
+6 -14
View File
@@ -16,13 +16,7 @@ cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# 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"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
@@ -59,8 +53,8 @@ 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"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
@@ -144,15 +138,13 @@ cp .env data/env/env
# non-interactive install.
log "Restarting service so the new adapter picks up the token…"
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
;;
Linux)
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
systemctl --user restart nanoclaw >&2 2>/dev/null \
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|| true
;;
esac
+3 -9
View File
@@ -20,13 +20,7 @@ BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
QRCODE_VERSION="qrcode@1.5.4"
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
PINO_VERSION="pino@9.6.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
# 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"
CHANNELS_BRANCH="origin/channels"
emit_status() {
local status=$1 error=${2:-}
@@ -53,8 +47,8 @@ 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"
git fetch origin channels >&2 2>/dev/null || {
emit_status failed "git fetch origin channels failed"
exit 1
}
+130 -603
View File
File diff suppressed because it is too large Load Diff
+7 -94
View File
@@ -27,11 +27,9 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, brandBody, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
@@ -48,14 +46,9 @@ interface AppInfo {
}
export async function runDiscordChannel(displayName: string): Promise<void> {
const hasBot = await askHasBotToken();
if (!hasBot) {
if (!(await askHasBotToken())) {
await walkThroughBotCreation();
}
// Even users who said "yes" often can't find the token on demand — the
// Dev Portal resets it if you don't store it, and people forget which
// app it belongs to. A quick reminder before the paste prompt is cheap.
showTokenLocationReminder(hasBot);
const token = await collectDiscordToken();
const botUsername = await validateDiscordToken(token);
@@ -63,13 +56,6 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
const ownerUserId = await resolveOwnerUserId(app.owner);
// Before inviting: do they have a server to invite into? Walkthrough if
// not — a fresh Discord account without a server makes the invite page a
// dead end.
if (!(await askHasDiscordServer())) {
await walkThroughServerCreation();
}
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
@@ -143,7 +129,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
async function askHasBotToken(): Promise<boolean> {
const answer = ensureAnswer(
await brightSelect({
await p.select({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
@@ -156,7 +142,7 @@ async function askHasBotToken(): Promise<boolean> {
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
note(
p.note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
@@ -179,83 +165,10 @@ async function walkThroughBotCreation(): Promise<void> {
);
}
function showTokenLocationReminder(hasExistingBot: boolean): void {
// If we just walked them through creating a bot, they're staring at the
// token. If they came in with an existing one, they may still need a nudge
// to find it — tokens in the Dev Portal aren't visible after first reveal,
// and "Reset Token" issues a new one.
if (hasExistingBot) {
note(
[
"Where to find your bot token:",
'',
' 1. discord.com/developers/applications → pick your app',
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
' 3. Copy the new token',
].join('\n'),
'Reminder',
);
}
}
async function askHasDiscordServer(): Promise<boolean> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you have a Discord server you can add the bot to?',
options: [
{ value: 'yes', label: 'Yes, I have a server' },
{ value: 'no', label: "No, walk me through creating one" },
],
}),
);
setupLog.userInput('discord_has_server', String(answer));
return answer === 'yes';
}
async function walkThroughServerCreation(): Promise<void> {
// Discord doesn't have a stable deep-link for "create server" so we open
// the web client and rely on the + button being visible. The steps below
// are the same whether they're in the desktop app or the browser.
const url = 'https://discord.com/channels/@me';
note(
[
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
'',
' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")',
'',
k.dim(url),
].join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
ensureAnswer(
await p.confirm({
message: "Server created?",
initialValue: true,
}),
);
}
async function collectDiscordToken(): Promise<string> {
const existing = process.env.DISCORD_BOT_TOKEN?.trim();
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('discord_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
@@ -399,14 +312,14 @@ async function resolveOwnerUserId(
}
} else {
p.log.info(
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
note(
p.note(
[
"To get your Discord user ID:",
'',
@@ -444,7 +357,7 @@ async function promptInviteBot(
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
note(
p.note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
@@ -520,7 +433,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
-328
View File
@@ -1,328 +0,0 @@
/**
* iMessage channel flow for setup:auto.
*
* `runIMessageChannel(displayName)` covers both deployment modes:
*
* Local (macOS): the bot runs on this Mac and talks via the signed-in
* iMessage account. Reading chat.db needs Full Disk Access granted to
* the Node binary we open the directory for them so they can drag
* the `node` file into System Settings.
*
* Remote (Photon API): the bot talks to a separate server (Photon)
* that owns an iMessage account on another Mac. Used when this host
* is Linux, or when the operator wants to keep their daily-driver
* Mac's chat history out of the loop.
*
* Flow:
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
* Remote: prompt for Photon server URL + API key
* 3. Ask for the phone or email the operator messages from this is
* the platform-id for first-agent wiring
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
* 5. Wire the agent via scripts/init-first-agent.ts the welcome
* iMessage goes out through the normal delivery path
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import { execSync } from 'child_process';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
type Mode = 'local' | 'remote';
interface RemoteCreds {
serverUrl: string;
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<void> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
if (!isMac) {
await fail(
'imessage',
"Local iMessage mode only works on macOS.",
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
);
}
await walkThroughFullDiskAccess();
} else {
remoteCreds = await collectRemoteCreds();
}
const handle = await askOperatorHandle();
const install = await runQuietChild(
'imessage-install',
'bash',
['setup/add-imessage.sh'],
{
running:
mode === 'local'
? "Connecting the iMessage adapter to this Mac…"
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}`,
done: 'iMessage adapter installed.',
},
{
env:
mode === 'local'
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
: {
IMESSAGE_LOCAL: 'false',
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
IMESSAGE_API_KEY: remoteCreds!.apiKey,
},
extraFields: { MODE: mode },
},
);
if (!install.ok) {
await fail(
'imessage-install',
"Couldn't install the iMessage adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const role = await askOperatorRole('iMessage');
setupLog.userInput('imessage_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'imessage',
'--user-id', handle,
'--platform-id', handle,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to iMessage…`,
done: `${agentName} is ready. Check iMessage for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'imessage',
AGENT_NAME: agentName,
PLATFORM_ID: handle,
MODE: mode,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
);
}
}
async function askMode(isMac: boolean): Promise<Mode> {
const choice = ensureAnswer(
await brightSelect<Mode>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
options: isMac
? [
{
value: 'local',
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
],
}),
);
setupLog.userInput('imessage_mode', String(choice));
return choice;
}
/**
* Grant Full Disk Access to the Node binary the host runs under without
* it, the adapter can't read chat.db and inbound messages never arrive.
* Opening the containing directory in Finder makes the drag-and-drop
* target obvious; falling back to printing the path keeps us working in
* SSH/headless contexts where `open` is a no-op.
*/
async function walkThroughFullDiskAccess(): Promise<void> {
let nodePath = process.execPath;
try {
// `which node` picks up the user's shell-resolved node, which may differ
// from process.execPath (e.g. they launched setup under a different
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
const which = execSync('which node', { encoding: 'utf-8' }).trim();
if (which) nodePath = which;
} catch {
// fall back to process.execPath
}
const nodeDir = path.dirname(nodePath);
note(
wrapForGutter(
[
`iMessage needs Full Disk Access granted to the Node binary:`,
'',
` ${nodePath}`,
'',
' 1. System Settings → Privacy & Security → Full Disk Access',
` 2. Click +, then drag the "node" file from the Finder window`,
' we just opened for you',
' 3. Toggle it on, then come back here',
].join('\n'),
6,
),
'Grant Full Disk Access',
);
try {
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
} catch {
// No Finder (SSH/headless) — user sees the path in the note above.
}
ensureAnswer(
await p.confirm({
message: "Granted Full Disk Access?",
initialValue: true,
}),
);
setupLog.userInput('imessage_fda_confirmed', 'true');
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim();
const existingKey = process.env.IMESSAGE_API_KEY?.trim();
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('imessage_remote_creds', 'reused-existing');
return { serverUrl: existingUrl, apiKey: existingKey };
}
}
note(
[
"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',
' 2. Copy the server URL and API key from your Photon dashboard',
].join('\n'),
'Remote iMessage via Photon',
);
const urlAnswer = ensureAnswer(
await p.text({
message: 'Photon server URL',
placeholder: 'https://photon.example.com',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'URL is required';
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
return undefined;
},
}),
);
const serverUrl = (urlAnswer as string).trim();
const keyAnswer = ensureAnswer(
await p.password({
message: 'Photon API key',
clearOnError: true,
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
}),
);
const apiKey = (keyAnswer as string).trim();
setupLog.userInput('imessage_server_url', serverUrl);
setupLog.userInput(
'imessage_api_key',
`${apiKey.slice(0, 4)}${apiKey.slice(-4)}`,
);
return { serverUrl, apiKey };
}
async function askOperatorHandle(): Promise<string> {
note(
[
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: full E.164, e.g. +15551234567'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',
);
const answer = ensureAnswer(
await p.text({
message: 'Phone number or email',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
const isPhone = /^\+\d{8,15}$/.test(t);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
if (!isPhone && !isEmail) {
return "Use a +E.164 phone number or an email address";
}
return undefined;
},
}),
);
const handle = (answer as string).trim();
setupLog.userInput('imessage_handle', handle);
return handle;
}
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 ${accentGreen('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;
}
-358
View File
@@ -1,358 +0,0 @@
/**
* 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';
import { accentGreen, note } from '../lib/theme.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') {
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 {
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 ${accentGreen('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;
}
-424
View File
@@ -1,424 +0,0 @@
/**
* Slack channel flow for setup:auto.
*
* `runSlackChannel(displayName)` owns the full branch from creating a
* Slack app through the welcome DM:
*
* 1. Walk through creating a Slack app (api.slack.com/apps) scopes,
* event subscriptions, and signing secret
* 2. Paste the bot token + signing secret (clack password prompts)
* 3. Validate via auth.test resolves workspace + bot identity
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
* 5. Ask for the operator's Slack user ID
* 6. conversations.open to get the DM channel ID
* 7. Ask for the messaging-agent name (defaulting to "Nano")
* 8. Wire the agent via scripts/init-first-agent.ts
*
* The welcome DM is sent via outbound delivery (chat.postMessage), which
* works without Event Subscriptions being configured. The user sees the
* greeting in Slack immediately; inbound replies require webhooks, so the
* post-install note covers that.
*
* All output obeys the three-level contract. See docs/setup-flow.md.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
const DEFAULT_AGENT_NAME = 'Nano';
interface WorkspaceInfo {
teamName: string;
teamId: string;
botName: string;
botUserId: string;
}
export async function runSlackChannel(displayName: string): Promise<void> {
await walkThroughAppCreation();
const token = await collectBotToken();
const signingSecret = await collectSigningSecret();
const info = await validateSlackToken(token);
const install = await runQuietChild(
'slack-install',
'bash',
['setup/add-slack.sh'],
{
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
done: 'Slack adapter installed.',
},
{
env: {
SLACK_BOT_TOKEN: token,
SLACK_SIGNING_SECRET: signingSecret,
},
extraFields: {
BOT_NAME: info.botName,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
},
},
);
if (!install.ok) {
await fail(
'slack-install',
"Couldn't connect Slack.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const ownerUserId = await collectSlackUserId();
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `slack:${dmChannelId}`;
const role = await askOperatorRole('Slack');
setupLog.userInput('slack_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'slack',
'--user-id', `slack:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Wiring ${agentName} to your Slack DMs…`,
done: 'Agent wired.',
},
{
extraFields: {
CHANNEL: 'slack',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/init-first-agent` in Claude Code.',
);
}
showPostInstallChecklist(info);
}
async function walkThroughAppCreation(): Promise<void> {
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, im:write, channels:history, groups:history,',
' im:history, channels:read, groups:read, users:read,',
' reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
'Create a Slack app',
);
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
ensureAnswer(
await p.confirm({
message: 'Got your bot token and signing secret?',
initialValue: true,
}),
);
}
async function collectBotToken(): Promise<string> {
const existing = process.env.SLACK_BOT_TOKEN?.trim();
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_bot_token', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack bot token',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
if (t.length < 24) return "That's shorter than a real Slack bot token";
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'slack_bot_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function collectSigningSecret(): Promise<string> {
const existing = process.env.SLACK_SIGNING_SECRET?.trim();
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
initialValue: true,
}));
if (reuse) {
setupLog.userInput('slack_signing_secret', 'reused-existing');
return existing;
}
}
const answer = ensureAnswer(
await p.password({
message: 'Paste your Slack signing secret',
clearOnError: true,
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Signing secret is required';
// Slack signing secrets are 32-char hex strings, but newer apps
// sometimes emit longer variants — leniently require hex only.
if (!/^[a-f0-9]{16,}$/i.test(t)) {
return 'Signing secrets are a string of hex characters';
}
return undefined;
},
}),
);
const secret = (answer as string).trim();
setupLog.userInput(
'slack_signing_secret',
`${secret.slice(0, 4)}${secret.slice(-4)}`,
);
return secret;
}
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${SLACK_API}/auth.test`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const data = (await res.json()) as {
ok?: boolean;
team?: string;
team_id?: string;
user?: string;
user_id?: string;
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
teamId: data.team_id ?? '',
botName: data.user,
botUserId: data.user_id ?? '',
};
setupLog.step('slack-validate', 'success', Date.now() - start, {
BOT_NAME: info.botName,
BOT_USER_ID: info.botUserId,
TEAM_NAME: info.teamName,
TEAM_ID: info.teamId,
});
return info;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Slack didn't accept that token: ${reason}`, 1);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'slack-validate',
"Slack didn't accept that token.",
reason === 'invalid_auth' || reason === 'token_revoked'
? 'Copy the token again from OAuth & Permissions and retry setup.'
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-validate',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
async function collectSlackUserId(): Promise<string> {
note(
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (top right)',
' 2. Click "Profile"',
' 3. Click the three dots (⋯) → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Slack member ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Member ID is required';
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
return "That doesn't look like a Slack member ID (starts with U)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('slack_user_id', id);
return id;
}
async function openDmChannel(token: string, userId: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Opening a DM channel…');
try {
const res = await fetch(`${SLACK_API}/conversations.open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ users: userId }),
});
const data = (await res.json()) as {
ok?: boolean;
channel?: { id?: string };
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
return data.channel.id;
}
const reason = data.error ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
if (reason === 'missing_scope') {
await fail(
'slack-open-dm',
"Your Slack app is missing the im:write scope.",
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
);
}
await fail(
'slack-open-dm',
"Couldn't open a DM channel with you.",
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'slack-open-dm',
"Couldn't reach Slack.",
'Check your internet connection and retry setup.',
);
}
}
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 ${accentGreen('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;
}
function showPostInstallChecklist(info: WorkspaceInfo): void {
note(
wrapForGutter(
[
`Your agent is wired to Slack and a welcome DM is on its way.`,
`To receive replies, Slack needs a public URL for delivering events:`,
'',
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
'',
' 2. In your Slack app → Event Subscriptions:',
' • Toggle "Enable Events" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Subscribe to bot events: message.channels, message.groups,',
' message.im, app_mention',
' • Save Changes',
'',
' 3. In your Slack app → Interactivity & Shortcuts:',
' • Toggle "Interactivity" on',
` • Request URL: https://<your-public-host>/webhook/slack`,
' • Save Changes',
'',
' 4. Slack will prompt you to reinstall the app — do it to apply',
' the new settings',
].join('\n'),
6,
),
'Finish setting up Slack',
);
}
+13 -38
View File
@@ -30,7 +30,6 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import {
isHelpEscape,
@@ -40,7 +39,6 @@ import {
} from '../lib/claude-handoff.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
const CHANNEL = 'teams';
@@ -60,28 +58,6 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
const collected: Collected = {};
const completed: string[] = [];
const existingAppId = process.env.TEAMS_APP_ID?.trim();
const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim();
if (existingAppId && existingPassword) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
initialValue: true,
}));
if (reuse) {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim();
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);
completed.push('Adapter installed and service restarted (reused existing credentials).');
await finishWithHandoff(collected, completed);
return;
}
}
printIntro();
await confirmPrereqs({ collected, completed });
@@ -102,7 +78,7 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
// ─── step: intro / prereqs ──────────────────────────────────────────────
function printIntro(): void {
note(
p.note(
[
'Setting up Teams is more involved than the other channels — about',
'7 steps across the Azure portal and Teams admin.',
@@ -116,7 +92,7 @@ function printIntro(): void {
}
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
note(
p.note(
[
'Before we start, confirm you have:',
'',
@@ -142,7 +118,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[]
// ─── step: public URL ──────────────────────────────────────────────────
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
note(
p.note(
[
"Azure Bot Service delivers messages to an HTTPS endpoint you",
"control. The endpoint needs to reach this machine's webhook",
@@ -198,7 +174,7 @@ async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
note(
p.note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
'2. Name it (e.g. "NanoClaw")',
@@ -247,7 +223,7 @@ async function askAppType(args: {
}): Promise<'SingleTenant' | 'MultiTenant'> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
await p.select({
message: 'Which account type did you pick?',
options: [
{
@@ -282,7 +258,7 @@ async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
note(
p.note(
[
`1. In your app registration, open "Certificates & secrets"`,
'2. Click "New client secret"',
@@ -299,7 +275,6 @@ async function stepClientSecret(args: {
const answer = ensureAnswer(
await p.password({
message: 'Paste the client secret Value',
clearOnError: true,
validate: validateWithHelpEscape((v) => {
const t = (v ?? '').trim();
if (!t) return 'Required';
@@ -352,7 +327,7 @@ async function stepAzureBot(args: {
` --appid ${args.collected.appId} \\\n` +
` ${tenantFlag}--endpoint "${endpoint}"`;
note(
p.note(
[
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
'',
@@ -389,7 +364,7 @@ async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
note(
p.note(
[
'1. Open your Azure Bot resource → Channels',
'2. Click Microsoft Teams → Accept terms → Apply',
@@ -459,7 +434,7 @@ async function stepSideload(args: {
completed: string[];
zipPath: string;
}): Promise<void> {
note(
p.note(
[
'1. Open Microsoft Teams',
'2. Go to Apps → Manage your apps → Upload an app',
@@ -525,7 +500,7 @@ async function finishWithHandoff(
collected: Collected,
completed: string[],
): Promise<void> {
note(
p.note(
[
'The Teams adapter is live and the service is running.',
'',
@@ -540,7 +515,7 @@ async function finishWithHandoff(
);
const choice = ensureAnswer(
await brightSelect({
await p.select({
message: 'Ready to finish?',
options: [
{
@@ -554,7 +529,7 @@ async function finishWithHandoff(
);
if (choice === 'self') {
note(
p.note(
[
' 1. Find your bot in Teams (search by name, or via the sideloaded',
' app) and send it a message ("hi" is fine)',
@@ -596,7 +571,7 @@ async function stepGate(args: {
}): Promise<void> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
await p.select({
message: 'How did that go?',
options: [
{ value: 'done', label: "Done — let's continue" },
+7 -20
View File
@@ -33,7 +33,7 @@ import {
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js';
import { brandBold } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -47,7 +47,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
// installed, or the bot's web profile if not. tg://resolve?domain= is
// more direct but silently fails when the scheme isn't registered.
const botUrl = `https://t.me/${botUsername}`;
note(
p.note(
[
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'',
@@ -132,19 +132,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
}
async function collectTelegramToken(): Promise<string> {
const existing = process.env.TELEGRAM_BOT_TOKEN?.trim();
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
initialValue: true,
}));
if (reuse) {
setupLog.userInput('telegram_token', 'reused-existing');
return existing;
}
}
note(
p.note(
[
"Your assistant talks to you through a Telegram bot you create.",
"Here's how:",
@@ -162,7 +150,6 @@ async function collectTelegramToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
clearOnError: true,
validate: (v) => {
if (!v || !v.trim()) return "Token is required";
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
@@ -253,12 +240,12 @@ async function runPairTelegram(): Promise<
} else {
stopSpinner("Old code expired. Here's a fresh one.");
}
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start('Waiting for you to send the code from Telegram…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start(fitToWidth('Waiting for the correct code…', ''));
s.start('Waiting for the correct code…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
@@ -304,7 +291,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+10 -13
View File
@@ -33,8 +33,6 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
type Block,
type StepResult,
@@ -46,7 +44,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js';
import { brandBold } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
@@ -150,7 +148,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
async function askAuthMethod(): Promise<AuthMethod> {
const choice = ensureAnswer(
await brightSelect({
await p.select({
message: 'How would you like to authenticate with WhatsApp?',
options: [
{
@@ -171,7 +169,7 @@ async function askAuthMethod(): Promise<AuthMethod> {
}
async function askPhoneNumber(): Promise<string> {
note(
p.note(
[
"Enter your phone number the way WhatsApp expects it:",
'',
@@ -249,7 +247,7 @@ async function runWhatsAppAuth(
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
const code = block.fields.CODE ?? '????';
stopSpinner('Your pairing code is ready.');
note(formatPairingCard(code), 'Pairing code');
p.note(formatPairingCard(code), 'Pairing code');
s.start('Waiting for you to enter the code…');
spinnerActive = true;
} else if (block.type === 'WHATSAPP_AUTH') {
@@ -267,7 +265,7 @@ async function runWhatsAppAuth(
if (spinnerActive) {
stopSpinner('WhatsApp linked.');
} else {
p.log.success(brandBody('WhatsApp linked.'));
p.log.success('WhatsApp linked.');
}
} else if (status === 'failed') {
if (qrLinesPrinted > 0) {
@@ -360,18 +358,17 @@ async function restartService(): Promise<void> {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync(
'systemctl',
['--user', 'restart', unit],
['--user', 'restart', 'nanoclaw'],
{ stdio: 'ignore' },
);
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], {
spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], {
stdio: 'ignore',
});
}
@@ -395,7 +392,7 @@ async function restartService(): Promise<void> {
}
async function askChatPhone(authedPhone: string): Promise<string> {
note(
p.note(
[
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
'',
@@ -462,7 +459,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: `What should your ${accentGreen('assistant')} be called?`,
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
+9 -22
View File
@@ -7,7 +7,6 @@ import path from 'path';
import { setTimeout as sleep } from 'timers/promises';
import { log } from '../src/log.js';
import { getDefaultContainerImage } from '../src/install-slug.js';
import { commandExists, getPlatform } from './platform.js';
import { emitStatus } from './status.js';
@@ -82,7 +81,7 @@ function parseArgs(args: string[]): { runtime: string } {
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { runtime } = parseArgs(args);
const image = getDefaultContainerImage(projectRoot);
const image = 'nanoclaw-agent:latest';
const logFile = path.join(projectRoot, 'logs', 'setup.log');
if (runtime !== 'docker') {
@@ -175,31 +174,19 @@ export async function run(args: string[]): Promise<void> {
// .env is optional; absence is normal on a fresh checkout
}
// Build — stdio inherit so the parent setup runner can tail docker's
// per-step output and render it in a rolling window. Previously we used
// execSync which buffered everything; users couldn't tell whether a
// 310 minute build was making progress or hung.
// Build
let buildOk = false;
log.info('Building container', { runtime, buildArgs });
const buildRes = spawnSync(
buildCmd.split(' ')[0],
[
...buildCmd.split(' ').slice(1),
...buildArgs.flatMap((a) => a.split(' ')),
'-t',
image,
'.',
],
{
try {
const argsStr = buildArgs.length > 0 ? ' ' + buildArgs.join(' ') : '';
execSync(`${buildCmd}${argsStr} -t ${image} .`, {
cwd: path.join(projectRoot, 'container'),
stdio: 'inherit',
},
);
if (buildRes.status === 0) {
stdio: ['ignore', 'pipe', 'pipe'],
});
buildOk = true;
log.info('Container build succeeded');
} else {
log.error('Container build failed', { exitCode: buildRes.status });
} catch (err) {
log.error('Container build failed', { err });
}
// Test
+45 -52
View File
@@ -1,7 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
@@ -19,63 +17,58 @@ describe('environment detection', () => {
});
});
describe('detectRegisteredGroups', () => {
let tempDir: string;
describe('registered groups DB query', () => {
let db: Database.Database;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
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
)`);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: 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);
});
it('returns false when no registration state exists', async () => {
const { detectRegisteredGroups } = await import('./environment.js');
expect(detectRegisteredGroups(tempDir)).toBe(false);
});
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);
});
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');
it('returns correct count after inserts', () => {
db.prepare(
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
).run('mga-1', 'mg-1', 'ag-1');
db.close();
`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,
);
expect(detectRegisteredGroups(tempDir)).toBe(true);
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,
);
const row = db
.prepare('SELECT COUNT(*) as count FROM registered_groups')
.get() as { count: number };
expect(row.count).toBe(2);
});
});
+21 -44
View File
@@ -7,53 +7,11 @@ 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 detectExistingDisplayName(projectRoot: string): string | null {
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return null;
let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`)
.get() as { display_name: string } | undefined;
return row?.display_name?.trim() || null;
} catch {
return null;
} finally {
db?.close();
}
}
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();
@@ -81,7 +39,26 @@ 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;
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
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
}
}
}
// Check for existing OpenClaw installation
const homedir = (await import('os')).homedir();
-1
View File
@@ -16,7 +16,6 @@ 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'),
-30
View File
@@ -1,30 +0,0 @@
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');
});
});
+4 -20
View File
@@ -13,21 +13,7 @@
*/
import { spawn } from 'child_process';
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 type PingResult = 'ok' | 'no_reply' | 'socket_error';
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
return new Promise((resolve) => {
@@ -35,7 +21,6 @@ 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;
@@ -47,14 +32,13 @@ 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);
resolve(classifyPingResult(code, stdout, stderr));
if (code === 2) resolve('socket_error');
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
else resolve('no_reply');
});
child.on('error', () => {
if (settled) return;
-122
View File
@@ -1,122 +0,0 @@
/**
* A drop-in alternative to `@clack/prompts`' `p.select` that renders
* unselected option labels at full brightness instead of dim gray.
*
* Why this exists: clack styles inactive options with `styleText("dim", …)`
* inline in its render function. There is no configuration hook to override
* it, and the feedback was clear non-selected options in the setup flow
* were "too light, need stronger font weight". So we write our own render
* against `@clack/core`'s `SelectPrompt`, keeping the visual shell of clack
* (diamond header, `` gutter, cyan in-progress / green on submit) but
* leaving the label un-dimmed. Only the bullet and hint stay dim, which
* gives enough contrast for the cursor to read as "active".
*
* Not a full clack-feature clone: no search, no maxItems paging, no custom
* bar characters. Just the bits the NanoClaw setup menus actually use.
*/
import { SelectPrompt } from '@clack/core';
import { isCancel } from '@clack/prompts';
import { styleText } from 'node:util';
import { brandBody } from './theme.js';
const BULLET_ACTIVE = '●';
const BULLET_INACTIVE = '○';
const BAR = '│';
const CAP_BOT = '└';
const DIAMOND = '◆';
const DIAMOND_CANCEL = '■';
const DIAMOND_SUBMIT = '◇';
type PromptState = 'initial' | 'active' | 'error' | 'cancel' | 'submit';
function stateColor(state: PromptState): 'cyan' | 'green' | 'red' | 'yellow' {
switch (state) {
case 'submit':
return 'green';
case 'cancel':
return 'red';
case 'error':
return 'yellow';
default:
return 'cyan';
}
}
function headerIcon(state: PromptState): string {
switch (state) {
case 'submit':
return styleText('green', DIAMOND_SUBMIT);
case 'cancel':
return styleText('red', DIAMOND_CANCEL);
default:
return styleText('cyan', DIAMOND);
}
}
export interface BrightSelectOption<T> {
value: T;
label?: string;
hint?: string;
}
export interface BrightSelectOptions<T> {
message: string;
options: BrightSelectOption<T>[];
initialValue?: T;
}
/**
* Matches the return shape of `p.select` resolves to the selected value
* on submit, or to clack's cancel symbol on Ctrl-C / Esc. Callers pass
* the result through `ensureAnswer(...)` the same way they do for
* `p.select`.
*/
export function brightSelect<T>(
opts: BrightSelectOptions<T>,
): Promise<T | symbol> {
const { message, options, initialValue } = opts;
return new SelectPrompt({
options: options as Array<{ value: T; label?: string; hint?: string }>,
initialValue,
render() {
const st = this.state as PromptState;
const color = stateColor(st);
const bar = styleText(color, BAR);
const grayBar = styleText('gray', BAR);
const lines: string[] = [];
lines.push(grayBar);
lines.push(`${headerIcon(st)} ${message}`);
if (st === 'submit' || st === 'cancel') {
const selected =
options.find((o) => o.value === this.value)?.label ??
String(this.value ?? '');
const shown =
st === 'cancel'
? styleText(['strikethrough', 'dim'], selected)
: styleText('dim', brandBody(selected));
lines.push(`${grayBar} ${shown}`);
return lines.join('\n');
}
const cursor = (this as unknown as { cursor: number }).cursor;
options.forEach((opt, idx) => {
const label = opt.label ?? String(opt.value);
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
const isActive = idx === cursor;
const marker = isActive
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
const shownLabel = isActive ? brandBody(label) : label;
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
});
lines.push(styleText(color, CAP_BOT));
return lines.join('\n');
},
}).prompt() as Promise<T | symbol>;
}
export { isCancel };
-38
View File
@@ -1,38 +0,0 @@
# channels-remote.sh — resolve the git remote that carries the `channels`
# branch. Source this file and call `resolve_channels_remote`; echoes the
# remote name (e.g. `origin` or `upstream`).
#
# Typical fork setups keep the upstream nanoclaw repo under a remote named
# `upstream`, with `origin` pointing at the user's fork. The channels branch
# only lives upstream, so a hardcoded `git fetch origin channels` fails for
# forks. This helper walks `git remote -v`, picks the remote whose URL points
# at qwibitai/nanoclaw, and prints its name.
#
# Fallback: if no existing remote matches, add `upstream` pointing at
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
# explicit upstream configured working on the first try.
#
# Explicit override: set NANOCLAW_CHANNELS_REMOTE=<name> to skip detection.
resolve_channels_remote() {
if [ -n "${NANOCLAW_CHANNELS_REMOTE:-}" ]; then
printf '%s' "$NANOCLAW_CHANNELS_REMOTE"
return 0
fi
local remote url
while IFS=$'\t' read -r remote url; do
case "$url" in
*qwibitai/nanoclaw*)
printf '%s' "$remote"
return 0
;;
esac
done < <(git remote -v 2>/dev/null | awk '$3 == "(fetch)" { print $1"\t"$2 }')
# No matching remote — add `upstream` and use it. Silent on failure so
# callers see the eventual `git fetch` error rather than a cryptic
# remote-add failure.
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
printf '%s' "upstream"
}
+27 -146
View File
@@ -2,11 +2,8 @@
* Offer Claude-assisted debugging when a setup step fails.
*
* Flow:
* 1. Check `claude` is on PATH if not, offer to install it via
* setup/install-claude.sh. Then check auth via `claude auth status`
* if not signed in, offer to run `claude setup-token` (browser
* OAuth with code-paste fallback for headless/remote systems).
* If either is declined or fails, silently skip.
* 1. Check `claude` is on PATH and has a working credential. If not,
* silently skip pre-auth failures can't use this path.
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
* 3. Build a minimal prompt: the one-paragraph situation, the failing
* step's name/message/hint, and a short list of *file references*
@@ -19,16 +16,15 @@
*
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
*/
import { execSync, spawn, spawnSync } from 'child_process';
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { ensureAnswer } from './runner.js';
import { brandBody, fitToWidth, note } from './theme.js';
import { fitToWidth } from './theme.js';
export interface AssistContext {
stepName: string;
@@ -68,10 +64,6 @@ const STEP_FILES: Record<string, string[]> = {
'telegram-validate': ['setup/channels/telegram.ts'],
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
'init-first-agent': [
@@ -94,7 +86,7 @@ export async function offerClaudeAssist(
projectRoot: string = process.cwd(),
): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!(await ensureClaudeReady(projectRoot))) return false;
if (!isClaudeUsable()) return false;
const want = ensureAnswer(
await p.confirm({
@@ -110,12 +102,12 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response);
if (!parsed) {
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
p.log.warn("Claude responded but I couldn't parse a command out of it.");
p.log.message(k.dim(response.trim().slice(0, 500)));
return false;
}
note(
p.note(
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
"Claude's suggestion",
);
@@ -123,7 +115,7 @@ export async function offerClaudeAssist(
const run = ensureAnswer(
await p.confirm({
message: 'Run this command? (you can edit it before executing)',
initialValue: true,
initialValue: false,
}),
);
if (!run) return false;
@@ -132,101 +124,15 @@ export async function offerClaudeAssist(
return true;
}
function isClaudeInstalled(): boolean {
function isClaudeUsable(): boolean {
try {
execSync('command -v claude', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function isClaudeAuthenticated(): boolean {
try {
execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 });
return true;
} catch {
return false;
}
}
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
if (!isClaudeInstalled()) {
const install = ensureAnswer(
await p.confirm({
message:
'Claude CLI is needed to diagnose this. Install it now?',
initialValue: true,
}),
);
if (!install) return false;
const code = spawnSync('bash', ['setup/install-claude.sh'], {
cwd: projectRoot,
stdio: 'inherit',
}).status;
if (code !== 0 || !isClaudeInstalled()) {
p.log.error("Couldn't install the Claude CLI.");
return false;
}
p.log.success('Claude CLI installed.');
}
if (!isClaudeAuthenticated()) {
const auth = ensureAnswer(
await p.confirm({
message:
"Claude CLI isn't signed in. Sign in now? (a browser will open)",
initialValue: true,
}),
);
if (!auth) return false;
// setup-token has an interactive TUI; reset terminal to cooked mode
// so its prompts render correctly after clack's raw-mode prompts.
spawnSync('stty', ['sane'], { stdio: 'inherit' });
// Run under script(1) to capture the OAuth token from PTY output
// while preserving interactive TTY for the browser OAuth flow.
// Same approach as register-claude-token.sh, but we set the env var
// instead of writing to OneCLI.
const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`);
try {
const isUtilLinux = (() => {
try {
return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux');
} catch { return false; }
})();
const scriptArgs = isUtilLinux
? ['-q', '-c', 'claude setup-token', tmpfile]
: ['-q', tmpfile, 'claude', 'setup-token'];
spawnSync('script', scriptArgs, {
cwd: projectRoot,
stdio: 'inherit',
});
if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) {
const raw = fs.readFileSync(tmpfile, 'utf-8');
const stripped = raw
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/[\n\r]/g, '');
const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g);
if (matches) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1];
}
}
} finally {
try { fs.unlinkSync(tmpfile); } catch {}
}
if (!isClaudeAuthenticated()) {
p.log.error("Couldn't complete Claude sign-in.");
return false;
}
p.log.success('Claude CLI signed in.');
}
// Availability without auth is half the story; a real query will still
// fail if the token isn't registered. We try first and surface the error
// rather than pre-checking auth with a separate round trip.
return true;
}
@@ -358,7 +264,7 @@ async function queryClaudeUnderSpinner(
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (kind === 'ok') {
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
resolve(payload);
} else {
p.log.error(
@@ -373,24 +279,18 @@ async function queryClaudeUnderSpinner(
// No hard timeout — debugging can take a long time, and the cost of
// cutting Claude off mid-investigation is worse than letting the
// spinner run. The user can Ctrl-C if they want to abort.
//
// Resume the same session on repeat invocations so Claude carries
// context across failures in one setup run.
const claudeArgs = [
'-p',
'--output-format',
'stream-json',
'--verbose',
'--permission-mode',
'bypassPermissions',
];
if (claudeSessionId) {
claudeArgs.push('--resume', claudeSessionId);
}
const child = spawn('claude', claudeArgs, {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
});
const child = spawn(
'claude',
[
'-p',
'--output-format',
'stream-json',
'--verbose',
'--permission-mode',
'bypassPermissions',
],
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
);
child.stdout.on('data', (c: Buffer) => {
lineBuf += c.toString('utf-8');
@@ -401,16 +301,6 @@ async function queryClaudeUnderSpinner(
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as StreamEvent;
// Capture the session id on the very first claude invocation of
// this process so later calls can --resume it.
if (
!claudeSessionId &&
event.type === 'system' &&
event.subtype === 'init' &&
typeof event.session_id === 'string'
) {
claudeSessionId = event.session_id;
}
handleStreamEvent(event, {
setAction: (a) => {
actions.push(a);
@@ -445,14 +335,10 @@ async function queryClaudeUnderSpinner(
}
// Minimal shape of the stream-json events we care about. Claude emits
// many more, but we only read tool_use blocks (for breadcrumbs), text
// blocks (to reassemble the final REASON/COMMAND answer), and the
// session_id on the init event so follow-up invocations can resume the
// same conversation.
// many more, but we only read tool_use blocks (for breadcrumbs) and text
// blocks (to reassemble the final REASON/COMMAND answer).
interface StreamEvent {
type: string;
subtype?: string;
session_id?: string;
message?: {
content?: Array<
| { type: 'text'; text: string }
@@ -461,11 +347,6 @@ interface StreamEvent {
};
}
// The session id from the first claude-assist invocation in this process.
// Subsequent invocations pass `--resume <id>` so Claude sees prior failures
// as conversation history instead of treating each failure in isolation.
let claudeSessionId: string | null = null;
function handleStreamEvent(
event: StreamEvent,
cb: { setAction: (a: string) => void; appendText: (t: string) => void },
+3 -5
View File
@@ -27,8 +27,6 @@ import { execSync, spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
/** Channel this handoff is happening in (e.g., 'teams'). */
channel: string;
@@ -64,14 +62,14 @@ export interface HandoffContext {
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
if (!isClaudeUsable()) {
p.log.warn(
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
);
return false;
}
const systemPrompt = buildSystemPrompt(ctx);
note(
p.note(
[
"I'm handing you off to Claude in interactive mode.",
"It has the context of where you are in setup.",
@@ -93,7 +91,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success(brandBody("Back from Claude. Let's continue."));
p.log.success("Back from Claude. Let's continue.");
resolve(true);
});
child.on('error', () => {
-37
View File
@@ -1,37 +0,0 @@
# install-slug.sh — shell mirror of setup/lib/install-slug.ts.
#
# Source this file after $PROJECT_ROOT is set:
#
# source "$PROJECT_ROOT/setup/lib/install-slug.sh"
# label=$(launchd_label) # com.nanoclaw-v2-<slug>
# unit=$(systemd_unit) # nanoclaw-v2-<slug>
# image=$(container_image_base) # nanoclaw-agent-v2-<slug>
#
# Slug is sha1(PROJECT_ROOT)[:8] — must match the TS helper exactly so both
# halves of setup name things consistently.
_nanoclaw_install_slug() {
local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}"
if command -v shasum >/dev/null 2>&1; then
printf '%s' "$root" | shasum | cut -c 1-8
elif command -v sha1sum >/dev/null 2>&1; then
printf '%s' "$root" | sha1sum | cut -c 1-8
else
# Fallback: hash the path with something deterministic-ish. Not ideal —
# but shasum is present on every modern macOS/Linux, so this is just
# belt-and-braces against a truly minimal system.
printf '%s' "$root" | od -An -tx1 | tr -d ' \n' | cut -c 1-8
fi
}
launchd_label() {
printf 'com.nanoclaw-v2-%s' "$(_nanoclaw_install_slug)"
}
systemd_unit() {
printf 'nanoclaw-v2-%s' "$(_nanoclaw_install_slug)"
}
container_image_base() {
printf 'nanoclaw-agent-v2-%s' "$(_nanoclaw_install_slug)"
}
+4 -3
View File
@@ -8,7 +8,8 @@
* surfaces admin/member for the edge cases (shared instance, collaborators
* with limited access), but hitting Enter assigns owner.
*/
import { brightSelect } from './bright-select.js';
import * as p from '@clack/prompts';
import { ensureAnswer } from './runner.js';
export type OperatorRole = 'owner' | 'admin' | 'member';
@@ -17,7 +18,7 @@ export async function askOperatorRole(
channelLabel: string,
): Promise<OperatorRole> {
const choice = ensureAnswer(
await brightSelect<OperatorRole>({
await p.select({
message: `How should this ${channelLabel} account be registered?`,
initialValue: 'owner',
options: [
@@ -38,6 +39,6 @@ export async function askOperatorRole(
},
],
}),
);
) as OperatorRole;
return choice;
}
+6 -36
View File
@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import { brandBody, fitToWidth } from './theme.js';
import { fitToWidth } from './theme.js';
export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields };
@@ -102,19 +102,12 @@ export class StatusStream {
* raw log file (level 3) and parsed for status blocks (level 2 summary).
* The onBlock callback fires per status block as they close so the UI can
* react mid-stream.
*
* `onLine`, if provided, fires for every line from stdout + stderr (minus
* status-block control lines) so callers can render a rolling tail. Status
* block lines are still parsed by the `StatusStream` they're just
* excluded from the line feed so they don't fill the user-facing window
* with `=== NANOCLAW SETUP: …` noise.
*/
export function spawnStep(
stepName: string,
extra: string[],
onBlock: (block: Block) => void,
rawLogPath: string,
onLine?: (line: string) => void,
): Promise<StepResult> {
return new Promise((resolve) => {
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
@@ -125,34 +118,13 @@ export function spawnStep(
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
raw.write(`# ${stepName}${new Date().toISOString()}\n\n`);
// Per-line forwarder for the optional onLine callback. We keep our own
// buffer (separate from StatusStream's) so the parser still gets raw
// chunks and isn't forced through a line-by-line path it doesn't need.
let lineBuf = '';
const pushLines = (chunk: string): void => {
if (!onLine) return;
lineBuf += chunk;
let idx: number;
while ((idx = lineBuf.indexOf('\n')) !== -1) {
const line = lineBuf.slice(0, idx).replace(/\r/g, '');
lineBuf = lineBuf.slice(idx + 1);
if (line.startsWith('=== NANOCLAW SETUP:')) continue;
if (line.startsWith('=== END ===')) continue;
if (line.trim()) onLine(line);
}
};
child.stdout.on('data', (chunk: Buffer) => {
const s = chunk.toString('utf-8');
stream.write(s);
stream.write(chunk.toString('utf-8'));
raw.write(chunk);
pushLines(s);
});
child.stderr.on('data', (chunk: Buffer) => {
const s = chunk.toString('utf-8');
stream.transcript += s;
stream.transcript += chunk.toString('utf-8');
raw.write(chunk);
pushLines(s);
});
child.on('close', (code) => {
@@ -322,12 +294,10 @@ async function runUnderSpinner<
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
// Bold the outcome so the step's headline reads stronger than the prose
// body copy around it. The trailing `(Ns)` timing stays dim.
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1);
dumpTranscriptOnFailure(result.transcript);
}
return result;
@@ -390,7 +360,7 @@ export async function fail(
const skipList = [
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
].join(',');
p.log.step(brandBody(`Retrying from ${stepName}`));
p.log.step(`Retrying from ${stepName}`);
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_SKIP: skipList },
-161
View File
@@ -1,161 +0,0 @@
/**
* Parser/reader/writer for the advanced-config registry (setup-config.ts).
*
* readFromEnv() values found in process.env
* parseFlags() values from argv, plus --help and any pass-through args
* applyToEnv() write resolved values back to process.env so existing
* step code keeps reading env vars unchanged
* printHelp() render --help from the registry
*
* Flag parsing supports:
* --key value space form
* --key=value equals form
* --key booleans only (sets true)
* --no-key booleans only (sets false)
*/
import {
CONFIG,
envVarFor,
flagFor,
findByFlag,
type Entry,
} from './setup-config.js';
export type ConfigValues = Record<string, string | boolean | number>;
function coerce(e: Entry, raw: string): string | number | boolean | undefined {
switch (e.type) {
case 'boolean': {
const v = raw.toLowerCase();
if (['true', '1', 'yes'].includes(v)) return true;
if (['false', '0', 'no'].includes(v)) return false;
return undefined;
}
case 'integer': {
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
default:
return raw;
}
}
export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues {
const out: ConfigValues = {};
for (const e of CONFIG) {
const raw = env[envVarFor(e)];
if (raw === undefined || raw === '') continue;
const v = coerce(e, raw);
if (v !== undefined) out[e.key] = v;
}
return out;
}
export type FlagParseResult = {
values: ConfigValues;
rest: string[];
help: boolean;
errors: string[];
};
export function parseFlags(argv: string[]): FlagParseResult {
const values: ConfigValues = {};
const rest: string[] = [];
const errors: string[] = [];
let help = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
help = true;
continue;
}
// POSIX end-of-options. pnpm passes a bare `--` through when invoked as
// `pnpm run script --` with nothing after it; treat the rest as
// pass-through positional args.
if (arg === '--') {
rest.push(...argv.slice(i + 1));
break;
}
if (!arg.startsWith('--')) {
rest.push(arg);
continue;
}
const eq = arg.indexOf('=');
let name = eq === -1 ? arg : arg.slice(0, eq);
const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1);
let negated = false;
if (name.startsWith('--no-')) {
negated = true;
name = `--${name.slice(5)}`;
}
const entry = findByFlag(name);
if (!entry) {
errors.push(`Unknown flag: ${arg}`);
continue;
}
if (entry.type === 'boolean') {
if (negated) values[entry.key] = false;
else if (inline !== undefined) {
const v = coerce(entry, inline);
if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`);
else values[entry.key] = v;
} else values[entry.key] = true;
continue;
}
const raw = inline !== undefined ? inline : argv[++i];
if (raw === undefined) {
errors.push(`Missing value for ${name}`);
continue;
}
const v = coerce(entry, raw);
if (v === undefined) {
errors.push(`Invalid ${entry.type} for ${name}: ${raw}`);
continue;
}
if (entry.type === 'string' || entry.type === 'url') {
const err = entry.validate?.(raw);
if (err) {
errors.push(`${name}: ${err}`);
continue;
}
}
values[entry.key] = v;
}
return { values, rest, help, errors };
}
export function applyToEnv(
values: ConfigValues,
env: NodeJS.ProcessEnv = process.env,
): void {
for (const e of CONFIG) {
if (!(e.key in values)) continue;
const v = values[e.key];
env[envVarFor(e)] =
typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v);
}
}
export function printHelp(stream: NodeJS.WritableStream = process.stdout): void {
const lines: string[] = [];
lines.push('Usage: bash nanoclaw.sh [flags...]');
lines.push('');
lines.push('Flags:');
const width = Math.max(...CONFIG.map((e) => flagFor(e).length));
for (const e of CONFIG) {
const flag = flagFor(e).padEnd(width + 2);
lines.push(` ${flag}${e.help}`);
}
lines.push('');
lines.push('Each flag also reads from its corresponding NANOCLAW_<KEY> env var.');
lines.push('Run without flags for the default interactive flow.');
stream.write(lines.join('\n') + '\n');
}
-127
View File
@@ -1,127 +0,0 @@
/**
* Advanced-settings screen menu of UI-visible entries from the config
* registry. The user picks one entry, edits it, returns to the menu, and
* exits via "Done". Returns a fresh values object; the caller passes it to
* applyToEnv() so downstream step code reads them via env vars.
*
* Per-entry edit contract:
* - Blank input on text/password/integer = leave current value unchanged.
* - Enums get a synthetic "leave unchanged" first option.
* - Booleans use confirm with the current value as initialValue.
* - Secret entries mask the current value as bullets in hints/labels.
*/
import * as p from '@clack/prompts';
import { brightSelect } from './bright-select.js';
import { ensureAnswer } from './runner.js';
import { CONFIG, type Entry } from './setup-config.js';
import type { ConfigValues } from './setup-config-parse.js';
const SKIP_SENTINEL = '__leave_unchanged__';
const DONE_SENTINEL = '__done__';
const MASK = '••••••••';
export async function runAdvancedScreen(
initial: ConfigValues,
): Promise<ConfigValues> {
const result: ConfigValues = { ...initial };
const visible = CONFIG.filter((e) => e.surface === 'flag+ui');
while (true) {
const options = [
...visible.map((e) => ({
value: e.key,
label: e.label,
hint: hintFor(e, result),
})),
{ value: DONE_SENTINEL, label: 'Done — continue with setup' },
];
const choice = ensureAnswer(
await brightSelect<string>({
message: 'Pick a setting to override',
options,
initialValue: DONE_SENTINEL,
}),
) as string;
if (choice === DONE_SENTINEL) return result;
const entry = visible.find((e) => e.key === choice);
if (entry) await promptOne(entry, result);
}
}
function hintFor(e: Entry, values: ConfigValues): string {
const v = values[e.key];
if (v === undefined) return 'not set';
if (e.secret) return MASK;
return String(v);
}
async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
if (e.type === 'boolean') {
const init =
typeof values[e.key] === 'boolean'
? (values[e.key] as boolean)
: (e.default ?? false);
const ans = ensureAnswer(
await p.confirm({ message: e.label, initialValue: init }),
);
values[e.key] = ans as boolean;
return;
}
if (e.type === 'enum') {
const ans = ensureAnswer(
await brightSelect<string>({
message: e.label,
options: [
{ value: SKIP_SENTINEL, label: 'Leave unchanged' },
...e.options,
],
initialValue: SKIP_SENTINEL,
}),
);
if (ans !== SKIP_SENTINEL) values[e.key] = ans as string;
return;
}
if (e.type === 'integer') {
const ans = ensureAnswer(
await p.text({
message: e.label,
placeholder: e.default !== undefined ? String(e.default) : undefined,
validate: (v) => {
const s = (v ?? '').trim();
if (!s) return undefined;
const n = Number(s);
if (!Number.isFinite(n)) return 'Must be a number';
if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`;
if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`;
return undefined;
},
}),
);
const trimmed = ((ans as string) ?? '').trim();
if (trimmed) values[e.key] = Number(trimmed);
return;
}
// string | url
const validate = (v: string | undefined): string | undefined => {
const s = (v ?? '').trim();
if (!s) return undefined;
return e.validate?.(s);
};
const ans = ensureAnswer(
e.secret
? await p.password({ message: e.label, clearOnError: true, validate })
: await p.text({
message: e.label,
placeholder: e.placeholder ?? e.default,
validate,
}),
);
const trimmed = ((ans as string) ?? '').trim();
if (trimmed) values[e.key] = trimmed;
}
-142
View File
@@ -1,142 +0,0 @@
/**
* Setup-time advanced-config registry.
*
* One source of truth for: CLI flags, env-var names, the advanced-settings
* screen, and `--help` output. The flag parser, env reader, and UI screen
* all consume this list and write resolved values back to `process.env` so
* existing step code keeps reading env vars unchanged.
*
* Default name conventions (overridable per entry):
* key 'fooBar' envVar 'NANOCLAW_FOO_BAR' flag '--foo-bar'
*
* Surface levels:
* 'flag' CLI flag + env var only (debug/internal knobs)
* 'flag+ui' also shown in the advanced-settings screen
*/
export type EntrySurface = 'flag' | 'flag+ui';
interface BaseEntry {
/** Canonical camelCase key. */
key: string;
/** Override of the auto-derived NANOCLAW_<UPPER_SNAKE> env var. */
envVar?: string;
/** Override of the auto-derived --kebab-case flag. */
flag?: string;
label: string;
help: string;
surface: EntrySurface;
/** UI section header. Entries without a group land in 'Other'. */
group?: string;
/** Mask in UI, redact in logs. */
secret?: boolean;
}
interface StringEntry extends BaseEntry {
type: 'string' | 'url';
default?: string;
placeholder?: string;
validate?: (v: string) => string | undefined;
}
interface EnumEntry extends BaseEntry {
type: 'enum';
options: { value: string; label: string; hint?: string }[];
default?: string;
}
interface BoolEntry extends BaseEntry {
type: 'boolean';
default?: boolean;
}
interface IntEntry extends BaseEntry {
type: 'integer';
default?: number;
min?: number;
max?: number;
}
export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry;
const httpUrl = (v: string): string | undefined =>
/^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…';
export const CONFIG: Entry[] = [
{
key: 'onecliApiHost',
label: 'OneCLI vault URL',
help: 'Use a remote OneCLI vault instead of installing one locally.',
surface: 'flag+ui',
group: 'OneCLI',
type: 'url',
default: 'https://app.onecli.sh',
placeholder: 'https://app.onecli.sh',
validate: httpUrl,
},
{
key: 'onecliApiToken',
label: 'OneCLI access token',
help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.',
surface: 'flag+ui',
group: 'OneCLI',
type: 'string',
secret: true,
placeholder: 'oc_…',
validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'),
},
{
key: 'anthropicBaseUrl',
label: 'Anthropic API base URL',
help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.',
surface: 'flag+ui',
group: 'Anthropic',
type: 'url',
placeholder: 'https://api.anthropic.com',
validate: httpUrl,
},
{
key: 'anthropicAuthToken',
label: 'Anthropic auth token',
help: 'Bearer token for the custom Anthropic endpoint. Used together with --anthropic-base-url.',
surface: 'flag+ui',
group: 'Anthropic',
type: 'string',
secret: true,
validate: (v) => (v.trim() ? undefined : 'Required'),
},
// Existing env-var knobs — flag-only so they don't clutter the UI screen.
{
key: 'skip',
envVar: 'NANOCLAW_SKIP',
label: 'Skip steps',
help: 'Comma-separated step names to skip (debugging only).',
surface: 'flag',
type: 'string',
},
{
key: 'displayName',
envVar: 'NANOCLAW_DISPLAY_NAME',
label: 'Display name',
help: 'Skip the "what should your assistant call you?" prompt.',
surface: 'flag',
type: 'string',
},
];
// ─── name derivation ───────────────────────────────────────────────────
export function envVarFor(e: Entry): string {
if (e.envVar) return e.envVar;
return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
}
export function flagFor(e: Entry): string {
if (e.flag) return e.flag;
return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
}
export function findByFlag(flag: string): Entry | null {
return CONFIG.find((e) => flagFor(e) === flag) ?? null;
}
+9 -52
View File
@@ -11,7 +11,6 @@
* - COLORTERM truecolor/24bit 24-bit ANSI (exact brand cyan)
* - Otherwise kleur's 16-color cyan (closest fallback)
*/
import * as p from '@clack/prompts';
import k from 'kleur';
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
@@ -39,41 +38,6 @@ export function brandChip(s: string): string {
return k.bgCyan(k.black(k.bold(s)));
}
/**
* Accent green (#3fba50) for emphasizing a single word inside prompt
* messages currently the "you" in "What should your assistant call
* you?" so the operator parses at a glance who the question is about.
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
*/
export function accentGreen(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
return k.green(s);
}
/**
* Brand body color for setup-flow prose. Used for card bodies (via the
* `note()` formatter) and `p.log.*` body arguments anywhere the
* previous "dim" treatment was making prose hard to read or washing
* out embedded brand emphasis.
*
* Multi-line input is colored line-by-line so embedded line breaks
* don't bleed the SGR sequence across clack's gutter prefix.
*/
export function brandBody(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return s
.split('\n')
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
.join('\n');
}
return s
.split('\n')
.map((line) => (line.length > 0 ? k.cyan(line) : line))
.join('\n');
}
/**
* Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with
@@ -94,24 +58,17 @@ export function wrapForGutter(text: string, gutter: number): string {
}
/**
* Wrap multi-line explanatory prose to the clack gutter. Previously
* dimmed its output (hence the name) that made body copy hard to read
* against dark terminals. Dim is now reserved for preview/debug blocks
* (failure transcript tails, claude-assist streams); prose renders at
* the terminal's regular weight.
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
* because clack resets styling at each line break when rendering
* multi-line log content a single outer dim envelope only colors the
* first line. Applying dim per-line gives each wrapped row its own
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
*/
export function dimWrap(text: string, gutter: number): string {
return wrapForGutter(text, gutter);
}
/**
* Wrap clack's `p.note` so card bodies render in the brand body color
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
* on each line individually, so `brandBody` colors each line cleanly
* without bleeding across the gutter prefix.
*/
export function note(message: string, title?: string): void {
p.note(message, title, { format: brandBody });
return wrapForGutter(text, gutter)
.split('\n')
.map((line) => k.dim(line))
.join('\n');
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
-229
View File
@@ -1,229 +0,0 @@
/**
* Windowed step runner: shows a fixed-height rolling tail of a long step's
* output so the user can see it's making progress, plus a stall detector
* that interrupts with a "keep waiting or ask for help?" prompt when the
* output stream goes silent for too long.
*
* Used for the container build (310 minutes on a fresh machine, no user
* feedback with a plain spinner). Models the UI on claude-assist.ts's
* 3-line action window a single-line spinner header sitting above three
* gutter-prefixed lines of the most recent output, redrawn in place via
* ANSI cursor controls.
*
* Stall detection: a silence timer resets on every new line. When it hits
* STALL_THRESHOLD_MS we pause the render, show `offerClaudeAssist` with
* the step's raw log, and either resume (user said "keep waiting") or
* let the step run its course while giving them the exit path.
*/
import * as p from '@clack/prompts';
import k from 'kleur';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
import * as setupLog from '../logs.js';
import { brandBody, fitToWidth } from './theme.js';
const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
const HIDE_CURSOR = '\x1b[?25l';
const SHOW_CURSOR = '\x1b[?25h';
const STALL_THRESHOLD_MS = 60_000;
/**
* Run a step with a 3-line rolling tail + stall detector. Same signature
* shape as `runQuietStep` (so auto.ts can swap them), but tails the
* child's stdout/stderr into a fixed-height window.
*/
export async function runWindowedStep(
stepName: string,
labels: SpinnerLabels,
extra: string[] = [],
): Promise<StepResult & { rawLog: string; durationMs: number }> {
const rawLog = setupLog.stepRawLog(stepName);
const start = Date.now();
phEmit('step_started', { step: stepName });
const result = await runUnderWindow(stepName, labels, extra, rawLog);
const durationMs = Date.now() - start;
writeStepEntry(stepName, result, durationMs, rawLog);
phEmit('step_completed', {
step: stepName,
status: outcomeStatus(result),
duration_ms: durationMs,
});
return { ...result, rawLog, durationMs };
}
function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' {
const rawStatus = result.terminal?.fields.STATUS;
if (!result.ok) return 'failed';
return rawStatus === 'skipped' ? 'skipped' : 'success';
}
/**
* The core render + spawn loop. Kept separate from `runWindowedStep` so
* the logging bookkeeping (writeStepEntry, phEmit) lives with the
* public-facing wrapper and this function stays focused on terminal IO.
*/
async function runUnderWindow(
stepName: string,
labels: SpinnerLabels,
extra: string[],
rawLog: string,
): Promise<StepResult> {
const out = process.stdout;
const start = Date.now();
const actions: string[] = [];
let frameIdx = 0;
let lastLineAt = Date.now();
let stallPromptActive = false;
let handledStall = false;
const redraw = (): void => {
if (stallPromptActive) return;
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
const elapsed = Math.round((Date.now() - start) / 1000);
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
const suffix = ` (${elapsed}s)`;
const header = fitToWidth(labels.running, suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
for (let i = 0; i < WINDOW_SIZE; i++) {
const idx = actions.length - WINDOW_SIZE + i;
const action = idx >= 0 ? actions[idx] : '';
out.write('\x1b[2K');
if (action) {
out.write(`${k.gray('│')} ${k.dim(fitToWidth(action, ''))}`);
} else {
out.write(k.gray('│'));
}
out.write('\n');
}
};
const clearBlock = (): void => {
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
for (let i = 0; i < WINDOW_SIZE + 1; i++) {
out.write('\x1b[2K\n');
}
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
};
out.write(HIDE_CURSOR);
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
redraw();
const restoreCursorOnExit = (): void => {
out.write(SHOW_CURSOR);
};
process.once('exit', restoreCursorOnExit);
const frameTick = setInterval(() => {
frameIdx++;
redraw();
}, 250);
const stallCheck = setInterval(() => {
if (handledStall || stallPromptActive) return;
if (Date.now() - lastLineAt < STALL_THRESHOLD_MS) return;
handledStall = true;
void handleStall(stepName, rawLog, {
pauseRender: () => {
stallPromptActive = true;
clearBlock();
out.write(SHOW_CURSOR);
},
resumeRender: () => {
out.write(HIDE_CURSOR);
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
stallPromptActive = false;
lastLineAt = Date.now();
redraw();
},
});
}, 5_000);
const onLine = (line: string): void => {
lastLineAt = Date.now();
// Strip ANSI escape sequences — Docker Buildx writes color codes that
// mangle the rolling window layout when replayed in a narrow cell.
// eslint-disable-next-line no-control-regex
const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim();
if (clean) actions.push(clean);
redraw();
};
const result = await spawnStep(stepName, extra, () => {}, rawLog, onLine);
clearInterval(frameTick);
clearInterval(stallCheck);
clearBlock();
out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
dumpTranscriptOnFailure(result.transcript);
}
return result;
}
async function handleStall(
stepName: string,
rawLog: string,
render: { pauseRender: () => void; resumeRender: () => void },
): Promise<void> {
render.pauseRender();
p.log.warn(
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
);
phEmit('step_stalled', { step: stepName });
const { ensureAnswer } = await import('./runner.js');
const { brightSelect } = await import('./bright-select.js');
const choice = ensureAnswer(
await brightSelect<'wait' | 'help'>({
message: "What now?",
options: [
{
value: 'wait',
label: "Keep waiting",
hint: "large images can take 510 minutes",
},
{
value: 'help',
label: 'Ask Claude to take a look',
hint: 'reads the raw build log and suggests a fix',
},
],
}),
);
if (choice === 'help') {
// offerClaudeAssist runs its own spinner and may propose a fix command.
// We don't attempt to restart the stalled build from here — if Claude
// proposes a command the user accepts, they can retry setup afterwards.
await offerClaudeAssist({
stepName,
msg: `The ${stepName} step has produced no output for 60 seconds.`,
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
rawLogPath: rawLog,
});
// Keep the spinner going — the underlying process is still running,
// and cancelling it here would race with Claude's investigation. The
// user can Ctrl-C if they want to bail.
}
render.resumeRender();
}
+22 -267
View File
@@ -1,15 +1,13 @@
/**
* Step: onecli Install + configure the OneCLI gateway and CLI.
*
* Two modes:
* (default) run the OneCLI installer, configure api-host, write .env.
* --reuse skip the installer; reuse the onecli instance already running
* on the host. Required for users who have other apps bound to
* an existing gateway, since re-running the installer rebinds
* the listener and breaks those consumers.
* Aggregates what the old /setup + /init-onecli skills ran as loose shell
* commands. Idempotent: skips install if `onecli` already works, and safely
* re-applies PATH, api-host, and .env updates.
*
* Emits ONECLI_URL and polls /health so downstream steps (auth, service)
* get a ready gateway.
* Emits ONECLI_URL so /new-setup SKILL.md can forward it downstream (e.g. as
* ${ONECLI_URL} in status messages). Polls /health to give downstream steps
* (auth, service) a ready gateway.
*/
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
@@ -39,32 +37,6 @@ function onecliVersion(): string | null {
}
}
/**
* Ask the installed onecli CLI for its configured api-host. Returns null if
* onecli isn't on PATH, errors, or has no api-host configured.
*
* Tolerates both JSON output (onecli 1.3+) and older raw-text output.
*/
export function getOnecliApiHost(): string | null {
try {
const out = execFileSync('onecli', ['config', 'get', 'api-host'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
try {
const parsed = JSON.parse(out) as { data?: unknown; value?: unknown };
const val = parsed.data ?? parsed.value;
if (typeof val === 'string' && val.trim()) return val.trim();
} catch {
// not JSON — fall through to URL extraction
}
return extractUrlFromOutput(out);
} catch {
return null;
}
}
function extractUrlFromOutput(output: string): string | null {
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
return match ? match[0] : null;
@@ -86,161 +58,40 @@ function ensureShellProfilePath(): void {
}
}
function writeEnvVar(name: string, value: string): void {
function writeEnvOnecliUrl(url: string): void {
const envFile = path.join(process.cwd(), '.env');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
const re = new RegExp(`^${name}=.*$`, 'm');
if (re.test(content)) {
content = content.replace(re, `${name}=${value}`);
if (/^ONECLI_URL=/m.test(content)) {
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
} else {
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
}
fs.writeFileSync(envFile, content);
}
function writeEnvOnecliUrl(url: string): void {
writeEnvVar('ONECLI_URL', url);
}
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
function installOnecliCliOnly(): { stdout: string; ok: boolean } {
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
if (upstream.ok) return { stdout: upstream.stdout, ok: true };
const fallback = installOnecliCliDirect();
return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok };
}
function installOnecli(): { stdout: string; ok: boolean } {
// OneCLI's own install script handles gateway + CLI + PATH.
// We run the two canonical installers in sequence and capture stdout so
// we can extract the printed URL as a fallback to `onecli config get`.
let stdout = '';
// Gateway install (docker-compose based, no rate-limit concerns).
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
stdout += gw.stdout;
if (!gw.ok) {
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
return { stdout: stdout + (gw.stderr ?? ''), ok: false };
}
// CLI install. The upstream script calls the GitHub releases API
// (api.github.com) to resolve the latest tag — which 403s anonymous
// callers after 60 requests/hour per IP. Try upstream first; on failure
// resolve the version ourselves (via HTTP redirect, which isn't
// API-throttled) and download the release archive directly.
const upstream = runInstall('curl -fsSL onecli.sh/cli/install | sh');
stdout += upstream.stdout;
if (upstream.ok) return { stdout, ok: true };
log.warn('Upstream CLI installer failed — falling back to direct download', {
stderr: upstream.stderr,
});
stdout += (upstream.stderr ?? '') + '\n';
const fallback = installOnecliCliDirect();
stdout += fallback.stdout;
if (!fallback.ok) {
log.error('OneCLI CLI install failed (both upstream and direct fallback)');
return { stdout, ok: false };
}
return { stdout, ok: true };
}
function runInstall(cmd: string): { stdout: string; stderr?: string; ok: boolean } {
try {
const stdout = execSync(cmd, {
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
return { stdout: e.stdout ?? '', stderr: e.stderr, ok: false };
log.error('OneCLI install failed', { stderr: e.stderr });
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
}
}
/**
* Reinstate the OneCLI CLI install without hitting GitHub's rate-limited
* releases API. Resolves the version via the HTTP redirect from
* /releases/latest /releases/tag/vX.Y.Z, then downloads the archive
* directly. Falls back to ONECLI_CLI_FALLBACK_VERSION if the redirect
* probe also fails.
*/
function installOnecliCliDirect(): { stdout: string; ok: boolean } {
const lines: string[] = [];
const append = (s: string): void => {
lines.push(s);
};
const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : null;
if (!osName) {
append(`Unsupported platform: ${process.platform}`);
return { stdout: lines.join('\n'), ok: false };
}
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : null;
if (!arch) {
append(`Unsupported arch: ${process.arch}`);
return { stdout: lines.join('\n'), ok: false };
}
let version: string | null = null;
try {
const redirect = execSync(
`curl -fsSL -o /dev/null -w '%{url_effective}' https://github.com/${ONECLI_CLI_REPO}/releases/latest`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] },
).trim();
const m = redirect.match(/\/tag\/v?([^/]+)$/);
if (m) version = m[1];
} catch {
// redirect probe failed — we'll pin the fallback
}
if (!version) {
version = ONECLI_CLI_FALLBACK_VERSION;
append(`Version probe failed; installing pinned fallback ${version}.`);
} else {
append(`Resolved onecli CLI ${version} via release redirect.`);
}
const archive = `onecli_${version}_${osName}_${arch}.tar.gz`;
const url = `https://github.com/${ONECLI_CLI_REPO}/releases/download/v${version}/${archive}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onecli-'));
const archivePath = path.join(tmpDir, archive);
try {
append(`Downloading ${url}`);
execSync(`curl -fsSL -o ${JSON.stringify(archivePath)} ${JSON.stringify(url)}`, {
stdio: ['ignore', 'pipe', 'pipe'],
});
execSync(`tar -xzf ${JSON.stringify(archivePath)} -C ${JSON.stringify(tmpDir)}`, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let installDir = '/usr/local/bin';
try {
fs.accessSync(installDir, fs.constants.W_OK);
} catch {
installDir = LOCAL_BIN;
fs.mkdirSync(installDir, { recursive: true });
}
const binSrc = path.join(tmpDir, 'onecli');
const binDest = path.join(installDir, 'onecli');
fs.copyFileSync(binSrc, binDest);
fs.chmodSync(binDest, 0o755);
append(`onecli ${version} installed to ${binDest}.`);
return { stdout: lines.join('\n'), ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string; message?: string };
append(`Direct install failed: ${e.stderr ?? e.message ?? String(err)}`);
return { stdout: lines.join('\n'), ok: false };
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
export async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@@ -255,105 +106,9 @@ export async function pollHealth(url: string, timeoutMs: number): Promise<boolea
return false;
}
export async function run(args: string[]): Promise<void> {
const reuse = args.includes('--reuse');
const remoteUrlIdx = args.indexOf('--remote-url');
const remoteUrl = remoteUrlIdx !== -1 ? args[remoteUrlIdx + 1] : null;
export async function run(_args: string[]): Promise<void> {
ensureShellProfilePath();
if (remoteUrl) {
// Remote-mode: install only the CLI, point it at the remote gateway, and
// record the URL in .env. No local gateway is started.
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
const res = installOnecliCliOnly();
if (!res.ok || !onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'cli_install_failed',
HINT: 'CLI binary install failed. Make sure curl is installed and ~/.local/bin is writable.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
try {
execFileSync('onecli', ['config', 'set', 'api-host', remoteUrl], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
writeEnvOnecliUrl(remoteUrl);
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
if (remoteToken) {
// Two auth surfaces: `onecli auth login` persists the key for CLI
// calls during setup itself (e.g. detecting an existing Anthropic
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
// read by the runtime SDK at request time. Both are needed.
try {
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli auth login failed', { err });
}
writeEnvVar('ONECLI_API_KEY', remoteToken);
log.info('Wrote ONECLI_API_KEY to .env');
}
const healthy = await pollHealth(remoteUrl, 5000);
emitStatus('ONECLI', {
INSTALLED: true,
REMOTE: true,
ONECLI_URL: remoteUrl,
HEALTHY: healthy,
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
if (reuse) {
// Reuse-mode: don't touch the running gateway at all. Just verify it
// exists, read its api-host, write ONECLI_URL to .env, and move on.
const version = onecliVersion();
if (!version) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'onecli_not_found_for_reuse',
HINT: 'onecli not on PATH. Re-run setup and choose "install fresh".',
LOG: 'logs/setup.log',
});
process.exit(1);
}
const url = getOnecliApiHost();
if (!url) {
emitStatus('ONECLI', {
INSTALLED: true,
STATUS: 'failed',
ERROR: 'onecli_api_host_not_configured',
HINT: 'Existing onecli has no api-host set. Run `onecli config set api-host <url>` or re-run setup with install-fresh.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
writeEnvOnecliUrl(url);
log.info('Reusing existing OneCLI', { url });
const healthy = await pollHealth(url, 5000);
emitStatus('ONECLI', {
INSTALLED: true,
REUSED: true,
ONECLI_URL: url,
HEALTHY: healthy,
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
log.info('Installing OneCLI gateway and CLI');
const res = installOnecli();
if (!res.ok) {
-186
View File
@@ -1,186 +0,0 @@
/**
* Detect and clean up unhealthy NanoClaw peer services.
*
* Runs as a setup preflight before we install our own service. A crash-looping
* peer install (typically the legacy v1 `com.nanoclaw` plist) silently trashes
* this install's containers on every respawn because its `cleanupOrphans()`
* reaps anything matching `nanoclaw-`. We scope our reaper by label now, but
* we still need to stop the peer from killing us on its way down.
*
* A peer is "unhealthy" when:
* - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD`
* - systemd: unit is in `failed` state, OR `activating` with many restarts
*
* Healthy peers are left alone multiple installs can coexist fine now that
* container-reaper is label-scoped.
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { log } from '../src/log.js';
const UNHEALTHY_RUNS_THRESHOLD = 10;
export interface PeerStatus {
label: string;
configPath: string;
state: string;
runs: number;
unhealthy: boolean;
}
export interface PeerCleanupResult {
checked: PeerStatus[];
unloaded: PeerStatus[];
failures: Array<{ label: string; err: string }>;
}
/**
* Scan for peer NanoClaw services and unload any that are crash-looping.
* Returns a summary suitable for emitStatus / setup-log reporting.
*/
export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): PeerCleanupResult {
const platform = os.platform();
if (platform === 'darwin') {
return cleanupLaunchdPeers(projectRoot);
}
if (platform === 'linux') {
return cleanupSystemdPeers(projectRoot);
}
return { checked: [], unloaded: [], failures: [] };
}
// ---- launchd (macOS) --------------------------------------------------------
function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult {
const ownLabel = getLaunchdLabel(projectRoot);
const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
let plists: string[];
try {
plists = fs
.readdirSync(agentsDir)
.filter((f) => /^com\.nanoclaw.*\.plist$/.test(f))
.map((f) => path.join(agentsDir, f));
} catch {
return result;
}
const uid = process.getuid?.() ?? 0;
for (const plistPath of plists) {
const label = path.basename(plistPath, '.plist');
if (label === ownLabel) continue;
const status = probeLaunchdPeer(label, plistPath, uid);
if (!status) continue;
result.checked.push(status);
if (!status.unhealthy) continue;
try {
execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' });
log.info('Unloaded unhealthy peer launchd service', {
label,
state: status.state,
runs: status.runs,
plistPath,
});
result.unloaded.push(status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn('Failed to unload peer launchd service', { label, err: message });
result.failures.push({ label, err: message });
}
}
return result;
}
function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerStatus | null {
let output: string;
try {
output = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], {
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
});
} catch {
// Not loaded → not currently a threat. Skip silently.
return null;
}
const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1] ?? 'unknown';
const runsStr = /^\s*runs\s*=\s*(\d+)/m.exec(output)?.[1];
const runs = runsStr ? parseInt(runsStr, 10) : 0;
const unhealthy = state !== 'running' && runs > UNHEALTHY_RUNS_THRESHOLD;
return { label, configPath: plistPath, state, runs, unhealthy };
}
// ---- systemd (Linux) --------------------------------------------------------
function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult {
const ownUnit = getSystemdUnit(projectRoot);
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] };
let units: string[];
try {
units = fs
.readdirSync(unitDir)
.filter((f) => /^nanoclaw.*\.service$/.test(f))
.map((f) => f.replace(/\.service$/, ''));
} catch {
return result;
}
for (const unit of units) {
if (unit === ownUnit) continue;
const status = probeSystemdPeer(unit);
if (!status) continue;
result.checked.push(status);
if (!status.unhealthy) continue;
try {
execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' });
log.info('Disabled unhealthy peer systemd unit', {
unit,
state: status.state,
runs: status.runs,
});
result.unloaded.push(status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn('Failed to disable peer systemd unit', { unit, err: message });
result.failures.push({ label: unit, err: message });
}
}
return result;
}
function probeSystemdPeer(unit: string): PeerStatus | null {
const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${unit}.service`);
try {
const output = execFileSync(
'systemctl',
['--user', 'show', '--property=ActiveState,NRestarts', `${unit}.service`],
{ stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const activeState = /^ActiveState=(.+)$/m.exec(output)?.[1]?.trim() ?? 'unknown';
const restartsStr = /^NRestarts=(\d+)/m.exec(output)?.[1];
const runs = restartsStr ? parseInt(restartsStr, 10) : 0;
const unhealthy =
activeState === 'failed' || (activeState !== 'active' && runs > UNHEALTHY_RUNS_THRESHOLD);
return { label: unit, configPath: unitPath, state: activeState, runs, unhealthy };
} catch {
return null;
}
}
+3 -9
View File
@@ -19,13 +19,7 @@ START_S=$(date +%s)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_BIN="$HOME/.local/bin"
# Per-checkout install names (match setup/lib/install-slug.ts).
# shellcheck source=setup/lib/install-slug.sh
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
LAUNCHD_LABEL=$(launchd_label)
SYSTEMD_UNIT=$(systemd_unit)
AGENT_IMAGE="$(container_image_base):latest"
AGENT_IMAGE="nanoclaw-agent:latest"
export PATH="$LOCAL_BIN:$PATH"
@@ -150,7 +144,7 @@ probe_service_status() {
macos)
command_exists launchctl || { echo "not_configured"; return; }
local line
line=$(with_timeout launchctl list 2>/dev/null | grep "$LAUNCHD_LABEL") || {
line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || {
echo "not_configured"; return; }
local pid
pid=$(echo "$line" | awk '{print $1}')
@@ -162,7 +156,7 @@ probe_service_status() {
;;
linux|wsl)
command_exists systemctl || { echo "not_configured"; return; }
if with_timeout systemctl --user is-active "$SYSTEMD_UNIT" >/dev/null 2>&1; then
if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then
echo "running"
elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then
echo "stopped"
+15 -17
View File
@@ -20,7 +20,6 @@ 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';
@@ -113,10 +112,12 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
// 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);
// 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}`;
}
log.info('Registering channel', parsed);
@@ -166,22 +167,19 @@ export async function run(args: string[]): Promise<void> {
if (!existing) {
newlyWired = true;
const mgaId = generateId('mga');
// 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;
const triggerRules = parsed.trigger
? JSON.stringify({
pattern: parsed.trigger,
requiresTrigger: parsed.requiresTrigger,
})
: null;
createMessagingGroupAgent({
id: mgaId,
messaging_group_id: messagingGroup.id,
agent_group_id: agentGroup.id,
engage_mode: engageMode,
engage_pattern: engagePattern,
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
trigger_rules: triggerRules,
response_scope: 'all',
session_mode: parsed.sessionMode,
priority: 0,
created_at: new Date().toISOString(),
});
+8 -9
View File
@@ -1,8 +1,6 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import { getLaunchdLabel } from '../src/install-slug.js';
/**
* Tests for service configuration generation.
*
@@ -16,13 +14,12 @@ function generatePlist(
projectRoot: string,
homeDir: string,
): string {
const label = getLaunchdLabel(projectRoot);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -76,11 +73,13 @@ WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`;
}
describe('plist generation', () => {
it('contains the slug-scoped label', () => {
const projectRoot = '/home/user/nanoclaw';
const plist = generatePlist('/usr/local/bin/node', projectRoot, '/home/user');
expect(plist).toContain(`<string>${getLaunchdLabel(projectRoot)}</string>`);
expect(plist).toMatch(/<string>com\.nanoclaw-v2-[0-9a-f]{8}<\/string>/);
it('contains the correct label', () => {
const plist = generatePlist(
'/usr/local/bin/node',
'/home/user/nanoclaw',
'/home/user',
);
expect(plist).toContain('<string>com.nanoclaw</string>');
});
it('uses the correct node path', () => {
+8 -30
View File
@@ -10,8 +10,6 @@ import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { cleanupUnhealthyPeers } from './peer-cleanup.js';
import {
commandExists,
getPlatform,
@@ -54,19 +52,6 @@ export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
// Peer preflight — a crash-looping peer install (most often the legacy v1
// `com.nanoclaw` plist) will keep trashing this install's containers on
// every respawn via its own cleanupOrphans. Detect and unload any peer
// that's unhealthy before we install our service. Healthy peers are left
// alone now that container reaping is install-label-scoped.
const peerReport = cleanupUnhealthyPeers(projectRoot);
if (peerReport.unloaded.length > 0) {
log.warn('Unloaded unhealthy peer NanoClaw services', {
count: peerReport.unloaded.length,
labels: peerReport.unloaded.map((p) => p.label),
});
}
if (platform === 'macos') {
setupLaunchd(projectRoot, nodePath, homeDir);
} else if (platform === 'linux') {
@@ -89,14 +74,11 @@ function setupLaunchd(
nodePath: string,
homeDir: string,
): void {
// Per-checkout service label so multiple NanoClaw installs can coexist
// without clobbering each other's plist.
const label = getLaunchdLabel(projectRoot);
const plistPath = path.join(
homeDir,
'Library',
'LaunchAgents',
`${label}.plist`,
'com.nanoclaw.plist',
);
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
@@ -105,7 +87,7 @@ function setupLaunchd(
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<string>com.nanoclaw</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -164,14 +146,13 @@ function setupLaunchd(
let serviceLoaded = false;
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
serviceLoaded = output.includes(label);
serviceLoaded = output.includes('com.nanoclaw');
} catch {
// launchctl list failed
}
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: 'launchd',
SERVICE_LABEL: label,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
PLIST_PATH: plistPath,
@@ -244,15 +225,13 @@ function setupSystemd(
homeDir: string,
): void {
const runningAsRoot = isRoot();
const unitName = getSystemdUnit(projectRoot);
const unitFileName = `${unitName}.service`;
// Root uses system-level service, non-root uses user-level
let unitPath: string;
let systemctlPrefix: string;
if (runningAsRoot) {
unitPath = `/etc/systemd/system/${unitFileName}`;
unitPath = '/etc/systemd/system/nanoclaw.service';
systemctlPrefix = 'systemctl';
log.info('Running as root — installing system-level systemd unit');
} else {
@@ -268,7 +247,7 @@ function setupSystemd(
}
const unitDir = path.join(homeDir, '.config', 'systemd', 'user');
fs.mkdirSync(unitDir, { recursive: true });
unitPath = path.join(unitDir, unitFileName);
unitPath = path.join(unitDir, 'nanoclaw.service');
systemctlPrefix = 'systemctl --user';
}
@@ -349,7 +328,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
}
try {
execSync(`${systemctlPrefix} enable ${unitName}`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' });
} catch (err) {
log.error('systemctl enable failed', { err });
}
@@ -360,7 +339,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
// `restart` on a stopped unit is equivalent to `start`, so this is safe
// as a first-install path too.
try {
execSync(`${systemctlPrefix} restart ${unitName}`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' });
} catch (err) {
log.error('systemctl restart failed', { err });
}
@@ -368,7 +347,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
// Verify
let serviceLoaded = false;
try {
execSync(`${systemctlPrefix} is-active ${unitName}`, { stdio: 'ignore' });
execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' });
serviceLoaded = true;
} catch {
// Not active
@@ -376,7 +355,6 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user',
SERVICE_UNIT: unitName,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
UNIT_PATH: unitPath,
-182
View File
@@ -1,182 +0,0 @@
/**
* 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
View File
@@ -1,55 +0,0 @@
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');
});
});
+15 -38
View File
@@ -14,8 +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, type PingResult } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { pingCliAgent } from './lib/agent-ping.js';
import {
getPlatform,
getServiceManager,
@@ -46,13 +45,10 @@ export async function run(_args: string[]): Promise<void> {
let runningFromPath: string | null = null;
const mgr = getServiceManager();
const launchdLabel = getLaunchdLabel(projectRoot);
const systemdUnit = getSystemdUnit(projectRoot);
if (mgr === 'launchd') {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
const line = output.split('\n').find((l) => l.includes(launchdLabel));
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
if (line) {
const pidField = line.trim().split(/\s+/)[0];
if (pidField !== '-' && pidField) {
@@ -71,11 +67,11 @@ export async function run(_args: string[]): Promise<void> {
} else if (mgr === 'systemd') {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' });
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
service = 'running';
try {
const pidStr = execSync(
`${prefix} show ${systemdUnit} -p MainPID --value`,
`${prefix} show nanoclaw -p MainPID --value`,
{ encoding: 'utf-8' },
).trim();
const pid = Number(pidStr);
@@ -90,7 +86,7 @@ export async function run(_args: string[]): Promise<void> {
const output = execSync(`${prefix} list-unit-files`, {
encoding: 'utf-8',
});
if (output.includes(systemdUnit)) {
if (output.includes('nanoclaw')) {
service = 'stopped';
}
} catch {
@@ -220,22 +216,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' | 'auth_error' | 'skipped' = 'skipped';
let agentPing: 'ok' | 'no_reply' | 'socket_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. 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,
});
// Determine overall status
const status =
service === 'running' &&
credentials !== 'missing' &&
anyChannelConfigured &&
registeredGroups > 0 &&
(agentPing === 'ok' || agentPing === 'skipped')
? 'success'
: 'failed';
log.info('Verification complete', { status, channelAuth });
@@ -255,25 +251,6 @@ 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
-71
View File
@@ -1,71 +0,0 @@
import { describe, it, expect } from 'vitest';
import { deriveAttachmentName, extForMime } from './attachment-naming.js';
describe('extForMime', () => {
it('returns empty for undefined / non-string / empty', () => {
expect(extForMime(undefined)).toBe('');
expect(extForMime('')).toBe('');
expect(extForMime({})).toBe('');
expect(extForMime(null)).toBe('');
expect(extForMime(42)).toBe('');
});
it('maps common MIME types to canonical extensions', () => {
expect(extForMime('image/jpeg')).toBe('jpg');
expect(extForMime('application/pdf')).toBe('pdf');
expect(extForMime('audio/ogg')).toBe('ogg');
});
it('strips parameters and is case-insensitive', () => {
expect(extForMime('image/JPEG; foo=bar')).toBe('jpg');
expect(extForMime(' Application/PDF ')).toBe('pdf');
expect(extForMime('text/plain; charset=utf-8')).toBe('txt');
});
it('returns empty for unknown MIMEs', () => {
expect(extForMime('application/octet-stream')).toBe('');
expect(extForMime('application/x-totally-made-up')).toBe('');
});
});
describe('deriveAttachmentName', () => {
it('returns explicit name when set, no derivation', () => {
expect(deriveAttachmentName({ name: 'photo.jpg', mimeType: 'application/pdf' })).toBe('photo.jpg');
});
it('ignores empty / non-string explicit name and falls through to derivation', () => {
const out = deriveAttachmentName({ name: '', mimeType: 'application/pdf' });
expect(out).toMatch(/^attachment-\d+\.pdf$/);
const out2 = deriveAttachmentName({ name: 42, mimeType: 'application/pdf' });
expect(out2).toMatch(/^attachment-\d+\.pdf$/);
});
it('derives extension from mimeType when no name', () => {
expect(deriveAttachmentName({ mimeType: 'application/pdf' })).toMatch(/^attachment-\d+\.pdf$/);
expect(deriveAttachmentName({ mimeType: 'image/jpeg' })).toMatch(/^attachment-\d+\.jpg$/);
});
it('falls back to att.type when mimeType is missing (Telegram photos/stickers)', () => {
expect(deriveAttachmentName({ type: 'photo' })).toMatch(/^attachment-\d+\.jpg$/);
expect(deriveAttachmentName({ type: 'sticker' })).toMatch(/^attachment-\d+\.webp$/);
expect(deriveAttachmentName({ type: 'voice' })).toMatch(/^attachment-\d+\.ogg$/);
expect(deriveAttachmentName({ type: 'animation' })).toMatch(/^attachment-\d+\.mp4$/);
});
it('case-insensitive att.type lookup', () => {
expect(deriveAttachmentName({ type: 'PHOTO' })).toMatch(/^attachment-\d+\.jpg$/);
});
it('returns bare timestamp when nothing matches', () => {
expect(deriveAttachmentName({})).toMatch(/^attachment-\d+$/);
expect(deriveAttachmentName({ mimeType: 'application/octet-stream' })).toMatch(/^attachment-\d+$/);
expect(deriveAttachmentName({ type: 'mystery-class' })).toMatch(/^attachment-\d+$/);
});
it('does not crash on non-string mimeType (defensive against buggy bridges)', () => {
expect(() => deriveAttachmentName({ mimeType: { foo: 'bar' } })).not.toThrow();
expect(deriveAttachmentName({ mimeType: { foo: 'bar' } })).toMatch(/^attachment-\d+$/);
});
});
-69
View File
@@ -1,69 +0,0 @@
/**
* Derive a safe, extensioned filename for inbound attachments when the
* channel bridge passes data without an explicit `name`.
*
* Two-step lookup:
* 1. `mimeType` extension (Discord/Slack documents, Telegram document
* uploads channels that set the MIME but not a filename).
* 2. `att.type` extension (Telegram photos/stickers/voice/animations
* coarse media-class set by the chat-sdk bridge with no MIME).
*
* Output is still passed through `isSafeAttachmentName` at the call site.
* The maps emit static values, so no derivation path can construct a
* traversal payload only an attacker-controlled `att.name` can, and that
* goes through the safety guard unchanged.
*/
// Map common MIME types to canonical file extensions. Without an extension,
// agents (and humans) can't tell what kind of file landed in the inbox, and
// tools keyed on extension (image viewers, exiftool, etc.) misbehave.
const MIME_TO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/heic': 'heic',
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/mp4': 'm4a',
'video/mp4': 'mp4',
'video/webm': 'webm',
'video/quicktime': 'mov',
'application/pdf': 'pdf',
'text/plain': 'txt',
'application/json': 'json',
'application/zip': 'zip',
};
// Fallback when `mimeType` is missing — Telegram photos and stickers arrive
// without an explicit MIME on the attachment object. The channel bridge sets
// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.)
// which is reliable enough to derive a canonical extension. Telegram's GIFs
// are actually MP4, hence `animation: 'mp4'`.
const TYPE_TO_EXT: Record<string, string> = {
image: 'jpg',
photo: 'jpg',
sticker: 'webp',
voice: 'ogg',
audio: 'mp3',
video: 'mp4',
animation: 'mp4',
};
export function extForMime(mime: unknown): string {
if (typeof mime !== 'string' || !mime) return '';
const clean = mime.split(';')[0].trim().toLowerCase();
return MIME_TO_EXT[clean] ?? '';
}
export function deriveAttachmentName(att: Record<string, unknown>): string {
const explicit = att.name;
if (typeof explicit === 'string' && explicit) return explicit;
let ext = extForMime(att.mimeType);
if (!ext && typeof att.type === 'string') {
ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? '';
}
const ts = Date.now();
return ext ? `attachment-${ts}.${ext}` : `attachment-${ts}`;
}
-23
View File
@@ -1,23 +0,0 @@
import path from 'path';
/**
* Is `name` safe to use as the last segment of a path inside an
* attachment-staging directory? Filenames originate from untrusted sources
* channel messages from any chat participant, agent-to-agent forwards from
* a possibly-compromised peer agent and land in `path.join(dir, name)`
* sinks on the host. Without this guard, a `..`-laden name escapes the
* inbox and writes anywhere the host process has filesystem permission.
*
* Rejects:
* - non-string / empty
* - `.` / `..` (traversal sentinels that path.basename returns as-is)
* - anything containing a path separator (`/` or `\`) or NUL
* - any value where `path.basename(name) !== name`, catching OS-specific
* separators and covering drives/prefixes on Windows runtimes
*/
export function isSafeAttachmentName(name: string): boolean {
if (typeof name !== 'string' || name.length === 0) return false;
if (name === '.' || name === '..') return false;
if (/[\\/\0]/.test(name)) return false;
return path.basename(name) === name;
}
-4
View File
@@ -56,8 +56,6 @@ export interface InboundEvent {
* See InboundMessage.isMention for the full explanation.
*/
isMention?: boolean;
/** True when the source is a group/channel thread, false for DMs. */
isGroup?: boolean;
};
replyTo?: DeliveryAddress;
}
@@ -83,8 +81,6 @@ export interface InboundMessage {
* router falls back to text-match against agent_group_name.
*/
isMention?: boolean;
/** True when the source is a group/channel thread, false for DMs. */
isGroup?: boolean;
}
/** A file attachment to deliver alongside a message. */
+11 -55
View File
@@ -81,26 +81,6 @@ export interface ChatSdkBridgeConfig {
* chunk boundary will render as two independent blocks on the receiving
* platform, which is the same behavior as manually re-opening a fence.
*/
/**
* Decode the actual option value from a button callback. Buttons are encoded
* with an integer index (to keep under Telegram's 64-byte callback_data cap),
* and the real value is looked up via `getAskQuestionRender(questionId)`.
* Falls back to treating the tail as a literal value so old in-flight cards
* (encoded before this shortening landed) still resolve.
*/
function resolveSelectedOption(
render: { options: NormalizedOption[] } | undefined,
eventValue: string | undefined,
tail: string | undefined,
): string {
const candidate = eventValue ?? tail ?? '';
if (render && /^\d+$/.test(candidate)) {
const idx = Number(candidate);
if (render.options[idx]) return render.options[idx].value;
}
return candidate;
}
export function splitForLimit(text: string, limit: number): string[] {
if (text.length <= limit) return [text];
const chunks: string[] = [];
@@ -125,11 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
let setupConfig: ChannelSetup;
let gatewayAbort: AbortController | null = null;
async function messageToInbound(
message: ChatMessage,
isMention: boolean,
isGroup?: boolean,
): Promise<InboundMessage> {
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serialized = message.toJSON() as Record<string, any>;
@@ -186,7 +162,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
content: serialized,
timestamp: message.metadata.dateSent.toISOString(),
isMention,
isGroup,
};
}
@@ -220,17 +195,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// wirings still fire on in-thread mentions.
chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(
channelId,
thread.id,
await messageToInbound(message, message.isMention === true, true),
);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true));
});
// @mention in an unsubscribed thread — SDK-confirmed bot mention.
chat.onNewMention(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true));
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
});
// DMs — by definition addressed to the bot. Thread id flows through
@@ -245,7 +216,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown',
threadId: thread.id,
});
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false));
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true));
});
// Plain messages in unsubscribed threads.
@@ -260,7 +231,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// flood gate.
chat.onNewMessage(/./, async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));
});
// Handle button clicks (ask_user_question)
@@ -269,15 +240,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
const parts = event.actionId.split(':');
if (parts.length < 3) return;
const questionId = parts[1];
const tail = parts.slice(2).join(':');
const selectedOption = event.value || '';
const userId = event.user?.userId || '';
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
const render = getAskQuestionRender(questionId);
// New format: button id/value is an integer index into options (kept
// short to fit Telegram's 64-byte callback_data cap). Old format:
// the full value is embedded in actionId/value directly.
const selectedOption = resolveSelectedOption(render, event.value, tail);
const title = render?.title ?? '❓ Question';
const matched = render?.options.find((o) => o.value === selectedOption);
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
@@ -381,13 +348,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
children: [
CardText(question),
Actions(
// Encode button id/value with the option index rather than the
// full value. Telegram caps callback_data at 64 bytes, and
// long values (e.g. ISO datetimes, URLs) push the JSON payload
// well past that. The onAction handlers resolve the index back
// to the real value via getAskQuestionRender(questionId).
options.map((opt, idx) =>
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }),
options.map((opt) =>
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
),
),
],
@@ -539,21 +501,18 @@ async function handleForwardedEvent(
// type 3 = MessageComponent (button/select)
if (interaction.type === 3) {
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
// In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly.
const user =
((interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined) ??
(interaction.user as Record<string, string> | undefined);
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined;
const interactionId = interaction.id as string;
const interactionToken = interaction.token as string;
// Parse the selected option from custom_id
let questionId: string | undefined;
let tail: string | undefined;
let selectedOption: string | undefined;
if (customId?.startsWith('ncq:')) {
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
if (colonIdx !== -1) {
questionId = customId.slice(4, colonIdx);
tail = customId.slice(colonIdx + 1);
selectedOption = customId.slice(colonIdx + 1);
}
}
@@ -562,9 +521,6 @@ async function handleForwardedEvent(
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
const originalDescription = (originalEmbeds[0]?.description as string) || '';
const render = questionId ? getAskQuestionRender(questionId) : undefined;
// Discord custom_id mirrors the new index-based encoding (see Button
// construction). Decode back to the real option value for downstream.
const selectedOption = resolveSelectedOption(render, tail, tail);
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
-197
View File
@@ -1,197 +0,0 @@
/**
* Unit tests for the startup circuit breaker.
*
* Covers state transitions, the documented backoff schedule, and the
* fresh-install case where DATA_DIR doesn't exist yet (the breaker runs
* before initDb, so it has to create the dir itself).
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// vi.mock factories are hoisted above imports, so they can't close over local
// consts. vi.hoisted is hoisted alongside the mock and runs before any
// `import` — so it can only use globals (no path/os modules). Use require()
// inside the callback to compute the test dir.
const { TEST_DIR } = vi.hoisted(() => {
const nodePath = require('path') as typeof import('path');
const nodeOs = require('os') as typeof import('os');
return { TEST_DIR: nodePath.join(nodeOs.tmpdir(), 'nanoclaw-cb-test') };
});
const CB_PATH = path.join(TEST_DIR, 'circuit-breaker.json');
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: TEST_DIR };
});
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
function readState(): { attempt: number; timestamp: string } {
return JSON.parse(fs.readFileSync(CB_PATH, 'utf-8'));
}
function seedState(attempt: number, timestamp = new Date().toISOString()): void {
fs.writeFileSync(CB_PATH, JSON.stringify({ attempt, timestamp }));
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
vi.useRealTimers();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
describe('resetCircuitBreaker', () => {
it('deletes the state file', () => {
seedState(3);
expect(fs.existsSync(CB_PATH)).toBe(true);
resetCircuitBreaker();
expect(fs.existsSync(CB_PATH)).toBe(false);
});
it('is a no-op when the file does not exist', () => {
expect(fs.existsSync(CB_PATH)).toBe(false);
expect(() => resetCircuitBreaker()).not.toThrow();
});
});
describe('enforceStartupBackoff — state transitions', () => {
it('first run writes attempt=1 and does not delay', async () => {
vi.useFakeTimers();
const start = Date.now();
await enforceStartupBackoff();
// No timers should have been queued — clean first start is 0s.
expect(Date.now() - start).toBe(0);
expect(readState().attempt).toBe(1);
});
it('within reset window, attempt is incremented', async () => {
seedState(1);
vi.useFakeTimers();
const promise = enforceStartupBackoff();
await vi.runAllTimersAsync();
await promise;
expect(readState().attempt).toBe(2);
});
it('outside reset window (>1h), attempt resets to 1', async () => {
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
seedState(5, longAgo);
await enforceStartupBackoff();
expect(readState().attempt).toBe(1);
});
it('exactly at the reset window boundary still counts as "within"', async () => {
// RESET_WINDOW_MS = 60min. Use 59min59s to stay inside even if the test
// takes a few ms to execute.
const justInside = new Date(Date.now() - (60 * 60 * 1000 - 1000)).toISOString();
seedState(2, justInside);
vi.useFakeTimers();
const promise = enforceStartupBackoff();
await vi.runAllTimersAsync();
await promise;
expect(readState().attempt).toBe(3);
});
it('treats a malformed state file as no prior state', async () => {
fs.writeFileSync(CB_PATH, '{ this is not json');
await enforceStartupBackoff();
expect(readState().attempt).toBe(1);
});
it('resetCircuitBreaker after a startup actually clears the counter for the next startup', async () => {
// Simulate: crash, restart (attempt=2), graceful shutdown, restart again.
seedState(1);
vi.useFakeTimers();
const p1 = enforceStartupBackoff();
await vi.runAllTimersAsync();
await p1;
expect(readState().attempt).toBe(2);
resetCircuitBreaker();
expect(fs.existsSync(CB_PATH)).toBe(false);
await enforceStartupBackoff();
expect(readState().attempt).toBe(1);
});
});
describe('enforceStartupBackoff — backoff schedule', () => {
/**
* Documented schedule:
*
* clean start 1 crash 2 crash 3 crash 4 crash 5 crash 6+ crash
* 0s 0s 10s 30s 2min 5min 15min cap
*
* Each row is [priorAttempt seeded in the file, expected delay this run
* produces in seconds]. priorAttempt=null = no file = very first start.
*
* To assert the *requested* delay (not just observed elapsed real time),
* we spy on global.setTimeout and look at the longest call. runAllTimersAsync
* lets the function complete so we can move on.
*/
const cases: Array<{ label: string; priorAttempt: number | null; expectedDelaySec: number }> = [
{ label: 'clean first start (no file)', priorAttempt: null, expectedDelaySec: 0 },
{ label: 'first crash (attempt=2)', priorAttempt: 1, expectedDelaySec: 0 },
{ label: 'second crash (attempt=3)', priorAttempt: 2, expectedDelaySec: 10 },
{ label: 'third crash (attempt=4)', priorAttempt: 3, expectedDelaySec: 30 },
{ label: 'fourth crash (attempt=5)', priorAttempt: 4, expectedDelaySec: 120 },
{ label: 'fifth crash (attempt=6)', priorAttempt: 5, expectedDelaySec: 300 },
{ label: 'sixth crash (attempt=7) — cap', priorAttempt: 6, expectedDelaySec: 900 },
{ label: 'far past cap (attempt=20)', priorAttempt: 19, expectedDelaySec: 900 },
];
for (const { label, priorAttempt, expectedDelaySec } of cases) {
it(`${label}: delays ${expectedDelaySec}s`, async () => {
if (priorAttempt !== null) seedState(priorAttempt);
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const promise = enforceStartupBackoff();
await vi.runAllTimersAsync();
await promise;
// enforceStartupBackoff only calls setTimeout when delaySec > 0. Pick
// the longest delay it requested (vitest may queue small internal
// timers we don't care about).
const requestedDelays = setTimeoutSpy.mock.calls.map((c) => c[1] ?? 0);
const maxDelayMs = requestedDelays.length ? Math.max(...requestedDelays) : 0;
expect(maxDelayMs).toBe(expectedDelaySec * 1000);
});
}
});
describe('enforceStartupBackoff — fresh install (DATA_DIR missing)', () => {
/**
* The breaker runs before initDb (which is what creates DATA_DIR). On a
* fresh checkout the dir doesn't exist yet, so write() must create it
* before writing the state file otherwise the host crashes on its very
* first start.
*/
it('creates DATA_DIR on demand and does not throw', async () => {
fs.rmSync(TEST_DIR, { recursive: true });
expect(fs.existsSync(TEST_DIR)).toBe(false);
await expect(enforceStartupBackoff()).resolves.toBeUndefined();
expect(fs.existsSync(TEST_DIR)).toBe(true);
expect(fs.existsSync(CB_PATH)).toBe(true);
expect(readState().attempt).toBe(1);
});
});
-84
View File
@@ -1,84 +0,0 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { log } from './log.js';
const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json');
const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour
// Index = number of consecutive crashes (0 = clean start, attempt 1).
// 6+ crashes capped at 15min.
const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900];
interface CircuitBreakerState {
attempt: number;
timestamp: string;
}
function read(): CircuitBreakerState | null {
try {
const raw = fs.readFileSync(CB_PATH, 'utf-8');
return JSON.parse(raw) as CircuitBreakerState;
} catch {
return null;
}
}
function write(state: CircuitBreakerState): void {
// The breaker runs before initDb (which is what creates DATA_DIR), so on a
// fresh checkout the dir may not exist yet.
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n');
}
function getDelay(attempt: number): number {
const idx = Math.min(attempt - 1, BACKOFF_SCHEDULE_S.length - 1);
return BACKOFF_SCHEDULE_S[idx];
}
export function resetCircuitBreaker(): void {
try {
fs.unlinkSync(CB_PATH);
log.info('Circuit breaker reset on clean shutdown');
} catch {}
}
export async function enforceStartupBackoff(): Promise<void> {
const now = new Date();
const prev = read();
let attempt: number;
if (!prev) {
attempt = 1;
} else {
const elapsedMs = now.getTime() - new Date(prev.timestamp).getTime();
if (elapsedMs < RESET_WINDOW_MS) {
attempt = prev.attempt + 1;
log.warn('Previous startup was not a clean shutdown', {
previousAttempt: prev.attempt,
previousTimestamp: prev.timestamp,
elapsedSec: Math.round(elapsedMs / 1000),
});
} else {
attempt = 1;
log.info('Circuit breaker reset — last startup was over 1h ago', {
previousAttempt: prev.attempt,
previousTimestamp: prev.timestamp,
});
}
}
write({ attempt, timestamp: now.toISOString() });
const delaySec = getDelay(attempt);
if (delaySec > 0) {
const resumeAt = new Date(now.getTime() + delaySec * 1000).toISOString();
log.warn('Circuit breaker: delaying startup due to repeated crashes', {
attempt,
delaySec,
resumeAt,
});
await new Promise((resolve) => setTimeout(resolve, delaySec * 1000));
log.info('Circuit breaker: backoff complete, resuming startup', { attempt });
}
}
+1 -9
View File
@@ -2,7 +2,6 @@ import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
@@ -23,14 +22,7 @@ export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
// Per-checkout image tag so two installs on the same host don't share
// `nanoclaw-agent:latest` and clobber each other on rebuild.
export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT);
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT);
// Install slug — stamped onto every spawned container via --label so
// cleanupOrphans only reaps containers from this install, not peers.
export const INSTALL_SLUG = getInstallSlug(PROJECT_ROOT);
export const CONTAINER_INSTALL_LABEL = `nanoclaw-install=${INSTALL_SLUG}`;
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10);
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default
export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
-32
View File
@@ -1,32 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveProviderName } from './container-runner.js';
describe('resolveProviderName', () => {
it('prefers session over group and container.json', () => {
expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex');
});
it('falls back to group when session is null', () => {
expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex');
});
it('falls back to container.json when session and group are null', () => {
expect(resolveProviderName(null, null, 'opencode')).toBe('opencode');
});
it('defaults to claude when nothing is set', () => {
expect(resolveProviderName(null, null, undefined)).toBe('claude');
});
it('lowercases the resolved name', () => {
expect(resolveProviderName('CODEX', null, null)).toBe('codex');
expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode');
expect(resolveProviderName(null, null, 'Claude')).toBe('claude');
});
it('treats empty string as unset (falls through)', () => {
expect(resolveProviderName('', 'codex', null)).toBe('codex');
expect(resolveProviderName(null, '', 'opencode')).toBe('opencode');
});
});
+24 -74
View File
@@ -9,16 +9,7 @@ import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import {
CONTAINER_IMAGE,
CONTAINER_IMAGE_BASE,
CONTAINER_INSTALL_LABEL,
DATA_DIR,
GROUPS_DIR,
ONECLI_API_KEY,
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js';
import { readContainerConfig, writeContainerConfig } from './container-config.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { composeGroupClaudeMd } from './claude-md-compose.js';
@@ -36,13 +27,7 @@ import {
type ProviderContainerContribution,
type VolumeMount,
} from './providers/provider-container-registry.js';
import {
heartbeatPath,
markContainerRunning,
markContainerStopped,
sessionDir,
writeSessionRouting,
} from './session-manager.js';
import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js';
import type { AgentGroup, Session } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY });
@@ -58,7 +43,7 @@ const activeContainers = new Map<string, { process: ChildProcess; containerName:
* a duplicate container against the same session directory, producing
* racy double-replies.
*/
const wakePromises = new Map<string, Promise<boolean>>();
const wakePromises = new Map<string, Promise<void>>();
export function getActiveContainerCount(): number {
return activeContainers.size;
@@ -73,32 +58,20 @@ export function isContainerRunning(sessionId: string): boolean {
* (the in-flight wake promise is reused).
*
* The container runs the v2 agent-runner which polls the session DB.
*
* Contract: never throws. Returns `true` on successful spawn, `false` on
* transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't
* need to wrap the inbound row stays pending and host-sweep retries on
* its next tick. Callers that care (e.g. the router's typing indicator)
* can branch on the boolean.
*/
export function wakeContainer(session: Session): Promise<boolean> {
export function wakeContainer(session: Session): Promise<void> {
if (activeContainers.has(session.id)) {
log.debug('Container already running', { sessionId: session.id });
return Promise.resolve(true);
return Promise.resolve();
}
const existing = wakePromises.get(session.id);
if (existing) {
log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id });
return existing;
}
const promise = spawnContainer(session)
.then(() => true)
.catch((err) => {
log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err });
return false;
})
.finally(() => {
wakePromises.delete(session.id);
});
const promise = spawnContainer(session).finally(() => {
wakePromises.delete(session.id);
});
wakePromises.set(session.id, promise);
return promise;
}
@@ -149,12 +122,6 @@ async function spawnContainer(session: Session): Promise<void> {
log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName });
// Clear any orphan heartbeat from a previous container instance — the
// sweep's ceiling check treats a missing file as "fresh spawn, give grace"
// (host-sweep.ts line 87). Without this, the stale mtime can trigger an
// immediate kill before the new container touches the file itself.
fs.rmSync(heartbeatPath(agentGroup.id, session.id), { force: true });
const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
activeContainers.set(session.id, { process: container, containerName });
@@ -203,31 +170,12 @@ export function killContainer(sessionId: string, reason: string): void {
}
}
/**
* Resolve the provider name for a session using the precedence documented in
* the provider-install skills:
*
* sessions.agent_provider
* agent_groups.agent_provider
* container.json `provider`
* 'claude'
*
* Pure so the precedence can be unit-tested without a DB or filesystem.
*/
export function resolveProviderName(
sessionProvider: string | null | undefined,
agentGroupProvider: string | null | undefined,
containerConfigProvider: string | null | undefined,
): string {
return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase();
}
function resolveProviderContribution(
session: Session,
agentGroup: AgentGroup,
containerConfig: import('./container-config.js').ContainerConfig,
): { provider: string; contribution: ProviderContainerContribution } {
const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider);
const provider = (containerConfig.provider || 'claude').toLowerCase();
const fn = getProviderContainerConfig(provider);
const contribution = fn
? fn({
@@ -433,7 +381,7 @@ async function buildContainerArgs(
providerContribution: ProviderContainerContribution,
agentIdentifier?: string,
): Promise<string[]> {
const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];
const args: string[] = ['run', '--rm', '--name', containerName];
// Environment — only vars read by code we don't own.
// Everything NanoClaw-specific is in container.json (read by runner at startup).
@@ -447,18 +395,20 @@ async function buildContainerArgs(
}
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection. Treated as
// a transient hard failure: if we can't wire the gateway, we don't spawn.
// The caller (router or host-sweep) catches the throw, leaves the inbound
// message pending, and the next sweep tick retries.
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
// are routed through the agent vault for credential injection.
try {
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (onecliApplied) {
log.info('OneCLI gateway applied', { containerName });
} else {
log.warn('OneCLI gateway not applied — container will have no credentials', { containerName });
}
} catch (err) {
log.warn('OneCLI gateway error — container will have no credentials', { containerName, err });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
log.info('OneCLI gateway applied', { containerName });
// Host gateway
args.push(...hostGatewayArgs());
@@ -519,7 +469,7 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
}
dockerfile += 'USER node\n';
const imageTag = `${CONTAINER_IMAGE_BASE}:${agentGroupId}`;
const imageTag = `nanoclaw-agent:${agentGroupId}`;
log.info('Building per-agent-group image', { agentGroupId, imageTag, apt: aptPackages, npm: npmPackages });
-12
View File
@@ -24,7 +24,6 @@ import {
ensureContainerRuntimeRunning,
cleanupOrphans,
} from './container-runtime.js';
import { CONTAINER_INSTALL_LABEL } from './config.js';
import { log } from './log.js';
beforeEach(() => {
@@ -85,17 +84,6 @@ describe('ensureContainerRuntimeRunning', () => {
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('filters ps by the install label so peers are not reaped', () => {
mockExecSync.mockReturnValueOnce('');
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`,
expect.any(Object),
);
});
it('stops orphaned nanoclaw containers', () => {
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n');
+5 -15
View File
@@ -5,7 +5,6 @@
import { execSync } from 'child_process';
import os from 'os';
import { CONTAINER_INSTALL_LABEL } from './config.js';
import { log } from './log.js';
/** The container runtime binary name. */
@@ -57,22 +56,13 @@ export function ensureContainerRuntimeRunning(): void {
}
}
/**
* Kill orphaned NanoClaw containers from THIS install's previous runs.
*
* Scoped by label `nanoclaw-install=<slug>` so a crash-looping peer install
* cannot reap our containers, and we cannot reap theirs. The label is
* stamped onto every container at spawn time see container-runner.ts.
*/
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(
`${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`,
{
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
},
);
const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
@@ -1,27 +0,0 @@
/**
* Persist ask_question render metadata (title + options_json) on
* `pending_channel_approvals` and `pending_sender_approvals`, mirroring the
* columns migration 003 / module-approvals-title-options added to
* `pending_approvals`.
*
* Before this, `getAskQuestionRender` hardcoded the title + option labels
* for these two tables in the DB-access layer duplicating wording that
* also lived in the approval modules and causing a visible drift between
* the initial card title ("📣 Bot mentioned in new chat" / "💬 New direct
* message", chosen per event) and the post-click render ("📣 Channel
* registration", constant). Storing the render metadata alongside the row
* lets both sides read from the same source.
*/
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration013: Migration = {
version: 13,
name: 'approval-render-metadata',
up(db: Database.Database) {
db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`);
db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`);
db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`);
db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`);
},
};
-2
View File
@@ -9,7 +9,6 @@ import { migration009 } from './009-drop-pending-credentials.js';
import { migration010 } from './010-engage-modes.js';
import { migration011 } from './011-pending-sender-approvals.js';
import { migration012 } from './012-channel-registration.js';
import { migration013 } from './013-approval-render-metadata.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
@@ -30,7 +29,6 @@ const migrations: Migration[] = [
migration010,
migration011,
migration012,
migration013,
];
export function runMigrations(db: Database.Database): void {
+4 -4
View File
@@ -139,10 +139,10 @@ export function getMessageForRetry(
db: Database.Database,
messageId: string,
status: string,
): { id: string; tries: number; processAfter: string | null } | undefined {
return db
.prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?')
.get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined;
): { id: string; tries: number } | undefined {
return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as
| { id: string; tries: number }
| undefined;
}
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
+9 -39
View File
@@ -1,5 +1,5 @@
import type { PendingApproval, PendingQuestion, Session } from '../types.js';
import { getDb, hasTable } from './connection.js';
import { getDb } from './connection.js';
// ── Sessions ──
@@ -97,16 +97,10 @@ export function deleteSession(id: string): void {
// ── Pending Questions ──
/**
* Insert a pending question row. Idempotent: when delivery fails and retries,
* the second attempt calls this with the same question_id without `OR
* IGNORE` that would throw UNIQUE and prevent the retry from reaching the
* actual send step. Returns true if a new row was inserted.
*/
export function createPendingQuestion(pq: PendingQuestion): boolean {
const result = getDb()
export function createPendingQuestion(pq: PendingQuestion): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at)
`INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at)
VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @title, @options_json, @created_at)`,
)
.run({
@@ -120,7 +114,6 @@ export function createPendingQuestion(pq: PendingQuestion): boolean {
options_json: JSON.stringify(pq.options),
created_at: pq.created_at,
});
return result.changes > 0;
}
export function getPendingQuestion(questionId: string): PendingQuestion | undefined {
@@ -138,21 +131,16 @@ export function deletePendingQuestion(questionId: string): void {
// ── Pending Approvals ──
/**
* Insert a pending approval row. Idempotent for the same reason as
* createPendingQuestion: delivery retries with the same approval_id must not
* fail on UNIQUE before the send step gets a chance to succeed.
*/
export function createPendingApproval(
pa: Partial<PendingApproval> &
Pick<
PendingApproval,
'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json'
>,
): boolean {
const result = getDb()
): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO pending_approvals
`INSERT INTO pending_approvals
(approval_id, session_id, request_id, action, payload, created_at,
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
title, options_json)
@@ -171,7 +159,6 @@ export function createPendingApproval(
status: 'pending',
...pa,
});
return result.changes > 0;
}
export function getPendingApproval(approvalId: string): PendingApproval | undefined {
@@ -205,23 +192,6 @@ export function getAskQuestionRender(
const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as
| { title: string; options_json: string }
| undefined;
if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) };
// Channel-registration + unknown-sender approvals persist title/options_json
// the same way pending_approvals does — just SELECT and return.
if (hasTable(getDb(), 'pending_channel_approvals')) {
const c = getDb()
.prepare('SELECT title, options_json FROM pending_channel_approvals WHERE messaging_group_id = ?')
.get(id) as { title: string; options_json: string } | undefined;
if (c?.title) return { title: c.title, options: JSON.parse(c.options_json) };
}
if (hasTable(getDb(), 'pending_sender_approvals')) {
const s = getDb().prepare('SELECT title, options_json FROM pending_sender_approvals WHERE id = ?').get(id) as
| { title: string; options_json: string }
| undefined;
if (s?.title) return { title: s.title, options: JSON.parse(s.options_json) };
}
return undefined;
if (!a || !a.title) return undefined;
return { title: a.title, options: JSON.parse(a.options_json) };
}
+2 -4
View File
@@ -321,7 +321,7 @@ async function deliverMessage(
questionId: content.questionId,
});
} else {
const inserted = createPendingQuestion({
createPendingQuestion({
question_id: content.questionId,
session_id: session.id,
message_out_id: msg.id,
@@ -332,9 +332,7 @@ async function deliverMessage(
options: normalizeOptions(rawOptions as never),
created_at: new Date().toISOString(),
});
if (inserted) {
log.info('Pending question created', { questionId: content.questionId, sessionId: session.id });
}
log.info('Pending question created', { questionId: content.questionId, sessionId: session.id });
}
}
-37
View File
@@ -173,43 +173,6 @@ describe('session manager', () => {
expect(getSession(session.id)!.last_active).not.toBeNull();
});
it('should refuse path-traversal in attachment filenames', () => {
// Regression: attachment.name comes from untrusted senders (E2EE-protected
// chat platforms can't sanitize it server-side). Without the guard, a
// `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere
// the host process can reach.
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox');
const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary');
if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget);
writeSessionMessage('ag-1', session.id, {
id: 'msg-attack',
kind: 'chat',
timestamp: now(),
content: JSON.stringify({
text: 'pwn',
attachments: [
{
type: 'document',
name: '../../../../../../../../tmp/nanoclaw-traversal-canary',
data: Buffer.from('owned').toString('base64'),
},
],
}),
});
expect(fs.existsSync(escapeTarget)).toBe(false);
// The bytes should still land — under a synthesized safe name inside the
// inbox — so the agent doesn't lose data on a malicious filename.
const inboxDir = path.join(inboxBase, 'msg-attack');
expect(fs.existsSync(inboxDir)).toBe(true);
const written = fs.readdirSync(inboxDir);
expect(written).toHaveLength(1);
expect(written[0]).not.toContain('/');
expect(written[0]).not.toContain('..');
});
});
describe('router', () => {
+10 -26
View File
@@ -159,33 +159,23 @@ async function sweepSession(session: Session): Promise<void> {
syncProcessingAcks(inDb, outDb);
}
// 2. Wake a container if work is due and nothing is running. Ordered
// before the crashed-container cleanup so a fresh container gets a chance
// to clean its own orphan processing_ack rows on startup (see
// container/agent-runner/src/db/connection.ts). Otherwise the reset path
// would keep bumping process_after into the future, dueCount would stay 0,
// and the wake would never fire.
const dueCount = countDueMessages(inDb);
if (dueCount > 0 && !isContainerRunning(session.id)) {
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
// wakeContainer never throws — transient spawn failures (OneCLI down,
// etc.) return false and leave messages pending for the next tick.
await wakeContainer(session);
}
const alive = isContainerRunning(session.id);
// 2. Crashed-container cleanup: processing rows left behind get retried.
if (!alive && outDb) {
resetStuckProcessingRows(inDb, outDb, session, 'container not running');
}
// 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
if (alive && outDb) {
enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
}
// 4. Crashed-container cleanup: processing rows left behind get retried.
// Only fires when wake in step 2 didn't pick up the work (no due messages,
// or wake failed). resetStuckProcessingRows itself is idempotent — it
// skips messages already scheduled for a future retry.
if (!alive && outDb) {
resetStuckProcessingRows(inDb, outDb, session, 'container not running');
// 4. Wake a container if new work is due and nothing is running.
const dueCount = countDueMessages(inDb);
if (dueCount > 0 && !isContainerRunning(session.id)) {
log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
await wakeContainer(session);
}
// 5. Recurrence fanout for completed recurring tasks.
@@ -256,16 +246,10 @@ function resetStuckProcessingRows(
reason: string,
): void {
const claims = getProcessingClaims(outDb);
const now = Date.now();
for (const { message_id } of claims) {
const msg = getMessageForRetry(inDb, message_id, 'pending');
if (!msg) continue;
// Already rescheduled for a future retry — don't bump tries again. The
// wake path (sweep step 2) will fire when process_after elapses and a
// fresh container will clean the orphan claim on startup.
if (msg.processAfter && Date.parse(msg.processAfter) > now) continue;
if (msg.tries >= MAX_TRIES) {
markMessageFailed(inDb, msg.id);
log.warn('Message marked as failed after max retries', {
+2 -14
View File
@@ -7,7 +7,6 @@
import path from 'path';
import { DATA_DIR } from './config.js';
import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js';
import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
import { initDb } from './db/connection.js';
import { runMigrations } from './db/migrations/index.js';
@@ -59,9 +58,6 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from
async function main(): Promise<void> {
log.info('NanoClaw starting');
// 0. Circuit breaker — backoff on rapid restarts
await enforceStartupBackoff();
// 1. Init central DB
const dbPath = path.join(DATA_DIR, 'v2.db');
const db = initDb(dbPath);
@@ -89,7 +85,6 @@ async function main(): Promise<void> {
content: JSON.stringify(message.content),
timestamp: message.timestamp,
isMention: message.isMention,
isGroup: message.isGroup,
},
}).catch((err) => {
log.error('Failed to route inbound message', { channelType: adapter.channelType, err });
@@ -178,15 +173,8 @@ async function shutdown(signal: string): Promise<void> {
}
stopDeliveryPolls();
stopHostSweep();
try {
await teardownChannelAdapters();
} finally {
// Always reset on graceful shutdown — even if teardown threw, we got here
// via SIGTERM/SIGINT, not a crash, so the next start shouldn't be counted
// as one.
resetCircuitBreaker();
process.exit(0);
}
await teardownChannelAdapters();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
-33
View File
@@ -1,33 +0,0 @@
/**
* Per-checkout install identifiers. Lets two NanoClaw installs coexist on
* one host without clobbering each other's service registration or the
* shared `nanoclaw-agent:latest` docker image tag.
*
* Slug is sha1(projectRoot)[:8] deterministic per checkout path, stable
* across re-runs, unique enough across installs.
*/
import { createHash } from 'crypto';
export function getInstallSlug(projectRoot: string = process.cwd()): string {
return createHash('sha1').update(projectRoot).digest('hex').slice(0, 8);
}
/** launchd Label + plist basename. e.g. `com.nanoclaw-v2-ab12cd34`. */
export function getLaunchdLabel(projectRoot?: string): string {
return `com.nanoclaw-v2-${getInstallSlug(projectRoot)}`;
}
/** systemd unit name (no .service suffix). e.g. `nanoclaw-v2-ab12cd34`. */
export function getSystemdUnit(projectRoot?: string): string {
return `nanoclaw-v2-${getInstallSlug(projectRoot)}`;
}
/** Docker image base (no tag). e.g. `nanoclaw-agent-v2-ab12cd34`. */
export function getContainerImageBase(projectRoot?: string): string {
return `nanoclaw-agent-v2-${getInstallSlug(projectRoot)}`;
}
/** Default full container image reference with `:latest` tag. */
export function getDefaultContainerImage(projectRoot?: string): string {
return `${getContainerImageBase(projectRoot)}:latest`;
}
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isSafeAttachmentName } from './agent-route.js';
/**
* `forwardAttachedFiles` has a filesystem side that's awkward to unit-test
* without mocking DATA_DIR. The guarantee worth pinning is that the
* filename validator rejects everything that could escape the inbox dir
* `forwardAttachedFiles` runs this guard before any I/O, so traversal is
* impossible as long as this matrix holds.
*/
describe('isSafeAttachmentName', () => {
it('accepts plain filenames', () => {
expect(isSafeAttachmentName('baby-duck.png')).toBe(true);
expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true);
expect(isSafeAttachmentName('report.v2.docx')).toBe(true);
expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..`
});
it('rejects empty / sentinel values', () => {
expect(isSafeAttachmentName('')).toBe(false);
expect(isSafeAttachmentName('.')).toBe(false);
expect(isSafeAttachmentName('..')).toBe(false);
});
it('rejects path separators', () => {
expect(isSafeAttachmentName('../evil.png')).toBe(false);
expect(isSafeAttachmentName('/etc/passwd')).toBe(false);
expect(isSafeAttachmentName('nested/file.txt')).toBe(false);
expect(isSafeAttachmentName('windows\\path.exe')).toBe(false);
});
it('rejects NUL bytes', () => {
expect(isSafeAttachmentName('clean\0.png')).toBe(false);
});
it('rejects anything path.basename would strip', () => {
expect(isSafeAttachmentName('a/b')).toBe(false);
expect(isSafeAttachmentName('./thing')).toBe(false);
});
it('rejects non-string input', () => {
expect(isSafeAttachmentName(null as unknown as string)).toBe(false);
expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false);
});
});
+6 -148
View File
@@ -3,13 +3,9 @@
*
* Outbound messages with `channel_type === 'agent'` target another agent
* group rather than a channel. Permission is enforced via `agent_destinations`
* the source agent must have a row for the target. Content is copied into the
* target's inbound DB; if the source message had `files` (from `send_file`),
* the actual bytes are copied from the source's outbox into the target's
* `inbox/<a2a-msg-id>/` directory and surfaced to the target agent as
* `attachments` (existing formatter convention see formatter.ts:230).
* The target agent can then forward the file onward via its own `send_file`
* call using the absolute `/workspace/inbox/<a2a-msg-id>/<filename>` path.
* the source agent must have a row for the target. Content is copied verbatim;
* the target's formatter looks up the source agent in its own local map to
* display a name.
*
* Self-messages are always allowed (used for system notes injected back into
* an agent's own session, e.g. post-approval follow-up prompts).
@@ -18,85 +14,14 @@
* `channel_type === 'agent'` check. When the module is absent the check in
* core throws with a "module not installed" message so retry mark failed.
*/
import fs from 'fs';
import path from 'path';
import { isSafeAttachmentName } from '../../attachment-safety.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { log } from '../../log.js';
import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
import { resolveSession, writeSessionMessage } from '../../session-manager.js';
import type { Session } from '../../types.js';
import { hasDestination } from './db/agent-destinations.js';
export { isSafeAttachmentName };
export interface ForwardedAttachment {
name: string;
filename: string;
type: 'file';
localPath: string;
}
/**
* Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing
* `{name, type, localPath}` convention target agent reads `localPath`
* as relative to `/workspace/`, matching how channel-inbound attachments
* are surfaced today.
*
* Missing source files and unsafe (path-traversal) filenames are skipped
* with a warning rather than failing the whole route a bad filename
* reference shouldn't kill the accompanying text.
*/
export function forwardAttachedFiles(
source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] },
target: { agentGroupId: string; sessionId: string; messageId: string },
): ForwardedAttachment[] {
if (source.filenames.length === 0) return [];
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
if (!fs.existsSync(sourceDir)) {
log.warn('agent-route: source outbox dir missing, no files forwarded', {
sourceMsgId: source.messageId,
sourceDir,
});
return [];
}
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
fs.mkdirSync(targetInboxDir, { recursive: true });
const attachments: ForwardedAttachment[] = [];
for (const filename of source.filenames) {
if (!isSafeAttachmentName(filename)) {
log.warn('agent-route: rejecting unsafe attachment filename (path traversal attempt?)', {
sourceMsgId: source.messageId,
filename,
});
continue;
}
const src = path.join(sourceDir, filename);
if (!fs.existsSync(src)) {
log.warn('agent-route: referenced file missing in source outbox, skipped', {
sourceMsgId: source.messageId,
filename,
});
continue;
}
const dst = path.join(targetInboxDir, filename);
fs.copyFileSync(src, dst);
attachments.push({
name: filename,
filename,
type: 'file',
localPath: `inbox/${target.messageId}/${filename}`,
});
}
return attachments;
}
export interface RoutableAgentMessage {
id: string;
platform_id: string | null;
@@ -120,87 +45,20 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
}
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// If the source message references files (via `send_file`), forward the
// bytes from the source's outbox into the target's inbox so the target
// agent can actually see and re-send them. Without this, agent-to-agent
// file attachments look like they arrive but the target has no way to
// read the bytes — they live in a session dir it doesn't mount.
const forwardedContent = forwardFileAttachments(msg, a2aMsgId, session, targetAgentGroupId, targetSession.id);
writeSessionMessage(targetAgentGroupId, targetSession.id, {
id: a2aMsgId,
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: forwardedContent,
content: msg.content,
});
log.info('Agent message routed', {
from: session.agent_group_id,
to: targetAgentGroupId,
targetSession: targetSession.id,
a2aMsgId,
forwardedFileCount: countForwardedFiles(forwardedContent),
});
const fresh = getSession(targetSession.id);
if (fresh) await wakeContainer(fresh);
}
/**
* Parse source content, copy any referenced `files` from source outbox to
* target inbox, and return a JSON string with an `attachments` array added
* (formatter.ts:223 already knows how to render this shape).
*
* If the source content isn't JSON or has no files, returns the original
* content string unchanged this is safe to call on every route.
*/
function forwardFileAttachments(
msg: RoutableAgentMessage,
a2aMsgId: string,
sourceSession: Session,
targetAgentGroupId: string,
targetSessionId: string,
): string {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(msg.content);
} catch {
return msg.content;
}
const files = parsed.files as unknown;
if (!Array.isArray(files) || files.length === 0) return msg.content;
const filenames = files.filter((f): f is string => typeof f === 'string');
if (filenames.length === 0) return msg.content;
const attachments = forwardAttachedFiles(
{
agentGroupId: sourceSession.agent_group_id,
sessionId: sourceSession.id,
messageId: msg.id,
filenames,
},
{
agentGroupId: targetAgentGroupId,
sessionId: targetSessionId,
messageId: a2aMsgId,
},
);
// Merge into any existing `attachments` (unlikely in a2a context but safe).
const existing = Array.isArray(parsed.attachments) ? (parsed.attachments as Record<string, unknown>[]) : [];
parsed.attachments = [...existing, ...attachments];
return JSON.stringify(parsed);
}
function countForwardedFiles(contentStr: string): number {
try {
const parsed = JSON.parse(contentStr);
return Array.isArray(parsed.attachments) ? parsed.attachments.length : 0;
} catch {
return 0;
}
}
+5 -20
View File
@@ -101,26 +101,13 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
return;
}
const isGroup = event.message?.isGroup ?? originMg?.is_group === 1;
// Extract sender name from the event content for a human-readable card.
let senderName: string | undefined;
try {
const parsed = JSON.parse(event.message.content) as Record<string, unknown>;
senderName = (parsed.senderName ?? parsed.sender) as string | undefined;
} catch {
// non-critical — fall through to generic wording
}
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
const isGroup = originMg?.is_group === 1;
const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
const question = isGroup
? senderName
? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
: `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?`
: senderName
? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`
: `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`;
const options = normalizeOptions(APPROVAL_OPTIONS);
? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?`
: `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`;
createPendingChannelApproval({
messaging_group_id: messagingGroupId,
@@ -128,8 +115,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
original_message: JSON.stringify(event),
approver_user_id: delivery.userId,
created_at: new Date().toISOString(),
title,
options_json: JSON.stringify(options),
});
const adapter = getDeliveryAdapter();
@@ -154,7 +139,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
questionId: messagingGroupId,
title,
question,
options,
options: normalizeOptions(APPROVAL_OPTIONS),
}),
);
log.info('Channel registration card delivered', {
@@ -17,10 +17,6 @@ export interface PendingChannelApproval {
original_message: string;
approver_user_id: string;
created_at: string;
/** Card title shown at creation and re-used by getAskQuestionRender on click. */
title: string;
/** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */
options_json: string;
}
export function createPendingChannelApproval(row: PendingChannelApproval): void {
@@ -28,11 +24,11 @@ export function createPendingChannelApproval(row: PendingChannelApproval): void
.prepare(
`INSERT INTO pending_channel_approvals (
messaging_group_id, agent_group_id, original_message,
approver_user_id, created_at, title, options_json
approver_user_id, created_at
)
VALUES (
@messaging_group_id, @agent_group_id, @original_message,
@approver_user_id, @created_at, @title, @options_json
@approver_user_id, @created_at
)`,
)
.run(row);
@@ -19,10 +19,6 @@ export interface PendingSenderApproval {
original_message: string;
approver_user_id: string;
created_at: string;
/** Card title shown at creation and re-used by getAskQuestionRender on click. */
title: string;
/** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */
options_json: string;
}
export function createPendingSenderApproval(row: PendingSenderApproval): void {
@@ -30,13 +26,11 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void {
.prepare(
`INSERT INTO pending_sender_approvals (
id, messaging_group_id, agent_group_id, sender_identity,
sender_name, original_message, approver_user_id, created_at,
title, options_json
sender_name, original_message, approver_user_id, created_at
)
VALUES (
@id, @messaging_group_id, @agent_group_id, @sender_identity,
@sender_name, @original_message, @approver_user_id, @created_at,
@title, @options_json
@sender_name, @original_message, @approver_user_id, @created_at
)`,
)
.run(row);
+2 -5
View File
@@ -88,11 +88,10 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
const approvalId = generateId();
const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity;
const originName = originMg?.name ?? `a ${originChannelType} channel`;
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
const title = '👤 New sender';
const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`;
const options = normalizeOptions(APPROVAL_OPTIONS);
createPendingSenderApproval({
id: approvalId,
@@ -103,8 +102,6 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
original_message: JSON.stringify(event),
approver_user_id: target.userId,
created_at: new Date().toISOString(),
title,
options_json: JSON.stringify(options),
});
const adapter = getDeliveryAdapter();
@@ -129,7 +126,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
questionId: approvalId,
title,
question,
options,
options: APPROVAL_OPTIONS,
}),
);
log.info('Unknown-sender approval card delivered', {
-23
View File
@@ -1,23 +0,0 @@
/**
* Determine whether a platform ID needs a channel-type prefix.
*
* Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their
* platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan".
* The router stores channel_type and platform_id in separate columns, but
* Chat SDK adapters send the prefixed form as the platform_id so any code
* that writes messaging_groups rows must produce the same shape the adapter
* will later emit as event.platformId, or router lookups miss and messages
* get silently dropped.
*
* Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and
* send them as-is no channel prefix. WhatsApp/iMessage emit JIDs/emails
* containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs
* and 'group:<id>' for group chats. Prefixing any of these would cause a
* mismatch with what the adapter later emits.
*/
export function namespacedPlatformId(channel: string, raw: string): string {
if (raw.startsWith(`${channel}:`)) return raw;
if (raw.includes('@')) return raw;
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
return `${channel}:${raw}`;
}
-28
View File
@@ -1,28 +0,0 @@
/**
* Claude provider container config only registered when the user has
* configured a custom Anthropic-compatible endpoint via setup. Setup
* appends `import './claude.js'` to providers/index.ts at that point;
* standard installs hitting api.anthropic.com don't need this file
* loaded.
*
* The real auth token never enters the container. Setup creates an
* OneCLI generic secret (host-pattern = base URL hostname, header-name
* = Authorization, value-format = "Bearer {value}") so the proxy
* rewrites the Authorization header on the wire. The container only
* needs:
* - ANTHROPIC_BASE_URL so the SDK knows where to call
* - ANTHROPIC_AUTH_TOKEN=placeholder so the SDK adds an
* Authorization: Bearer header for OneCLI to overwrite
*/
import { readEnvFile } from '../env.js';
import { registerProviderContainerConfig } from './provider-container-registry.js';
registerProviderContainerConfig('claude', () => {
const dotenv = readEnvFile(['ANTHROPIC_BASE_URL']);
const env: Record<string, string> = {};
if (dotenv.ANTHROPIC_BASE_URL) {
env.ANTHROPIC_BASE_URL = dotenv.ANTHROPIC_BASE_URL;
env.ANTHROPIC_AUTH_TOKEN = 'placeholder';
}
return { env };
});
+4 -15
View File
@@ -27,7 +27,7 @@ import {
getMessagingGroupWithAgentCount,
} from './db/messaging-groups.js';
import { findSessionForAgent } from './db/sessions.js';
import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js';
import { startTypingRefresh } from './modules/typing/index.js';
import { log } from './log.js';
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
import { wakeContainer } from './container-runner.js';
@@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
channel_type: event.channelType,
platform_id: event.platformId,
name: null,
is_group: event.message.isGroup ? 1 : 0,
is_group: 0,
unknown_sender_policy: 'request_approval',
denied_at: null,
created_at: new Date().toISOString(),
@@ -289,14 +289,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err });
});
}
} else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) {
// Accumulate stores the message as silent context. We allow it when
// engagement simply didn't fire, but NOT when engagement fired and
// the access/scope gate refused — those refusals are security
// decisions about an untrusted sender, and silently storing their
// message (which also stages their attachments to disk via
// writeSessionMessage → extractAttachmentFiles) is exactly what the
// gate is meant to prevent.
} else if (agent.ignored_message_policy === 'accumulate') {
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
accumulatedCount++;
} else {
@@ -457,11 +450,7 @@ async function deliverToAgent(
startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId);
const freshSession = getSession(session.id);
if (freshSession) {
const woke = await wakeContainer(freshSession);
// wakeContainer never throws — it returns false on transient spawn
// failure (host-sweep retries). Stop the typing indicator we just
// started so it doesn't leak; the inbound row stays pending.
if (!woke) stopTypingRefresh(freshSession.id);
await wakeContainer(freshSession);
}
}
}

Some files were not shown because too many files have changed in this diff Show More