mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 780265225f | |||
| 9fa85ccf95 | |||
| dfd3ee31a9 | |||
| 47f8296e67 | |||
| dab93fc592 | |||
| 0ffe5582f0 | |||
| 4b5576faea | |||
| 904871aaa7 | |||
| 5e76b9d7e8 | |||
| b429ab37b8 | |||
| 09ddde33e1 | |||
| c0c46c14d6 | |||
| c993527e25 | |||
| 0367dbb6f0 | |||
| c7a7a709ed | |||
| 7e37b13aab | |||
| f8c3d02348 | |||
| b808ab4fd2 | |||
| b9b186c9cf | |||
| b44bcf5dcf | |||
| be86bd3c2d | |||
| 6591062fbb | |||
| 26fc3ff322 | |||
| 4ebf56e2a3 | |||
| 7693a20970 | |||
| e706dcac00 | |||
| f048447ec5 | |||
| efdd05a7ef | |||
| 7de1fc1b3c | |||
| 6b431c195d | |||
| 0bc082a17c | |||
| b6be3b9bf4 | |||
| 7e99d0eaf7 | |||
| 8d8522202a | |||
| 0df647be74 | |||
| 2825f657ca | |||
| 15a6950b5b | |||
| 226fc93795 | |||
| 15e2ac7649 | |||
| f804ebf2e9 | |||
| fc375ca72b | |||
| 88d3da76c3 | |||
| 6d35c85129 | |||
| f0ebc8d6e1 | |||
| c7f8e98471 | |||
| 52f8661f0c | |||
| f37e775358 | |||
| 41162517d9 | |||
| 7c8d220115 | |||
| 2afcee3a4f | |||
| 9bb416c157 | |||
| beb73d792a | |||
| 8b783daa67 | |||
| 5cbfccec05 | |||
| 8637143216 | |||
| 44067e73cb | |||
| 72d0134d0a | |||
| 2b51a4e707 | |||
| 3d6837c411 | |||
| 9fd694c763 | |||
| 4fc2c4275c | |||
| 1de5a0356b | |||
| f41c162009 | |||
| 2d2c3204bc | |||
| fd03b89333 | |||
| 672e228876 | |||
| 81ef193e69 | |||
| 4836cb59df | |||
| 9e33274e2a | |||
| d0c608c751 | |||
| a4346f566c | |||
| 1df8dec9bd | |||
| 82baa39f20 | |||
| 5845a5a980 | |||
| ce28e7f558 | |||
| 9e480a0624 | |||
| 3fa001409e | |||
| 78b0ad68f6 | |||
| e3f4a8b0d8 | |||
| c1d0395d11 | |||
| 0eeeecf75e | |||
| 7a628bfb3c | |||
| 2fd2bf3bde | |||
| f351e46008 | |||
| 5f3bd9c880 | |||
| 5d32efbce4 | |||
| 7eda2628fa | |||
| ffd38f660a | |||
| 57eeed6cb6 | |||
| 2861009d95 | |||
| bd032c2b83 | |||
| 0e0794ca10 | |||
| 83254b12b4 | |||
| cf2b1c9755 | |||
| f3524a33bb | |||
| c6d2f45f93 | |||
| c20213a133 | |||
| e5a7a33084 | |||
| 0ec56b732d | |||
| 97868af5a7 | |||
| ff277c0d49 | |||
| a67b4abd79 | |||
| 500353c182 | |||
| a8eb82d529 | |||
| 237876c2c6 | |||
| 209061f54f | |||
| bee80b0072 | |||
| 539af750d4 | |||
| 438dedad77 | |||
| 6475e0f0b5 | |||
| dd5bc85b02 | |||
| 97e356d243 | |||
| 94d33bcc1d | |||
| cca22e9270 | |||
| 3a9b98f1a4 | |||
| 677cc47bd1 | |||
| 40f5683c36 | |||
| 15f30682d7 | |||
| d121cd1cd6 | |||
| 61ca43d193 | |||
| 3101f65a72 | |||
| d8b1f52f2b | |||
| c84a6ba80e | |||
| 73c931594a | |||
| 2383bde80f | |||
| dee7e0be32 | |||
| 990d243dbd | |||
| 910342fd80 | |||
| 7f4583d0fe | |||
| 092f16dfaa | |||
| d9ed98fd65 | |||
| 5ae1c33fff | |||
| af542adad5 | |||
| ad97829151 | |||
| bc0b559461 | |||
| 06918f35e0 | |||
| 894b154e41 | |||
| 831ef88f16 | |||
| d05923f274 | |||
| aba618215d | |||
| 138c277fae | |||
| 1e7cb8b8c8 | |||
| eb0055a0b0 | |||
| ef6ea87628 | |||
| b29df213ad | |||
| dd53875574 | |||
| a1ce73c376 | |||
| 48e4172899 |
@@ -0,0 +1,243 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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.0–2.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);
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
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 2–3s 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.
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
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.
|
||||
@@ -208,7 +208,7 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
|
||||
### Per group / per session
|
||||
|
||||
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).
|
||||
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'`.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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`.)
|
||||
@@ -0,0 +1,318 @@
|
||||
---
|
||||
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 17–25 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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,7 +1,12 @@
|
||||
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:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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.
|
||||
@@ -7,6 +7,7 @@
|
||||
"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",
|
||||
},
|
||||
@@ -44,6 +45,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,20 @@
|
||||
* Persistent key/value state for the container. Lives in outbound.db
|
||||
* (container-owned, already scoped per channel/thread).
|
||||
*
|
||||
* Primary use: remember the SDK session ID so the agent's conversation
|
||||
* resumes across container restarts. Cleared by /clear.
|
||||
* 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.
|
||||
*/
|
||||
import { getOutboundDb } from './connection.js';
|
||||
|
||||
const SDK_SESSION_KEY = 'sdk_session_id';
|
||||
const LEGACY_KEY = 'sdk_session_id';
|
||||
|
||||
function continuationKey(providerName: string): string {
|
||||
return `continuation:${providerName.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getValue(key: string): string | undefined {
|
||||
const row = getOutboundDb()
|
||||
@@ -18,9 +26,7 @@ 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());
|
||||
}
|
||||
|
||||
@@ -28,14 +34,46 @@ function deleteValue(key: string): void {
|
||||
getOutboundDb().prepare('DELETE FROM session_state WHERE key = ?').run(key);
|
||||
}
|
||||
|
||||
export function getStoredSessionId(): string | undefined {
|
||||
return getValue(SDK_SESSION_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 setStoredSessionId(sessionId: string): void {
|
||||
setValue(SDK_SESSION_KEY, sessionId);
|
||||
export function getContinuation(providerName: string): string | undefined {
|
||||
return getValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
export function clearStoredSessionId(): void {
|
||||
deleteValue(SDK_SESSION_KEY);
|
||||
export function setContinuation(providerName: string, id: string): void {
|
||||
setValue(continuationKey(providerName), id);
|
||||
}
|
||||
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ async function main(): Promise<void> {
|
||||
|
||||
await runPollLoop({
|
||||
provider,
|
||||
providerName,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
});
|
||||
|
||||
@@ -98,6 +98,7 @@ async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSigna
|
||||
return Promise.race([
|
||||
runPollLoop({
|
||||
provider,
|
||||
providerName: 'mock',
|
||||
cwd: '/tmp',
|
||||
}),
|
||||
new Promise<void>((_, reject) => {
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} 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';
|
||||
|
||||
@@ -19,6 +23,12 @@ 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;
|
||||
@@ -39,8 +49,9 @@ 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.).
|
||||
let continuation: string | undefined = getStoredSessionId();
|
||||
// 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);
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
@@ -94,7 +105,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;
|
||||
clearStoredSessionId();
|
||||
clearContinuation(config.providerName);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
@@ -160,10 +171,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);
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setStoredSessionId(continuation);
|
||||
setContinuation(config.providerName, continuation);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
@@ -175,7 +186,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;
|
||||
clearStoredSessionId();
|
||||
clearContinuation(config.providerName);
|
||||
}
|
||||
|
||||
// Write error response so the user knows something went wrong
|
||||
@@ -238,6 +249,7 @@ async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
providerName: string,
|
||||
): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
@@ -288,7 +300,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.
|
||||
setStoredSessionId(event.continuation);
|
||||
setContinuation(providerName, 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
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
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));
|
||||
@@ -0,0 +1,267 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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,6 +2,7 @@ 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', () => {
|
||||
@@ -9,6 +10,10 @@ 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,4 +3,6 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './codex.js';
|
||||
import './mock.js';
|
||||
import './opencode.js';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
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));
|
||||
+19
-7
@@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# 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')"
|
||||
# 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')"
|
||||
|
||||
# ─── 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 "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
"$(dim "Small. Runs 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 installed" "$BOOTSTRAP_DUR"
|
||||
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
@@ -245,7 +245,19 @@ 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.
|
||||
exec pnpm --silent run setup:auto
|
||||
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
|
||||
exec pnpm --silent run setup:auto -- "$@"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.14",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -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="128k tokens, 64% of context window">
|
||||
<title>128k tokens, 64% 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="133k tokens, 66% of context window">
|
||||
<title>133k tokens, 66% 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">128k</text>
|
||||
<text x="71" y="14">128k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">133k</text>
|
||||
<text x="71" y="14">133k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -48,6 +48,7 @@ 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';
|
||||
@@ -137,16 +138,6 @@ 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)}`;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,20 @@ 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
|
||||
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# and restart the service. Non-interactive — the Full Disk Access walkthrough
|
||||
# (local mode) and Photon URL/key prompts (remote mode) live in
|
||||
# setup/channels/imessage.ts. Creds come in via env vars:
|
||||
# IMESSAGE_LOCAL 'true' | 'false' (required)
|
||||
# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true)
|
||||
# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false)
|
||||
# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-imessage/SKILL.md.
|
||||
ADAPTER_VERSION="chat-adapter-imessage@0.1.1"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local mode=${IMESSAGE_LOCAL:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_IMESSAGE ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-imessage] $*" >&2; }
|
||||
|
||||
# Validate creds based on mode.
|
||||
if [ -z "${IMESSAGE_LOCAL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${IMESSAGE_LOCAL}" = "true" ]; then
|
||||
if [ -z "${IMESSAGE_ENABLED:-}" ]; then
|
||||
emit_status failed "IMESSAGE_ENABLED env var not set for local mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
emit_status failed "local mode requires macOS"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then
|
||||
emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${IMESSAGE_API_KEY:-}" ]; then
|
||||
emit_status failed "IMESSAGE_API_KEY env var not set for remote mode"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/imessage.ts ] && return 0
|
||||
! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './imessage.js';" src/channels/index.ts; then
|
||||
echo "import './imessage.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
|
||||
remove_env() {
|
||||
local key=$1
|
||||
if grep -q "^${key}=" .env 2>/dev/null; then
|
||||
grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env
|
||||
fi
|
||||
}
|
||||
|
||||
# Write the canonical keys for the chosen mode, strip the opposite mode's
|
||||
# keys so stale values can't confuse the adapter's factory.
|
||||
upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL"
|
||||
if [ "$IMESSAGE_LOCAL" = "true" ]; then
|
||||
upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED"
|
||||
remove_env IMESSAGE_SERVER_URL
|
||||
remove_env IMESSAGE_API_KEY
|
||||
else
|
||||
upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL"
|
||||
upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY"
|
||||
remove_env IMESSAGE_ENABLED
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the creds…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the adapter a moment to open chat.db (local) or handshake with
|
||||
# Photon (remote) before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Signal adapter in an already-running NanoClaw checkout.
|
||||
# Non-interactive — the operator-facing "install signal-cli" + QR scan
|
||||
# live in setup/channels/signal.ts. This script only:
|
||||
#
|
||||
# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels
|
||||
# branch.
|
||||
# 2. Appends the self-registration import to src/channels/index.ts.
|
||||
# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has
|
||||
# no npm deps).
|
||||
# 4. Builds.
|
||||
#
|
||||
# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli
|
||||
# link has produced a number; that keeps this script idempotent and
|
||||
# re-runnable without re-auth.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All
|
||||
# chatty progress goes to stderr so setup:auto's raw-log capture sees
|
||||
# the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-signal/SKILL.md.
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SIGNAL ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-signal] $*" >&2; }
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/signal.ts ] && return 0
|
||||
! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/signal.ts \
|
||||
src/channels/signal.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f" || {
|
||||
emit_status failed "git show ${CHANNELS_BRANCH}:$f failed"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if ! grep -q "^import './signal.js';" src/channels/index.ts; then
|
||||
echo "import './signal.js';" >> src/channels/index.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
# qrcode is needed by setup/signal-auth.ts to render the linking URL as a
|
||||
# terminal QR. Install idempotently — if it's already present (e.g. from a
|
||||
# prior WhatsApp install) pnpm is a no-op.
|
||||
if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then
|
||||
log "Installing ${QRCODE_VERSION}…"
|
||||
pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${QRCODE_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
emit_status success
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# operator-facing app creation walkthrough + credential paste live in
|
||||
# setup/channels/slack.ts. Credentials come in via env vars:
|
||||
# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-slack/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
|
||||
|
||||
# Resolve which remote carries the channels branch — handles forks where
|
||||
# upstream lives on a different remote than `origin`.
|
||||
# shellcheck source=setup/lib/channels-remote.sh
|
||||
source "$PROJECT_ROOT/setup/lib/channels-remote.sh"
|
||||
CHANNELS_REMOTE=$(resolve_channels_remote)
|
||||
CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_SLACK ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-slack] $*" >&2; }
|
||||
|
||||
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "SLACK_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
emit_status failed "SLACK_SIGNING_SECRET env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/slack.ts ] && return 0
|
||||
! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './slack.js';" src/channels/index.ts; then
|
||||
echo "import './slack.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates via auth.test before this point, so
|
||||
# bad values here would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN"
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Slack adapter a moment to finish starting the webhook listener
|
||||
# before emitting success.
|
||||
sleep 3
|
||||
|
||||
emit_status success
|
||||
+334
-131
@@ -22,23 +22,33 @@
|
||||
* 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';
|
||||
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 { runWindowedStep } from './lib/windowed-runner.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
resolveTimezoneViaClaude,
|
||||
} from './lib/tz-from-claude.js';
|
||||
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 * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
@@ -48,11 +58,48 @@ 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';
|
||||
|
||||
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(',')
|
||||
@@ -75,12 +122,7 @@ 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 3–10 minutes.',
|
||||
@@ -125,57 +167,95 @@ async function main(): Promise<void> {
|
||||
),
|
||||
);
|
||||
|
||||
// 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 remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
|
||||
|
||||
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') {
|
||||
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);
|
||||
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.',
|
||||
`Couldn't reach OneCLI at ${remoteHost}.`,
|
||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
s.stop('Remote OneCLI is reachable.');
|
||||
|
||||
const res = await runQuietStep(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
{
|
||||
running: `Connecting to remote OneCLI at ${remoteHost}…`,
|
||||
done: 'OneCLI vault ready.',
|
||||
},
|
||||
['--remote-url', remoteHost],
|
||||
);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,21 +284,12 @@ 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(
|
||||
k.dim(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||
' systemctl --user restart nanoclaw',
|
||||
),
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -248,10 +319,33 @@ 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 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
await runFirstChat();
|
||||
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();
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
@@ -260,7 +354,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.'
|
||||
@@ -274,8 +368,7 @@ async function main(): Promise<void> {
|
||||
await runTimezoneStep();
|
||||
}
|
||||
|
||||
let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' =
|
||||
'skip';
|
||||
let channelChoice: ChannelChoice = 'skip';
|
||||
if (!skip.has('channel')) {
|
||||
channelChoice = await askChannelChoice();
|
||||
if (channelChoice === 'telegram') {
|
||||
@@ -284,12 +377,18 @@ 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') {
|
||||
await runSlackChannel(displayName!);
|
||||
} else if (channelChoice === 'imessage') {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).',
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
@@ -305,7 +404,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') {
|
||||
@@ -331,7 +430,9 @@ 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");
|
||||
@@ -365,9 +466,7 @@ 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
|
||||
@@ -389,19 +488,14 @@ 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`."));
|
||||
}
|
||||
}
|
||||
|
||||
function channelDmLabel(
|
||||
choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip',
|
||||
): string | null {
|
||||
function channelDmLabel(choice: ChannelChoice): string | null {
|
||||
switch (choice) {
|
||||
case 'telegram':
|
||||
return 'Telegram';
|
||||
@@ -409,8 +503,17 @@ function channelDmLabel(
|
||||
return 'Discord DMs';
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp';
|
||||
case 'signal':
|
||||
return 'Signal';
|
||||
case 'teams':
|
||||
return 'Teams';
|
||||
case 'imessage':
|
||||
return 'iMessage';
|
||||
case 'slack':
|
||||
// Slack install doesn't wire an agent or send a welcome DM — the
|
||||
// driver prints its own "finish in your Slack app" note. Falling
|
||||
// through to null avoids a misleading "check your Slack DMs" banner.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -442,13 +545,11 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
|
||||
s.stop(`${k.bold(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(`${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(`${k.bold(fitToWidth(msg, suffix))}${k.dim(suffix)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -462,8 +563,8 @@ function renderPingFailureNote(result: PingResult): void {
|
||||
6,
|
||||
),
|
||||
'',
|
||||
k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`),
|
||||
k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`),
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
|
||||
` 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`.',
|
||||
@@ -503,9 +604,7 @@ 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;
|
||||
@@ -521,11 +620,9 @@ 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());
|
||||
});
|
||||
@@ -540,6 +637,16 @@ 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?',
|
||||
@@ -573,15 +680,11 @@ 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) {
|
||||
@@ -621,11 +724,16 @@ 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…`,
|
||||
@@ -644,6 +752,92 @@ 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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -664,10 +858,7 @@ 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)
|
||||
@@ -689,8 +880,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".
|
||||
@@ -717,7 +908,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'),
|
||||
}),
|
||||
@@ -786,16 +977,30 @@ async function askDisplayName(fallback: string): Promise<string> {
|
||||
return value;
|
||||
}
|
||||
|
||||
async function askChannelChoice(): Promise<
|
||||
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
||||
> {
|
||||
async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect({
|
||||
await brightSelect<ChannelChoice>({
|
||||
message: 'Want to chat with your assistant from your phone?',
|
||||
options: [
|
||||
{ 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)',
|
||||
hint: isMac ? 'local macOS mode' : 'remote Photon only',
|
||||
},
|
||||
{
|
||||
value: 'slack',
|
||||
label: 'Yes, connect Slack (experimental)',
|
||||
hint: 'needs public URL',
|
||||
},
|
||||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
@@ -803,7 +1008,7 @@ async function askChannelChoice(): Promise<
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
phEmit('channel_chosen', { channel: String(choice) });
|
||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
||||
return choice;
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
@@ -899,17 +1104,15 @@ 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;
|
||||
}
|
||||
|
||||
// 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.")}`);
|
||||
// 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.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* iMessage channel flow for setup:auto.
|
||||
*
|
||||
* `runIMessageChannel(displayName)` covers both deployment modes:
|
||||
*
|
||||
* Local (macOS): the bot runs on this Mac and talks via the signed-in
|
||||
* iMessage account. Reading chat.db needs Full Disk Access granted to
|
||||
* the Node binary — we open the directory for them so they can drag
|
||||
* the `node` file into System Settings.
|
||||
*
|
||||
* Remote (Photon API): the bot talks to a separate server (Photon)
|
||||
* that owns an iMessage account on another Mac. Used when this host
|
||||
* is Linux, or when the operator wants to keep their daily-driver
|
||||
* Mac's chat history out of the loop.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Pick mode (auto-defaults to local on macOS, remote elsewhere)
|
||||
* 2. Local: FDA walkthrough (open node bin directory, wait for ack)
|
||||
* Remote: prompt for Photon server URL + API key
|
||||
* 3. Ask for the phone or email the operator messages from — this is
|
||||
* the platform-id for first-agent wiring
|
||||
* 4. Install the adapter (setup/add-imessage.sh, non-interactive)
|
||||
* 5. Wire the agent via scripts/init-first-agent.ts — the welcome
|
||||
* iMessage goes out through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { brightSelect } from '../lib/bright-select.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
type Mode = 'local' | 'remote';
|
||||
|
||||
interface RemoteCreds {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export async function runIMessageChannel(displayName: string): Promise<void> {
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
const mode = await askMode(isMac);
|
||||
let remoteCreds: RemoteCreds | null = null;
|
||||
|
||||
if (mode === 'local') {
|
||||
if (!isMac) {
|
||||
await fail(
|
||||
'imessage',
|
||||
"Local iMessage mode only works on macOS.",
|
||||
'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.',
|
||||
);
|
||||
}
|
||||
await walkThroughFullDiskAccess();
|
||||
} else {
|
||||
remoteCreds = await collectRemoteCreds();
|
||||
}
|
||||
|
||||
const handle = await askOperatorHandle();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'imessage-install',
|
||||
'bash',
|
||||
['setup/add-imessage.sh'],
|
||||
{
|
||||
running:
|
||||
mode === 'local'
|
||||
? "Connecting the iMessage adapter to this Mac…"
|
||||
: `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`,
|
||||
done: 'iMessage adapter installed.',
|
||||
},
|
||||
{
|
||||
env:
|
||||
mode === 'local'
|
||||
? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' }
|
||||
: {
|
||||
IMESSAGE_LOCAL: 'false',
|
||||
IMESSAGE_SERVER_URL: remoteCreds!.serverUrl,
|
||||
IMESSAGE_API_KEY: remoteCreds!.apiKey,
|
||||
},
|
||||
extraFields: { MODE: mode },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'imessage-install',
|
||||
"Couldn't install the iMessage adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('iMessage');
|
||||
setupLog.userInput('imessage_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'imessage',
|
||||
'--user-id', handle,
|
||||
'--platform-id', handle,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to iMessage…`,
|
||||
done: `${agentName} is ready. Check iMessage for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'imessage',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: handle,
|
||||
MODE: mode,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askMode(isMac: boolean): Promise<Mode> {
|
||||
const choice = ensureAnswer(
|
||||
await brightSelect<Mode>({
|
||||
message: 'How should iMessage run?',
|
||||
initialValue: isMac ? 'local' : 'remote',
|
||||
options: isMac
|
||||
? [
|
||||
{
|
||||
value: 'local',
|
||||
label: 'Local (this Mac)',
|
||||
hint: "uses this machine's iMessage account",
|
||||
},
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'the bot lives on another server',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
value: 'remote',
|
||||
label: 'Remote (Photon API)',
|
||||
hint: 'only option off macOS',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_mode', String(choice));
|
||||
return choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant Full Disk Access to the Node binary the host runs under — without
|
||||
* it, the adapter can't read chat.db and inbound messages never arrive.
|
||||
* Opening the containing directory in Finder makes the drag-and-drop
|
||||
* target obvious; falling back to printing the path keeps us working in
|
||||
* SSH/headless contexts where `open` is a no-op.
|
||||
*/
|
||||
async function walkThroughFullDiskAccess(): Promise<void> {
|
||||
let nodePath = process.execPath;
|
||||
try {
|
||||
// `which node` picks up the user's shell-resolved node, which may differ
|
||||
// from process.execPath (e.g. they launched setup under a different
|
||||
// Node via `nvm`). If it succeeds and is resolvable, prefer it.
|
||||
const which = execSync('which node', { encoding: 'utf-8' }).trim();
|
||||
if (which) nodePath = which;
|
||||
} catch {
|
||||
// fall back to process.execPath
|
||||
}
|
||||
const nodeDir = path.dirname(nodePath);
|
||||
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`iMessage needs Full Disk Access granted to the Node binary:`,
|
||||
'',
|
||||
` ${nodePath}`,
|
||||
'',
|
||||
' 1. System Settings → Privacy & Security → Full Disk Access',
|
||||
` 2. Click +, then drag the "node" file from the Finder window`,
|
||||
' we just opened for you',
|
||||
' 3. Toggle it on, then come back here',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Grant Full Disk Access',
|
||||
);
|
||||
|
||||
try {
|
||||
execSync(`open "${nodeDir}"`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// No Finder (SSH/headless) — user sees the path in the note above.
|
||||
}
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Granted Full Disk Access?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('imessage_fda_confirmed', 'true');
|
||||
}
|
||||
|
||||
async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
p.note(
|
||||
[
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.im',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
);
|
||||
|
||||
const urlAnswer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Photon server URL',
|
||||
placeholder: 'https://photon.example.com',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'URL is required';
|
||||
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const serverUrl = (urlAnswer as string).trim();
|
||||
|
||||
const keyAnswer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Photon API key',
|
||||
validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'),
|
||||
}),
|
||||
);
|
||||
const apiKey = (keyAnswer as string).trim();
|
||||
|
||||
setupLog.userInput('imessage_server_url', serverUrl);
|
||||
setupLog.userInput(
|
||||
'imessage_api_key',
|
||||
`${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`,
|
||||
);
|
||||
return { serverUrl, apiKey };
|
||||
}
|
||||
|
||||
async function askOperatorHandle(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"What phone number or email do you iMessage with?",
|
||||
"That's where your assistant will send its welcome message.",
|
||||
'',
|
||||
k.dim(' • Phone: full E.164, e.g. +15551234567'),
|
||||
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
|
||||
].join('\n'),
|
||||
'Your iMessage handle',
|
||||
);
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number or email',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
const isPhone = /^\+\d{8,15}$/.test(t);
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
|
||||
if (!isPhone && !isEmail) {
|
||||
return "Use a +E.164 phone number or an email address";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handle = (answer as string).trim();
|
||||
setupLog.userInput('imessage_handle', handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your 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;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Signal channel flow for setup:auto.
|
||||
*
|
||||
* `runSignalChannel(displayName)` owns the full branch from signal-cli
|
||||
* presence check through the welcome DM:
|
||||
*
|
||||
* 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it,
|
||||
* offer `brew install signal-cli` inline. On Linux, surface the
|
||||
* GitHub releases URL and bail with an actionable error.
|
||||
* 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent).
|
||||
* 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as
|
||||
* a terminal QR the operator scans from Signal → Linked Devices.
|
||||
* 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env).
|
||||
* 5. Kick the service so the adapter picks up the new credentials.
|
||||
* 6. Ask operator role + agent name.
|
||||
* 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter.
|
||||
*
|
||||
* Signal's `link` flow creates a *secondary* device. The phone number
|
||||
* comes from the primary (the phone that scanned the QR); this host then
|
||||
* sends/receives as that primary number. No registration of new numbers.
|
||||
*
|
||||
* Output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runSignalChannel(displayName: string): Promise<void> {
|
||||
await ensureSignalCli();
|
||||
|
||||
const install = await runQuietChild(
|
||||
'signal-install',
|
||||
'bash',
|
||||
['setup/add-signal.sh'],
|
||||
{
|
||||
running: 'Installing the Signal adapter…',
|
||||
done: 'Signal adapter installed.',
|
||||
skipped: 'Signal adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'signal-install',
|
||||
"Couldn't install the Signal adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runSignalAuth();
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
await fail(
|
||||
'signal-auth',
|
||||
`Signal link failed (${reason}).`,
|
||||
reason === 'qr_timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = auth.terminal?.fields.ACCOUNT;
|
||||
if (!account) {
|
||||
await fail(
|
||||
'signal-auth',
|
||||
'Linked with Signal but couldn\'t read the phone number back.',
|
||||
'Run `signal-cli listAccounts` to confirm, then re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
writeSignalAccount(account!);
|
||||
await restartService();
|
||||
|
||||
const role = await askOperatorRole('Signal');
|
||||
setupLog.userInput('signal_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'signal',
|
||||
'--user-id', account!,
|
||||
'--platform-id', account!,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to Signal…`,
|
||||
done: `${agentName} is ready. Check Signal for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'signal',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: account!,
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSignalCli(): Promise<void> {
|
||||
const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (!probe.error && probe.status === 0) return;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
p.note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
'The quickest way on macOS is Homebrew:',
|
||||
'',
|
||||
k.cyan(' brew install signal-cli'),
|
||||
'',
|
||||
"Install it in another terminal, then re-run setup.",
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
);
|
||||
} else {
|
||||
p.note(
|
||||
[
|
||||
"NanoClaw talks to Signal through signal-cli, which isn't installed yet.",
|
||||
'',
|
||||
'Grab the latest release from GitHub:',
|
||||
'',
|
||||
k.cyan(' https://github.com/AsamK/signal-cli/releases'),
|
||||
'',
|
||||
"Install it, make sure `signal-cli --version` works, then re-run setup.",
|
||||
].join('\n'),
|
||||
'signal-cli not found',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'signal-install',
|
||||
'signal-cli is required but not installed.',
|
||||
'Install it and re-run setup.',
|
||||
);
|
||||
}
|
||||
|
||||
async function runSignalAuth(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('signal-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting Signal link…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number): void => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how many lines the QR block occupies so we can wipe it in-place
|
||||
// once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's,
|
||||
// but we still want to erase the QR from screen once it's served).
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const result = await spawnStep(
|
||||
'signal-auth',
|
||||
[],
|
||||
(block: Block) => {
|
||||
if (block.type === 'SIGNAL_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
void renderQr(qr).then((lines) => {
|
||||
stopSpinner('Scan this QR from Signal → Settings → Linked Devices.');
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
s.start('Waiting for you to scan…');
|
||||
spinnerActive = true;
|
||||
});
|
||||
} else if (block.type === 'SIGNAL_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
// Wipe the QR block regardless of outcome — it's either scanned
|
||||
// and useless, or expired and misleading.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const account = block.fields.ACCOUNT;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner(
|
||||
account
|
||||
? `Signal already linked as ${k.cyan(account)}.`
|
||||
: 'Signal already linked.',
|
||||
);
|
||||
} else if (status === 'success') {
|
||||
stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`);
|
||||
} else if (status === 'failed') {
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
stopSpinner(`Signal link failed: ${err}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Signal link ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('signal-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw linking URL as a block-art QR, returned line-by-line so
|
||||
* the caller can count lines for in-place cleanup. Uses small-mode so the
|
||||
* code stays scannable on 24-row terminals. If qrcode isn't installed
|
||||
* (add-signal.sh should have handled it, but we're defensive), fall back
|
||||
* to the raw URL and ask the user to paste it into an external renderer.
|
||||
*/
|
||||
async function renderQr(url: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(url, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Signal → Settings → Linked Devices → Link New Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return [
|
||||
'Linking URL (render at https://qr.io or similar):',
|
||||
'',
|
||||
url,
|
||||
'',
|
||||
k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^SIGNAL_ACCOUNT=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^SIGNAL_ACCOUNT=.*$/m,
|
||||
`SIGNAL_ACCOUNT=${account}`,
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += `SIGNAL_ACCOUNT=${account}\n`;
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
|
||||
setupLog.userInput('signal_account', account);
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your Signal account…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const unit = getSystemdUnit();
|
||||
const user = spawnSync('systemctl', ['--user', 'restart', unit], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
// Give the adapter a moment to connect to signal-cli before
|
||||
// init-first-agent's welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
setupLog.step('signal-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('signal-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Slack channel flow for setup:auto.
|
||||
*
|
||||
* `runSlackChannel(displayName)` walks the operator from a bare Slack
|
||||
* workspace through a running bot, then stops before wiring an agent:
|
||||
*
|
||||
* 1. Walk through creating a Slack app (api.slack.com/apps) — scopes,
|
||||
* event subscriptions, and signing secret
|
||||
* 2. Paste the bot token + signing secret (clack password prompts)
|
||||
* 3. Validate via auth.test → resolves workspace + bot identity
|
||||
* 4. Install the adapter (setup/add-slack.sh, non-interactive)
|
||||
* 5. Print the post-install checklist: set the public webhook URL in
|
||||
* Slack's Event Subscriptions, DM the bot to bootstrap the channel,
|
||||
* then `/manage-channels` to wire an agent.
|
||||
*
|
||||
* Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll),
|
||||
* Slack needs a public Event Subscriptions URL for inbound events, and
|
||||
* opening an unsolicited DM would need `im:write` scope we don't force
|
||||
* the SKILL.md to require. Shipping a honest "here's what's left" note
|
||||
* is better than a welcome DM the user won't receive until they
|
||||
* configure the webhook anyway.
|
||||
*
|
||||
* All output obeys the three-level contract. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { wrapForGutter } from '../lib/theme.js';
|
||||
|
||||
const SLACK_API = 'https://slack.com/api';
|
||||
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
||||
|
||||
interface WorkspaceInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
botName: string;
|
||||
botUserId: string;
|
||||
}
|
||||
|
||||
// displayName is reserved for when we start wiring the first agent here.
|
||||
// Kept to match the `run<X>Channel(displayName)` signature every other
|
||||
// channel driver uses, so auto.ts can dispatch without a branch.
|
||||
export async function runSlackChannel(_displayName: string): Promise<void> {
|
||||
await walkThroughAppCreation();
|
||||
|
||||
const token = await collectBotToken();
|
||||
const signingSecret = await collectSigningSecret();
|
||||
const info = await validateSlackToken(token);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'slack-install',
|
||||
'bash',
|
||||
['setup/add-slack.sh'],
|
||||
{
|
||||
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
|
||||
done: 'Slack adapter installed.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
SLACK_BOT_TOKEN: token,
|
||||
SLACK_SIGNING_SECRET: signingSecret,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_NAME: info.botName,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'slack-install',
|
||||
"Couldn't connect Slack.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
showPostInstallChecklist(info);
|
||||
}
|
||||
|
||||
async function walkThroughAppCreation(): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Slack app that the assistant talks through.",
|
||||
"Free and stays inside the workspaces you pick.",
|
||||
'',
|
||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||
' chat:write, channels:history, groups:history, im:history,',
|
||||
' channels:read, groups:read, users:read, reactions:write',
|
||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||
' slash commands and messages from the messages tab"',
|
||||
' 4. Basic Information → copy the "Signing Secret"',
|
||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||
'',
|
||||
k.dim(SLACK_APPS_URL),
|
||||
].join('\n'),
|
||||
'Create a Slack app',
|
||||
);
|
||||
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Got your bot token and signing secret?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectBotToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack bot token',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
|
||||
if (t.length < 24) return "That's shorter than a real Slack bot token";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_bot_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function collectSigningSecret(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your Slack signing secret',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Signing secret is required';
|
||||
// Slack signing secrets are 32-char hex strings, but newer apps
|
||||
// sometimes emit longer variants — leniently require hex only.
|
||||
if (!/^[a-f0-9]{16,}$/i.test(t)) {
|
||||
return 'Signing secrets are a string of hex characters';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const secret = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'slack_signing_secret',
|
||||
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${SLACK_API}/auth.test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
user?: string;
|
||||
user_id?: string;
|
||||
error?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.team && data.user) {
|
||||
s.stop(
|
||||
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
|
||||
);
|
||||
const info: WorkspaceInfo = {
|
||||
teamName: data.team,
|
||||
teamId: data.team_id ?? '',
|
||||
botName: data.user,
|
||||
botUserId: data.user_id ?? '',
|
||||
};
|
||||
setupLog.step('slack-validate', 'success', Date.now() - start, {
|
||||
BOT_NAME: info.botName,
|
||||
BOT_USER_ID: info.botUserId,
|
||||
TEAM_NAME: info.teamName,
|
||||
TEAM_ID: info.teamId,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
const reason = data.error ?? `HTTP ${res.status}`;
|
||||
s.stop(`Slack didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Slack didn't accept that token.",
|
||||
reason === 'invalid_auth' || reason === 'token_revoked'
|
||||
? 'Copy the token again from OAuth & Permissions and retry setup.'
|
||||
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'slack-validate',
|
||||
"Couldn't reach Slack.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
||||
p.note(
|
||||
wrapForGutter(
|
||||
[
|
||||
`The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`,
|
||||
'',
|
||||
' 1. A public URL so Slack can deliver events.',
|
||||
' NanoClaw serves a webhook on port 3000 by default — expose it',
|
||||
' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
||||
'',
|
||||
' 2. In your Slack app → Event Subscriptions:',
|
||||
' • Toggle "Enable Events" on',
|
||||
` • Request URL: https://<your-public-host>/webhook/slack`,
|
||||
' • Subscribe to bot events: message.channels, message.groups,',
|
||||
' message.im, app_mention',
|
||||
' • Save, then reinstall the app when Slack prompts',
|
||||
'',
|
||||
` 3. DM @${info.botName} from Slack once — that bootstraps the`,
|
||||
' messaging group. Then run `/manage-channels` in `claude` to',
|
||||
' wire an agent to it.',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
'Finish setting up Slack',
|
||||
);
|
||||
}
|
||||
+52
-45
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
@@ -17,58 +19,63 @@ describe('environment detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered groups DB query', () => {
|
||||
let db: Database.Database;
|
||||
describe('detectRegisteredGroups', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||
jid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
trigger_pattern TEXT NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
container_config TEXT,
|
||||
requires_trigger INTEGER DEFAULT 1
|
||||
)`);
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true });
|
||||
});
|
||||
|
||||
it('returns 0 for empty table', () => {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(0);
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns correct count after inserts', () => {
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'123@g.us',
|
||||
'Group 1',
|
||||
'group-1',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
it('returns false when no registration state exists', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
'456@g.us',
|
||||
'Group 2',
|
||||
'group-2',
|
||||
'@Andy',
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
1,
|
||||
);
|
||||
it('detects pre-migration registered_groups.json', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]');
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
expect(row.count).toBe(2);
|
||||
it('returns false for an empty v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects wired agent groups in the v2 central DB', async () => {
|
||||
const { detectRegisteredGroups } = await import('./environment.js');
|
||||
const db = new Database(path.join(tempDir, 'data', 'v2.db'));
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL,
|
||||
agent_group_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1');
|
||||
db.prepare(
|
||||
'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)',
|
||||
).run('mga-1', 'mg-1', 'ag-1');
|
||||
db.close();
|
||||
|
||||
expect(detectRegisteredGroups(tempDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+26
-21
@@ -7,11 +7,35 @@ import path from 'path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { STORE_DIR } from '../src/config.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
export function detectRegisteredGroups(projectRoot: string): boolean {
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dbPath = path.join(projectRoot, 'data', 'v2.db');
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag
|
||||
JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`,
|
||||
)
|
||||
.get() as { count: number };
|
||||
return row.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
@@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const authDir = path.join(projectRoot, 'store', 'auth');
|
||||
const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
||||
|
||||
let hasRegisteredGroups = false;
|
||||
// Check JSON file first (pre-migration)
|
||||
if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) {
|
||||
hasRegisteredGroups = true;
|
||||
} else {
|
||||
// Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed)
|
||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) as count FROM registered_groups')
|
||||
.get() as { count: number };
|
||||
if (row.count > 0) hasRegisteredGroups = true;
|
||||
db.close();
|
||||
} catch {
|
||||
// Table might not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasRegisteredGroups = detectRegisteredGroups(projectRoot);
|
||||
|
||||
// Check for existing OpenClaw installation
|
||||
const homedir = (await import('os')).homedir();
|
||||
|
||||
@@ -16,6 +16,7 @@ const STEPS: Record<
|
||||
register: () => import('./register.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
'signal-auth': () => import('./signal-auth.js'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { classifyPingResult } from './agent-ping.js';
|
||||
|
||||
describe('classifyPingResult', () => {
|
||||
it('treats a normal text reply as ok', () => {
|
||||
expect(classifyPingResult(0, 'pong\n')).toBe('ok');
|
||||
});
|
||||
|
||||
it('detects Anthropic auth errors printed as a chat reply', () => {
|
||||
expect(
|
||||
classifyPingResult(
|
||||
0,
|
||||
'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}',
|
||||
),
|
||||
).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('detects auth errors on stderr too', () => {
|
||||
expect(classifyPingResult(1, '', 'Authentication error')).toBe('auth_error');
|
||||
});
|
||||
|
||||
it('preserves socket errors', () => {
|
||||
expect(classifyPingResult(2, '')).toBe('socket_error');
|
||||
});
|
||||
|
||||
it('treats empty output as no reply', () => {
|
||||
expect(classifyPingResult(0, '')).toBe('no_reply');
|
||||
});
|
||||
});
|
||||
+20
-4
@@ -13,7 +13,21 @@
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error' | 'auth_error';
|
||||
|
||||
export function classifyPingResult(exitCode: number | null, stdout: string, stderr = ''): PingResult {
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
if (
|
||||
/Invalid bearer token/i.test(output) ||
|
||||
/authentication[_ ]error/i.test(output) ||
|
||||
/Failed to authenticate/i.test(output)
|
||||
) {
|
||||
return 'auth_error';
|
||||
}
|
||||
if (exitCode === 2) return 'socket_error';
|
||||
if (exitCode === 0 && stdout.trim().length > 0) return 'ok';
|
||||
return 'no_reply';
|
||||
}
|
||||
|
||||
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -21,6 +35,7 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
@@ -32,13 +47,14 @@ export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 2) resolve('socket_error');
|
||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
||||
else resolve('no_reply');
|
||||
resolve(classifyPingResult(code, stdout, stderr));
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
|
||||
+44
-15
@@ -64,6 +64,10 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'],
|
||||
'slack-validate': ['setup/channels/slack.ts'],
|
||||
'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'],
|
||||
'imessage': ['setup/channels/imessage.ts'],
|
||||
'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
@@ -115,7 +119,7 @@ export async function offerClaudeAssist(
|
||||
const run = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Run this command? (you can edit it before executing)',
|
||||
initialValue: false,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!run) return false;
|
||||
@@ -279,18 +283,24 @@ 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.
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
],
|
||||
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
//
|
||||
// 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'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', (c: Buffer) => {
|
||||
lineBuf += c.toString('utf-8');
|
||||
@@ -301,6 +311,16 @@ 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);
|
||||
@@ -335,10 +355,14 @@ 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) and text
|
||||
// blocks (to reassemble the final REASON/COMMAND answer).
|
||||
// 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.
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
message?: {
|
||||
content?: Array<
|
||||
| { type: 'text'; text: string }
|
||||
@@ -347,6 +371,11 @@ 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 },
|
||||
|
||||
+4
-2
@@ -322,10 +322,12 @@ async function runUnderSpinner<
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||
// 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)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1);
|
||||
s.stop(`${k.bold(fitToWidth(failMsg, suffix))}${k.dim(suffix)}`, 1);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+6
-9
@@ -58,17 +58,14 @@ export function wrapForGutter(text: string, gutter: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function dimWrap(text: string, gutter: number): string {
|
||||
return wrapForGutter(text, gutter)
|
||||
.split('\n')
|
||||
.map((line) => k.dim(line))
|
||||
.join('\n');
|
||||
return wrapForGutter(text, gutter);
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
+192
-15
@@ -86,40 +86,161 @@ function ensureShellProfilePath(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeEnvOnecliUrl(url: string): void {
|
||||
function writeEnvVar(name: string, value: string): void {
|
||||
const envFile = path.join(process.cwd(), '.env');
|
||||
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||
if (/^ONECLI_URL=/m.test(content)) {
|
||||
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
|
||||
const re = new RegExp(`^${name}=.*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, `${name}=${value}`);
|
||||
} else {
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
|
||||
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\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 {
|
||||
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
|
||||
const stdout = execSync(cmd, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { stdout, ok: true };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string };
|
||||
log.error('OneCLI install failed', { stderr: e.stderr });
|
||||
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
|
||||
return { stdout: e.stdout ?? '', stderr: e.stderr, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
/**
|
||||
* 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> {
|
||||
// `/api/health` matches the path probe.sh uses — keep them aligned.
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -136,8 +257,64 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
+17
-15
@@ -20,6 +20,7 @@ import {
|
||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
@@ -112,12 +113,10 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
// Chat SDK adapters prefix platform IDs with the channel type
|
||||
// (e.g. "telegram:123", "discord:guild:channel"). Normalize here so
|
||||
// the stored ID always matches what the adapter sends at runtime.
|
||||
if (!parsed.platformId.startsWith(`${parsed.channel}:`)) {
|
||||
parsed.platformId = `${parsed.channel}:${parsed.platformId}`;
|
||||
}
|
||||
// Normalize platform_id to the same shape the adapter will emit at runtime,
|
||||
// so the router's (channel_type, platform_id) lookup matches what we store.
|
||||
// Chat SDK adapters prefix, native adapters (WhatsApp/iMessage/Signal) don't.
|
||||
parsed.platformId = namespacedPlatformId(parsed.channel, parsed.platformId);
|
||||
|
||||
log.info('Registering channel', parsed);
|
||||
|
||||
@@ -167,19 +166,22 @@ export async function run(args: string[]): Promise<void> {
|
||||
if (!existing) {
|
||||
newlyWired = true;
|
||||
const mgaId = generateId('mga');
|
||||
const triggerRules = parsed.trigger
|
||||
? JSON.stringify({
|
||||
pattern: parsed.trigger,
|
||||
requiresTrigger: parsed.requiresTrigger,
|
||||
})
|
||||
: null;
|
||||
// Mirrors scripts/init-first-agent.ts:wireIfMissing so both setup paths
|
||||
// create rows with the same shape. Groups default to 'mention' (bot only
|
||||
// responds when addressed); DMs default to 'pattern'/'.' (respond to
|
||||
// every message). An explicit --trigger overrides the pattern regex.
|
||||
const isGroup = messagingGroup.is_group === 1;
|
||||
const engageMode: 'pattern' | 'mention' = isGroup && !parsed.trigger ? 'mention' : 'pattern';
|
||||
const engagePattern: string | null = engageMode === 'pattern' ? parsed.trigger || '.' : null;
|
||||
createMessagingGroupAgent({
|
||||
id: mgaId,
|
||||
messaging_group_id: messagingGroup.id,
|
||||
agent_group_id: agentGroup.id,
|
||||
trigger_rules: triggerRules,
|
||||
response_scope: 'all',
|
||||
session_mode: parsed.sessionMode,
|
||||
engage_mode: engageMode,
|
||||
engage_pattern: engagePattern,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -53,6 +54,19 @@ 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') {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Step: signal-auth — link this host to an existing Signal account via
|
||||
* signal-cli's QR-code flow.
|
||||
*
|
||||
* signal-cli `link` opens a bi-directional handshake with the Signal
|
||||
* servers: it prints one line containing a linking URL (`sgnl://linkdevice?…`
|
||||
* or older `tsdevice://linkdevice?…`), then blocks until either the user
|
||||
* scans it from an existing Signal install, or the code expires. On
|
||||
* success, a secondary account is created under the user's signal-cli
|
||||
* data directory, associated with the phone number of the scanner.
|
||||
*
|
||||
* Methods:
|
||||
* (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR
|
||||
* with the URL, wait for completion.
|
||||
*
|
||||
* Block schema (parent parses these):
|
||||
* SIGNAL_AUTH_QR { QR: "<sgnl:// or tsdevice:// url>" } — one-shot
|
||||
* SIGNAL_AUTH { STATUS: success, ACCOUNT: +<digits> } — terminal
|
||||
* { STATUS: skipped, ACCOUNT, REASON: already-authenticated }
|
||||
* { STATUS: failed, ERROR: <reason> }
|
||||
*
|
||||
* STATUS values match the runner's vocabulary (success/skipped/failed) so
|
||||
* spawnStep recognises them and sets `ok` correctly; Signal-specific UI
|
||||
* lives in setup/channels/signal.ts.
|
||||
*
|
||||
* If one or more accounts are already linked (discovered via
|
||||
* `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH
|
||||
* STATUS=skipped with the first account so the driver can reuse it.
|
||||
* Selecting a different existing account is a driver concern.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LINK_TIMEOUT_MS = 180_000;
|
||||
const DEFAULT_DEVICE_NAME = 'NanoClaw';
|
||||
|
||||
interface SignalAccount {
|
||||
account?: string;
|
||||
registered?: boolean;
|
||||
}
|
||||
|
||||
function cliPath(): string {
|
||||
return process.env.SIGNAL_CLI_PATH || 'signal-cli';
|
||||
}
|
||||
|
||||
/**
|
||||
* Query signal-cli for currently linked accounts. Empty array if none
|
||||
* configured, no binary, or the call fails for any other reason.
|
||||
*/
|
||||
function listAccounts(): string[] {
|
||||
const cli = cliPath();
|
||||
try {
|
||||
const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (res.status !== 0) return [];
|
||||
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
|
||||
return parsed
|
||||
.filter((a) => a.registered !== false)
|
||||
.map((a) => a.account ?? '')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function run(_args: string[]): Promise<void> {
|
||||
const cli = cliPath();
|
||||
|
||||
// Verify signal-cli exists before we commit to the long-running link.
|
||||
// The driver checks too, but this keeps the step honest when run alone.
|
||||
const probe = spawnSync(cli, ['--version'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (probe.error || probe.status !== 0) {
|
||||
emitStatus('SIGNAL_AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'signal-cli not found. Install signal-cli first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = listAccounts();
|
||||
if (existing.length > 0) {
|
||||
emitStatus('SIGNAL_AUTH', {
|
||||
STATUS: 'skipped',
|
||||
ACCOUNT: existing[0],
|
||||
REASON: 'already-authenticated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let qrEmitted = false;
|
||||
|
||||
const finish = (block: Record<string, string | number | boolean>, code: number): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
emitStatus('SIGNAL_AUTH', block);
|
||||
resolve();
|
||||
setTimeout(() => process.exit(code), 500);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1);
|
||||
}, LINK_TIMEOUT_MS);
|
||||
|
||||
const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// stdout carries the URL on the first line; subsequent lines may print
|
||||
// status like "Associated with: +1555…". We don't strictly need to parse
|
||||
// the number — listAccounts after exit is the source of truth — but the
|
||||
// URL match drives the QR emit, which is the whole point.
|
||||
let stdoutBuf = '';
|
||||
const handleStdout = (chunk: Buffer): void => {
|
||||
stdoutBuf += chunk.toString('utf-8');
|
||||
let idx: number;
|
||||
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, idx).trim();
|
||||
stdoutBuf = stdoutBuf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
// Match both modern (sgnl://) and legacy (tsdevice://) schemes.
|
||||
if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) {
|
||||
qrEmitted = true;
|
||||
emitStatus('SIGNAL_AUTH_QR', { QR: line });
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', handleStdout);
|
||||
|
||||
// Capture stderr for the transcript / log — signal-cli writes warnings
|
||||
// and errors there. We don't emit on partial stderr lines since a
|
||||
// successful link can still produce noise.
|
||||
let stderrBuf = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
// After a successful link, signal-cli exits 0 and the newly linked
|
||||
// account shows up in listAccounts. Use that as the source of truth
|
||||
// rather than scraping stdout — more robust across signal-cli versions.
|
||||
if (code === 0) {
|
||||
const post = listAccounts();
|
||||
if (post.length === 0) {
|
||||
finish(
|
||||
{ STATUS: 'failed', ERROR: 'link exited 0 but no account registered' },
|
||||
1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish({ STATUS: 'success', ACCOUNT: post[0] }, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-zero exit. Surface the last non-empty stderr line for context;
|
||||
// signal-cli's own error messages are usually informative.
|
||||
const lastErr =
|
||||
stderrBuf
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-1)[0] ?? `signal-cli link exited with code ${code}`;
|
||||
finish({ STATUS: 'failed', ERROR: lastErr }, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { determineVerifyStatus } from './verify.js';
|
||||
|
||||
const healthyBase = {
|
||||
service: 'running' as const,
|
||||
credentials: 'configured',
|
||||
anyChannelConfigured: false,
|
||||
registeredGroups: 1,
|
||||
agentPing: 'ok' as const,
|
||||
};
|
||||
|
||||
describe('determineVerifyStatus', () => {
|
||||
it('accepts a working CLI-only install', () => {
|
||||
expect(determineVerifyStatus(healthyBase)).toBe('success');
|
||||
});
|
||||
|
||||
it('accepts a messaging-channel install when CLI ping is skipped', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
anyChannelConfigured: true,
|
||||
agentPing: 'skipped',
|
||||
}),
|
||||
).toBe('success');
|
||||
});
|
||||
|
||||
it('fails when neither CLI nor messaging channels are usable', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
agentPing: 'skipped',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the CLI agent does not respond', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
anyChannelConfigured: true,
|
||||
agentPing: 'no_reply',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when no agent groups are registered', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
registeredGroups: 0,
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
});
|
||||
+30
-11
@@ -14,7 +14,7 @@ import Database from 'better-sqlite3';
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { readEnvFile } from '../src/env.js';
|
||||
import { log } from '../src/log.js';
|
||||
import { pingCliAgent } from './lib/agent-ping.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||
import {
|
||||
getPlatform,
|
||||
@@ -220,22 +220,22 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
// 7. End-to-end: ping the CLI agent and confirm it replies. Only run if
|
||||
// everything upstream looks healthy, since a broken socket would just hang.
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped';
|
||||
let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'auth_error' | 'skipped' = 'skipped';
|
||||
if (service === 'running' && registeredGroups > 0) {
|
||||
log.info('Pinging CLI agent');
|
||||
agentPing = await pingCliAgent();
|
||||
log.info('Agent ping result', { agentPing });
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
const status =
|
||||
service === 'running' &&
|
||||
credentials !== 'missing' &&
|
||||
anyChannelConfigured &&
|
||||
registeredGroups > 0 &&
|
||||
(agentPing === 'ok' || agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
// Determine overall status. A CLI-only install is valid when the local
|
||||
// agent round-trip succeeds; messaging app credentials are optional.
|
||||
const status = determineVerifyStatus({
|
||||
service,
|
||||
credentials,
|
||||
anyChannelConfigured,
|
||||
registeredGroups,
|
||||
agentPing,
|
||||
});
|
||||
|
||||
log.info('Verification complete', { status, channelAuth });
|
||||
|
||||
@@ -255,6 +255,25 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
|
||||
export function determineVerifyStatus(input: {
|
||||
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
||||
credentials: string;
|
||||
anyChannelConfigured: boolean;
|
||||
registeredGroups: number;
|
||||
agentPing: PingResult | 'skipped';
|
||||
}): 'success' | 'failed' {
|
||||
const cliAgentResponds = input.agentPing === 'ok';
|
||||
const hasUsableChannel = input.anyChannelConfigured || cliAgentResponds;
|
||||
|
||||
return input.service === 'running' &&
|
||||
input.credentials !== 'missing' &&
|
||||
hasUsableChannel &&
|
||||
input.registeredGroups > 0 &&
|
||||
(cliAgentResponds || input.agentPing === 'skipped')
|
||||
? 'success'
|
||||
: 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
@@ -56,6 +56,8 @@ 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;
|
||||
}
|
||||
@@ -81,6 +83,8 @@ 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. */
|
||||
|
||||
@@ -81,6 +81,26 @@ 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[] = [];
|
||||
@@ -105,7 +125,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
let setupConfig: ChannelSetup;
|
||||
let gatewayAbort: AbortController | null = null;
|
||||
|
||||
async function messageToInbound(message: ChatMessage, isMention: boolean): Promise<InboundMessage> {
|
||||
async function messageToInbound(
|
||||
message: ChatMessage,
|
||||
isMention: boolean,
|
||||
isGroup?: boolean,
|
||||
): Promise<InboundMessage> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serialized = message.toJSON() as Record<string, any>;
|
||||
|
||||
@@ -162,6 +186,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
content: serialized,
|
||||
timestamp: message.metadata.dateSent.toISOString(),
|
||||
isMention,
|
||||
isGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,13 +220,17 @@ 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));
|
||||
await setupConfig.onInbound(
|
||||
channelId,
|
||||
thread.id,
|
||||
await messageToInbound(message, message.isMention === true, 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));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true));
|
||||
});
|
||||
|
||||
// DMs — by definition addressed to the bot. Thread id flows through
|
||||
@@ -216,7 +245,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));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false));
|
||||
});
|
||||
|
||||
// Plain messages in unsubscribed threads.
|
||||
@@ -231,7 +260,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));
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true));
|
||||
});
|
||||
|
||||
// Handle button clicks (ask_user_question)
|
||||
@@ -240,11 +269,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
const parts = event.actionId.split(':');
|
||||
if (parts.length < 3) return;
|
||||
const questionId = parts[1];
|
||||
const selectedOption = event.value || '';
|
||||
const tail = parts.slice(2).join(':');
|
||||
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)';
|
||||
@@ -348,8 +381,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
children: [
|
||||
CardText(question),
|
||||
Actions(
|
||||
options.map((opt) =>
|
||||
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
|
||||
// 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) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -501,18 +539,21 @@ async function handleForwardedEvent(
|
||||
// type 3 = MessageComponent (button/select)
|
||||
if (interaction.type === 3) {
|
||||
const customId = (interaction.data as Record<string, unknown>)?.custom_id as string;
|
||||
const user = (interaction.member as Record<string, unknown>)?.user as Record<string, string> | undefined;
|
||||
// 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 interactionId = interaction.id as string;
|
||||
const interactionToken = interaction.token as string;
|
||||
|
||||
// Parse the selected option from custom_id
|
||||
let questionId: string | undefined;
|
||||
let selectedOption: string | undefined;
|
||||
let tail: string | undefined;
|
||||
if (customId?.startsWith('ncq:')) {
|
||||
const colonIdx = customId.indexOf(':', 4); // after "ncq:"
|
||||
if (colonIdx !== -1) {
|
||||
questionId = customId.slice(4, colonIdx);
|
||||
selectedOption = customId.slice(colonIdx + 1);
|
||||
tail = customId.slice(colonIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +562,9 @@ 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;
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { readEnvFile } from './env.js';
|
||||
import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js';
|
||||
import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js';
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
@@ -27,6 +27,10 @@ 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;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+35
-3
@@ -12,6 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk';
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_IMAGE_BASE,
|
||||
CONTAINER_INSTALL_LABEL,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
ONECLI_API_KEY,
|
||||
@@ -35,7 +36,13 @@ import {
|
||||
type ProviderContainerContribution,
|
||||
type VolumeMount,
|
||||
} from './providers/provider-container-registry.js';
|
||||
import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js';
|
||||
import {
|
||||
heartbeatPath,
|
||||
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 });
|
||||
@@ -130,6 +137,12 @@ 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 });
|
||||
@@ -178,12 +191,31 @@ 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 = (containerConfig.provider || 'claude').toLowerCase();
|
||||
const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider);
|
||||
const fn = getProviderContainerConfig(provider);
|
||||
const contribution = fn
|
||||
? fn({
|
||||
@@ -389,7 +421,7 @@ async function buildContainerArgs(
|
||||
providerContribution: ProviderContainerContribution,
|
||||
agentIdentifier?: string,
|
||||
): Promise<string[]> {
|
||||
const args: string[] = ['run', '--rm', '--name', containerName];
|
||||
const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL];
|
||||
|
||||
// Environment — only vars read by code we don't own.
|
||||
// Everything NanoClaw-specific is in container.json (read by runner at startup).
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ensureContainerRuntimeRunning,
|
||||
cleanupOrphans,
|
||||
} from './container-runtime.js';
|
||||
import { CONTAINER_INSTALL_LABEL } from './config.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -84,6 +85,17 @@ 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,6 +5,7 @@
|
||||
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. */
|
||||
@@ -56,13 +57,22 @@ export function ensureContainerRuntimeRunning(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill orphaned NanoClaw containers from previous runs. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function cleanupOrphans(): void {
|
||||
try {
|
||||
const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const output = execSync(
|
||||
`${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`,
|
||||
{
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
},
|
||||
);
|
||||
const orphans = output.trim().split('\n').filter(Boolean);
|
||||
for (const name of orphans) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 '[]'`);
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -29,6 +30,7 @@ const migrations: Migration[] = [
|
||||
migration010,
|
||||
migration011,
|
||||
migration012,
|
||||
migration013,
|
||||
];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
|
||||
@@ -139,10 +139,10 @@ export function getMessageForRetry(
|
||||
db: Database.Database,
|
||||
messageId: string,
|
||||
status: string,
|
||||
): { 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;
|
||||
): { 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;
|
||||
}
|
||||
|
||||
export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void {
|
||||
|
||||
+39
-9
@@ -1,5 +1,5 @@
|
||||
import type { PendingApproval, PendingQuestion, Session } from '../types.js';
|
||||
import { getDb } from './connection.js';
|
||||
import { getDb, hasTable } from './connection.js';
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
@@ -97,10 +97,16 @@ export function deleteSession(id: string): void {
|
||||
|
||||
// ── Pending Questions ──
|
||||
|
||||
export function createPendingQuestion(pq: PendingQuestion): void {
|
||||
getDb()
|
||||
/**
|
||||
* 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()
|
||||
.prepare(
|
||||
`INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at)
|
||||
`INSERT OR IGNORE 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({
|
||||
@@ -114,6 +120,7 @@ export function createPendingQuestion(pq: PendingQuestion): void {
|
||||
options_json: JSON.stringify(pq.options),
|
||||
created_at: pq.created_at,
|
||||
});
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getPendingQuestion(questionId: string): PendingQuestion | undefined {
|
||||
@@ -131,16 +138,21 @@ 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'
|
||||
>,
|
||||
): void {
|
||||
getDb()
|
||||
): boolean {
|
||||
const result = getDb()
|
||||
.prepare(
|
||||
`INSERT INTO pending_approvals
|
||||
`INSERT OR IGNORE 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)
|
||||
@@ -159,6 +171,7 @@ export function createPendingApproval(
|
||||
status: 'pending',
|
||||
...pa,
|
||||
});
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getPendingApproval(approvalId: string): PendingApproval | undefined {
|
||||
@@ -192,6 +205,23 @@ 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 || !a.title) return undefined;
|
||||
return { title: a.title, options: JSON.parse(a.options_json) };
|
||||
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;
|
||||
}
|
||||
|
||||
+4
-2
@@ -321,7 +321,7 @@ async function deliverMessage(
|
||||
questionId: content.questionId,
|
||||
});
|
||||
} else {
|
||||
createPendingQuestion({
|
||||
const inserted = createPendingQuestion({
|
||||
question_id: content.questionId,
|
||||
session_id: session.id,
|
||||
message_out_id: msg.id,
|
||||
@@ -332,7 +332,9 @@ async function deliverMessage(
|
||||
options: normalizeOptions(rawOptions as never),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
log.info('Pending question created', { questionId: content.questionId, sessionId: session.id });
|
||||
if (inserted) {
|
||||
log.info('Pending question created', { questionId: content.questionId, sessionId: session.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,43 @@ 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', () => {
|
||||
|
||||
+24
-10
@@ -159,23 +159,31 @@ async function sweepSession(session: Session): Promise<void> {
|
||||
syncProcessingAcks(inDb, outDb);
|
||||
}
|
||||
|
||||
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');
|
||||
// 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);
|
||||
|
||||
// 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
|
||||
if (alive && outDb) {
|
||||
enforceRunningContainerSla(inDb, outDb, session, agentGroup.id);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 5. Recurrence fanout for completed recurring tasks.
|
||||
@@ -246,10 +254,16 @@ 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', {
|
||||
|
||||
@@ -85,6 +85,7 @@ 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 });
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@
|
||||
*
|
||||
* 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 verbatim;
|
||||
* the target's formatter looks up the source agent in its own local map to
|
||||
* display a name.
|
||||
* 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.
|
||||
*
|
||||
* Self-messages are always allowed (used for system notes injected back into
|
||||
* an agent's own session, e.g. post-approval follow-up prompts).
|
||||
@@ -14,14 +18,85 @@
|
||||
* `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, writeSessionMessage } from '../../session-manager.js';
|
||||
import { resolveSession, sessionDir, 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;
|
||||
@@ -45,20 +120,87 @@ 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: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: a2aMsgId,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: msg.content,
|
||||
content: forwardedContent,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,13 +101,26 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
return;
|
||||
}
|
||||
|
||||
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
|
||||
const isGroup = originMg?.is_group === 1;
|
||||
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 title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message';
|
||||
const question = isGroup
|
||||
? `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?`;
|
||||
? 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);
|
||||
|
||||
createPendingChannelApproval({
|
||||
messaging_group_id: messagingGroupId,
|
||||
@@ -115,6 +128,8 @@ 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();
|
||||
@@ -139,7 +154,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
questionId: messagingGroupId,
|
||||
title,
|
||||
question,
|
||||
options: normalizeOptions(APPROVAL_OPTIONS),
|
||||
options,
|
||||
}),
|
||||
);
|
||||
log.info('Channel registration card delivered', {
|
||||
|
||||
@@ -17,6 +17,10 @@ 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 {
|
||||
@@ -24,11 +28,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
|
||||
approver_user_id, created_at, title, options_json
|
||||
)
|
||||
VALUES (
|
||||
@messaging_group_id, @agent_group_id, @original_message,
|
||||
@approver_user_id, @created_at
|
||||
@approver_user_id, @created_at, @title, @options_json
|
||||
)`,
|
||||
)
|
||||
.run(row);
|
||||
|
||||
@@ -19,6 +19,10 @@ 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 {
|
||||
@@ -26,11 +30,13 @@ 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
|
||||
sender_name, original_message, approver_user_id, created_at,
|
||||
title, options_json
|
||||
)
|
||||
VALUES (
|
||||
@id, @messaging_group_id, @agent_group_id, @sender_identity,
|
||||
@sender_name, @original_message, @approver_user_id, @created_at
|
||||
@sender_name, @original_message, @approver_user_id, @created_at,
|
||||
@title, @options_json
|
||||
)`,
|
||||
)
|
||||
.run(row);
|
||||
|
||||
@@ -88,10 +88,11 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
|
||||
|
||||
const approvalId = generateId();
|
||||
const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity;
|
||||
const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat';
|
||||
const originName = originMg?.name ?? `a ${originChannelType} channel`;
|
||||
|
||||
const title = '👤 New sender';
|
||||
const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`;
|
||||
const options = normalizeOptions(APPROVAL_OPTIONS);
|
||||
|
||||
createPendingSenderApproval({
|
||||
id: approvalId,
|
||||
@@ -102,6 +103,8 @@ 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();
|
||||
@@ -126,7 +129,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput):
|
||||
questionId: approvalId,
|
||||
title,
|
||||
question,
|
||||
options: APPROVAL_OPTIONS,
|
||||
options,
|
||||
}),
|
||||
);
|
||||
log.info('Unknown-sender approval card delivered', {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,6 @@
|
||||
// needs (claude, mock) don't appear here.
|
||||
//
|
||||
// Skills add a new provider by appending one import line below.
|
||||
|
||||
import './codex.js';
|
||||
import './opencode.js';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
});
|
||||
+9
-2
@@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise<void> {
|
||||
channel_type: event.channelType,
|
||||
platform_id: event.platformId,
|
||||
name: null,
|
||||
is_group: 0,
|
||||
is_group: event.message.isGroup ? 1 : 0,
|
||||
unknown_sender_policy: 'request_approval',
|
||||
denied_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -289,7 +289,14 @@ 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') {
|
||||
} 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.
|
||||
await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false);
|
||||
accumulatedCount++;
|
||||
} else {
|
||||
|
||||
+17
-1
@@ -14,6 +14,7 @@ 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';
|
||||
@@ -252,11 +253,26 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user