Compare commits

..

1 Commits

Author SHA1 Message Date
gavrielc cd7997c99f feat(setup): add Slack and iMessage channel flows (experimental)
Slack: interactive driver walks through app creation, validates the
bot token via auth.test, installs the adapter, and prints a
post-install checklist for the webhook URL + Event Subscriptions
config. No welcome DM since Slack needs a public URL before inbound
events work — the driver's own "finish in Slack" note replaces the
outro "check your DMs" banner.

iMessage: picks local (macOS) vs remote (Photon) mode. Local mode
opens the node binary's directory in Finder so the user can drag it
into Full Disk Access. Remote mode prompts for Photon URL + API key.
Asks for the operator's phone/email, then wires the first agent
including a welcome iMessage.

Both marked "(experimental)" in the askChannelChoice picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:09:30 +03:00
96 changed files with 373 additions and 7504 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);
-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`.
+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:
-15
View File
@@ -1,15 +0,0 @@
You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn.
## Communication
Be concise. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result.
When you produce a file for the user in the workspace — a document, export, or asset — deliver it with `send_file` in the same turn; announcing without sending is an unfinished reply.
## Workspace
Files you create are saved in `/workspace/agent/`. Use this for notes, research, artifacts, and anything that should persist across turns in this group.
## Conversation History
The `conversations/` folder holds searchable past conversation transcripts or exchange archives for this group. Use it to recall prior context when a request references something that happened before.
-3
View File
@@ -7,7 +7,6 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "^1.4.3",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
},
@@ -45,8 +44,6 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-EJxSfc7D/dda/vrw8zQe4g7yVTxERktvb5SvIBlGBnKYQJGOgo9RyA/1EL3l208rHeo6jm1sdrAF0E6o/k94ug=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
-1
View File
@@ -11,7 +11,6 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "^1.4.3",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
},
@@ -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
@@ -1,162 +0,0 @@
import { describe, expect, it, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
type AppServer,
attachCodexAutoApproval,
buildCodexProcessEnv,
tomlBasicString,
writeCodexConfigToml,
} from './codex-app-server.js';
let tmpHome: string | null = null;
const originalHome = process.env.HOME;
afterEach(() => {
process.env.HOME = originalHome;
if (tmpHome) {
fs.rmSync(tmpHome, { recursive: true, force: true });
tmpHome = null;
}
});
describe('Codex config TOML', () => {
it('escapes basic strings', () => {
expect(tomlBasicString('a "quoted" \\\\ value')).toBe('"a \\"quoted\\" \\\\\\\\ value"');
});
it('rejects newlines', () => {
expect(() => tomlBasicString('bad\nvalue')).toThrow(/newline/);
});
it('hardcodes danger-full-access + never and writes model, effort, and MCP servers', () => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
process.env.HOME = tmpHome;
writeCodexConfigToml(
{
nanoclaw: {
command: 'bun',
args: ['run', '/app/src/mcp-tools/index.ts'],
env: { FOO: 'bar' },
},
},
{ model: 'gpt-5', effort: 'medium' },
);
const content = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf-8');
expect(content).toContain('sandbox_mode = "danger-full-access"');
expect(content).toContain('approval_policy = "never"');
expect(content).toContain('project_doc_max_bytes = 32768');
expect(content).toContain('model = "gpt-5"');
expect(content).toContain('model_reasoning_effort = "medium"');
expect(content).not.toContain('[sandbox_workspace_write]');
expect(content).not.toContain('writable_roots =');
expect(content).toContain('[mcp_servers.nanoclaw]');
expect(content).toContain('command = "bun"');
expect(content).toContain('args = ["run", "/app/src/mcp-tools/index.ts"]');
expect(content).toContain('[mcp_servers.nanoclaw.env]');
expect(content).toContain('FOO = "bar"');
});
});
describe('Codex auto-approval', () => {
// NanoClaw (container isolation + OneCLI) is the boundary, so the handler accepts
// every request unconditionally — even paths/commands a sandbox policy would refuse.
it('grants full filesystem + network for permission requests', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({
id: 1,
method: 'item/permissions/requestApproval',
params: { permissions: { fileSystem: { read: ['/workspace/agent'], write: ['/workspace/agent'] } } },
});
const result = JSON.parse(writes[0]).result as {
permissions: { fileSystem: { read: string[]; write: string[] }; network: { enabled: boolean } };
scope: string;
};
expect(result.scope).toBe('turn');
expect(result.permissions.fileSystem.read).toEqual(['/']);
expect(result.permissions.fileSystem.write).toEqual(['/']);
expect(result.permissions.network.enabled).toBe(true);
});
it('accepts file-change and command-exec approvals regardless of path', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({ id: 2, method: 'item/fileChange/requestApproval', params: { grantRoot: '/etc' } });
server.serverRequestHandlers[0]({
id: 3,
method: 'item/commandExecution/requestApproval',
params: { command: 'rm -rf /', cwd: '/' },
});
expect(JSON.parse(writes[0]).result).toEqual({ decision: 'accept' });
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'accept' });
});
it('approves legacy patch and command-exec approvals regardless of path', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({
id: 4,
method: 'applyPatchApproval',
params: { fileChanges: { '/etc/passwd': {} } },
});
server.serverRequestHandlers[0]({ id: 5, method: 'execCommandApproval', params: { command: 'rm -rf /', cwd: '/' } });
expect(JSON.parse(writes[0]).result).toEqual({ decision: 'approved' });
expect(JSON.parse(writes[1]).result).toEqual({ decision: 'approved' });
});
it('fails closed for unknown server requests', () => {
const { server, writes } = fakeServer();
attachCodexAutoApproval(server);
server.serverRequestHandlers[0]({ id: 6, method: 'new/unknown/request' });
const response = JSON.parse(writes[0]);
expect(response.error.message).toContain('Unhandled Codex app-server request');
});
});
describe('Codex process env', () => {
it('forwards proxy/runtime env without leaking secret-like host env', () => {
const env = buildCodexProcessEnv({
PATH: '/bin',
HOME: '/home/node',
CODEX_HOME: '/home/node/.codex',
HTTPS_PROXY: 'http://proxy',
OPENAI_API_KEY: 'sk-test',
ONECLI_API_KEY: 'onecli-secret',
SOME_TOKEN: 'token',
});
expect(env.PATH).toBe('/bin');
expect(env.HOME).toBe('/home/node');
expect(env.CODEX_HOME).toBe('/home/node/.codex');
expect(env.HTTPS_PROXY).toBe('http://proxy');
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.ONECLI_API_KEY).toBeUndefined();
expect(env.SOME_TOKEN).toBeUndefined();
});
});
function fakeServer(): { server: AppServer; writes: string[] } {
const writes: string[] = [];
const server = {
process: { stdin: { write: (line: string) => writes.push(line) } },
readline: { close: () => {} },
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
} as unknown as AppServer;
return { server, writes };
}
@@ -1,441 +0,0 @@
import fs from 'fs';
import path from 'path';
import { spawn, type ChildProcess } from 'child_process';
import { createInterface, type Interface as ReadlineInterface } from 'readline';
// Cap Codex's project-doc loading (AGENTS.md). The host-side composer
// (src/providers/codex-agents-md.ts) enforces the same cap at compose time —
// host and container share no modules, so the constant lives in both.
const CODEX_PROJECT_DOC_MAX_BYTES = 32 * 1024;
function log(msg: string): void {
console.error(`[codex-app-server] ${msg}`);
}
const INIT_TIMEOUT_MS = 30_000;
export const STALE_THREAD_RE = /thread\s+not\s+found|unknown\s+thread|thread[_\s]id|no such thread/i;
let nextRequestId = 1;
export interface JsonRpcResponse {
id: number | string;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
export interface JsonRpcNotification {
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcServerRequest {
id: number | string;
method: string;
params?: Record<string, unknown>;
}
type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
export interface AppServer {
process: ChildProcess;
readline: ReadlineInterface;
pending: Map<number | string, { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }>;
notificationHandlers: Array<(n: JsonRpcNotification) => void>;
serverRequestHandlers: Array<(r: JsonRpcServerRequest) => void>;
/**
* Fired when the app-server process dies (exit or spawn error). Pending
* request/response pairs are rejected separately via failPending but a
* turn in flight has NO pending request (turn/start already resolved); it
* is parked on a notification waker that a dead process will never kick.
* Without these handlers a mid-turn crash surfaces as a 10-minute turn
* timeout instead of the real exit code, after the --rm container has
* already taken the server's stderr with it.
*/
exitHandlers: Array<(err: Error) => void>;
}
export interface CodexMcpServer {
command: string;
args?: string[];
env?: Record<string, string>;
}
export type CodexReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
// Codex runs unrestricted inside the container. NanoClaw's container isolation and
// the OneCLI allow-list are the security boundary — not Codex's own sandbox/approval
// primitives (which can't run here anyway: workspace-write/read-only need user
// namespaces, which the agent containers deny). Both are hardcoded as instance-level
// defaults in config.toml; threads and turns inherit them, never override them.
const CODEX_SANDBOX_MODE = 'danger-full-access';
const CODEX_APPROVAL_POLICY = 'never';
const CODEX_ENV_ALLOWLIST = new Set([
'ALL_PROXY',
'CURL_CA_BUNDLE',
'GIT_SSL_CAINFO',
'HOME',
'HTTP_PROXY',
'HTTPS_PROXY',
'LANG',
'LC_ALL',
'NODE_EXTRA_CA_CERTS',
'NO_PROXY',
'PATH',
'PNPM_HOME',
'REQUESTS_CA_BUNDLE',
'SSL_CERT_DIR',
'SSL_CERT_FILE',
'TEMP',
'TERM',
'TMP',
'TMPDIR',
'TZ',
'USER',
'all_proxy',
'http_proxy',
'https_proxy',
'no_proxy',
'CODEX_HOME',
]);
export interface ThreadParams {
model?: string;
cwd: string;
baseInstructions?: string;
developerInstructions?: string;
}
export interface TurnParams {
threadId: string;
inputText: string;
model?: string;
effort?: string;
cwd?: string;
}
export function spawnCodexAppServer(): AppServer {
const args = ['app-server', '--listen', 'stdio://'];
log(`Spawning: codex ${args.join(' ')}`);
const proc = spawn('codex', args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: buildCodexProcessEnv(process.env),
});
const rl = createInterface({ input: proc.stdout! });
const server: AppServer = {
process: proc,
readline: rl,
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
};
proc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) log(`[stderr] ${text}`);
});
rl.on('line', (line: string) => {
if (!line.trim()) return;
let msg: JsonRpcMessage;
try {
msg = JSON.parse(line) as JsonRpcMessage;
} catch {
log(`[parse-error] ${line.slice(0, 200)}`);
return;
}
if (isResponse(msg)) {
const handler = server.pending.get(msg.id);
if (handler) {
server.pending.delete(msg.id);
handler.resolve(msg);
}
} else if (isServerRequest(msg)) {
for (const h of server.serverRequestHandlers) h(msg);
} else if ('method' in msg) {
for (const h of server.notificationHandlers) h(msg as JsonRpcNotification);
}
});
const failPending = (err: Error): void => {
for (const [, handler] of server.pending) handler.reject(err);
server.pending.clear();
};
proc.on('error', (err) => {
log(`[process-error] ${err.message}`);
failPending(err);
for (const h of [...server.exitHandlers]) h(err);
});
proc.on('exit', (code, signal) => {
log(`[exit] code=${code} signal=${signal}`);
const err = new Error(`Codex app-server exited: code=${code} signal=${signal}`);
failPending(err);
for (const h of [...server.exitHandlers]) h(err);
});
return server;
}
export function sendCodexRequest(
server: AppServer,
method: string,
params?: Record<string, unknown>,
timeoutMs = 60_000,
): Promise<JsonRpcResponse> {
const id = nextRequestId++;
const req = params === undefined ? { id, method } : { id, method, params };
const line = JSON.stringify(req) + '\n';
return new Promise<JsonRpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
server.pending.delete(id);
reject(new Error(`Timeout waiting for ${method} response (${timeoutMs}ms)`));
}, timeoutMs);
server.pending.set(id, {
resolve: (r) => {
clearTimeout(timer);
resolve(r);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
try {
server.process.stdin!.write(line);
} catch (err) {
clearTimeout(timer);
server.pending.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
export function sendCodexNotification(server: AppServer, method: string, params?: Record<string, unknown>): void {
const line = JSON.stringify(params === undefined ? { method } : { method, params }) + '\n';
server.process.stdin!.write(line);
}
export function sendCodexResponse(server: AppServer, id: number | string, result: unknown): void {
try {
server.process.stdin!.write(JSON.stringify({ id, result }) + '\n');
} catch (err) {
log(`[send-error] response id=${id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
export function killCodexAppServer(server: AppServer): void {
try {
server.readline.close();
server.process.kill('SIGTERM');
} catch {
/* ignore */
}
}
export async function initializeCodexAppServer(server: AppServer): Promise<void> {
const resp = await sendCodexRequest(
server,
'initialize',
{
clientInfo: { name: 'nanoclaw', title: 'NanoClaw', version: '2.0' },
capabilities: { experimentalApi: true },
},
INIT_TIMEOUT_MS,
);
if (resp.error) throw new Error(`initialize failed: ${resp.error.message}`);
sendCodexNotification(server, 'initialized');
}
export async function startOrResumeCodexThread(
server: AppServer,
threadId: string | undefined,
params: ThreadParams,
): Promise<string> {
const baseParams = {
model: params.model,
cwd: params.cwd,
approvalPolicy: CODEX_APPROVAL_POLICY,
sandbox: CODEX_SANDBOX_MODE,
baseInstructions: params.baseInstructions,
developerInstructions: params.developerInstructions,
personality: 'friendly',
sessionStartSource: 'startup',
persistExtendedHistory: false,
};
if (threadId) {
const resp = await sendCodexRequest(server, 'thread/resume', {
threadId,
...baseParams,
excludeTurns: true,
});
if (!resp.error) return threadId;
if (!STALE_THREAD_RE.test(resp.error.message)) {
throw new Error(`thread/resume failed: ${resp.error.message}`);
}
log(`Stale thread ${threadId}; starting fresh thread.`);
}
const resp = await sendCodexRequest(server, 'thread/start', {
...baseParams,
experimentalRawEvents: false,
});
if (resp.error) throw new Error(`thread/start failed: ${resp.error.message}`);
const result = resp.result as { thread?: { id?: string } } | undefined;
const newThreadId = result?.thread?.id;
if (!newThreadId) throw new Error('thread/start response missing thread ID');
return newThreadId;
}
export async function startCodexTurn(server: AppServer, params: TurnParams): Promise<string> {
const resp = await sendCodexRequest(server, 'turn/start', {
threadId: params.threadId,
input: [{ type: 'text', text: params.inputText, text_elements: [] }],
model: params.model,
effort: params.effort,
cwd: params.cwd,
});
if (resp.error) throw new Error(`turn/start failed: ${resp.error.message}`);
const result = resp.result as { turn?: { id?: string } } | undefined;
const turnId = result?.turn?.id;
if (!turnId) throw new Error('turn/start response missing turn ID');
return turnId;
}
export async function steerCodexTurn(
server: AppServer,
threadId: string,
turnId: string,
inputText: string,
): Promise<void> {
const resp = await sendCodexRequest(server, 'turn/steer', {
threadId,
expectedTurnId: turnId,
input: [{ type: 'text', text: inputText, text_elements: [] }],
});
if (resp.error) throw new Error(`turn/steer failed: ${resp.error.message}`);
}
export async function interruptCodexTurn(server: AppServer, threadId: string, turnId: string): Promise<void> {
const resp = await sendCodexRequest(server, 'turn/interrupt', { threadId, turnId }, 10_000);
if (resp.error) throw new Error(`turn/interrupt failed: ${resp.error.message}`);
}
// With approval_policy=never the command/patch approval requests don't fire, but the
// app-server still sends a few non-approval server→client requests (permission
// negotiation, MCP elicitations, tool calls) that must be answered or the turn hangs.
// NanoClaw is the boundary, so accept/grant everything.
export function attachCodexAutoApproval(server: AppServer): void {
server.serverRequestHandlers.push((req) => {
switch (req.method) {
case 'item/commandExecution/requestApproval':
case 'item/fileChange/requestApproval':
sendCodexResponse(server, req.id, { decision: 'accept' });
break;
case 'applyPatchApproval':
case 'execCommandApproval':
sendCodexResponse(server, req.id, { decision: 'approved' });
break;
case 'item/permissions/requestApproval':
sendCodexResponse(server, req.id, {
permissions: { fileSystem: { read: ['/'], write: ['/'] }, network: { enabled: true } },
scope: 'turn',
strictAutoReview: true,
});
break;
case 'item/tool/requestUserInput':
sendCodexResponse(server, req.id, { answers: {} });
break;
case 'mcpServer/elicitation/request':
sendCodexResponse(server, req.id, { action: 'cancel', content: null, _meta: null });
break;
case 'item/tool/call':
sendCodexResponse(server, req.id, { success: false, contentItems: [] });
break;
default:
sendCodexError(server, req.id, `Unhandled Codex app-server request: ${req.method}`);
break;
}
});
}
export function writeCodexConfigToml(
servers: Record<string, CodexMcpServer>,
opts: { model?: string; effort?: string } = {},
): void {
const codexConfigDir = path.join(process.env.HOME || '/home/node', '.codex');
fs.mkdirSync(codexConfigDir, { recursive: true });
const configTomlPath = path.join(codexConfigDir, 'config.toml');
// Instance-level defaults the app-server reads on startup; threads/turns inherit them.
const lines: string[] = [
`sandbox_mode = ${tomlBasicString(CODEX_SANDBOX_MODE)}`,
`approval_policy = ${tomlBasicString(CODEX_APPROVAL_POLICY)}`,
`project_doc_max_bytes = ${CODEX_PROJECT_DOC_MAX_BYTES}`,
];
if (opts.model) lines.push(`model = ${tomlBasicString(opts.model)}`);
if (opts.effort) lines.push(`model_reasoning_effort = ${tomlBasicString(opts.effort)}`);
lines.push('');
for (const [name, config] of Object.entries(servers)) {
lines.push(`[mcp_servers.${name}]`);
lines.push(`command = ${tomlBasicString(config.command)}`);
if (config.args && config.args.length > 0) {
lines.push(`args = [${config.args.map(tomlBasicString).join(', ')}]`);
}
if (config.env && Object.keys(config.env).length > 0) {
lines.push(`[mcp_servers.${name}.env]`);
for (const [key, value] of Object.entries(config.env)) {
lines.push(`${key} = ${tomlBasicString(value)}`);
}
}
lines.push('');
}
fs.writeFileSync(configTomlPath, lines.join('\n'));
}
export function buildCodexProcessEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = {};
for (const key of CODEX_ENV_ALLOWLIST) {
const value = env[key];
if (value !== undefined) next[key] = value;
}
if (!next.CODEX_HOME) next.CODEX_HOME = next.HOME ? path.join(next.HOME, '.codex') : '/home/node/.codex';
if (!next.HOME) next.HOME = '/home/node';
return next;
}
export function tomlBasicString(value: string): string {
if (value.includes('\n') || value.includes('\r')) {
throw new Error(`MCP config value contains newline: ${JSON.stringify(value.slice(0, 40))}`);
}
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function sendCodexError(server: AppServer, id: number | string, message: string, data?: unknown): void {
try {
server.process.stdin!.write(JSON.stringify({ id, error: { code: -32000, message, data } }) + '\n');
} catch (err) {
log(`[send-error] error id=${id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
return 'id' in msg && ('result' in msg || 'error' in msg) && !('method' in msg);
}
function isServerRequest(msg: JsonRpcMessage): msg is JsonRpcServerRequest {
return 'id' in msg && 'method' in msg;
}
@@ -1,39 +0,0 @@
// Structural guard for the Codex CLI install in container/cli-tools.json.
//
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
// json-merge seam), not an importable package, so the barrel-driven
// registration tests cannot see it. This test reads the real cli-tools.json
// and asserts the @openai/codex entry is present and pinned to an exact
// version. It goes red if the manifest entry is dropped or unpins.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/cli-tools.json
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
const manifestPresent = existsSync(MANIFEST);
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
// read has to be guarded for the bare-branch (no manifest) case.
const tools: Array<{ name: string; version: string }> = manifestPresent
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
: [];
const codex = tools.find((t) => t.name === '@openai/codex');
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
// so skip there. In an installed tree (trunk + this payload) it must carry the
// pinned @openai/codex entry.
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
it('includes the @openai/codex entry', () => {
expect(codex).toBeDefined();
});
it('pins it to an exact semver (no latest, no ranges)', () => {
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
});
});
@@ -1,22 +0,0 @@
/**
* Integration test for the codex provider's CONTAINER-side reach-in: the self-registration
* import in container/agent-runner/src/providers/index.ts. Importing the barrel runs
* codex.ts's top-level registerProvider('codex', ); without that import line
* createProvider('codex') throws 'Unknown provider' at runtime.
*
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
* never ./codex.js directly, then asserts listProviderNames() contains the provider. The
* existing codex.factory.test.ts imports ./codex.js directly, so it self-registers and
* stays GREEN when the barrel line is deleted a unit test, not a registration guard.
* This goes red if the barrel import is deleted/drifts or the barrel fails to evaluate. codex uses the @openai/codex CLI *binary* (not an importable package), so this test does not guard that dependency the Dockerfile install line is guarded structurally + by the container build (see the skill validate step).
*/
import { describe, it, expect } from 'bun:test';
import { listProviderNames } from './provider-registry.js';
import './index.js'; // the real container provider barrel — triggers each provider's registerProvider()
describe('codex provider registration', () => {
it('registers codex via the provider barrel', () => {
expect(listProviderNames()).toContain('codex');
});
});
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { CodexProvider } from './codex.js';
describe('CodexProvider', () => {
it('rejects unsupported reasoning effort values', () => {
expect(() => new CodexProvider({ effort: 'max' })).toThrow(/Unsupported Codex reasoning effort/);
});
it('normalizes supported reasoning effort values', () => {
expect(new CodexProvider({ effort: 'HIGH' })).toBeInstanceOf(CodexProvider);
});
it('accepts supported reasoning effort values', () => {
expect(new CodexProvider({ effort: 'xhigh' })).toBeInstanceOf(CodexProvider);
});
});
@@ -1,419 +0,0 @@
import fs from 'fs';
import path from 'path';
import { registerProvider } from './provider-registry.js';
import type {
AgentProvider,
AgentQuery,
McpServerConfig,
ProviderEvent,
ProviderExchange,
ProviderOptions,
QueryInput,
} from './types.js';
import { archiveProviderExchange } from './exchange-archive.js';
import {
type AppServer,
type CodexReasoningEffort,
type JsonRpcNotification,
STALE_THREAD_RE,
attachCodexAutoApproval,
initializeCodexAppServer,
interruptCodexTurn,
killCodexAppServer,
spawnCodexAppServer,
startCodexTurn,
startOrResumeCodexThread,
steerCodexTurn,
writeCodexConfigToml,
} from './codex-app-server.js';
const TURN_TIMEOUT_MS = 10 * 60 * 1000;
const SUPPORTED_EFFORTS = new Set<CodexReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
export interface CodexRuntimeDeps {
writeCodexConfigToml: typeof writeCodexConfigToml;
spawnCodexAppServer: typeof spawnCodexAppServer;
attachCodexAutoApproval: typeof attachCodexAutoApproval;
initializeCodexAppServer: typeof initializeCodexAppServer;
startOrResumeCodexThread: typeof startOrResumeCodexThread;
startCodexTurn: typeof startCodexTurn;
steerCodexTurn: typeof steerCodexTurn;
interruptCodexTurn: typeof interruptCodexTurn;
killCodexAppServer: typeof killCodexAppServer;
}
const defaultCodexRuntimeDeps: CodexRuntimeDeps = {
writeCodexConfigToml,
spawnCodexAppServer,
attachCodexAutoApproval,
initializeCodexAppServer,
startOrResumeCodexThread,
startCodexTurn,
steerCodexTurn,
interruptCodexTurn,
killCodexAppServer,
};
function classifyError(message: string): string | undefined {
if (/auth|api key|unauthorized|login|credential/i.test(message)) return 'auth';
if (/quota|rate limit|insufficient|billing|credit/i.test(message)) return 'quota';
if (/sandbox|permission|denied/i.test(message)) return 'sandbox';
if (/thread|conversation|session/i.test(message)) return 'stale-session';
return undefined;
}
function normalizeEffort(effort: string | undefined): CodexReasoningEffort | undefined {
const normalized = effort?.trim().toLowerCase();
if (!normalized) return undefined;
if (!SUPPORTED_EFFORTS.has(normalized as CodexReasoningEffort)) {
throw new Error(`Unsupported Codex reasoning effort: ${effort}`);
}
return normalized as CodexReasoningEffort;
}
export class CodexProvider implements AgentProvider {
readonly supportsNativeSlashCommands = false;
// Codex has no native NanoClaw memory — opt in to the runner's persistent
// memory/ scaffold (see memory-scaffold.ts).
readonly usesMemoryScaffold = true;
// The app-server keeps history server-side; there is no on-disk transcript,
// so the provider persists each exchange itself into `conversations/`
// (see exchange-archive.ts). The poll-loop reports exchanges through this
// hook and does nothing else — archiving is payload code, not runner code.
onExchangeComplete(exchange: ProviderExchange): void {
archiveProviderExchange({
provider: 'codex',
prompt: exchange.prompt,
result: exchange.result,
continuation: exchange.continuation,
status: exchange.status,
});
}
private readonly mcpServers: Record<string, McpServerConfig>;
private readonly model?: string;
private readonly effort?: CodexReasoningEffort;
private readonly runtime: CodexRuntimeDeps;
constructor(options: ProviderOptions = {}, runtime: CodexRuntimeDeps = defaultCodexRuntimeDeps) {
this.mcpServers = options.mcpServers ?? {};
this.model = options.model;
this.runtime = runtime;
this.effort = normalizeEffort(options.effort);
}
isSessionInvalid(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return STALE_THREAD_RE.test(msg);
}
query(input: QueryInput): AgentQuery {
const pending: string[] = [input.prompt];
let waiting: (() => void) | null = null;
let ended = false;
let aborted = false;
let activeServer: AppServer | null = null;
let activeThreadId: string | null = null;
let activeTurnId: string | null = null;
let wakeActiveTurn: (() => void) | null = null;
const wake = (): void => {
waiting?.();
waiting = null;
};
const pushOrSteer = (message: string): void => {
if (activeServer && activeThreadId && activeTurnId) {
void this.runtime.steerCodexTurn(activeServer, activeThreadId, activeTurnId, message).catch(() => {
pending.push(message);
wake();
});
return;
}
pending.push(message);
wake();
};
const self = this;
async function* gen(): AsyncGenerator<ProviderEvent> {
self.runtime.writeCodexConfigToml(self.mcpServers, { model: self.model, effort: self.effort });
const server = self.runtime.spawnCodexAppServer();
activeServer = server;
self.runtime.attachCodexAutoApproval(server);
let threadId: string | undefined = input.continuation;
let initYielded = false;
try {
await self.runtime.initializeCodexAppServer(server);
threadId = await self.runtime.startOrResumeCodexThread(server, threadId, {
model: self.model,
cwd: input.cwd,
baseInstructions: input.systemContext?.instructions,
});
activeThreadId = threadId;
while (!aborted) {
while (pending.length === 0 && !ended && !aborted) {
await new Promise<void>((resolve) => {
waiting = resolve;
});
}
if (aborted) return;
if (pending.length === 0 && ended) return;
const text = pending.shift()!;
yield* runOneTurn(
server,
threadId,
text,
self.model,
self.effort,
input.cwd,
(turnId) => {
activeTurnId = turnId;
},
() => {
activeTurnId = null;
},
() => initYielded,
() => {
initYielded = true;
},
() => aborted,
(waker) => {
wakeActiveTurn = waker;
},
self.runtime.startCodexTurn,
);
}
} finally {
activeTurnId = null;
activeThreadId = null;
activeServer = null;
wakeActiveTurn = null;
self.runtime.killCodexAppServer(server);
}
}
return {
push: pushOrSteer,
end: () => {
ended = true;
wake();
},
abort: () => {
aborted = true;
if (activeServer && activeThreadId && activeTurnId) {
void this.runtime.interruptCodexTurn(activeServer, activeThreadId, activeTurnId).catch(() => {});
}
wakeActiveTurn?.();
wake();
},
events: gen(),
};
}
}
async function* runOneTurn(
server: AppServer,
threadId: string,
inputText: string,
model: string | undefined,
effort: string | undefined,
cwd: string,
setActiveTurn: (turnId: string) => void,
clearActiveTurn: () => void,
hasInit: () => boolean,
markInit: () => void,
isAborted: () => boolean,
setAbortWaker: (waker: (() => void) | null) => void,
startTurn: typeof startCodexTurn,
): AsyncGenerator<ProviderEvent> {
const state: { error: Error | null } = { error: null };
let resultText = '';
let turnDone = false;
let turnId: string | null = null;
// A finished turn can no longer absorb steered input: codex's turn/steer
// against a completed turn resolves as a no-op, so a follow-up routed there
// is lost silently. Clear the active-turn marker the moment the turn ends —
// before the generator drains and tears down in its `finally` — so
// pushOrSteer queues any racing follow-up into a fresh turn instead.
const finishTurn = (): void => {
turnDone = true;
clearActiveTurn();
};
const buffer: ProviderEvent[] = [];
let waker: (() => void) | null = null;
const kick = (): void => {
waker?.();
waker = null;
};
setAbortWaker(kick);
const handler = (n: JsonRpcNotification): void => {
const method = n.method;
const params = n.params ?? {};
buffer.push({ type: 'activity' });
switch (method) {
case 'thread/started': {
const thread = params.thread as { id?: string } | undefined;
if (thread?.id && !hasInit()) {
markInit();
buffer.push({ type: 'init', continuation: thread.id });
}
break;
}
case 'turn/started': {
const turn = params.turn as { id?: string } | undefined;
if (turn?.id) {
turnId = turn.id;
setActiveTurn(turn.id);
}
break;
}
case 'item/agentMessage/delta': {
const delta = params.delta as string | undefined;
if (delta) resultText += delta;
break;
}
case 'item/completed': {
const item = params.item as { type?: string; text?: string } | undefined;
if (item?.type === 'agentMessage' && item.text) resultText = item.text;
break;
}
case 'thread/status/changed': {
const status = params.status as string | undefined;
if (status) buffer.push({ type: 'progress', message: `status: ${status}` });
break;
}
case 'error': {
const err = params.error as { message?: string; additionalDetails?: string | null } | undefined;
const msg = [err?.message, err?.additionalDetails].filter(Boolean).join(': ') || 'Codex turn failed';
state.error = new Error(msg);
finishTurn();
break;
}
case 'turn/completed': {
const turn = params.turn as
| { error?: { message?: string; additionalDetails?: string | null } | null; items?: unknown[] }
| undefined;
const agentMessage = turn?.items
?.filter((item): item is { type: string; text?: string } => typeof item === 'object' && item !== null)
.find((item) => item.type === 'agentMessage' && item.text);
if (agentMessage?.text) resultText = agentMessage.text;
if (turn?.error) {
const msg =
[turn.error.message, turn.error.additionalDetails].filter(Boolean).join(': ') || 'Codex turn failed';
state.error = new Error(msg);
}
finishTurn();
break;
}
default:
break;
}
kick();
};
server.notificationHandlers.push(handler);
// A dead app-server can't send the notification this turn is parked on —
// end the turn immediately with the real cause instead of the 10-min timeout.
const onServerExit = (err: Error): void => {
if (turnDone) return;
state.error = err;
finishTurn();
kick();
};
server.exitHandlers.push(onServerExit);
const timer = setTimeout(() => {
state.error = new Error(`Turn timed out after ${TURN_TIMEOUT_MS}ms`);
finishTurn();
kick();
}, TURN_TIMEOUT_MS);
try {
if (!hasInit()) {
markInit();
buffer.push({ type: 'init', continuation: threadId });
}
turnId = await startTurn(server, {
threadId,
inputText,
model,
effort,
cwd,
});
setActiveTurn(turnId);
const imagesBefore = listGeneratedImages(threadId);
if (isAborted()) return;
while (true) {
while (buffer.length > 0) {
yield buffer.shift()!;
}
if (turnDone || isAborted()) break;
await new Promise<void>((resolve) => {
waker = resolve;
});
waker = null;
}
while (buffer.length > 0) yield buffer.shift()!;
if (isAborted()) return;
if (state.error) {
yield {
type: 'error',
message: state.error.message,
retryable: false,
classification: classifyError(state.error.message),
};
throw state.error;
}
for (const imagePath of listGeneratedImages(threadId)) {
if (!imagesBefore.has(imagePath)) {
yield { type: 'file', path: imagePath };
}
}
yield { type: 'result', text: resultText || null };
} finally {
clearTimeout(timer);
clearActiveTurn();
setAbortWaker(null);
const idx = server.notificationHandlers.indexOf(handler);
if (idx >= 0) server.notificationHandlers.splice(idx, 1);
const exitIdx = server.exitHandlers.indexOf(onServerExit);
if (exitIdx >= 0) server.exitHandlers.splice(exitIdx, 1);
}
}
/**
* Codex's built-in image generation saves into CODEX_HOME/generated_images/
* <threadId>/ its native client renders those to the user, so the model
* believes delivery already happened and won't send_file them. The runner
* must deliver them itself: snapshot the dir at turn start, emit a `file`
* event for anything new at turn end.
*/
function listGeneratedImages(threadId: string): Set<string> {
const dir = path.join(process.env.CODEX_HOME || '/home/node/.codex', 'generated_images', threadId);
try {
return new Set(fs.readdirSync(dir).map((f) => path.join(dir, f)));
} catch {
return new Set();
}
}
registerProvider('codex', (opts) => new CodexProvider(opts));
@@ -1,267 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { CodexProvider, type CodexRuntimeDeps } from './codex.js';
import type { AppServer, JsonRpcNotification, TurnParams } from './codex-app-server.js';
import type { ProviderEvent } from './types.js';
describe('CodexProvider active turns', () => {
it('steers follow-ups into the active turn and yields liveness activity', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.push('follow-up prompt');
await waitFor(() => fake.steerCalls.length === 1);
query.end();
fake.completeTurn('final answer');
await collect;
expect(fake.startCalls).toHaveLength(1);
expect(fake.startCalls[0].inputText).toBe('first prompt');
expect(fake.steerCalls).toEqual([{ threadId: 'thread-1', turnId: 'turn-1', inputText: 'follow-up prompt' }]);
expect(events.filter((event) => event.type === 'activity').length).toBeGreaterThanOrEqual(2);
expect(events.filter((event) => event.type === 'result')).toEqual([{ type: 'result', text: 'final answer' }]);
expect(fake.killed).toBe(true);
});
it('queues follow-ups for the next turn when steering is rejected', async () => {
const fake = createFakeCodexRuntime({ rejectSteer: true });
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.push('queued follow-up');
await waitFor(() => fake.steerCalls.length === 1);
await sleep(0);
fake.completeTurn('first answer');
await waitFor(() => fake.startCalls.length === 2);
query.end();
fake.completeTurn('second answer');
await collect;
expect(fake.startCalls.map((call) => call.inputText)).toEqual(['first prompt', 'queued follow-up']);
expect(fake.steerCalls).toHaveLength(1);
expect(events.filter((event) => event.type === 'result')).toEqual([
{ type: 'result', text: 'first answer' },
{ type: 'result', text: 'second answer' },
]);
});
it('queues a follow-up that races turn completion into a new turn, never steering the finished turn', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
// The turn completes, then a follow-up lands in the same tick — before the
// generator has drained and torn the turn down. codex's turn/steer no-ops
// on a finished turn (resolves without error), so steering here would drop
// the message silently. It must start a fresh turn instead.
fake.completeTurn('first answer');
query.push('racing follow-up');
await waitFor(() => fake.startCalls.length === 2);
query.end();
fake.completeTurn('second answer');
await collect;
expect(fake.steerCalls).toHaveLength(0);
expect(fake.startCalls.map((call) => call.inputText)).toEqual(['first prompt', 'racing follow-up']);
expect(events.filter((event) => event.type === 'result')).toEqual([
{ type: 'result', text: 'first answer' },
{ type: 'result', text: 'second answer' },
]);
});
it('interrupts the active turn and closes the stream on abort', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.abort();
await collect;
expect(fake.interruptCalls).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
expect(events.some((event) => event.type === 'result')).toBe(false);
expect(fake.killed).toBe(true);
});
it('threads the configured model and effort into the turn', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({ model: 'gpt-5.5', effort: 'high' }, fake.runtime);
const query = provider.query({ prompt: 'first prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
query.end();
fake.completeTurn('final answer');
await collect;
expect(fake.startCalls[0].model).toBe('gpt-5.5');
expect(fake.startCalls[0].effort).toBe('high');
expect(events.filter((event) => event.type === 'result')).toEqual([{ type: 'result', text: 'final answer' }]);
});
it('delivers harness-generated images as file events — the model never sends them itself', async () => {
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-home-'));
const prevHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
try {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'make an image', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
// Codex's built-in image_gen writes into CODEX_HOME mid-turn.
const imagesDir = path.join(codexHome, 'generated_images', 'thread-1');
fs.mkdirSync(imagesDir, { recursive: true });
fs.writeFileSync(path.join(imagesDir, 'ig_abc.png'), 'png-bytes');
query.end();
fake.completeTurn('Here you go — created the image.');
await collect;
const files = events.filter((event) => event.type === 'file') as Array<{ type: 'file'; path: string }>;
expect(files).toHaveLength(1);
expect(files[0].path).toBe(path.join(imagesDir, 'ig_abc.png'));
// file events arrive before the result so delivery shares the turn.
expect(events.findIndex((e) => e.type === 'file')).toBeLessThan(events.findIndex((e) => e.type === 'result'));
} finally {
if (prevHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevHome;
fs.rmSync(codexHome, { recursive: true, force: true });
}
});
it('ends the turn immediately with the real cause when the app-server dies mid-turn', async () => {
const fake = createFakeCodexRuntime();
const provider = new CodexProvider({}, fake.runtime);
const query = provider.query({ prompt: 'prompt', cwd: '/workspace/agent' });
const events: ProviderEvent[] = [];
const collect = collectEvents(query.events, events);
await waitFor(() => fake.startCalls.length === 1);
// No pending request exists mid-turn (turn/start already resolved), so
// only the exitHandlers seam can end the turn — without it this parks
// on the waker until the 10-minute turn timeout.
fake.crashServer(new Error('Codex app-server exited: code=1 signal=null'));
// The generator yields the error event, then rethrows to its consumer.
await collect.catch(() => {});
const errors = events.filter((event) => event.type === 'error');
expect(errors).toHaveLength(1);
expect((errors[0] as { message: string }).message).toContain('app-server exited');
});
});
function createFakeCodexRuntime(opts: { rejectSteer?: boolean } = {}) {
const server = fakeServer();
const startCalls: TurnParams[] = [];
const steerCalls: Array<{ threadId: string; turnId: string; inputText: string }> = [];
const interruptCalls: Array<{ threadId: string; turnId: string }> = [];
let killed = false;
const notify = (method: string, params?: Record<string, unknown>): void => {
const notification: JsonRpcNotification = { method, params };
for (const handler of [...server.notificationHandlers]) handler(notification);
};
const runtime: CodexRuntimeDeps = {
writeCodexConfigToml: () => {},
spawnCodexAppServer: () => server,
attachCodexAutoApproval: () => {},
initializeCodexAppServer: async () => {},
startOrResumeCodexThread: async (_server, threadId) => threadId ?? 'thread-1',
startCodexTurn: async (_server, params) => {
startCalls.push(params);
const turnId = `turn-${startCalls.length}`;
notify('turn/started', { turn: { id: turnId } });
return turnId;
},
steerCodexTurn: async (_server, threadId, turnId, inputText) => {
steerCalls.push({ threadId, turnId, inputText });
if (opts.rejectSteer) throw new Error('steer rejected');
},
interruptCodexTurn: async (_server, threadId, turnId) => {
interruptCalls.push({ threadId, turnId });
},
killCodexAppServer: () => {
killed = true;
},
};
return {
runtime,
startCalls,
steerCalls,
interruptCalls,
get killed() {
return killed;
},
completeTurn(text: string) {
notify('turn/completed', { turn: { items: [{ type: 'agentMessage', text }] } });
},
crashServer(err: Error) {
for (const h of [...server.exitHandlers]) h(err);
},
};
}
function fakeServer(): AppServer {
return {
process: { stdin: { write: () => true }, kill: () => true },
readline: { close: () => {} },
pending: new Map(),
notificationHandlers: [],
exitHandlers: [],
serverRequestHandlers: [],
} as unknown as AppServer;
}
async function collectEvents(events: AsyncIterable<ProviderEvent>, sink: ProviderEvent[]): Promise<void> {
for await (const event of events) {
sink.push(event);
}
}
async function waitFor(condition: () => boolean, timeoutMs = 1000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout');
await sleep(10);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -1,136 +0,0 @@
import { afterEach, describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { archiveProviderExchange } from './exchange-archive.js';
let tmpDir: string | null = null;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
function makeTmpDir(): string {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-archive-'));
return tmpDir;
}
describe('provider exchange archive', () => {
it('appends same-thread exchanges into one file with a single header', () => {
const conversationsDir = makeTmpDir();
const timestamp = new Date('2026-06-03T12:34:56.789Z');
const first = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'hello',
result: 'world',
continuation: 'thread-123',
status: 'completed',
timestamp,
});
const second = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'hello again',
result: 'world again',
continuation: 'thread-123',
status: 'completed',
timestamp,
});
// Same thread → same date-prefixed, thread-stable file, not one per exchange.
expect(first).toBe('2026-06-03-codex-thread-123.md');
expect(second).toBe(first);
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
const content = fs.readFileSync(path.join(conversationsDir, first!), 'utf-8');
// Header (thread-level metadata) written exactly once.
expect(content.match(/# Codex Conversation/g)).toHaveLength(1);
expect(content).toContain('Provider: codex');
expect(content).toContain('Continuation/thread id: thread-123');
// Both exchanges present, each with its own status line.
expect(content).toContain('**User**: hello');
expect(content).toContain('**Assistant**: world');
expect(content).toContain('**User**: hello again');
expect(content).toContain('**Assistant**: world again');
expect(content.match(/Status: completed/g)).toHaveLength(2);
});
it('writes a separate file per thread', () => {
const conversationsDir = makeTmpDir();
const timestamp = new Date('2026-06-03T12:34:56.789Z');
const a = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'p',
result: 'r',
continuation: 'thread-a',
status: 'completed',
timestamp,
});
const b = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'p',
result: 'r',
continuation: 'thread-b',
status: 'completed',
timestamp,
});
expect(a).toBe('2026-06-03-codex-thread-a.md');
expect(b).toBe('2026-06-03-codex-thread-b.md');
expect(fs.readdirSync(conversationsDir)).toHaveLength(2);
});
it('keeps the creation-date prefix stable when later exchanges land on another day', () => {
const conversationsDir = makeTmpDir();
const first = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'a',
result: 'b',
continuation: 'thread-x',
status: 'completed',
timestamp: new Date('2026-06-03T10:00:00.000Z'),
});
// A later exchange on a different day must append to the same file, not
// mint a new 2026-06-05-* one (the bug a naive date-from-timestamp scheme
// would introduce).
const second = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'c',
result: 'd',
continuation: 'thread-x',
status: 'completed',
timestamp: new Date('2026-06-05T10:00:00.000Z'),
});
expect(first).toBe('2026-06-03-codex-thread-x.md');
expect(second).toBe(first);
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
});
it('skips empty result text', () => {
const conversationsDir = makeTmpDir();
const filename = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'hello',
result: ' ',
continuation: 'thread-123',
status: 'completed',
});
expect(filename).toBeNull();
expect(fs.readdirSync(conversationsDir)).toHaveLength(0);
});
});
@@ -1,105 +0,0 @@
import fs from 'fs';
import path from 'path';
/**
* Per-thread conversation archive for providers with no on-disk transcript
* payload code, shipped with the provider that needs it. The provider's
* `onExchangeComplete` hook (see types.ts) calls this with each completed
* exchange; the runner never archives on a provider's behalf.
*
* One file per thread (keyed on the continuation id), named
* `<date>-<provider>-<thread>.md` and appended to as exchanges complete
* mirroring the Claude path's one-file-per-session granularity and its
* date-prefixed, name-sortable filenames, since the Codex app-server keeps
* history server-side with no transcript to roll up at a compaction boundary.
* The date is the thread's creation day and stays stable across later appends.
*/
const DEFAULT_CONVERSATIONS_DIR = '/workspace/agent/conversations';
export interface ProviderExchangeArchiveOptions {
provider: string;
prompt: string;
result: string | null | undefined;
continuation?: string;
status: string;
timestamp?: Date;
conversationsDir?: string;
}
/**
* Append a single prompt/result exchange to its thread's conversation file,
* writing the thread-level header once when the file is first created. Returns
* the (thread-stable) filename, or null when there is nothing to archive
* (empty result).
*/
export function archiveProviderExchange(options: ProviderExchangeArchiveOptions): string | null {
const result = options.result?.trim();
if (!result) return null;
const timestamp = options.timestamp ?? new Date();
const conversationsDir =
options.conversationsDir || process.env.NANOCLAW_CONVERSATIONS_DIR || DEFAULT_CONVERSATIONS_DIR;
fs.mkdirSync(conversationsDir, { recursive: true });
const filename = threadArchiveFilename(conversationsDir, options.provider, options.continuation, timestamp);
const filePath = path.join(conversationsDir, filename);
// Thread-level metadata (provider, thread id) belongs in the header, written
// once. Per-exchange metadata (timestamp, status) rides in each appended
// block. Each block leads with a blank line + `---` so the separator renders
// as a thematic break, not a setext heading underline on the prior line.
const parts: string[] = [];
if (!fs.existsSync(filePath)) {
parts.push(
`# ${titleCase(options.provider)} Conversation`,
'',
`Provider: ${options.provider}`,
`Continuation/thread id: ${options.continuation || '(none)'}`,
);
}
parts.push(
'',
'---',
'',
`Archived: ${timestamp.toISOString()} · Status: ${options.status}`,
'',
`**User**: ${truncate(options.prompt)}`,
'',
`**Assistant**: ${truncate(result)}`,
'',
);
fs.appendFileSync(filePath, parts.join('\n'));
return filename;
}
function threadArchiveFilename(
dir: string,
provider: string,
continuation: string | undefined,
timestamp: Date,
): string {
const thread = sanitizeSlug(continuation || 'no-thread').slice(0, 48) || 'no-thread';
const suffix = `${sanitizeSlug(provider)}-${thread}.md`;
// Reuse this thread's existing file whatever day it was created; only stamp a
// new date when none exists. Match on the suffix after the date prefix.
const dated = /^\d{4}-\d{2}-\d{2}-/;
const existing = fs.readdirSync(dir).find((f) => dated.test(f) && f.replace(dated, '') === suffix);
if (existing) return existing;
return `${timestamp.toISOString().split('T')[0]}-${suffix}`;
}
function sanitizeSlug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function titleCase(value: string): string {
return value ? value[0].toUpperCase() + value.slice(1) : 'Provider';
}
function truncate(value: string): string {
return value.length > 2000 ? value.slice(0, 2000) + '...' : value;
}
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'bun:test';
import { createProvider, type ProviderName } from './factory.js';
import { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js';
import { MockProvider } from './mock.js';
describe('createProvider', () => {
@@ -10,10 +9,6 @@ describe('createProvider', () => {
expect(createProvider('claude')).toBeInstanceOf(ClaudeProvider);
});
it('returns CodexProvider for codex', () => {
expect(createProvider('codex')).toBeInstanceOf(CodexProvider);
});
it('returns MockProvider for mock', () => {
expect(createProvider('mock')).toBeInstanceOf(MockProvider);
});
@@ -3,6 +3,4 @@
// level. Skills add a new provider by appending one import line below.
import './claude.js';
import './codex.js';
import './mock.js';
import './opencode.js';
@@ -1,59 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
describe('mcpServersToOpenCodeConfig', () => {
it('maps nanoclaw + extra server like v2 index.ts merge', () => {
const servers = {
nanoclaw: {
command: 'node',
args: ['/app/src/mcp-tools/index.js'],
env: {
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
},
},
extra: {
command: 'npx',
args: ['-y', 'some-mcp'],
env: { FOO: 'bar' },
},
};
const mcp = mcpServersToOpenCodeConfig(servers);
expect(mcp.nanoclaw).toEqual({
type: 'local',
command: ['node', '/app/src/mcp-tools/index.js'],
environment: {
SESSION_INBOUND_DB_PATH: '/workspace/inbound.db',
SESSION_OUTBOUND_DB_PATH: '/workspace/outbound.db',
SESSION_HEARTBEAT_PATH: '/workspace/.heartbeat',
},
enabled: true,
});
expect(mcp.extra).toEqual({
type: 'local',
command: ['npx', '-y', 'some-mcp'],
environment: { FOO: 'bar' },
enabled: true,
});
});
it('omits environment when env is empty', () => {
const mcp = mcpServersToOpenCodeConfig({
x: { command: 'true', args: [], env: {} },
});
expect(mcp.x).toEqual({
type: 'local',
command: ['true'],
enabled: true,
});
});
it('returns empty record for undefined', () => {
expect(mcpServersToOpenCodeConfig(undefined)).toEqual({});
});
});
@@ -1,39 +0,0 @@
import type { McpServerConfig } from './types.js';
/** OpenCode `mcp` entry shape (local stdio server). */
export type OpenCodeMcpLocal = {
type: 'local';
command: string[];
environment?: Record<string, string>;
enabled: true;
};
/** OpenCode `mcp` entry shape (remote HTTP server). */
export type OpenCodeMcpRemote = {
type: 'remote';
url: string;
headers?: Record<string, string>;
enabled: true;
};
export type OpenCodeMcpEntry = OpenCodeMcpLocal | OpenCodeMcpRemote;
/**
* Map NanoClaw v2 MCP definitions (same shape as Claude Agent SDK) into
* OpenCode config `mcp` field. Stdio-only until `McpServerConfig` gains remote.
*/
export function mcpServersToOpenCodeConfig(
servers: Record<string, McpServerConfig> | undefined,
): Record<string, OpenCodeMcpEntry> {
const out: Record<string, OpenCodeMcpEntry> = {};
if (!servers) return out;
for (const [name, cfg] of Object.entries(servers)) {
out[name] = {
type: 'local',
command: [cfg.command, ...cfg.args],
...(Object.keys(cfg.env).length > 0 ? { environment: cfg.env } : {}),
enabled: true,
};
}
return out;
}
@@ -1,22 +0,0 @@
/**
* Integration test for the opencode provider's CONTAINER-side reach-in: the self-registration
* import in container/agent-runner/src/providers/index.ts. Importing the barrel runs
* opencode.ts's top-level registerProvider('opencode', ); without that import line
* createProvider('opencode') throws 'Unknown provider' at runtime.
*
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
* never ./opencode.js directly, then asserts listProviderNames() contains the provider. The
* existing opencode.factory.test.ts imports ./opencode.js directly, so it self-registers and
* stays GREEN when the barrel line is deleted a unit test, not a registration guard.
* This goes red if the barrel import is deleted/drifts or the barrel fails to evaluate, or if @opencode-ai/sdk is not installed (the unmocked barrel import throws) so it also implicitly guards that dependency.
*/
import { describe, it, expect } from 'bun:test';
import { listProviderNames } from './provider-registry.js';
import './index.js'; // the real container provider barrel — triggers each provider's registerProvider()
describe('opencode provider registration', () => {
it('registers opencode via the provider barrel', () => {
expect(listProviderNames()).toContain('opencode');
});
});
@@ -1,10 +0,0 @@
import { describe, it, expect } from 'bun:test';
import { createProvider } from './factory.js';
import { OpenCodeProvider } from './opencode.js';
describe('createProvider (opencode)', () => {
it('returns OpenCodeProvider for opencode', () => {
expect(createProvider('opencode')).toBeInstanceOf(OpenCodeProvider);
});
});
@@ -1,423 +0,0 @@
import { spawn, type ChildProcess } from 'child_process';
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
import { mcpServersToOpenCodeConfig } from './mcp-to-opencode.js';
function log(msg: string): void {
console.error(`[opencode-provider] ${msg}`);
}
const SESSION_STATUS_RETRY_ERROR_AFTER = 3;
/** Stale / dead OpenCode session heuristics (complement Claude-centric host patterns). */
const STALE_SESSION_RE =
/no conversation found|ENOENT.*\.jsonl|session.*not found|NotFoundError|connection reset|ECONNRESET|404|event timeout/i;
function killProcessTree(proc: ChildProcess): void {
if (!proc.pid) return;
try {
process.kill(-proc.pid, 'SIGKILL');
} catch {
try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
}
function spawnOpencodeServer(config: Record<string, unknown>, timeoutMs = 10_000): Promise<{ url: string; proc: ChildProcess }> {
return new Promise((resolve, reject) => {
const hostname = '127.0.0.1';
const port = 4096;
const proc = spawn('opencode', ['serve', `--hostname=${hostname}`, `--port=${port}`], {
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(config),
},
detached: true,
});
const id = setTimeout(() => {
killProcessTree(proc);
reject(new Error(`Timeout waiting for OpenCode server to start after ${timeoutMs}ms`));
}, timeoutMs);
let output = '';
proc.stdout?.on('data', (chunk: Buffer) => {
output += chunk.toString();
for (const line of output.split('\n')) {
if (line.startsWith('opencode server listening')) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (match) {
clearTimeout(id);
resolve({ url: match[1], proc });
}
}
}
});
proc.stderr?.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
proc.on('exit', (code) => {
clearTimeout(id);
let msg = `OpenCode server exited with code ${code}`;
if (output.trim()) msg += `\nServer output: ${output}`;
reject(new Error(msg));
});
proc.on('error', (err) => {
clearTimeout(id);
reject(err);
});
});
}
function wrapPromptWithContext(text: string, systemInstructions?: string): string {
let out = text;
if (systemInstructions) {
out = `<system>\n${systemInstructions}\n</system>\n\n${out}`;
}
return out;
}
function buildOpenCodeConfig(options: ProviderOptions): Record<string, unknown> {
const provider = process.env.OPENCODE_PROVIDER || 'anthropic';
const model = process.env.OPENCODE_MODEL;
const smallModel = process.env.OPENCODE_SMALL_MODEL;
const proxyUrl = process.env.ANTHROPIC_BASE_URL;
const providerModelId = model ? model.replace(new RegExp(`^${provider}/`), '') : undefined;
const providerSmallModelId = smallModel ? smallModel.replace(new RegExp(`^${provider}/`), '') : undefined;
const modelsToRegister = [providerModelId, providerSmallModelId]
.filter(Boolean)
.filter((mid, i, a) => a.indexOf(mid as string) === i);
const providerOptions: Record<string, unknown> =
provider === 'anthropic'
? {}
: {
[provider]: {
options: { apiKey: 'placeholder', baseURL: proxyUrl },
...(modelsToRegister.length > 0
? {
models: Object.fromEntries(
modelsToRegister.map((mid) => [mid, { id: mid, name: mid, tool_call: true }]),
),
}
: {}),
},
};
const mcp = mcpServersToOpenCodeConfig(options.mcpServers);
// Load shared base + per-group fragments + per-group memory through OpenCode's
// native instructions pipeline (session/instruction.ts). Absolute paths with
// globs are supported. Files are read raw — `@./...` includes are NOT expanded
// by OpenCode, so point at the concrete files, not at composed CLAUDE.md.
const instructions = [
'/app/CLAUDE.md',
'/workspace/agent/.claude-fragments/*.md',
'/workspace/agent/CLAUDE.local.md',
];
return {
...(model ? { model } : {}),
...(smallModel ? { small_model: smallModel } : {}),
enabled_providers: [provider],
permission: 'allow',
autoupdate: false,
snapshot: false,
provider: providerOptions,
instructions,
mcp,
};
}
type SharedRuntime = {
proc: ChildProcess;
client: OpencodeClient;
stream: AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
streamRelease: () => void;
};
let sharedRuntime: SharedRuntime | null = null;
let sharedConfigKey: string | null = null;
let sharedInit: Promise<SharedRuntime> | null = null;
function runtimeConfigKey(options: ProviderOptions): string {
return JSON.stringify({
mcp: mcpServersToOpenCodeConfig(options.mcpServers),
model: process.env.OPENCODE_MODEL,
small: process.env.OPENCODE_SMALL_MODEL,
op: process.env.OPENCODE_PROVIDER,
});
}
async function ensureSharedRuntime(options: ProviderOptions): Promise<SharedRuntime> {
const key = runtimeConfigKey(options);
if (sharedRuntime && sharedConfigKey === key) return sharedRuntime;
if (sharedInit) return sharedInit;
sharedInit = (async () => {
if (sharedRuntime) {
destroySharedRuntime();
}
const config = buildOpenCodeConfig(options);
const { url, proc } = await spawnOpencodeServer(config);
const client = createOpencodeClient({ baseUrl: url });
const sub = await client.event.subscribe();
const stream = sub.stream as AsyncGenerator<{ type: string; properties: Record<string, unknown> }, void, void>;
sharedRuntime = {
proc,
client,
stream,
streamRelease: () => {
void stream.return?.(undefined);
},
};
sharedConfigKey = key;
sharedInit = null;
return sharedRuntime;
})();
return sharedInit;
}
export function destroySharedRuntime(): void {
if (sharedRuntime) {
try {
sharedRuntime.streamRelease();
} catch {
/* ignore */
}
killProcessTree(sharedRuntime.proc);
sharedRuntime = null;
sharedConfigKey = null;
}
sharedInit = null;
}
function sessionErrorMessage(props: { error?: unknown }): string {
const err = props.error as { data?: { message?: string } } | undefined;
if (err && typeof err === 'object' && err.data && typeof err.data.message === 'string') {
return err.data.message;
}
return JSON.stringify(props.error) || 'OpenCode session error';
}
export class OpenCodeProvider implements AgentProvider {
readonly supportsNativeSlashCommands = false;
private readonly options: ProviderOptions;
private activeSessionId: string | undefined;
constructor(options: ProviderOptions = {}) {
this.options = options;
}
isSessionInvalid(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return STALE_SESSION_RE.test(msg);
}
query(input: QueryInput): AgentQuery {
if (input.continuation) {
this.activeSessionId = input.continuation;
} else {
this.activeSessionId = undefined;
}
const pending: string[] = [];
let waiting: (() => void) | null = null;
let ended = false;
let aborted = false;
const systemInstructions = input.systemContext?.instructions;
pending.push(wrapPromptWithContext(input.prompt, systemInstructions));
const kick = (): void => {
waiting?.();
};
const self = this;
const IDLE_TIMEOUT_MS = Number(process.env.OPENCODE_IDLE_TIMEOUT_MS) || 300_000;
async function* gen(): AsyncGenerator<ProviderEvent> {
let initYielded = false;
const rt = await ensureSharedRuntime(self.options);
const { client, stream } = rt;
while (!aborted) {
while (pending.length === 0 && !ended && !aborted) {
await new Promise<void>((resolve) => {
waiting = resolve;
});
waiting = null;
}
if (aborted) return;
if (pending.length === 0 && ended) return;
const text = pending.shift()!;
let sessionId = self.activeSessionId;
if (!sessionId) {
const created = await client.session.create();
if (created.error) {
throw new Error(`OpenCode: failed to create session: ${JSON.stringify(created.error)}`);
}
sessionId = created.data?.id;
if (!sessionId) throw new Error('OpenCode: failed to create session (no id)');
self.activeSessionId = sessionId;
}
if (!initYielded) {
yield { type: 'init', continuation: sessionId };
initYielded = true;
}
const promptRes = await client.session.promptAsync({
path: { id: sessionId },
body: { parts: [{ type: 'text', text }] },
});
if (promptRes.error) {
self.activeSessionId = undefined;
throw new Error(`OpenCode promptAsync: ${JSON.stringify(promptRes.error)}`);
}
const partTextByMessageId = new Map<string, string>();
const roleByMessageId = new Map<string, string>();
let lastEventAt = Date.now();
let eventTimedOut = false;
const timeoutCheck = setInterval(() => {
if (Date.now() - lastEventAt > IDLE_TIMEOUT_MS) {
log(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms) — clearing session ${sessionId}`);
eventTimedOut = true;
self.activeSessionId = undefined;
destroySharedRuntime();
kick();
}
}, 5000);
try {
turn: while (true) {
if (aborted) return;
if (eventTimedOut) {
throw new Error(`OpenCode event timeout (${IDLE_TIMEOUT_MS}ms)`);
}
const { value: ev, done } = await stream.next();
if (done) {
throw new Error('OpenCode SSE stream ended unexpectedly');
}
if (!ev?.type || ev.type === 'server.connected' || ev.type === 'server.heartbeat') continue;
lastEventAt = Date.now();
yield { type: 'activity' };
switch (ev.type) {
case 'message.updated': {
const info = ev.properties.info as { id?: string; role?: string } | undefined;
if (info?.id && info?.role) {
roleByMessageId.set(info.id, info.role);
}
break;
}
case 'message.part.updated': {
const part = ev.properties.part as { type?: string; messageID?: string; text?: string } | undefined;
if (part?.type === 'text' && part.messageID && part.text) {
partTextByMessageId.set(part.messageID, part.text);
}
break;
}
case 'permission.updated': {
const perm = ev.properties as { id?: string; sessionID?: string };
if (perm.sessionID === sessionId && perm.id) {
try {
await client.postSessionIdPermissionsPermissionId({
path: { id: sessionId, permissionID: perm.id },
body: { response: 'always' },
});
} catch (err) {
log(`Failed to auto-reply permission: ${err instanceof Error ? err.message : String(err)}`);
}
}
break;
}
case 'session.status': {
const props = ev.properties as {
sessionID?: string;
status?: { type?: string; attempt?: number; message?: string };
};
if (props.sessionID !== sessionId) break;
const st = props.status;
if (
st?.type === 'retry' &&
typeof st.attempt === 'number' &&
st.attempt >= SESSION_STATUS_RETRY_ERROR_AFTER &&
st.message
) {
self.activeSessionId = undefined;
throw new Error(`OpenCode retry limit (${st.attempt}): ${st.message}`);
}
break;
}
case 'session.error': {
const props = ev.properties as { sessionID?: string; error?: unknown };
if (props.sessionID === sessionId || props.sessionID === undefined) {
self.activeSessionId = undefined;
throw new Error(sessionErrorMessage(props));
}
break;
}
case 'session.idle': {
const sid = (ev.properties as { sessionID?: string }).sessionID;
if (sid === sessionId) {
break turn;
}
break;
}
default:
break;
}
}
} finally {
clearInterval(timeoutCheck);
}
let resultText = '';
for (const [msgId, role] of roleByMessageId) {
if (role === 'assistant') {
resultText = partTextByMessageId.get(msgId) ?? resultText;
}
}
yield { type: 'result', text: resultText || null };
}
}
return {
push: (message: string) => {
pending.push(wrapPromptWithContext(message, systemInstructions));
kick();
},
end: () => {
ended = true;
kick();
},
events: gen(),
abort: () => {
aborted = true;
this.activeSessionId = undefined;
kick();
destroySharedRuntime();
},
};
}
}
registerProvider('opencode', (opts) => new OpenCodeProvider(opts));
+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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.14",
"version": "2.0.4",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="133k tokens, 66% of context window">
<title>133k tokens, 66% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
<title>128k tokens, 64% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">133k</text>
<text x="71" y="14">133k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
<text x="71" y="14">128k</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
-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
+127 -302
View File
@@ -22,15 +22,12 @@
* headless `claude -p` call for IANA-zone resolution.
*/
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
import { runSlackChannel } from './channels/slack.js';
import { runTeamsChannel } from './channels/teams.js';
import { runTelegramChannel } from './channels/telegram.js';
@@ -38,17 +35,12 @@ import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import {
applyToEnv,
parseFlags,
printHelp,
readFromEnv,
} from './lib/setup-config-parse.js';
import { runAdvancedScreen } from './lib/setup-config-screen.js';
import { runWindowedStep } from './lib/windowed-runner.js';
import { pollHealth } from './onecli.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
import {
claudeCliAvailable,
resolveTimezoneViaClaude,
} from './lib/tz-from-claude.js';
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js';
@@ -58,48 +50,20 @@ import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent';
const RUN_START = Date.now();
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
type ChannelChoice =
| 'telegram'
| 'discord'
| 'whatsapp'
| 'teams'
| 'slack'
| 'imessage'
| 'skip';
async function main(): Promise<void> {
// Parse CLI flags first — `--help` short-circuits before we render anything,
// and flag values get folded into process.env so existing step code reading
// NANOCLAW_* sees them unchanged.
const flagResult = parseFlags(process.argv.slice(2));
if (flagResult.help) {
printHelp();
process.exit(0);
}
if (flagResult.errors.length > 0) {
for (const err of flagResult.errors) console.error(`error: ${err}`);
console.error('');
console.error('Run with --help for the full list of supported flags.');
process.exit(1);
}
let configValues = { ...readFromEnv(), ...flagResult.values };
applyToEnv(configValues);
printIntro();
initProgressionLog();
phEmit('auto_started');
// Welcome menu — default path or open advanced overrides before any setup
// work begins. Default lands on standard so Enter is the happy path.
const startChoice = ensureAnswer(
await brightSelect<'default' | 'advanced'>({
message: 'How would you like to begin?',
options: [
{ value: 'default', label: 'Standard setup' },
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
],
initialValue: 'default',
}),
) as 'default' | 'advanced';
setupLog.userInput('start_choice', startChoice);
if (startChoice === 'advanced') {
configValues = await runAdvancedScreen(configValues);
applyToEnv(configValues);
}
const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '')
.split(',')
@@ -122,7 +86,12 @@ async function main(): Promise<void> {
}
if (!skip.has('container')) {
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
p.log.message(
dimWrap(
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
4,
),
);
p.log.message(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
@@ -167,95 +136,57 @@ async function main(): Promise<void> {
),
);
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
if (remoteHost) {
// Advanced-settings override: user has already named a remote vault,
// so skip the local-vs-fresh prompt entirely. Health-check it here
// rather than letting the step fail silently — a typo in the URL is a
// common mistake and the answer is human-fixable.
const s = p.spinner();
s.start(`Checking remote OneCLI at ${remoteHost}`);
const healthy = await pollHealth(remoteHost, 5000);
if (!healthy) {
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
await fail(
'onecli',
`Couldn't reach OneCLI at ${remoteHost}.`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
);
}
s.stop('Remote OneCLI is reachable.');
const res = await runQuietStep(
await fail(
'onecli',
{
running: `Connecting to remote OneCLI at ${remoteHost}`,
done: 'OneCLI vault ready.',
},
['--remote-url', remoteHost],
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
await fail(
'onecli',
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
'Check the URL and that OneCLI is running on the remote machine, then retry.',
);
}
} else {
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await brightSelect({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
await fail(
'onecli',
'OneCLI was installed but your shell needs to refresh to see it.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
);
}
await fail(
'onecli',
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
);
}
}
}
@@ -284,12 +215,21 @@ async function main(): Promise<void> {
done: 'NanoClaw is running.',
});
if (!res.ok) {
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
await fail(
'service',
"Couldn't start NanoClaw.",
'See logs/nanoclaw.error.log for details.',
);
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
p.log.warn(
"NanoClaw's permissions need a tweak before it can reach Docker.",
);
p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
k.dim(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
' systemctl --user restart nanoclaw',
),
);
}
}
@@ -319,33 +259,10 @@ async function main(): Promise<void> {
);
}
if (!skip.has('first-chat')) {
p.log.message(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
),
);
const ping = await confirmAssistantResponds();
if (ping === 'ok') {
phEmit('first_chat_ready');
const next = ensureAnswer(
await p.select({
message: 'What next?',
options: [
{
value: 'continue',
label: 'Continue with setup',
hint: 'recommended',
},
{
value: 'chat',
label: 'Pause here and chat with your agent from the terminal',
},
],
}),
) as 'continue' | 'chat';
setupLog.userInput('first_chat_choice', next);
if (next === 'chat') await runFirstChat();
await runFirstChat();
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
@@ -354,7 +271,7 @@ async function main(): Promise<void> {
msg:
ping === 'socket_error'
? "NanoClaw service isn't listening on its CLI socket."
: 'No reply from the assistant within 30 seconds.',
: "No reply from the assistant within 30 seconds.",
hint:
ping === 'socket_error'
? 'Socket at data/cli.sock did not accept a connection.'
@@ -377,8 +294,6 @@ async function main(): Promise<void> {
await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
@@ -404,7 +319,7 @@ async function main(): Promise<void> {
if (!res.ok) {
const notes: string[] = [];
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
notes.push("• Your Claude account isn't connected. Re-run setup and try again.");
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
}
const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') {
@@ -430,9 +345,7 @@ async function main(): Promise<void> {
}
}
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push(
'• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.',
);
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
}
if (notes.length > 0) {
p.note(notes.join('\n'), "What's left");
@@ -466,7 +379,9 @@ async function main(): Promise<void> {
['Open Claude Code:', 'claude'],
];
const labelWidth = Math.max(...rows.map(([l]) => l.length));
const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n');
const nextSteps = rows
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
.join('\n');
p.note(nextSteps, 'Try these');
// Always-on warning goes before the "check your DMs" directive so the
@@ -488,7 +403,10 @@ async function main(): Promise<void> {
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
// as the last thing before outro.
p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi');
p.note(
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
'Go say hi',
);
p.outro(k.green("You're set."));
} else {
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
@@ -503,8 +421,6 @@ function channelDmLabel(choice: ChannelChoice): string | null {
return 'Discord DMs';
case 'whatsapp':
return 'WhatsApp';
case 'signal':
return 'Signal';
case 'teams':
return 'Teams';
case 'imessage':
@@ -545,11 +461,13 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
} else {
const msg =
result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";
s.stop(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
result === 'socket_error'
? "Couldn't reach the NanoClaw service."
: "Your assistant didn't reply in time.";
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1);
}
return result;
}
@@ -563,8 +481,8 @@ function renderPingFailureNote(result: PingResult): void {
6,
),
'',
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
` Linux: systemctl --user restart ${getSystemdUnit()}`,
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
].join('\n')
: wrapForGutter(
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
@@ -604,7 +522,9 @@ async function runFirstChat(): Promise<void> {
message: first
? 'Try a quick hello — or press Enter to continue setup'
: 'Another message? Press Enter to continue setup',
placeholder: first ? 'e.g. "hi, what can you do?"' : 'press Enter to continue',
placeholder: first
? 'e.g. "hi, what can you do?"'
: 'press Enter to continue',
}),
);
first = false;
@@ -620,9 +540,11 @@ function sendChatMessage(message: string): Promise<void> {
// agent's reply reads as a clean block under the prompt. Splitting on
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
// with spaces on the far side.
const child = spawn('pnpm', ['--silent', 'run', 'chat', ...message.split(/\s+/)], {
stdio: ['ignore', 'inherit', 'inherit'],
});
const child = spawn(
'pnpm',
['--silent', 'run', 'chat', ...message.split(/\s+/)],
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
child.on('close', () => resolve());
child.on('error', () => resolve());
});
@@ -637,16 +559,6 @@ async function runAuthStep(): Promise<void> {
return;
}
// Custom Anthropic-compatible endpoint flow. Both URL and token must be set;
// OneCLI stores the token as a generic Bearer secret keyed to the URL host,
// so the container only ever sees ANTHROPIC_BASE_URL + a placeholder.
const customBaseUrl = process.env.NANOCLAW_ANTHROPIC_BASE_URL?.trim();
const customAuthToken = process.env.NANOCLAW_ANTHROPIC_AUTH_TOKEN?.trim();
if (customBaseUrl && customAuthToken) {
await runCustomEndpointAuth(customBaseUrl, customAuthToken);
return;
}
const method = ensureAnswer(
await brightSelect({
message: 'How would you like to connect to Claude?',
@@ -680,11 +592,15 @@ async function runAuthStep(): Promise<void> {
}
async function runSubscriptionAuth(): Promise<void> {
p.log.step('Opening the Claude sign-in flow…');
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
p.log.step("Opening the Claude sign-in flow…");
console.log(
k.dim(' (a browser will open for sign-in; this part is interactive)'),
);
console.log();
const start = Date.now();
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
const code = await runInheritScript('bash', [
'setup/register-claude-token.sh',
]);
const durationMs = Date.now() - start;
console.log();
if (code !== 0) {
@@ -724,16 +640,11 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
'auth',
'onecli',
[
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'anthropic',
'--value',
token,
'--host-pattern',
'api.anthropic.com',
'secrets', 'create',
'--name', 'Anthropic',
'--type', 'anthropic',
'--value', token,
'--host-pattern', 'api.anthropic.com',
],
{
running: `Saving your ${label} to your OneCLI vault…`,
@@ -752,92 +663,6 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
}
}
/**
* Set up Anthropic auth for a custom endpoint. The token is stored as a
* OneCLI generic secret with header injection so the proxy rewrites the
* Authorization header on the wire the container only ever sees
* ANTHROPIC_BASE_URL + a placeholder bearer.
*/
async function runCustomEndpointAuth(
baseUrl: string,
token: string,
): Promise<void> {
let host: string;
try {
host = new URL(baseUrl).hostname;
} catch {
await fail(
'auth',
`Invalid Anthropic base URL: ${baseUrl}`,
'Check --anthropic-base-url and retry.',
);
return;
}
const res = await runQuietChild(
'auth',
'onecli',
[
'secrets',
'create',
'--name',
'Anthropic',
'--type',
'generic',
'--value',
token,
'--host-pattern',
host,
'--header-name',
'Authorization',
'--value-format',
'Bearer {value}',
],
{
running: `Saving your Anthropic auth token to your OneCLI vault…`,
done: 'Claude account connected.',
},
{ extraFields: { METHOD: 'custom-endpoint', HOST: host } },
);
if (!res.ok) {
await fail(
'auth',
`Couldn't save your Anthropic auth token to the vault.`,
'Make sure OneCLI is running (`onecli version`), then retry.',
);
}
// ANTHROPIC_BASE_URL has to be in .env so the runtime provider config
// reads it when building container env. The token is *not* written —
// OneCLI holds it.
writeEnvLine('ANTHROPIC_BASE_URL', baseUrl);
// Register the claude provider so the runtime passes ANTHROPIC_BASE_URL
// and the placeholder bearer into the container. Only appended when the
// user has configured a custom endpoint; standard installs don't load
// the file at all.
appendProviderImport('./claude.js');
}
function writeEnvLine(key: string, value: string): void {
const envFile = path.join(process.cwd(), '.env');
const content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
const re = new RegExp(`^${key}=.*$`, 'm');
const next = re.test(content)
? content.replace(re, `${key}=${value}`)
: content.trimEnd() + (content ? '\n' : '') + `${key}=${value}\n`;
fs.writeFileSync(envFile, next);
}
function appendProviderImport(modulePath: string): void {
const file = path.join(process.cwd(), 'src', 'providers', 'index.ts');
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
const line = `import '${modulePath}';`;
if (content.includes(line)) return;
const sep = content && !content.endsWith('\n') ? '\n' : '';
fs.writeFileSync(file, content + sep + line + '\n');
}
// ─── timezone step ─────────────────────────────────────────────────────
/**
@@ -858,7 +683,10 @@ async function runTimezoneStep(): Promise<void> {
const fields = res.terminal?.fields ?? {};
const resolvedTz = fields.RESOLVED_TZ;
const needsInput = fields.NEEDS_USER_INPUT === 'true';
const isUtc = resolvedTz === 'UTC' || resolvedTz === 'Etc/UTC' || resolvedTz === 'Universal';
const isUtc =
resolvedTz === 'UTC' ||
resolvedTz === 'Etc/UTC' ||
resolvedTz === 'Universal';
// Three branches:
// - no TZ detected: ask where they are (or leave as UTC)
@@ -880,8 +708,8 @@ async function runTimezoneStep(): Promise<void> {
const message = needsInput
? "Your system didn't expose a timezone. Which one are you in?"
: !isUtc
? 'Where are you, then?'
: 'Your system reports UTC as the timezone. Is that right, or are you somewhere else?';
? "Where are you, then?"
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
// straight to the free-text prompt — the user already said "not that".
@@ -908,7 +736,7 @@ async function runTimezoneStep(): Promise<void> {
const answer = ensureAnswer(
await p.text({
message: 'Where are you? (city, region, or IANA zone)',
message: "Where are you? (city, region, or IANA zone)",
placeholder: 'e.g. New York, London, Asia/Tokyo',
validate: (v) => (v && v.trim() ? undefined : 'Required'),
}),
@@ -986,11 +814,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
{ value: 'discord', label: 'Yes, connect Discord' },
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
{
value: 'signal',
label: 'Yes, connect Signal',
hint: 'needs signal-cli installed',
},
{
value: 'imessage',
label: 'Yes, connect iMessage (experimental)',
@@ -1104,15 +927,17 @@ function printIntro(): void {
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) {
p.intro(`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`);
p.intro(
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
);
return;
}
// bash already printed the wordmark above us; the clack intro carries the
// welcome framing alone so the two don't double up. Standalone runs of
// setup:auto still see this as the first line — fine without the wordmark
// since the line itself signals the start of the flow.
p.intro("Let's get you set up.");
// Always include the wordmark inside the clack intro line. When bash ran
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
// above us; the small repeat is worth it to keep the brand anchored at
// the visible top of the clack session once the bash output scrolls away.
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
}
/**
-357
View File
@@ -1,357 +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';
const DEFAULT_AGENT_NAME = 'Nano';
export async function runSignalChannel(displayName: string): Promise<void> {
await ensureSignalCli();
const install = await runQuietChild(
'signal-install',
'bash',
['setup/add-signal.sh'],
{
running: 'Installing the Signal adapter…',
done: 'Signal adapter installed.',
skipped: 'Signal adapter already installed.',
},
);
if (!install.ok) {
await fail(
'signal-install',
"Couldn't install the Signal adapter.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const auth = await runSignalAuth();
if (!auth.ok) {
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
await fail(
'signal-auth',
`Signal link failed (${reason}).`,
reason === 'qr_timeout'
? 'The code expired. Re-run setup to get a fresh one.'
: 'Re-run setup to try again.',
);
}
const account = auth.terminal?.fields.ACCOUNT;
if (!account) {
await fail(
'signal-auth',
'Linked with Signal but couldn\'t read the phone number back.',
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
);
}
writeSignalAccount(account!);
await restartService();
const role = await askOperatorRole('Signal');
setupLog.userInput('signal_role', role);
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'signal',
'--user-id', account!,
'--platform-id', account!,
'--display-name', displayName,
'--agent-name', agentName,
'--role', role,
],
{
running: `Connecting ${agentName} to Signal…`,
done: `${agentName} is ready. Check Signal for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'signal',
AGENT_NAME: agentName,
PLATFORM_ID: account!,
ROLE: role,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'You can retry later with `/manage-channels`.',
);
}
}
async function ensureSignalCli(): Promise<void> {
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
const probe = spawnSync(cli, ['--version'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!probe.error && probe.status === 0) return;
if (process.platform === 'darwin') {
p.note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'The quickest way on macOS is Homebrew:',
'',
k.cyan(' brew install signal-cli'),
'',
"Install it in another terminal, then re-run setup.",
].join('\n'),
'signal-cli not found',
);
} else {
p.note(
[
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
'',
'Grab the latest release from GitHub:',
'',
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
'',
"Install it, make sure `signal-cli --version` works, then re-run setup.",
].join('\n'),
'signal-cli not found',
);
}
await fail(
'signal-install',
'signal-cli is required but not installed.',
'Install it and re-run setup.',
);
}
async function runSignalAuth(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('signal-auth');
const start = Date.now();
const s = p.spinner();
s.start('Starting Signal link…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number): void => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
// Tracks how many lines the QR block occupies so we can wipe it in-place
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
// but we still want to erase the QR from screen once it's served).
let qrLinesPrinted = 0;
const result = await spawnStep(
'signal-auth',
[],
(block: Block) => {
if (block.type === 'SIGNAL_AUTH_QR') {
const qr = block.fields.QR ?? '';
if (!qr) return;
void renderQr(qr).then((lines) => {
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
process.stdout.write(lines.join('\n') + '\n');
qrLinesPrinted = lines.length;
s.start('Waiting for you to scan…');
spinnerActive = true;
});
} else if (block.type === 'SIGNAL_AUTH') {
const status = block.fields.STATUS;
// Wipe the QR block regardless of outcome — it's either scanned
// and useless, or expired and misleading.
if (qrLinesPrinted > 0) {
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
qrLinesPrinted = 0;
}
const account = block.fields.ACCOUNT;
if (status === 'skipped') {
stopSpinner(
account
? `Signal already linked as ${k.cyan(account)}.`
: 'Signal already linked.',
);
} else if (status === 'success') {
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
} else if (status === 'failed') {
const err = block.fields.ERROR ?? 'unknown';
stopSpinner(`Signal link failed: ${err}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('signal-auth', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
/**
* Render the raw linking URL as a block-art QR, returned line-by-line so
* the caller can count lines for in-place cleanup. Uses small-mode so the
* code stays scannable on 24-row terminals. If qrcode isn't installed
* (add-signal.sh should have handled it, but we're defensive), fall back
* to the raw URL and ask the user to paste it into an external renderer.
*/
async function renderQr(url: string): Promise<string[]> {
try {
const QRCode = await import('qrcode');
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
const caption = k.dim(
' Signal → Settings → Linked Devices → Link New Device → scan.',
);
return [...qrText.trimEnd().split('\n'), '', caption];
} catch {
return [
'Linking URL (render at https://qr.io or similar):',
'',
url,
'',
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
];
}
}
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
function writeSignalAccount(account: string): void {
const envPath = path.join(process.cwd(), '.env');
let contents = '';
try {
contents = fs.readFileSync(envPath, 'utf-8');
} catch {
contents = '';
}
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
contents = contents.replace(
/^SIGNAL_ACCOUNT=.*$/m,
`SIGNAL_ACCOUNT=${account}`,
);
} else {
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
contents += `SIGNAL_ACCOUNT=${account}\n`;
}
fs.writeFileSync(envPath, contents);
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
fs.mkdirSync(containerEnvDir, { recursive: true });
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
setupLog.userInput('signal_account', account);
}
async function restartService(): Promise<void> {
const s = p.spinner();
s.start('Restarting NanoClaw so it sees your Signal account…');
const start = Date.now();
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
{ stdio: 'ignore' },
);
} else if (platform === 'linux') {
const unit = getSystemdUnit();
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
stdio: 'ignore',
});
if (user.status !== 0) {
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
}
}
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
s.stop(`Restart may have failed: ${message}`, 1);
setupLog.step('signal-restart', 'failed', Date.now() - start, {
ERROR: message,
});
// Non-fatal — the user can restart manually if init-first-agent fails.
}
}
async function resolveAgentName(): Promise<string> {
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
if (preset) {
setupLog.userInput('agent_name', preset);
return preset;
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),
);
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
setupLog.userInput('agent_name', value);
return value;
}
+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 -26
View File
@@ -7,35 +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 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();
@@ -63,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;
+15 -40
View File
@@ -119,7 +119,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;
@@ -283,24 +283,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');
@@ -311,16 +305,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);
@@ -355,14 +339,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 }
@@ -371,11 +351,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 },
+2 -4
View File
@@ -322,12 +322,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;
-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, 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 -6
View File
@@ -58,14 +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);
return wrapForGutter(text, gutter)
.split('\n')
.map((line) => k.dim(line))
.join('\n');
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
+15 -192
View File
@@ -86,161 +86,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) {
@@ -257,64 +136,8 @@ export async function pollHealth(url: string, timeoutMs: number): Promise<boolea
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;
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.
-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;
}
}
@@ -1,22 +0,0 @@
/**
* Setup-side registration guard for the codex provider (the third barrel of
* the multi-point archetype): imports the REAL setup/providers barrel and
* asserts the registry carries codex with its auth + install check. Red if
* the barrel line is deleted, the barrel fails to evaluate, or the payload
* module breaks. (Importing ./codex.js directly would self-register and stay
* green when the barrel line is deleted.)
*/
import { describe, expect, it } from 'vitest';
import { getSetupProvider } from './registry.js';
import './index.js'; // the real setup provider barrel
describe('codex setup registration', () => {
it('registers codex with auth + install check via the barrel', () => {
const codex = getSetupProvider('codex');
expect(codex).toBeDefined();
expect(typeof codex!.runAuth).toBe('function');
expect(typeof codex!.runInstallCheck).toBe('function');
expect(typeof codex!.offerFailureAssist).toBe('function');
});
});
-101
View File
@@ -1,101 +0,0 @@
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it, vi } from 'vitest';
// Mock child_process so runCodexLoginAuth never spawns a real codex CLI; the
// spawn stand-in plays `codex login` writing auth.json into whatever
// CODEX_HOME it was handed.
const mockSpawn = vi.fn();
const mockSpawnSync = vi.fn();
const mockExecFileSync = vi.fn();
vi.mock('child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
spawnSync: (...args: unknown[]) => mockSpawnSync(...args),
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
}));
// Keep the auth flow's structured logging out of logs/setup.log.
vi.mock('../logs.js', () => ({ step: vi.fn(), userInput: vi.fn() }));
import { buildCodexFailurePrompt, runCodexLoginAuth, verifyCodexInstall } from './codex.js';
// Structural guard for the codex payload wiring: provider files, both barrel
// imports, and the pinned Dockerfile install. Goes red if any of them is
// removed without going through the /add-codex (or its REMOVE.md) path.
describe('verifyCodexInstall', () => {
it('passes on a tree with the codex payload wired', () => {
const { ok, problems } = verifyCodexInstall();
expect(problems).toEqual([]);
expect(ok).toBe(true);
});
});
// Pure prompt builder for the failure-assist hook — no spawning involved.
describe('buildCodexFailurePrompt', () => {
it('carries the failure context and the de-duped reference list', () => {
const projectRoot = '/repo';
const prompt = buildCodexFailurePrompt(
{
stepName: 'verify',
msg: 'first-chat ping timed out',
hint: 'check the container logs',
rawLogPath: '/repo/logs/setup-steps/verify.log',
},
projectRoot,
);
expect(prompt).toContain('Failed step: verify');
expect(prompt).toContain('Error: first-chat ping timed out');
expect(prompt).toContain('Hint: check the container logs');
expect(prompt).toContain('README.md'); // BIG_PICTURE_FILES
expect(prompt).toContain('setup/verify.ts'); // STEP_FILES['verify']
expect(prompt).toContain('logs/setup.log');
expect(prompt).toContain('logs/setup-steps/verify.log'); // relativized rawLogPath
});
it('falls back to the step-log directory when no raw log path is given', () => {
const prompt = buildCodexFailurePrompt({ stepName: 'verify', msg: 'boom' }, '/repo');
expect(prompt).toContain('logs/setup-steps/');
expect(prompt).not.toContain('Hint:');
});
});
// Session-isolation invariant: the ChatGPT session vaulted for the gateway
// must never be the user's personal ~/.codex session — sharing one OAuth
// session across two consumers gets the whole family invalidated server-side
// when refresh tokens rotate (see the header of codex.ts).
describe('runCodexLoginAuth', () => {
it('logs in under an isolated CODEX_HOME, vaults from it, and deletes it', async () => {
mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
mockExecFileSync.mockReturnValue('');
let loginEnv: NodeJS.ProcessEnv | undefined;
mockSpawn.mockImplementation((...args: unknown[]) => {
const opts = args[2] as { env?: NodeJS.ProcessEnv };
loginEnv = opts.env;
fs.writeFileSync(path.join(opts.env!.CODEX_HOME!, 'auth.json'), '{"tokens":{}}');
const child = new EventEmitter();
setImmediate(() => child.emit('close', 0));
return child;
});
await runCodexLoginAuth('browser');
// The login spawn ran under a CODEX_HOME that is not the personal one.
const codexHome = loginEnv?.CODEX_HOME;
expect(codexHome).toBeDefined();
expect(codexHome).not.toBe(path.join(os.homedir(), '.codex'));
// The vault snapshot was read from the isolated dir, not ~/.codex.
const vaultCall = mockExecFileSync.mock.calls.find((c) => c[0] === 'onecli');
expect(vaultCall).toBeDefined();
const vaultArgs = vaultCall![1] as string[];
expect(vaultArgs[vaultArgs.indexOf('--file') + 1]).toBe(path.join(codexHome!, 'auth.json'));
// The isolated dir holds a live credential — gone once vaulted.
expect(fs.existsSync(codexHome!)).toBe(false);
});
});
-449
View File
@@ -1,449 +0,0 @@
/**
* Codex provider setup auth walk-through + install verification.
*
* Codex-owned payload code: when the codex provider moves to the `providers`
* branch, this file travels with it and `/add-codex` copies it back in. The
* only trunk reach-in is one import + one picker entry in setup/auto.ts.
*
* Auth honors the v2 credential invariant everything lands in the OneCLI
* vault, nothing in .env, nothing in the container:
* - ChatGPT subscription (the common case): `codex login` (browser) or
* `codex login --device-auth` (URL + pairing code) runs with CODEX_HOME
* pointed at a throwaway dir; the auth.json written there is stored
* WHOLE in the vault (`--file … --host-pattern chatgpt.com`) and the dir
* is deleted. The gateway injects it in flight; the container only ever
* sees the `onecli-managed` placeholder.
* - API key: pasted once, stored as an `openai` secret for api.openai.com.
*
* Session-isolation invariant: the vaulted ChatGPT session must be DEDICATED
* to the gateway. Never vault a copy of the user's live ~/.codex/auth.json.
* OpenAI rotates refresh tokens, so two consumers sharing one OAuth session
* strand each other on refresh, and replaying the stale token trips reuse
* detection which invalidates the whole session family server-side
* (`token_invalidated`) for the gateway AND the user's personal Codex CLI.
*/
import { execFileSync, spawn, spawnSync } 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 { brightSelect } from '../lib/bright-select.js';
import { type AssistContext, BIG_PICTURE_FILES, STEP_FILES } from '../lib/claude-assist.js';
import { brandBody, note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { type FailureAssistResult, registerSetupProvider } from './registry.js';
// ─── OneCLI vault helpers ────────────────────────────────────────────────
interface OnecliSecret {
id: string;
name: string;
type: string;
hostPattern: string | null;
}
function listSecrets(): OnecliSecret[] {
const out = execFileSync('onecli', ['secrets', 'list'], { encoding: 'utf-8' });
const parsed = JSON.parse(out) as { data?: unknown };
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
}
function findOpenAISecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
return secrets.find((s) => {
const name = s.name.toLowerCase();
const type = s.type.toLowerCase();
const hostPattern = (s.hostPattern ?? '').toLowerCase();
return (
name === 'codex' ||
name === 'openai' ||
type === 'openai' ||
hostPattern.includes('api.openai.com') ||
hostPattern.includes('chatgpt.com')
);
});
}
function openAISecretExists(): boolean {
try {
return findOpenAISecret(listSecrets()) !== undefined;
} catch {
return false;
}
}
// ─── auth step ───────────────────────────────────────────────────────────
function ensureAnswer<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Setup cancelled.');
process.exit(1);
}
return value as T;
}
export async function runCodexAuthStep(): Promise<void> {
if (openAISecretExists()) {
p.log.success(brandBody('Your OpenAI account is already connected.'));
setupLog.step('auth', 'skipped', 0, { REASON: 'openai-secret-already-present', PROVIDER: 'codex' });
return;
}
const method = ensureAnswer(
await brightSelect<'browser' | 'device' | 'api' | 'skip'>({
message: 'How would you like to connect Codex?',
options: [
{
value: 'browser',
label: 'Sign in with my ChatGPT subscription',
hint: 'recommended if you have Plus or Pro — opens a browser',
},
{
value: 'device',
label: 'ChatGPT device pairing',
hint: 'no browser handoff — shows a URL and a code',
},
{
value: 'api',
label: 'Paste an OpenAI API key',
hint: 'pay-per-use; stored in OneCLI, never copied into the container',
},
{
value: 'skip',
label: "Skip — I'll connect later",
hint: 'Codex groups will start, but model calls will fail auth',
},
],
}),
);
setupLog.userInput('codex_auth_method', method);
if (method === 'skip') {
const confirmed = ensureAnswer(
await p.confirm({
message: "Skip Codex sign-in? Codex won't be able to answer until you connect an OpenAI account.",
initialValue: false,
}),
);
if (!confirmed) return runCodexAuthStep();
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped', PROVIDER: 'codex' });
p.log.warn(brandBody('Codex sign-in skipped. Add an OpenAI account to OneCLI before using Codex groups.'));
return;
}
if (method === 'api') {
await runCodexApiKeyAuth();
return;
}
await runCodexLoginAuth(method);
}
async function runCodexApiKeyAuth(): Promise<void> {
const key = ensureAnswer(
await p.password({
message: 'Paste your OpenAI API key (sk-…)',
validate: (v) => (v && v.trim().startsWith('sk-') ? undefined : 'That does not look like an OpenAI API key.'),
}),
) as string;
try {
execFileSync(
'onecli',
[
'secrets',
'create',
'--name',
'Codex',
'--type',
'openai',
'--value',
key.trim(),
'--host-pattern',
'api.openai.com',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
} catch (err) {
setupLog.step('auth', 'failed', 0, { PROVIDER: 'codex', METHOD: 'api', ERROR: String(err) });
p.log.error(
brandBody(
"Couldn't save your OpenAI key to the vault. Make sure OneCLI is running (`onecli version`), then retry.",
),
);
process.exit(1);
}
setupLog.step('auth', 'success', 0, { PROVIDER: 'codex', METHOD: 'api' });
p.log.success(brandBody('OpenAI account connected.'));
}
export async function runCodexLoginAuth(method: 'browser' | 'device'): Promise<void> {
const codexCheck = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
if (codexCheck.status !== 0) {
p.log.error(
brandBody(
'The Codex CLI is not installed on this machine. Install it with `npm install -g @openai/codex`, then re-run setup — or choose the API key option instead.',
),
);
setupLog.step('auth', 'failed', 0, { PROVIDER: 'codex', METHOD: method, ERROR: 'codex_cli_missing' });
process.exit(1);
}
if (method === 'browser') {
p.log.step(brandBody('Opening the Codex sign-in flow…'));
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
} else {
p.log.step(brandBody('Starting Codex device-code pairing…'));
console.log(k.dim(' (a URL and code will appear below — open the URL and enter the code)'));
}
console.log();
// Session-isolation invariant (see file header): the login runs under a
// throwaway CODEX_HOME so the vaulted session is dedicated to the gateway
// and never shared with the user's personal ~/.codex.
const loginHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-vault-login-'));
// Holds a live credential after login — must go on every exit path. The
// failure branches call process.exit, which skips finally blocks, so each
// removes it explicitly.
const removeLoginHome = (): void => fs.rmSync(loginHome, { recursive: true, force: true });
const args = method === 'device' ? ['login', '--device-auth'] : ['login'];
const start = Date.now();
const code = await runInherit('codex', args, { CODEX_HOME: loginHome });
const durationMs = Date.now() - start;
console.log();
if (code !== 0) {
removeLoginHome();
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, EXIT_CODE: String(code) });
p.log.error(
brandBody(
"Couldn't complete the Codex sign-in. Re-run setup and try again, or choose the API key option instead.",
),
);
process.exit(1);
}
const authJsonPath = path.join(loginHome, 'auth.json');
if (!fs.existsSync(authJsonPath)) {
removeLoginHome();
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, ERROR: 'auth_json_not_found' });
p.log.error(
brandBody('Codex login succeeded but no auth.json was written. Try again, or paste an API key instead.'),
);
process.exit(1);
}
try {
execFileSync(
'onecli',
[
'secrets',
'create',
'--name',
'Codex',
'--type',
'openai',
'--file',
authJsonPath,
'--host-pattern',
'chatgpt.com',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
} catch (err) {
removeLoginHome();
setupLog.step('auth', 'failed', durationMs, { PROVIDER: 'codex', METHOD: method, ERROR: String(err) });
p.log.error(
brandBody(
"Couldn't save your Codex credentials to the vault. Make sure OneCLI is running (`onecli version`), then retry.",
),
);
process.exit(1);
}
removeLoginHome();
setupLog.step('auth', 'success', durationMs, { PROVIDER: 'codex', METHOD: method });
p.log.success(brandBody('OpenAI account connected — credentials live in your OneCLI vault, never in the container.'));
}
function runInherit(cmd: string, args: string[], extraEnv?: Record<string, string>): Promise<number> {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
stdio: 'inherit',
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
});
child.on('close', (code) => resolve(code ?? 1));
child.on('error', () => resolve(1));
});
}
// ─── failure assist ──────────────────────────────────────────────────────
/**
* The Codex CLI can debug a setup failure only if the binary runs AND
* ~/.codex/auth.json exists (API-key-only installs keep the key in the
* OneCLI vault, so the host-side CLI has nothing to authenticate with).
*/
export function isCodexCliUsable(): boolean {
const codexCheck = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
if (codexCheck.status !== 0) return false;
return fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'));
}
/**
* Failure prompt handed to the interactive Codex session same content as
* the dispatcher's Claude system prompt: what failed, the job ("diagnose and
* fix, be concise, exit when done"), and a de-duped file reference list.
*/
export function buildCodexFailurePrompt(ctx: AssistContext, projectRoot: string): string {
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
const references = [
...BIG_PICTURE_FILES,
...stepRefs,
'logs/setup.log',
ctx.rawLogPath ? path.relative(projectRoot, ctx.rawLogPath) : 'logs/setup-steps/',
].filter((v, i, a) => a.indexOf(v) === i);
const lines: string[] = [
"The user is running NanoClaw's interactive setup flow and hit a failure.",
'',
`Failed step: ${ctx.stepName}`,
`Error: ${ctx.msg}`,
];
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
lines.push(
'',
'Your job: help them diagnose and fix this issue. Read the referenced files',
'and logs to understand what went wrong, then help them fix it. You can read',
'files, run commands, check logs, and explain what happened. Be concise.',
"When they're ready to resume setup, tell them to exit Codex.",
'',
'Relevant files (read as needed):',
);
for (const f of references) lines.push(` - ${f}`);
return lines.join('\n');
}
/**
* Registry hook: offer to debug a setup failure with the Codex CLI. Returns
* 'unavailable' when the CLI can't run here so the dispatcher can fall back
* to its guarded Claude offer.
*/
export async function offerCodexFailureAssist(ctx: AssistContext, projectRoot: string): Promise<FailureAssistResult> {
if (!isCodexCliUsable()) return 'unavailable';
const want = ensureAnswer(
await p.confirm({
message: 'Want to debug this with Codex?',
initialValue: true,
}),
);
if (!want) return 'declined';
const prompt = buildCodexFailurePrompt(ctx, projectRoot);
note(
[
'Launching Codex to help debug this failure.',
'It has the context of what went wrong.',
'',
k.dim("Exit Codex (Ctrl-C or /quit) when you're ready to come back to setup."),
].join('\n'),
'Handing off to Codex',
);
return new Promise<FailureAssistResult>((resolve) => {
// codex accepts a positional initial prompt for the interactive TUI.
const child = spawn('codex', [prompt], { cwd: projectRoot, stdio: 'inherit' });
child.on('close', () => {
p.log.success(brandBody("Back from Codex. Let's continue."));
resolve('launched');
});
child.on('error', () => {
p.log.error("Couldn't launch Codex.");
resolve('unavailable');
});
});
}
// ─── install verification ────────────────────────────────────────────────
/**
* Verify the codex provider payload is fully wired the same pre-flight the
* /add-codex skill checks. While codex ships in trunk these always pass; once
* the payload moves to the providers branch, a failed check means the install
* step should run (or the user finishes via /add-codex).
*/
export function verifyCodexInstall(): { ok: boolean; problems: string[] } {
const problems: string[] = [];
const root = process.cwd();
const requiredFiles = [
'src/providers/codex.ts',
'src/providers/codex-agents-md.ts',
'container/agent-runner/src/providers/codex.ts',
'container/agent-runner/src/providers/codex-app-server.ts',
];
for (const file of requiredFiles) {
if (!fs.existsSync(path.join(root, file))) problems.push(`missing file: ${file}`);
}
for (const barrel of ['src/providers/index.ts', 'container/agent-runner/src/providers/index.ts']) {
const barrelPath = path.join(root, barrel);
if (!fs.existsSync(barrelPath) || !fs.readFileSync(barrelPath, 'utf-8').includes("import './codex.js';")) {
problems.push(`missing barrel import in ${barrel}`);
}
}
const manifestPath = path.join(root, 'container', 'cli-tools.json');
let hasCodexCli = false;
if (fs.existsSync(manifestPath)) {
try {
const tools = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Array<{ name?: string }>;
hasCodexCli = Array.isArray(tools) && tools.some((t) => t.name === '@openai/codex');
} catch {
hasCodexCli = false;
}
}
if (!hasCodexCli) {
problems.push('container/cli-tools.json missing the @openai/codex CLI entry');
}
return { ok: problems.length === 0, problems };
}
export async function runCodexInstallCheck(): Promise<void> {
p.log.step(brandBody('Checking the Codex provider install…'));
const { ok, problems } = verifyCodexInstall();
if (ok) {
setupLog.step('codex-install', 'success', 0, {});
p.log.success(brandBody('Codex installed properly.'));
return;
}
setupLog.step('codex-install', 'failed', 0, { PROBLEMS: problems.join('; ') });
p.log.warn(brandBody('The Codex provider is not fully installed:'));
for (const problem of problems) console.log(k.dim(`${problem}`));
p.log.warn(
brandBody(
'Finish it with your coding agent of choice: open Codex CLI or Claude Code in this repo and run the /add-codex skill. Setup will continue — Codex groups will work once the install completes.',
),
);
}
// Self-registration: the setup picker and the standalone `provider-auth` step
// render from the registry — this call is codex's only reach-in to the setup
// flow (guarded by the barrel-driven registration test).
registerSetupProvider({
value: 'codex',
label: 'Codex',
hint: 'OpenAI — ChatGPT subscription or API key',
runAuth: runCodexAuthStep,
runInstallCheck: runCodexInstallCheck,
offerFailureAssist: offerCodexFailureAssist,
});
+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(),
});
-14
View File
@@ -11,7 +11,6 @@ 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 +53,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') {
-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');
});
});
+11 -30
View File
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
import { DATA_DIR } from '../src/config.js';
import { readEnvFile } from '../src/env.js';
import { log } from '../src/log.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { pingCliAgent } from './lib/agent-ping.js';
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
import {
getPlatform,
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
// everything upstream looks healthy, since a broken socket would just hang.
let agentPing: 'ok' | 'no_reply' | 'socket_error' | '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 +255,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
-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;
+1 -5
View File
@@ -2,7 +2,7 @@ import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js';
import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
@@ -27,10 +27,6 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
// `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_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');
});
});
+3 -35
View File
@@ -12,7 +12,6 @@ import { OneCLI } from '@onecli-sh/sdk';
import {
CONTAINER_IMAGE,
CONTAINER_IMAGE_BASE,
CONTAINER_INSTALL_LABEL,
DATA_DIR,
GROUPS_DIR,
ONECLI_API_KEY,
@@ -36,13 +35,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 });
@@ -137,12 +130,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 });
@@ -191,31 +178,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({
@@ -421,7 +389,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).
-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 -24
View File
@@ -159,31 +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 });
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.
@@ -254,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', {
-1
View File
@@ -85,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 });
@@ -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 };
});
-112
View File
@@ -1,112 +0,0 @@
/**
* The 32KB Codex project-doc cap must DEGRADE, never throw: composeGroupAgentsMd
* runs inside the provider contribution at every spawn, and a throw there rides
* wakeContainer's transient-retry contract host-sweep respawns every 60s
* forever and the group goes silently dark (a permanent condition disguised as
* a transient one). Oversized docs drop their largest optional instruction
* sections, keep the core contract, and say so in the doc.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
DATA_DIR: '/tmp/nanoclaw-agents-md-test/data',
}));
import { composeGroupAgentsMd, CODEX_PROJECT_DOC_MAX_BYTES } from './codex-agents-md.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../db/index.js';
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
import type { AgentGroup } from '../types.js';
const TEST_ROOT = '/tmp/nanoclaw-agents-md-test';
function group(folder: string): AgentGroup {
return {
id: `ag-${folder}`,
name: folder,
folder,
agent_provider: null,
created_at: new Date().toISOString(),
} as AgentGroup;
}
describe('composeGroupAgentsMd cap handling', () => {
beforeEach(() => {
if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true });
fs.mkdirSync(path.join(TEST_ROOT, 'data'), { recursive: true });
const db = initTestDb();
runMigrations(db);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true });
});
it('writes the doc untouched when under the cap', () => {
const g = group('small');
createAgentGroup(g);
ensureContainerConfig(g.id);
const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-'));
try {
composeGroupAgentsMd(g, groupDir);
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(doc).not.toContain('Omitted for size');
// Agent-authored skills must be told their persistent home — without
// this, authored skills land on ephemeral container paths and vanish.
expect(doc).toContain('/workspace/agent/skills');
expect(Buffer.byteLength(doc, 'utf-8')).toBeLessThanOrEqual(CODEX_PROJECT_DOC_MAX_BYTES);
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
}
});
it('inlines the memory index so recall does not depend on a file read', () => {
const g = group('with-memory');
createAgentGroup(g);
ensureContainerConfig(g.id);
const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-'));
try {
fs.mkdirSync(path.join(groupDir, 'memory'), { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'memory', 'index.md'),
'# Memory Index\n- [People](memories/people/) - notes about people and their preferences\n',
);
composeGroupAgentsMd(g, groupDir);
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(doc).toContain('Current memory index');
expect(doc).toContain('notes about people and their preferences');
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
}
});
it('degrades instead of throwing when MCP instructions push the doc over the cap', () => {
const g = group('oversized');
createAgentGroup(g);
ensureContainerConfig(g.id);
updateContainerConfigJson(g.id, 'mcp_servers', {
bloated: { command: 'x', instructions: 'y'.repeat(CODEX_PROJECT_DOC_MAX_BYTES + 1024) },
lean: { command: 'x', instructions: 'short and useful' },
});
const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-'));
try {
composeGroupAgentsMd(g, groupDir); // must not throw
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(Buffer.byteLength(doc, 'utf-8')).toBeLessThanOrEqual(CODEX_PROJECT_DOC_MAX_BYTES);
// Largest optional section dropped, named in the doc; the rest survive.
expect(doc).toContain('Omitted for size');
expect(doc).toContain('MCP Server: bloated');
expect(doc).toContain('short and useful');
expect(doc).toContain('Memory System');
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
}
});
});
-188
View File
@@ -1,188 +0,0 @@
/**
* AGENTS.md composition for codex agent groups codex-owned payload code.
*
* AGENTS.md is Codex's project doc (its CLAUDE.md equivalent). Composed fresh
* on every spawn by the codex provider contribution (see ./codex.ts) from:
* - the shared base (`container/AGENTS.md`)
* - a pointer to the runner-scaffolded memory system (created container-side
* at boot via the `usesMemoryScaffold` capability nothing is written here)
* - a pointer to codex-native skills under `.agents/skills`
* - each enabled NanoClaw module's `*.instructions.md` fragment
* - MCP server `instructions` from container.json
*
* Codex hard-caps project-doc loading (`project_doc_max_bytes`, mirrored in
* the container provider's config.toml writer) compose fails loudly rather
* than letting Codex truncate silently.
*/
import fs from 'fs';
import path from 'path';
import type { McpServerConfig } from '../container-config.js';
import { getContainerConfig } from '../db/container-configs.js';
import { log } from '../log.js';
import type { AgentGroup } from '../types.js';
export const CODEX_PROJECT_DOC_MAX_BYTES = 32 * 1024;
export const CODEX_PROJECT_DOC_WARN_BYTES = 28 * 1024;
const HEADER = '<!-- Composed at spawn. Do not edit. Edit memory/system/definition.md for memory behavior. -->';
const MCP_TOOLS_HOST_SUBPATH = path.join('container', 'agent-runner', 'src', 'mcp-tools');
const MEMORY_POINTER = [
'Editable memory-system definition: `/workspace/agent/memory/system/definition.md`.',
'Top memory index: `/workspace/agent/memory/index.md`.',
'Read the definition and index, then use memories, data, and conversation archives when relevant.',
'Stored user preferences are binding: before your first reply in a session, check the index below and read any memory file relevant to the user or the request, and apply it without being asked.',
'Do not use `AGENTS.local.md` or `AGENTS.override.md` for memory.',
].join('\n\n');
/**
* Inline the group's current memory index into the composed doc. Recall must
* not depend on the model choosing to read a file before its first reply
* with the map already in the system prompt, applying a stored preference is
* one hop (read the relevant memory file), not three. The index is small
* (hundreds of bytes); the 32KB fit logic above bounds the worst case.
*/
function memoryIndexInline(groupDir: string): string {
const indexPath = path.join(groupDir, 'memory', 'index.md');
if (!fs.existsSync(indexPath)) return '';
const content = fs.readFileSync(indexPath, 'utf-8').trim();
if (!content) return '';
return ['Current memory index (paths relative to `/workspace/agent/memory/`):', content].join('\n\n');
}
const NATIVE_RUNTIME_SKILLS_POINTER = [
'Selected NanoClaw runtime skills are available as Codex-native skills at `/workspace/agent/.agents/skills`.',
'Each skill directory contains a `SKILL.md` with its trigger description plus any supporting files, and points to the read-only shared skill source under `/app/skills`.',
'Use skill discovery to load these skills only when their descriptions match the task. Full skill instructions live in the skill directories, not in `AGENTS.md`.',
'Skills YOU author or install yourself go in `/workspace/agent/skills/<name>/SKILL.md` — persistent, provider-neutral (they load under any agent provider this group runs on), and yours to write and update over time. They are linked into `$CODEX_HOME/skills` automatically at boot. Never write skills anywhere else: paths outside your workspace and `$CODEX_HOME` are ephemeral.',
].join('\n\n');
interface AgentsMdSection {
name: string;
content: string;
}
export function composeGroupAgentsMd(group: AgentGroup, groupDir: string): void {
if (!fs.existsSync(groupDir)) fs.mkdirSync(groupDir, { recursive: true });
const configRow = getContainerConfig(group.id);
const mcpServers: Record<string, McpServerConfig> = configRow
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
: {};
const sections: AgentsMdSection[] = [{ name: 'header', content: HEADER }];
const pushSection = (name: string, ...content: string[]): void => {
const body = content
.map((part) => part.trim())
.filter(Boolean)
.join('\n\n');
if (body) sections.push({ name, content: `# ${name}\n\n${body}` });
};
const sharedBase = path.join(process.cwd(), 'container', 'AGENTS.md');
if (fs.existsSync(sharedBase)) {
pushSection('NanoClaw Runtime Contract', fs.readFileSync(sharedBase, 'utf-8'));
}
pushSection('Memory System', MEMORY_POINTER, memoryIndexInline(groupDir));
pushSection('Native Runtime Skills', NATIVE_RUNTIME_SKILLS_POINTER);
const cliDisabled = configRow?.cli_scope === 'disabled';
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
if (fs.existsSync(mcpToolsHostDir)) {
for (const entry of fs.readdirSync(mcpToolsHostDir).sort()) {
const match = entry.match(/^(.+)\.instructions\.md$/);
if (!match) continue;
const moduleName = match[1];
if (moduleName === 'cli' && cliDisabled) continue;
pushSection(`NanoClaw Module: ${moduleName}`, fs.readFileSync(path.join(mcpToolsHostDir, entry), 'utf-8'));
}
}
for (const [name, mcp] of Object.entries(mcpServers)) {
if (mcp.instructions) {
pushSection(`MCP Server: ${name}`, mcp.instructions);
}
}
const content = fitAgentsMdToCap(group, sections);
writeAtomic(path.join(groupDir, 'AGENTS.md'), content);
}
function renderAgentsMd(sections: AgentsMdSection[]): string {
return (
sections
.map((section) => section.content.trim())
.filter(Boolean)
.join('\n\n') + '\n'
);
}
/**
* Fit the doc under Codex's 32KB project-doc cap by DEGRADING, never
* throwing: a per-spawn throw rides wakeContainer's transient-retry contract
* host-sweep respawns every 60s forever and the group goes silently dark.
* Instead, drop the largest optional instruction sections (per-module and
* per-MCP-server) until the doc fits, log what was dropped at error level,
* and tell the agent in the doc itself. The core contract (header, runtime
* contract, memory, skills pointer) is never dropped.
*/
function fitAgentsMdToCap(group: AgentGroup, sections: AgentsMdSection[]): string {
const sectionBytes = (): { section: string; bytes: number }[] =>
sections.map((section) => ({ section: section.name, bytes: Buffer.byteLength(section.content, 'utf-8') }));
const isDroppable = (s: AgentsMdSection): boolean =>
s.name.startsWith('MCP Server: ') || s.name.startsWith('NanoClaw Module: ');
const dropped: string[] = [];
const render = (): string => {
const parts = [...sections];
if (dropped.length > 0) {
parts.push({
name: 'omitted',
content:
`# Omitted for size\n\nThese instruction sections were omitted to fit Codex's project-doc cap: ` +
`${dropped.join(', ')}. Their tools still work; consult each tool's own description.`,
});
}
return renderAgentsMd(parts);
};
let content = render();
while (Buffer.byteLength(content, 'utf-8') > CODEX_PROJECT_DOC_MAX_BYTES) {
const candidates = sections
.filter(isDroppable)
.sort((a, b) => Buffer.byteLength(b.content, 'utf-8') - Buffer.byteLength(a.content, 'utf-8'));
if (candidates.length === 0) break; // only core left — write oversized rather than brick the group
sections.splice(sections.indexOf(candidates[0]), 1);
dropped.push(candidates[0].name);
content = render();
}
const bytes = Buffer.byteLength(content, 'utf-8');
if (dropped.length > 0) {
log.error('AGENTS.md exceeded Codex project-doc cap — dropped largest instruction sections', {
group: group.name,
bytes,
maxBytes: CODEX_PROJECT_DOC_MAX_BYTES,
dropped,
sections: sectionBytes(),
});
} else if (bytes >= CODEX_PROJECT_DOC_WARN_BYTES) {
log.warn('AGENTS.md is near Codex project-doc cap', {
group: group.name,
bytes,
warnBytes: CODEX_PROJECT_DOC_WARN_BYTES,
maxBytes: CODEX_PROJECT_DOC_MAX_BYTES,
sections: sectionBytes(),
});
}
return content;
}
function writeAtomic(filePath: string, content: string): void {
const tmp = `${filePath}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, filePath);
}
@@ -1,98 +0,0 @@
/**
* In-process seam test for the codex HOST contribution's runtime consumption
* of core (the "consumes core" leg the skill guidelines require): drive the
* REAL registered contribution via the real barrel and registry, never by
* importing codex.ts's internals against a real test DB and a temp
* GROUPS_DIR/DATA_DIR, then hand its result to the real buildMounts.
*
* This is what catches core drift that typecheck can't: the
* DATA_DIR/v2-sessions/<id>/.codex-shared session layout, the
* getAgentGroup/getContainerConfig reads, the mcp_servers JSON shape consumed
* by composeGroupAgentsMd, and the mount set buildMounts assembles for a
* surfaces-providing provider. (codex-registration.test.ts only guards that
* the name is registered; provider-surfaces.test.ts drives a FAKE provider to
* test the seam itself.)
*/
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-codex-host-contribution-test';
const DATA_DIR = path.join(TEST_ROOT, 'data');
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
vi.mock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
DATA_DIR: '/tmp/nanoclaw-codex-host-contribution-test/data',
GROUPS_DIR: '/tmp/nanoclaw-codex-host-contribution-test/groups',
}));
import { buildMounts } from '../container-runner.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../db/index.js';
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
import { getProviderContainerConfig } from './provider-container-registry.js';
import './index.js'; // the real host provider barrel
import type { ContainerConfig } from '../container-config.js';
import type { AgentGroup, Session } from '../types.js';
function group(id: string, folder: string): AgentGroup {
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
}
describe('codex host contribution against real core', () => {
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(GROUPS_DIR, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
it('creates the per-group state dir, composes AGENTS.md from the real config row, and mounts both', () => {
const ag = group('ag-codex', 'codex-group');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
updateContainerConfigJson(ag.id, 'mcp_servers', {
tooling: { command: 'x', instructions: 'use the tooling server for builds' },
});
const groupDir = path.join(GROUPS_DIR, ag.folder);
const contributionFn = getProviderContainerConfig('codex');
expect(contributionFn).toBeDefined();
const contribution = contributionFn!({
sessionDir: path.join(DATA_DIR, 'v2-sessions', ag.id, 'session-1'),
agentGroupId: ag.id,
groupDir,
selectedSkills: [],
hostEnv: process.env,
});
// Per-group codex state dir exists and is mounted RW at ~/.codex.
const codexShared = path.join(DATA_DIR, 'v2-sessions', ag.id, '.codex-shared');
expect(fs.existsSync(codexShared)).toBe(true);
// OneCLI's auth-stub mountpoint is pre-created — on macOS Docker can't
// create a missing file mountpoint inside a virtiofs dir mount (exit 125
// on first spawn). Red here = the pre-create line was dropped.
expect(fs.existsSync(path.join(codexShared, 'auth.json'))).toBe(true);
const codexMount = contribution.mounts?.find((m) => m.containerPath === '/home/node/.codex');
expect(codexMount).toMatchObject({ hostPath: codexShared, readonly: false });
// AGENTS.md composed from the real DB row — MCP instructions included.
const agentsMd = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(agentsMd).toContain('MCP Server: tooling');
expect(agentsMd).toContain('use the tooling server for builds');
// The full mount set: codex surfaces in, default claude surfaces out.
const session = { id: 'session-1', agent_group_id: ag.id } as Session;
const config: ContainerConfig = { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
const mounts = buildMounts(ag, session, config, 'codex', contribution);
const containerPaths = mounts.map((m) => m.containerPath);
expect(containerPaths).toContain('/home/node/.codex');
expect(containerPaths.some((p) => p.endsWith('AGENTS.md'))).toBe(true);
expect(containerPaths).not.toContain('/home/node/.claude');
});
});
-27
View File
@@ -1,27 +0,0 @@
/**
* Integration test for the codex provider's HOST-side reach-in: the self-registration
* import in the src/providers/index.ts barrel. Importing the barrel runs codex.ts's
* top-level registerProviderContainerConfig('codex', ); without that import line the
* host never wires the provider's per-session mounts / env passthrough.
*
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
* never ./codex.js directly, then asserts the registry actually contains the provider.
* Importing the provider module directly (as codex.factory.test.ts does) self-registers
* it and would stay GREEN even if the barrel line were deleted that is a unit test,
* not a registration guard. This test goes red if the barrel import is deleted/drifts,
* or the barrel fails to evaluate.
*
* A provider is a MULTI-POINT integration: this guards the HOST barrel; the CONTAINER
* barrel is guarded by the sibling bun test; the SDK/CLI dependency + Dockerfile install
* are guarded by the build/container legs (see the skill's validate step).
*/
import { describe, it, expect } from 'vitest';
import { listProviderContainerConfigNames } from './provider-container-registry.js';
import './index.js'; // the real host provider barrel — triggers each provider's self-registration
describe('codex provider host registration', () => {
it('registers codex host container-config via the barrel', () => {
expect(listProviderContainerConfigNames()).toContain('codex');
});
});
-108
View File
@@ -1,108 +0,0 @@
/**
* Host-side container config for the `codex` provider.
*
* Registers with `providesAgentSurfaces` codex owns its agent-facing
* surfaces, so core skips the default (Claude) compose/mounts and this
* contribution supplies them instead:
*
* - AGENTS.md codex's project doc, composed fresh every spawn
* (see ./codex-agents-md.ts), mounted RO over the RW group dir.
* - .agents/skills codex-native skill links synced to the group's
* container.json selection, mounted RO.
* - ~/.codex a per-GROUP private state dir (`.codex-shared`), persistent
* across sessions so thread metadata and config.toml survive respawns.
*
* Credentials: NONE here v2's invariant is that containers never receive
* raw API keys; OneCLI is the sole credential path. The OpenAI key (or
* ChatGPT token) lives in the OneCLI vault with an api.openai.com /
* chatgpt.com host pattern; codex's traffic already rides the gateway proxy
* (every spawn applies it see container-runner.ts), which injects the real
* credential in flight. The container only ever sees the `onecli-managed`
* placeholder. Model/effort come from container_config (`ncl groups config
* update --model/--effort`), not env.
*
* Memory and exchange archiving are NOT handled here either the
* container-side provider declares `usesMemoryScaffold` (the runner
* scaffolds the memory tree) and implements `onExchangeComplete` (the
* provider's own exchange-archive.ts persists each exchange).
*/
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from '../config.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { composeGroupAgentsMd } from './codex-agents-md.js';
import { registerProviderContainerConfig } from './provider-container-registry.js';
registerProviderContainerConfig(
'codex',
(ctx) => {
// Per-group codex state (config.toml, thread metadata).
const codexDir = path.join(DATA_DIR, 'v2-sessions', ctx.agentGroupId, '.codex-shared');
fs.mkdirSync(codexDir, { recursive: true });
// OneCLI bind-mounts its auth stub at ~/.codex/auth.json, nested inside
// this dir mount — Docker on macOS can't create a missing mountpoint file
// inside a virtiofs bind mount (runc: "mountpoint is outside of rootfs",
// exit 125), so it must exist before first spawn. Re-created here per
// spawn because a group reset that wipes .codex-shared re-triggers it.
// The 'a' flag creates the file if missing, never truncates an existing one.
fs.closeSync(fs.openSync(path.join(codexDir, 'auth.json'), 'a'));
// Compose this group's AGENTS.md and sync codex-native skill links.
const group = getAgentGroup(ctx.agentGroupId);
if (group) composeGroupAgentsMd(group, ctx.groupDir);
syncCodexSkillLinks(ctx.groupDir, ctx.selectedSkills);
// No credential env here — OneCLI's container-config drives auth end to
// end: the gateway serves a sentinel auth.json stub into ~/.codex for
// BOTH auth modes (ChatGPT subscription and API key) and swaps the real
// credential on the wire. Note the runner's CODEX_ENV_ALLOWLIST
// deliberately strips OPENAI_API_KEY from the codex process env — auth
// never rides env vars, only the stub. Duplicating any of it here would
// be a second source of truth.
const mounts = [{ hostPath: codexDir, containerPath: '/home/node/.codex', readonly: false }];
const composedAgentsMd = path.join(ctx.groupDir, 'AGENTS.md');
if (fs.existsSync(composedAgentsMd)) {
// RO over the RW group dir — regenerated every spawn, agent edits would
// be clobbered anyway. Memory behavior is edited via memory/system/.
mounts.push({ hostPath: composedAgentsMd, containerPath: '/workspace/agent/AGENTS.md', readonly: true });
}
const agentsDir = path.join(ctx.groupDir, '.agents');
if (fs.existsSync(agentsDir)) {
mounts.push({ hostPath: agentsDir, containerPath: '/workspace/agent/.agents', readonly: true });
}
return { mounts };
},
{ providesAgentSurfaces: true },
);
/**
* Sync `.agents/skills/<name>` symlinks to the selected skill set. Targets are
* container paths (`/app/skills/<name>`) dangling on the host, valid inside.
*/
function syncCodexSkillLinks(groupDir: string, selectedSkills: string[]): void {
const skillsDir = path.join(groupDir, '.agents', 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
const desired = new Set(selectedSkills);
for (const entry of fs.readdirSync(skillsDir)) {
const entryPath = path.join(skillsDir, entry);
let isSymlink = false;
try {
isSymlink = fs.lstatSync(entryPath).isSymbolicLink();
} catch {
continue;
}
if (isSymlink && !desired.has(entry)) fs.unlinkSync(entryPath);
}
for (const skill of selectedSkills) {
const linkPath = path.join(skillsDir, skill);
try {
fs.lstatSync(linkPath);
} catch {
fs.symlinkSync(`/app/skills/${skill}`, linkPath);
}
}
}
-3
View File
@@ -4,6 +4,3 @@
// needs (claude, mock) don't appear here.
//
// Skills add a new provider by appending one import line below.
import './codex.js';
import './opencode.js';
@@ -1,27 +0,0 @@
/**
* Integration test for the opencode provider's HOST-side reach-in: the self-registration
* import in the src/providers/index.ts barrel. Importing the barrel runs opencode.ts's
* top-level registerProviderContainerConfig('opencode', ); without that import line the
* host never wires the provider's per-session mounts / env passthrough.
*
* Behavior, not structural, and BARREL-ONLY: it imports the real barrel (./index.js),
* never ./opencode.js directly, then asserts the registry actually contains the provider.
* Importing the provider module directly (as opencode.factory.test.ts does) self-registers
* it and would stay GREEN even if the barrel line were deleted that is a unit test,
* not a registration guard. This test goes red if the barrel import is deleted/drifts,
* or the barrel fails to evaluate.
*
* A provider is a MULTI-POINT integration: this guards the HOST barrel; the CONTAINER
* barrel is guarded by the sibling bun test; the SDK/CLI dependency + Dockerfile install
* are guarded by the build/container legs (see the skill's validate step).
*/
import { describe, it, expect } from 'vitest';
import { listProviderContainerConfigNames } from './provider-container-registry.js';
import './index.js'; // the real host provider barrel — triggers each provider's self-registration
describe('opencode provider host registration', () => {
it('registers opencode host container-config via the barrel', () => {
expect(listProviderContainerConfigNames()).toContain('opencode');
});
});
-49
View File
@@ -1,49 +0,0 @@
/**
* Host-side container config for the `opencode` provider.
*
* OpenCode's `opencode serve` process stores state under XDG_DATA_HOME, which
* we pin to a per-session host directory mounted at /opencode-xdg. The
* OPENCODE_* env vars tell the CLI which provider/model to use at runtime
* (read on the host, injected into the container). NO_PROXY / no_proxy are
* merged with host values so the in-container OpenCode client can talk to
* 127.0.0.1 even when HTTPS_PROXY is set by OneCLI.
*/
import fs from 'fs';
import path from 'path';
import { registerProviderContainerConfig } from './provider-container-registry.js';
function mergeNoProxy(current: string | undefined, additions: string): string {
if (!current?.trim()) return additions;
const parts = new Set(
current
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean),
);
for (const addition of additions.split(',')) {
const trimmed = addition.trim();
if (trimmed) parts.add(trimmed);
}
return [...parts].join(',');
}
registerProviderContainerConfig('opencode', (ctx) => {
const opencodeDir = path.join(ctx.sessionDir, 'opencode-xdg');
fs.mkdirSync(opencodeDir, { recursive: true });
const env: Record<string, string> = {
XDG_DATA_HOME: '/opencode-xdg',
NO_PROXY: mergeNoProxy(ctx.hostEnv.NO_PROXY, '127.0.0.1,localhost'),
no_proxy: mergeNoProxy(ctx.hostEnv.no_proxy, '127.0.0.1,localhost'),
};
for (const key of ['OPENCODE_PROVIDER', 'OPENCODE_MODEL', 'OPENCODE_SMALL_MODEL'] as const) {
const value = ctx.hostEnv[key];
if (value) env[key] = value;
}
return {
mounts: [{ hostPath: opencodeDir, containerPath: '/opencode-xdg', readonly: false }],
env,
};
});
+2 -9
View File
@@ -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 {
+1 -17
View File
@@ -14,7 +14,6 @@ import type Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { isSafeAttachmentName } from './attachment-safety.js';
import type { OutboundFile } from './channels/adapter.js';
import { DATA_DIR } from './config.js';
import { getMessagingGroup } from './db/messaging-groups.js';
@@ -253,26 +252,11 @@ function extractAttachmentFiles(
let changed = false;
for (const att of attachments) {
if (typeof att.data === 'string') {
// The name field is attacker-controlled: chat platforms with E2E
// attachment encryption (WhatsApp, Matrix) cannot sanitize filename
// server-side, and other adapters pass att.name through raw. Without
// this guard, `path.join(inboxDir, '../../...')` writes anywhere the
// host process has fs permission — see Signal Desktop's Nov 2025
// attachment-fileName advisory for the same archetype.
const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`;
const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
if (filename !== rawName) {
log.warn('Refused unsafe attachment filename — would escape inbox', {
messageId,
rawName,
replacement: filename,
});
}
const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId);
fs.mkdirSync(inboxDir, { recursive: true });
const filename = (att.name as string) || `attachment-${Date.now()}`;
const filePath = path.join(inboxDir, filename);
fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'));
att.name = filename;
att.localPath = `inbox/${messageId}/${filename}`;
delete att.data;
changed = true;