Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6cb53e21c | |||
| 5a7e75f854 | |||
| d770e56596 | |||
| 08a1ac9753 | |||
| 273489badf | |||
| 504651633f | |||
| 694ab74aa1 | |||
| aae81321e9 | |||
| 6c46b1e43d | |||
| 71453707ba | |||
| 0aa9e668f6 | |||
| 023128def5 | |||
| c4a1679666 | |||
| 2e40e17155 | |||
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3cefbfccf4 | |||
| 6f22c73aac | |||
| 31dd37b3a8 | |||
| 0dfde3aa5b | |||
| 33d2366252 | |||
| 9bf1514f26 | |||
| be3502c23b | |||
| e3f38dbed9 | |||
| d8a6b99f0e | |||
| 8ca7564fbc | |||
| afa07f0566 | |||
| 8059ee4eec | |||
| 41c486cacc | |||
| 190a7d4f43 | |||
| e3d2d43b0e | |||
| afde23bbe6 | |||
| 776bc14ca0 | |||
| a9461afef1 | |||
| ca81558ec8 | |||
| 2f88e4fa15 | |||
| 9b0d1dd044 | |||
| 79e490dfcf | |||
| 01b07d6652 | |||
| be20e9f723 | |||
| e3d156f800 | |||
| b4da018d8c | |||
| 4b11079007 | |||
| 31cc35ea32 | |||
| bf0a3d2612 | |||
| 6dc25a9b8a | |||
| cafba7fb18 | |||
| 10d400ec64 | |||
| 1a9643cfa3 | |||
| 2ba2555032 | |||
| 687d7d13ac | |||
| 2938bbf94a | |||
| d1a2b04d32 | |||
| 455014e7b9 | |||
| 5032c431ae | |||
| ac8273c698 | |||
| 0835089a51 | |||
| c82f062d57 | |||
| 3906104960 | |||
| ed9a3e330d | |||
| 102ce80fda | |||
| 0626dcec92 | |||
| b42329551d | |||
| 855f204d8a | |||
| d4ca8a4ea6 | |||
| 7615d7d846 | |||
| f094f56bf6 | |||
| fcee39ea14 | |||
| e3b2ffce36 | |||
| 2bce84781b | |||
| f4be00e6ed | |||
| c9aa69dea9 | |||
| 3731e26769 | |||
| d472d9d32b | |||
| 6c2d26836f | |||
| b28c917997 | |||
| a00a5610bd | |||
| 05dc1b0a3c | |||
| a7b34bc872 | |||
| 8ee7915418 | |||
| 9a142302df | |||
| e856e924a5 | |||
| 0516bea638 | |||
| 32f067f5bb |
@@ -0,0 +1,22 @@
|
||||
# Remove /add-clidash
|
||||
|
||||
clidash is fully self-contained, so removal is a single directory delete. It
|
||||
made no edits to NanoClaw `src/`, added no dependency, and wired into nothing.
|
||||
|
||||
```bash
|
||||
# Stop the service first if you set one up:
|
||||
systemctl --user disable --now clidash 2>/dev/null || true
|
||||
rm -f ~/.config/systemd/user/clidash.service
|
||||
|
||||
# Remove the tool:
|
||||
rm -rf tools/clidash
|
||||
```
|
||||
|
||||
If you added the config to `.gitignore` in step 2 of the install, remove that
|
||||
line too:
|
||||
|
||||
```
|
||||
tools/clidash/clidash.config.json
|
||||
```
|
||||
|
||||
Nothing else needs reverting.
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: add-clidash
|
||||
description: Add clidash — a zero-dependency, read-only web dashboard that derives its tabs and tables at runtime from any CLI that lists resources as JSON. Ships pre-wired for NanoClaw's ncl CLI (agent groups, sessions, channels, users, roles), plus message-activity charts, a log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.
|
||||
---
|
||||
|
||||
# /add-clidash — CLI-derived read-only dashboard
|
||||
|
||||
clidash is a small, read-only web dashboard. You point it at any CLI that can
|
||||
list resources as JSON (NanoClaw's `ncl`, `docker`, `kubectl`, …) and it builds
|
||||
the dashboard at runtime: one tab per resource, a generic table over whatever
|
||||
columns the rows have. A new `ncl` resource becomes a new tab and a new column
|
||||
becomes a new table column with **zero code changes**.
|
||||
|
||||
It ships pre-wired for NanoClaw's `ncl` CLI and adds three NanoClaw-aware
|
||||
panels driven entirely by config:
|
||||
|
||||
- **Agents overview** — status cards joining groups + sessions + messaging
|
||||
groups + wirings (green <15m / amber <2h / red older).
|
||||
- **Activity** — per-session inbound/outbound message totals and a daily series,
|
||||
read directly from the session DBs (`ncl` has no messages resource).
|
||||
- **Logs** — last N lines of allowlisted host log files.
|
||||
- **Files** — a read-only viewer for group skills, `CLAUDE.md`, and profiles.
|
||||
|
||||
## Why it's safe
|
||||
|
||||
clidash is **read-only by construction**: the server can only `execFile` the
|
||||
argv templates in its config. `{resource}` is the sole substitution and is
|
||||
allowlist-validated against the discovered/static resource set before exec —
|
||||
never a shell, no free-form input reaches argv. There is no auth; **the network
|
||||
is the auth boundary** — it binds `127.0.0.1` by default. Only ever bind a
|
||||
private interface (e.g. a tailnet IP), never a public one.
|
||||
|
||||
It's distinct from `/add-dashboard` (which pushes JSON snapshots to a separate
|
||||
`@nanoco/nanoclaw-dashboard` npm package): clidash has **zero dependencies**, no
|
||||
build step, no push pipeline, and no edits to NanoClaw source — it just reads
|
||||
`ncl` and the session DBs.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Copy the tool into place
|
||||
|
||||
clidash is fully self-contained — copy the whole directory in:
|
||||
|
||||
`tools/` is not a standard NanoClaw directory and `cp -R` won't create it, so
|
||||
make it first:
|
||||
|
||||
```bash
|
||||
mkdir -p tools
|
||||
cp -R .claude/skills/add-clidash/add/tools/clidash tools/clidash
|
||||
```
|
||||
|
||||
That is the only file change this skill makes. Nothing in NanoClaw `src/` is
|
||||
touched, no dependency is added.
|
||||
|
||||
### 2. Create the config
|
||||
|
||||
The example config is pre-wired for NanoClaw with paths relative to the repo
|
||||
root, so it works as-is when you run clidash from `tools/clidash/`:
|
||||
|
||||
```bash
|
||||
cd tools/clidash
|
||||
cp clidash.config.example.json clidash.config.json
|
||||
```
|
||||
|
||||
`clidash.config.json` is your local config — add it to `.gitignore` if you
|
||||
don't want to commit install-specific paths:
|
||||
|
||||
```bash
|
||||
echo 'tools/clidash/clidash.config.json' >> ../../.gitignore
|
||||
```
|
||||
|
||||
The example assumes `ncl` is built at `bin/ncl`. If `bin/ncl` doesn't exist,
|
||||
build it first (`pnpm run build`) or point `clis.ncl.bin` at the right path.
|
||||
|
||||
### 3. Test
|
||||
|
||||
Tests use a stub CLI — no real `ncl` or `docker` needed:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests should pass (Node ≥ 22.5, `node:test`, zero dependencies).
|
||||
|
||||
### 4. Run and verify
|
||||
|
||||
```bash
|
||||
node server.js # serves http://127.0.0.1:4690
|
||||
```
|
||||
|
||||
In another shell, confirm it's live and that `ncl` discovery worked:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:4690/api/clis | head -c 400 # CLIs + discovered resources
|
||||
curl -s http://127.0.0.1:4690/api/r/ncl/groups | head -c 400 # a real resource table
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:4690/` in a browser. You should see the Agents
|
||||
overview plus a tab per `ncl` resource.
|
||||
|
||||
### 5. (Optional) Run as a service
|
||||
|
||||
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
|
||||
private (e.g. tailnet) IP via the `BIND` env var or `bind` in config — never a
|
||||
public interface.
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/clidash.service (Linux)
|
||||
[Unit]
|
||||
Description=clidash read-only CLI dashboard
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=%h/nanoclaw/tools/clidash
|
||||
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
|
||||
Environment=BIND=127.0.0.1
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl --user enable --now clidash
|
||||
```
|
||||
|
||||
On macOS, wrap `node server.js` (with `WorkingDirectory` = `tools/clidash`) in a
|
||||
launchd plist the same way the main NanoClaw service is configured.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
`clidash.config.json` keys (see `tools/clidash/README.md` and
|
||||
`clidash.config.example.json` for the full shape):
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `port`, `bind`, `refreshSeconds` | server bind + UI auto-refresh cadence |
|
||||
| `clis.<name>.bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) |
|
||||
| `clis.<name>.discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list |
|
||||
| `clis.<name>.list` | argv template; `{resource}` is the only substitution |
|
||||
| `clis.<name>.output` | `json` or `jsonlines` (docker/kubectl style) |
|
||||
| `clis.<name>.unwrap` | dot-path into a response envelope (e.g. `data`) |
|
||||
| `clis.<name>.enrich`/`badges`/`summary` | table decorations (ID→name joins, status colors, summary cards) |
|
||||
| `activity` | `sessionsRoot` + `days` for the message-activity charts |
|
||||
| `logs` | `dir`, `tailLines`, and an allowlist of `files` to tail |
|
||||
| `docs` | file viewer: `root`, a `deny` glob list, and `collections` of glob patterns |
|
||||
|
||||
Adding a second CLI is config-only — e.g. `docker` is included as a `jsonlines`
|
||||
example. View plugins (`views/<cli>-<view>.js`) are the only per-CLI code and
|
||||
are optional.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`ENOENT` / config not found** — run from `tools/clidash/` and make sure you
|
||||
copied `clidash.config.example.json` to `clidash.config.json` (step 2), or set
|
||||
`CLIDASH_CONFIG=/abs/path.json`.
|
||||
- **No `ncl` resources / discovery empty** — `bin/ncl` isn't built or the path
|
||||
is wrong. Build it (`pnpm run build`) or fix `clis.ncl.bin`.
|
||||
- **docker tab errors** — the docker daemon isn't running, or remove the
|
||||
`docker` CLI from config if you don't need it.
|
||||
- **Can't reach it from another device** — it binds `127.0.0.1`; set
|
||||
`BIND=<private-ip>` (tailnet), never a public interface.
|
||||
- **Empty Activity/Logs/Files** — check that `activity.sessionsRoot`,
|
||||
`logs.dir`, and `docs.root` resolve to your NanoClaw root (relative to where
|
||||
you launch `node server.js`).
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md).
|
||||
@@ -0,0 +1,109 @@
|
||||
# clidash
|
||||
|
||||
CLI-agnostic **read-only** web dashboard. Point it at any CLI that can list
|
||||
resources as JSON and it derives the dashboard at runtime: one tab per
|
||||
resource, a generic table over whatever columns the rows have. New resource →
|
||||
new tab; new column → new table column; **zero code changes**.
|
||||
|
||||
It ships pre-wired for NanoClaw's `ncl` CLI (agent groups, sessions, messaging
|
||||
groups, wirings, users, roles, …) plus `docker`, but the same config shape
|
||||
works for any list-as-JSON CLI.
|
||||
|
||||
- **Zero dependencies** — Node built-ins only (Node ≥ 22.5, for `node:sqlite`),
|
||||
no build step,
|
||||
vanilla-JS frontend.
|
||||
- **Read-only by construction** — the server can only `execFile` the configured
|
||||
argv templates; `{resource}` is the sole substitution and is validated
|
||||
against the discovered/static resource allowlist. Never a shell.
|
||||
- **Standalone** — no imports from NanoClaw source; the core is extractable to
|
||||
its own repo. The NanoClaw-specific knowledge lives entirely in the config
|
||||
and in the `views/ncl-overview.js` view plugin.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cp clidash.config.example.json clidash.config.json # then edit paths if needed
|
||||
node server.js # uses ./clidash.config.json
|
||||
CLIDASH_CONFIG=/path/to.json node server.js
|
||||
PORT=4690 BIND=127.0.0.1 node server.js # env overrides
|
||||
```
|
||||
|
||||
Run it from `tools/clidash/`; the example config uses paths relative to the
|
||||
NanoClaw root two levels up, so it works out of the box once `ncl` is built.
|
||||
|
||||
## Configure (`clidash.config.json`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"port": 4690,
|
||||
"bind": "127.0.0.1", // never a public interface; a tailnet IP at most
|
||||
"refreshSeconds": 60,
|
||||
"clis": {
|
||||
"ncl": {
|
||||
"bin": "bin/ncl", // relative to cwd below
|
||||
"cwd": "../..", // the NanoClaw root
|
||||
"discover": { "args": ["help"], "parser": "ncl-help" }, // runtime resource discovery
|
||||
"list": ["{resource}", "list", "--json"], // argv template
|
||||
"output": "json", // or "jsonlines" (docker/kubectl style)
|
||||
"unwrap": "data" // dot-path into a response envelope
|
||||
},
|
||||
"docker": {
|
||||
"bin": "docker",
|
||||
"resources": ["ps", "images"], // static alternative to discover
|
||||
"list": ["{resource}", "--format", "{{json .}}"],
|
||||
"output": "jsonlines"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`{resource}` may appear as a whole argv element or inside one — e.g. a remote
|
||||
CLI via ssh: `"list": ["-i", "key.pem", "user@host", "ncl {resource} list --json"]`.
|
||||
|
||||
Per-CLI `env` (merged over the server's env) and `cwd` are supported. See
|
||||
`clidash.config.example.json` for the full NanoClaw config, including the
|
||||
`enrich`/`badges`/`summary` table decorations and the `activity`/`logs`/`docs`
|
||||
sections.
|
||||
|
||||
## API
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /api/clis` | configured CLIs + discovered/static resources (discovery cached 60s) |
|
||||
| `GET /api/r/<cli>/<resource>` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout |
|
||||
| `GET /api/view/<cli>/<view>` | curated view plugin from `views/<cli>-<view>.js` |
|
||||
|
||||
View plugins are the only per-CLI *code*, and optional: a default-exported
|
||||
async function receiving `{ fetch }` (bound to that CLI) returning JSON.
|
||||
`views/ncl-overview.js` joins groups + sessions + messaging-groups + wirings
|
||||
into per-agent status cards (green <15m / amber <2h / red older).
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # unit + integration (node:test, stub CLI — no real CLI needed)
|
||||
./test/smoke.sh # against a running instance
|
||||
```
|
||||
|
||||
## Deploy as a service
|
||||
|
||||
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
|
||||
private (e.g. tailnet) IP — **never a public interface**; the network is the
|
||||
auth boundary. Example systemd user service:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/clidash.service
|
||||
[Unit]
|
||||
Description=clidash read-only CLI dashboard
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=%h/nanoclaw/tools/clidash
|
||||
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
|
||||
Environment=BIND=127.0.0.1
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Then `systemctl --user enable --now clidash`.
|
||||
@@ -0,0 +1,80 @@
|
||||
// Message-activity reader for clidash.
|
||||
//
|
||||
// ncl has no `messages` resource — message data lives in the per-session SQLite
|
||||
// DBs (`data/v2-sessions/<group>/<session>/{inbound,outbound}.db`). We read them
|
||||
// read-only with Node's built-in `node:sqlite` (no new dependency) and aggregate
|
||||
// per-session in/out totals + a daily time-series for charting.
|
||||
|
||||
import { readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
// Timestamps come in two shapes across tables: SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
|
||||
// and already-ISO "YYYY-MM-DDTHH:MM:SS.sssZ". Normalize to a comparable ISO form
|
||||
// so date-bucketing and max("last") work regardless of which a row used.
|
||||
function normTs(ts) {
|
||||
if (typeof ts !== 'string' || ts.length < 10) return null;
|
||||
if (ts.includes('T')) return ts; // already ISO
|
||||
return `${ts.replace(' ', 'T')}Z`;
|
||||
}
|
||||
|
||||
function readTable(dbPath, table) {
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const rows = db.prepare(`SELECT timestamp FROM ${table}`).all();
|
||||
const byDay = new Map();
|
||||
let last = null;
|
||||
for (const r of rows) {
|
||||
const ts = normTs(r.timestamp);
|
||||
if (!ts) continue;
|
||||
const day = ts.slice(0, 10); // ISO date prefix
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
if (last === null || ts > last) last = ts;
|
||||
}
|
||||
return { total: rows.length, byDay, last };
|
||||
} catch {
|
||||
return { total: 0, byDay: new Map(), last: null }; // missing/locked/corrupt → skip
|
||||
} finally {
|
||||
try { db?.close(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
|
||||
function listDirs(path) {
|
||||
try {
|
||||
return readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate message activity across all session DBs under `sessionsRoot`.
|
||||
* @returns {{ sessions: Array, series: Array<{date,in,out}> }}
|
||||
* sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
|
||||
* series — one bucket per day for the last `days` days (UTC, newest last)
|
||||
*/
|
||||
export function collectActivity(sessionsRoot, days, now) {
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10));
|
||||
}
|
||||
const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
|
||||
const sessions = [];
|
||||
|
||||
for (const group of listDirs(sessionsRoot)) {
|
||||
for (const session of listDirs(join(sessionsRoot, group))) {
|
||||
const base = join(sessionsRoot, group, session);
|
||||
// a real session dir has at least one of the two message DBs; skip shared
|
||||
// scaffolding dirs like `.claude-shared` that don't.
|
||||
if (!existsSync(join(base, 'inbound.db')) && !existsSync(join(base, 'outbound.db'))) continue;
|
||||
const inb = readTable(join(base, 'inbound.db'), 'messages_in');
|
||||
const out = readTable(join(base, 'outbound.db'), 'messages_out');
|
||||
const lastActivity = [inb.last, out.last].filter(Boolean).sort().at(-1) ?? null;
|
||||
sessions.push({ agent_group_id: group, session_id: session, in: inb.total, out: out.total, lastActivity });
|
||||
for (const [day, n] of inb.byDay) series.get(day)?.in !== undefined && (series.get(day).in += n);
|
||||
for (const [day, n] of out.byDay) series.get(day)?.out !== undefined && (series.get(day).out += n);
|
||||
}
|
||||
}
|
||||
return { sessions, series: dates.map((d) => series.get(d)) };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"port": 4690,
|
||||
"bind": "127.0.0.1",
|
||||
"refreshSeconds": 60,
|
||||
"clis": {
|
||||
"ncl": {
|
||||
"bin": "bin/ncl",
|
||||
"cwd": "../..",
|
||||
"discover": { "args": ["help"], "parser": "ncl-help" },
|
||||
"list": ["{resource}", "list", "--json"],
|
||||
"output": "json",
|
||||
"unwrap": "data",
|
||||
"commands": {
|
||||
"get": ["{resource}", "get", "--id", "{id}", "--json"],
|
||||
"config-get": ["groups", "config", "get", "--id", "{id}", "--json"]
|
||||
},
|
||||
"help": ["{resource}", "help"],
|
||||
"enrich": {
|
||||
"sessions": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
|
||||
},
|
||||
"wirings": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
|
||||
},
|
||||
"roles": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"user_id": { "ref": "users", "label": "display_name" },
|
||||
"granted_by": { "ref": "users", "label": "display_name" }
|
||||
},
|
||||
"members": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" },
|
||||
"user_id": { "ref": "users", "label": "display_name" }
|
||||
},
|
||||
"destinations": {
|
||||
"agent_group_id": { "ref": "groups", "label": "name" }
|
||||
},
|
||||
"user-dms": {
|
||||
"user_id": { "ref": "users", "label": "display_name" },
|
||||
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"container_status": { "running": "green", "idle": "green", "starting": "amber", "stopped": "gray", "error": "red" },
|
||||
"status": { "active": "green", "stopped": "gray", "error": "red", "pending": "amber" }
|
||||
},
|
||||
"summary": {
|
||||
"sessions": "container_status",
|
||||
"messaging-groups": "channel_type",
|
||||
"roles": "role",
|
||||
"users": "kind",
|
||||
"destinations": "target_type",
|
||||
"dropped-messages": "reason"
|
||||
}
|
||||
},
|
||||
"docker": {
|
||||
"bin": "docker",
|
||||
"resources": ["ps", "images"],
|
||||
"list": ["{resource}", "--format", "{{json .}}"],
|
||||
"output": "jsonlines"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"sessionsRoot": "../../data/v2-sessions",
|
||||
"days": 14
|
||||
},
|
||||
"logs": {
|
||||
"dir": "../../logs",
|
||||
"tailLines": 500,
|
||||
"files": [
|
||||
{ "name": "nanoclaw.log", "label": "host log" },
|
||||
{ "name": "nanoclaw.error.log", "label": "errors" }
|
||||
]
|
||||
},
|
||||
"docs": {
|
||||
"root": "../..",
|
||||
"deny": ["node_modules", ".env", "*token*", "*secret*", "*.pem", "*.key", "*.lock", "pnpm-lock.yaml"],
|
||||
"collections": [
|
||||
{
|
||||
"name": "skills",
|
||||
"label": "Skills",
|
||||
"lang": "markdown",
|
||||
"patterns": ["groups/*/skills/*/SKILL.md", "container/skills/*/SKILL.md"]
|
||||
},
|
||||
{
|
||||
"name": "claude-md",
|
||||
"label": "CLAUDE.md",
|
||||
"lang": "markdown",
|
||||
"patterns": ["groups/*/CLAUDE.md", "groups/*/CLAUDE.local.md"]
|
||||
},
|
||||
{
|
||||
"name": "profiles",
|
||||
"label": "Profiles",
|
||||
"lang": "json",
|
||||
"patterns": ["groups/*/profile.json"]
|
||||
},
|
||||
{
|
||||
"name": "conversations",
|
||||
"label": "Conversations",
|
||||
"lang": "markdown",
|
||||
"patterns": ["groups/*/conversations/*.md"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Read-only file viewer for clidash.
|
||||
//
|
||||
// Surfaces on-disk documents (skills, CLAUDE.md, profile.json, conversations)
|
||||
// that are NOT ncl resources. Same security posture as the rest of clidash:
|
||||
// only files matching a configured collection's glob patterns are listable or
|
||||
// readable; a deny-list blocks secrets; path traversal is impossible because a
|
||||
// requested path must be a member of the freshly-globbed allow-set.
|
||||
|
||||
import { readdirSync, realpathSync } from 'node:fs';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
|
||||
// Convert one glob segment to an anchored regex. `*` matches any run of
|
||||
// non-slash chars (so it works both as a whole segment and inside a filename,
|
||||
// e.g. `CLAUDE*.md`). All other regex metacharacters are escaped.
|
||||
function segToRegExp(seg) {
|
||||
const esc = seg.replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '[^/]*');
|
||||
return new RegExp('^' + esc + '$');
|
||||
}
|
||||
|
||||
// A path is denied if any of its segments matches any deny glob.
|
||||
function isDenied(relPath, deny) {
|
||||
const segs = relPath.split('/');
|
||||
return deny.some((d) => {
|
||||
const re = segToRegExp(d);
|
||||
return segs.some((s) => re.test(s));
|
||||
});
|
||||
}
|
||||
|
||||
// Directed walk: descend only entries matching each successive pattern segment.
|
||||
function walk(root, rel, segs, depth, out, deny) {
|
||||
if (depth >= segs.length) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(join(root, rel), { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const re = segToRegExp(segs[depth]);
|
||||
const last = depth === segs.length - 1;
|
||||
for (const e of entries) {
|
||||
if (e.name === '.' || e.name === '..') continue;
|
||||
if (!re.test(e.name)) continue;
|
||||
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (isDenied(childRel, deny)) continue;
|
||||
if (last) {
|
||||
if (e.isFile()) out.add(childRel);
|
||||
} else if (e.isDirectory()) {
|
||||
walk(root, childRel, segs, depth + 1, out, deny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative paths under `root` matching any of `patterns`, minus `deny` matches.
|
||||
* Sorted, de-duplicated. Patterns use `*` per the segment rules above; no `**`.
|
||||
*/
|
||||
export function globFiles(root, patterns, deny = []) {
|
||||
const out = new Set();
|
||||
for (const pattern of patterns) {
|
||||
walk(root, '', pattern.split('/'), 0, out, deny);
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly grouping/label for a relative path.
|
||||
* `groups/<g>/...` → group `<g>`; `container/...` → group `shared`.
|
||||
*/
|
||||
const CONTAINER_SEGS = new Set(['skills', 'conversations']); // redundant grouping dirs
|
||||
export function describeFile(relPath) {
|
||||
const parts = relPath.split('/');
|
||||
if (parts[0] === 'groups' && parts.length > 2) {
|
||||
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
|
||||
return { group: parts[1], label: `${parts[1]} / ${rest}` };
|
||||
}
|
||||
if (parts[0] === 'container') {
|
||||
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
|
||||
return { group: 'shared', label: `shared / ${rest}` };
|
||||
}
|
||||
return { group: '', label: relPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a requested doc path against a collection and return its absolute
|
||||
* path, or throw. A path is allowed only if it is a member of the collection's
|
||||
* freshly-globbed allow-set — this single check enforces the patterns, the
|
||||
* deny-list, and traversal safety at once.
|
||||
*/
|
||||
export function resolveDoc(root, collection, relPath, deny = []) {
|
||||
const allowed = new Set(globFiles(root, collection.patterns, deny));
|
||||
if (!allowed.has(relPath)) {
|
||||
throw new Error(`Path not allowed: ${relPath}`);
|
||||
}
|
||||
// Defence in depth: the resolved real path must still live under root.
|
||||
const abs = resolve(root, relPath);
|
||||
const rootReal = realpathSync(root);
|
||||
const absReal = realpathSync(abs);
|
||||
if (absReal !== rootReal && !absReal.startsWith(rootReal + sep)) {
|
||||
throw new Error(`Path not allowed: ${relPath}`);
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Log tailing for clidash — reads the last N lines of an allowlisted log file
|
||||
// and strips ANSI color codes (the host logger writes colored output).
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
/**
|
||||
* Last `maxLines` lines of a log file, ANSI-stripped.
|
||||
* @returns {{ lines: string[], text: string }}
|
||||
*/
|
||||
export async function tailFile(path, maxLines) {
|
||||
const raw = (await readFile(path, 'utf8')).replace(ANSI_RE, '');
|
||||
const all = raw.split('\n');
|
||||
if (all.length && all.at(-1) === '') all.pop(); // drop trailing newline's empty field
|
||||
const lines = all.slice(-maxLines);
|
||||
return { lines, text: lines.join('\n') };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "clidash",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI-agnostic read-only web dashboard — derives tabs and tables from any CLI that lists resources as JSON",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node --test 'test/*.test.js'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Pluggable parsers for clidash.
|
||||
//
|
||||
// discoveryParsers — turn a CLI's "help"-style output into a resource list.
|
||||
// parseOutput / unwrapPath — turn a CLI's list output into rows.
|
||||
// All per-CLI knowledge beyond these small functions lives in clidash.config.json.
|
||||
|
||||
/**
|
||||
* Discovery parsers, keyed by the `discover.parser` name in config.
|
||||
* Each receives the raw discovery output and returns
|
||||
* [{ name, description, verbs }] for resources that support `list`.
|
||||
* They must throw loudly on unrecognized formats — silent empty results
|
||||
* would render as silently-stale tabs.
|
||||
*/
|
||||
export const discoveryParsers = {
|
||||
/**
|
||||
* Parses ncl's two-column help format:
|
||||
*
|
||||
* Resources:
|
||||
* sessions Session — the runtime unit. ...
|
||||
* verbs: list, get
|
||||
* Commands:
|
||||
* help ...
|
||||
*/
|
||||
'ncl-help'(text) {
|
||||
const lines = String(text).split('\n');
|
||||
const start = lines.findIndex((l) => l.trim() === 'Resources:');
|
||||
if (start === -1) {
|
||||
throw new Error('ncl-help parser: no "Resources:" section in output — format may have changed');
|
||||
}
|
||||
const resources = [];
|
||||
let current = null;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
if (/^\S/.test(line)) break; // next top-level section, e.g. "Commands:"
|
||||
const verbsMatch = line.match(/^\s+verbs:\s*(.+)$/);
|
||||
if (verbsMatch && current) {
|
||||
current.verbs = verbsMatch[1].split(',').map((v) => v.trim()).filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
const resMatch = line.match(/^ (\S+)\s{2,}(\S.*)$/);
|
||||
if (resMatch) {
|
||||
current = { name: resMatch[1], description: resMatch[2].trim(), verbs: [] };
|
||||
resources.push(current);
|
||||
}
|
||||
}
|
||||
return resources.filter((r) => r.verbs.includes('list'));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a CLI's list output per the config's `output` field.
|
||||
* - 'json' — one JSON document.
|
||||
* - 'jsonlines' — one JSON object per line (docker/kubectl style).
|
||||
* Thrown errors carry the raw output on `err.raw` so the UI can show it.
|
||||
*/
|
||||
export function parseOutput(text, format) {
|
||||
if (format === 'json') {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
const err = new Error(`Invalid JSON output: ${e.message}`);
|
||||
err.raw = text;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (format === 'jsonlines') {
|
||||
const rows = [];
|
||||
const lines = String(text).split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch (e) {
|
||||
const err = new Error(`Invalid JSON on line ${i + 1}: ${e.message}`);
|
||||
err.raw = text;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
throw new Error(`Unknown output format: ${format}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a dot-path into a response envelope (e.g. 'data' for ncl's
|
||||
* {id, ok, data} frame). No path → value passes through unchanged.
|
||||
* Missing path throws — a changed envelope must fail loudly.
|
||||
*/
|
||||
export function unwrapPath(value, path) {
|
||||
if (!path) return value;
|
||||
let cur = value;
|
||||
for (const key of path.split('.')) {
|
||||
if (cur === null || typeof cur !== 'object' || !(key in cur)) {
|
||||
throw new Error(`Unwrap path "${path}" not found in CLI output (missing "${key}")`);
|
||||
}
|
||||
cur = cur[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
// clidash frontend — vanilla JS, no build step.
|
||||
//
|
||||
// Layout: a left sidebar with top-level items (Overview, Activity) and grouped
|
||||
// sections (one per CLI — ncl, docker — and a Files section for on-disk docs).
|
||||
// Each page shows the exact command that produced it. Tables auto-derive from
|
||||
// `ncl <resource> list --json`; rows drill into their `get` detail.
|
||||
//
|
||||
// Refresh UX: on first load every resource of every CLI is prefetched so nav is
|
||||
// instant. 60s auto-refresh + a manual button. Background refreshes diff-and-
|
||||
// inject (the data DOM rebuilds only when the data signature changes).
|
||||
|
||||
import { mdToHtml } from './md.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
clis: [],
|
||||
docCollections: [],
|
||||
activeView: 'overview', // 'overview' | 'activity' | 'r:<cli>:<resource>' | 'doc:<collection>'
|
||||
paused: false,
|
||||
refreshSeconds: 60,
|
||||
lastUpdated: null,
|
||||
refreshing: false,
|
||||
snapshots: new Map(), // "cli/resource" -> { rows, fetchedAt, command }
|
||||
errors: new Map(),
|
||||
activity: null, // { sessions, series }
|
||||
activityConfigured: false,
|
||||
activityCommand: null,
|
||||
logs: [], // [{ name, label }]
|
||||
logCache: new Map(), // name -> { text, command }
|
||||
activeDocPath: null,
|
||||
openDocGroups: new Set(), // which doc groups (e.g. agents) are expanded
|
||||
docCache: new Map(),
|
||||
configCache: new Map(), // groupId -> container config (for the overview page)
|
||||
helpCache: new Map(), // "cli/resource" -> help text | null (prefetched each cycle)
|
||||
detail: null,
|
||||
sidebarOpen: false,
|
||||
renderedSig: null,
|
||||
};
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
function svg(tag, attrs = {}, children = []) {
|
||||
const node = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||||
for (const c of [].concat(children)) if (c != null) node.append(c);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Lucide-style inline icons (static trusted markup) — crisp, themeable via currentColor.
|
||||
const ICONS = {
|
||||
overview: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
|
||||
activity: '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
||||
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3"/><path d="M13 15h4"/>',
|
||||
box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
||||
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
|
||||
logs: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>',
|
||||
};
|
||||
function icon(name) {
|
||||
const s = document.createElementNS(SVG_NS, 'svg');
|
||||
s.setAttribute('viewBox', '0 0 24 24');
|
||||
s.setAttribute('fill', 'none');
|
||||
s.setAttribute('stroke', 'currentColor');
|
||||
s.setAttribute('stroke-width', '1.8');
|
||||
s.setAttribute('stroke-linecap', 'round');
|
||||
s.setAttribute('stroke-linejoin', 'round');
|
||||
s.innerHTML = ICONS[name] ?? '';
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
||||
|
||||
function relTime(iso) {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 0) return new Date(iso).toLocaleString();
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 48) return `${h}h ago`;
|
||||
return `${Math.round(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function coarseAgo(date) {
|
||||
const s = (Date.now() - date.getTime()) / 1000;
|
||||
if (s < 60) return 'less than a minute ago';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h === 1 ? '1 hour ago' : `${h} hours ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return d === 1 ? '1 day ago' : `${d} days ago`;
|
||||
}
|
||||
|
||||
function staleness(lastActive) {
|
||||
if (!lastActive) return 'gray';
|
||||
const min = (Date.now() - new Date(lastActive).getTime()) / 60000;
|
||||
if (Number.isNaN(min)) return 'gray';
|
||||
return min < 15 ? 'green' : min < 120 ? 'amber' : 'red';
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, children = []) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
for (const child of [].concat(children)) {
|
||||
if (child == null) continue;
|
||||
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function fmtValue(value) {
|
||||
if (value === null || value === undefined) return { text: 'null', cls: 'null' };
|
||||
if (typeof value === 'string' && ISO_RE.test(value)) return { iso: value };
|
||||
return { text: typeof value === 'object' ? JSON.stringify(value) : String(value) };
|
||||
}
|
||||
|
||||
function cellFor(value) {
|
||||
const f = fmtValue(value);
|
||||
if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
|
||||
if (f.iso) {
|
||||
return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
|
||||
relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')),
|
||||
]));
|
||||
}
|
||||
if (f.text.length > 42) {
|
||||
const span = el('span', { class: 'trunc', title: f.text }, f.text.slice(0, 39) + '…');
|
||||
span.addEventListener('click', (e) => { e.stopPropagation(); span.textContent = f.text; span.classList.remove('trunc'); });
|
||||
return el('td', {}, span);
|
||||
}
|
||||
return el('td', {}, f.text);
|
||||
}
|
||||
|
||||
function kvRows(obj) {
|
||||
return Object.entries(obj ?? {}).map(([k, v]) => {
|
||||
let valEl;
|
||||
if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
|
||||
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`);
|
||||
else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
|
||||
else valEl = el('span', {}, String(v));
|
||||
return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRef(cliName, ref, id) {
|
||||
const snap = state.snapshots.get(`${cliName}/${ref.ref}`);
|
||||
const row = snap?.rows?.find((r) => String(r.id) === String(id));
|
||||
return row ? (row[ref.label] ?? null) : null;
|
||||
}
|
||||
|
||||
function badgeChip(value, colorMap) {
|
||||
const color = colorMap[String(value).toLowerCase()] ?? 'gray';
|
||||
return el('span', { class: `badge-status ${color}` }, [el('span', { class: `dot ${color}` }), String(value)]);
|
||||
}
|
||||
|
||||
function buildCell(value, column, ctx) {
|
||||
if (ctx.badges?.[column] && value != null && typeof value !== 'object') {
|
||||
return el('td', {}, badgeChip(value, ctx.badges[column]));
|
||||
}
|
||||
if (ctx.enrich?.[column] && value != null) {
|
||||
const name = resolveRef(ctx.cliName, ctx.enrich[column], value);
|
||||
if (name != null) {
|
||||
return el('td', { class: 'enriched', title: String(value) }, [
|
||||
el('span', {}, String(name)), el('span', { class: 'raw-id' }, String(value)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return cellFor(value);
|
||||
}
|
||||
|
||||
function summaryBar(resource, rows, col, cli) {
|
||||
let label = resource.replace(/-/g, ' ');
|
||||
if (rows.length === 1 && label.endsWith('s')) label = label.slice(0, -1);
|
||||
const bits = [el('span', { class: 'sum-count' }, `${rows.length} ${label}`)];
|
||||
if (col && rows.some((r) => col in r)) {
|
||||
const counts = new Map();
|
||||
for (const r of rows) { const v = r[col] ?? '—'; counts.set(v, (counts.get(v) ?? 0) + 1); }
|
||||
const colorMap = cli.badges?.[col];
|
||||
for (const [v, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
bits.push(el('span', { class: 'sum-sep' }, '·'));
|
||||
const c = colorMap?.[String(v).toLowerCase()] ?? null;
|
||||
bits.push(c
|
||||
? el('span', { class: `badge-status ${c}` }, [el('span', { class: `dot ${c}` }), `${v} ×${n}`])
|
||||
: el('span', { class: 'sum-chip' }, `${v} ×${n}`));
|
||||
}
|
||||
}
|
||||
return el('div', { class: 'summary-bar' }, bits);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
const nclCli = () => state.clis.find((c) => c.name === 'ncl') ?? state.clis[0];
|
||||
function currentView() {
|
||||
const v = state.activeView;
|
||||
if (v === 'overview' || v === 'activity') return { type: v };
|
||||
const m = v.match(/^r:([^:]+):(.+)$/);
|
||||
if (m) return { type: 'resource', cli: m[1], resource: m[2] };
|
||||
if (v.startsWith('doc:')) return { type: 'doc', collection: v.slice(4) };
|
||||
if (v.startsWith('log:')) return { type: 'log', name: v.slice(4) };
|
||||
return { type: 'overview' };
|
||||
}
|
||||
const activeCollection = () => {
|
||||
const v = currentView();
|
||||
return v.type === 'doc' ? state.docCollections.find((c) => c.name === v.collection) : null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- fetching
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url);
|
||||
return res.json().catch(() => ({ ok: false, error: `Bad response from ${url}` }));
|
||||
}
|
||||
|
||||
async function refresh(force = false) {
|
||||
state.refreshing = true;
|
||||
if (force) renderControls();
|
||||
|
||||
const [cliList, docList, logList] = await Promise.all([
|
||||
fetchJson('/api/clis').catch(() => null),
|
||||
fetchJson('/api/docs').catch(() => null),
|
||||
fetchJson('/api/logs').catch(() => null),
|
||||
]);
|
||||
if (cliList?.clis) {
|
||||
state.clis = cliList.clis;
|
||||
state.refreshSeconds = cliList.clis[0]?.refreshSeconds ?? state.refreshSeconds;
|
||||
}
|
||||
if (docList?.collections) state.docCollections = docList.collections;
|
||||
if (logList?.files) state.logs = logList.files;
|
||||
|
||||
render(); // paint sidebar + active view's loading state immediately
|
||||
|
||||
const jobs = [];
|
||||
jobs.push(fetchJson('/api/activity').then((body) => {
|
||||
if (body.ok && body.configured) {
|
||||
state.activity = { sessions: body.sessions, series: body.series };
|
||||
state.activityConfigured = true; state.activityCommand = body.command ?? null;
|
||||
} else state.activityConfigured = false;
|
||||
render();
|
||||
}));
|
||||
for (const lg of state.logs) {
|
||||
jobs.push(fetchJson(`/api/log/${encodeURIComponent(lg.name)}`).then((body) => {
|
||||
if (body.ok) state.logCache.set(lg.name, { text: body.text, command: body.command });
|
||||
render();
|
||||
}));
|
||||
}
|
||||
for (const c of state.clis) {
|
||||
for (const r of c.resources ?? []) {
|
||||
const key = `${c.name}/${r.name}`;
|
||||
jobs.push(fetchJson(`/api/r/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
|
||||
if (body.ok) { state.snapshots.set(key, { rows: body.rows, fetchedAt: body.fetchedAt, command: body.command }); state.errors.set(key, null); }
|
||||
else state.errors.set(key, body.raw ? `${body.error}\n\n${body.raw}` : body.error);
|
||||
render();
|
||||
}));
|
||||
if (c.help) {
|
||||
jobs.push(fetchJson(`/api/help/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
|
||||
state.helpCache.set(key, body.ok ? body.text : null);
|
||||
render();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
|
||||
// per-group container config (for the Overview page) — small, refetched each cycle
|
||||
const groups = state.snapshots.get('ncl/groups')?.rows ?? [];
|
||||
await Promise.all(groups.map(async (g) => {
|
||||
const c = await fetchJson(`/api/cmd/ncl/config-get?id=${encodeURIComponent(g.id)}`);
|
||||
if (c.ok) state.configCache.set(g.id, c.data);
|
||||
}));
|
||||
|
||||
state.lastUpdated = new Date();
|
||||
state.refreshing = false;
|
||||
render();
|
||||
}
|
||||
|
||||
async function openDoc(collectionName, path) {
|
||||
state.activeDocPath = path;
|
||||
const key = `${collectionName}\0${path}`;
|
||||
if (!state.docCache.has(key)) {
|
||||
const body = await fetchJson(`/api/doc?c=${encodeURIComponent(collectionName)}&p=${encodeURIComponent(path)}`);
|
||||
state.docCache.set(key, body.ok ? { lang: body.lang, content: body.content } : { lang: 'error', content: body.error || 'Failed to load' });
|
||||
}
|
||||
state.renderedSig = null;
|
||||
render();
|
||||
}
|
||||
|
||||
async function openDetail(cliName, resource, id) {
|
||||
state.detail = { cli: cliName, resource, id, loading: true };
|
||||
state.renderedSig = null;
|
||||
render();
|
||||
const rec = await fetchJson(`/api/cmd/${cliName}/get?resource=${encodeURIComponent(resource)}&id=${encodeURIComponent(id)}`);
|
||||
let config = null;
|
||||
if (resource === 'groups') {
|
||||
const cg = await fetchJson(`/api/cmd/${cliName}/config-get?id=${encodeURIComponent(id)}`);
|
||||
if (cg.ok) config = cg.data;
|
||||
}
|
||||
if (!state.detail || state.detail.id !== id) return;
|
||||
state.detail = { cli: cliName, resource, id, record: rec.ok ? rec.data : null, error: rec.ok ? null : rec.error, config };
|
||||
state.renderedSig = null;
|
||||
render();
|
||||
}
|
||||
|
||||
function closeDetail() { state.detail = null; state.renderedSig = null; render(); }
|
||||
|
||||
// Help panel: the description (first paragraph) is always visible; the verbs +
|
||||
// fields (everything after the first blank line) sit behind a collapse.
|
||||
function helpPanel(text) {
|
||||
if (text === null) return null; // explicitly no help
|
||||
if (text === undefined) return el('div', { class: 'help-panel' }, el('div', { class: 'help-head dim' }, 'loading help…'));
|
||||
const idx = text.indexOf('\n\n');
|
||||
const head = (idx >= 0 ? text.slice(0, idx) : text).trim();
|
||||
const body = idx >= 0 ? text.slice(idx + 2).trim() : '';
|
||||
return el('div', { class: 'help-panel' }, [
|
||||
el('div', { class: 'help-head' }, head),
|
||||
body ? el('details', { class: 'help-more' }, [
|
||||
el('summary', {}, 'verbs & fields'),
|
||||
el('pre', { class: 'help-text' }, body),
|
||||
]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
function go(view) {
|
||||
state.activeView = view;
|
||||
state.detail = null;
|
||||
state.sidebarOpen = false;
|
||||
state.renderedSig = null;
|
||||
const v = currentView();
|
||||
if (v.type === 'doc') {
|
||||
const coll = state.docCollections.find((c) => c.name === v.collection);
|
||||
const first = coll && (coll.name === 'conversations' ? coll.files.at(-1) : coll.files[0]); // newest conversation
|
||||
state.activeDocPath = state.activeDocPath && coll?.files.some((f) => f.path === state.activeDocPath)
|
||||
? state.activeDocPath : (first?.path ?? null);
|
||||
// expand only the group holding the active doc; the user picks the rest
|
||||
const activeFile = coll?.files.find((f) => f.path === state.activeDocPath);
|
||||
state.openDocGroups = new Set(activeFile ? [activeFile.group] : []);
|
||||
render();
|
||||
if (state.activeDocPath) openDoc(coll.name, state.activeDocPath);
|
||||
return;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rendering
|
||||
|
||||
function dataSignature() {
|
||||
const v = currentView();
|
||||
const key = v.type === 'resource' ? `${v.cli}/${v.resource}` : null;
|
||||
const coll = activeCollection();
|
||||
return JSON.stringify({
|
||||
view: state.activeView, clis: state.clis.map((c) => `${c.name}:${(c.resources || []).length}`),
|
||||
activityConfigured: state.activityConfigured,
|
||||
rows: key ? state.snapshots.get(key)?.rows ?? null : null,
|
||||
rowsError: key ? state.errors.get(key) ?? null : null,
|
||||
command: key ? state.snapshots.get(key)?.command ?? null : null,
|
||||
help: key ? state.helpCache.get(key) ?? null : null,
|
||||
overview: v.type === 'overview' ? {
|
||||
groups: state.snapshots.get('ncl/groups')?.rows ?? null,
|
||||
sessions: state.snapshots.get('ncl/sessions')?.rows ?? null,
|
||||
configs: [...state.configCache.entries()],
|
||||
activity: state.activity?.sessions ?? null,
|
||||
} : null,
|
||||
activity: v.type === 'activity' ? state.activity : null,
|
||||
log: v.type === 'log' ? state.logCache.get(v.name)?.text ?? null : null,
|
||||
docFiles: coll ? coll.files.map((f) => f.path) : null,
|
||||
docPath: state.activeDocPath,
|
||||
docGroupsOpen: coll ? [...state.openDocGroups] : null,
|
||||
docContent: coll ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`)?.content ?? null : null,
|
||||
detail: state.detail, paused: state.paused, sidebarOpen: state.sidebarOpen,
|
||||
});
|
||||
}
|
||||
|
||||
function renderControls() {
|
||||
$('updated').textContent = state.lastUpdated
|
||||
? `updated ${coarseAgo(state.lastUpdated)}${state.paused ? ' · paused' : ''}` : '';
|
||||
$('refresh').classList.toggle('spinning', state.refreshing);
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderControls();
|
||||
const sig = dataSignature();
|
||||
if (sig === state.renderedSig) return;
|
||||
state.renderedSig = sig;
|
||||
|
||||
$('sidebar').classList.toggle('open', state.sidebarOpen);
|
||||
$('scrim').hidden = !state.sidebarOpen;
|
||||
|
||||
renderNav();
|
||||
|
||||
const v = currentView();
|
||||
const banner = $('banner');
|
||||
const tabError = v.type === 'resource' ? state.errors.get(`${v.cli}/${v.resource}`) : null;
|
||||
const cli = v.type === 'resource' ? state.clis.find((c) => c.name === v.cli) : null;
|
||||
const bannerMsg = cli?.error ? `Discovery failed for ${v.cli}: ${cli.error}`
|
||||
: (tabError ? `CLI unreachable — showing last good snapshot. ${tabError.split('\n')[0]}` : null);
|
||||
banner.hidden = !bannerMsg;
|
||||
banner.textContent = bannerMsg ?? '';
|
||||
|
||||
renderCmdline(v);
|
||||
if (v.type === 'overview') renderOverviewPage();
|
||||
else if (v.type === 'activity') renderActivity();
|
||||
else if (v.type === 'doc') renderDocs();
|
||||
else if (v.type === 'log') renderLogPage(v.name);
|
||||
else renderTable(v.cli, v.resource);
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
function navItem(label, view, cls = '', iconName = null) {
|
||||
return el('button', {
|
||||
class: `nav-item ${cls}` + (state.activeView === view ? ' active' : ''),
|
||||
onclick: () => go(view),
|
||||
}, [iconName ? icon(iconName) : null, el('span', {}, label)]);
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = $('nav');
|
||||
const items = [navItem('Overview', 'overview', '', 'overview')];
|
||||
if (state.activityConfigured) items.push(navItem('Activity', 'activity', '', 'activity'));
|
||||
|
||||
for (const cli of state.clis) {
|
||||
items.push(el('div', { class: 'nav-section' }, [icon(cli.name === 'docker' ? 'box' : 'terminal'), el('span', {}, cli.name)]));
|
||||
for (const r of cli.resources ?? []) {
|
||||
items.push(navItem(r.name, `r:${cli.name}:${r.name}`, 'nav-sub'));
|
||||
}
|
||||
}
|
||||
if (state.docCollections.length) {
|
||||
items.push(el('div', { class: 'nav-section' }, [icon('folder'), el('span', {}, 'Files')]));
|
||||
for (const coll of state.docCollections) {
|
||||
items.push(navItem(coll.label, `doc:${coll.name}`, 'nav-sub'));
|
||||
}
|
||||
}
|
||||
if (state.logs.length) {
|
||||
items.push(el('div', { class: 'nav-section' }, [icon('logs'), el('span', {}, 'Logs')]));
|
||||
for (const lg of state.logs) {
|
||||
items.push(navItem(lg.label, `log:${lg.name}`, 'nav-sub'));
|
||||
}
|
||||
}
|
||||
nav.replaceChildren(...items);
|
||||
}
|
||||
|
||||
function renderCmdline(v) {
|
||||
const bar = $('cmdline');
|
||||
let cmd = null;
|
||||
if (v.type === 'resource') cmd = state.snapshots.get(`${v.cli}/${v.resource}`)?.command;
|
||||
else if (v.type === 'activity') cmd = state.activityCommand;
|
||||
else if (v.type === 'doc') cmd = state.activeDocPath ? `file · ${state.activeDocPath}` : null;
|
||||
else if (v.type === 'log') cmd = state.logCache.get(v.name)?.command ?? null;
|
||||
else if (v.type === 'overview') cmd = 'derived · ncl groups/sessions/messaging-groups/wirings + config-get + activity';
|
||||
bar.hidden = !cmd;
|
||||
bar.textContent = cmd ? `$ ${cmd}` : '';
|
||||
}
|
||||
|
||||
// ---- Overview page (rich agent cards) ----
|
||||
|
||||
function renderOverviewPage() {
|
||||
const content = $('content');
|
||||
const groups = state.snapshots.get('ncl/groups')?.rows;
|
||||
if (!groups) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
|
||||
const sessions = state.snapshots.get('ncl/sessions')?.rows ?? [];
|
||||
const wirings = state.snapshots.get('ncl/wirings')?.rows ?? [];
|
||||
const mgs = state.snapshots.get('ncl/messaging-groups')?.rows ?? [];
|
||||
const act = state.activity?.sessions ?? [];
|
||||
const mgName = (id) => mgs.find((m) => m.id === id)?.name ?? mgs.find((m) => m.id === id)?.platform_id ?? id;
|
||||
|
||||
const field = (k, v, cls = '') => el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, k), el('span', { class: `v ${cls}` }, v)]);
|
||||
|
||||
const cards = groups.map((g) => {
|
||||
const gs = sessions.filter((s) => s.agent_group_id === g.id);
|
||||
const lastActive = gs.map((s) => s.last_active).filter(Boolean).sort().at(-1) ?? null;
|
||||
const container = gs.some((s) => s.container_status === 'running') ? 'running' : (gs[0]?.container_status ?? 'none');
|
||||
const ga = act.filter((a) => a.agent_group_id === g.id);
|
||||
const msgIn = ga.reduce((a, s) => a + s.in, 0), msgOut = ga.reduce((a, s) => a + s.out, 0);
|
||||
const cfg = state.configCache.get(g.id);
|
||||
const chans = wirings.filter((w) => w.agent_group_id === g.id).map((w) => `${mgs.find((m) => m.id === w.messaging_group_id)?.channel_type ?? '?'}: ${mgName(w.messaging_group_id)}`);
|
||||
const status = staleness(lastActive);
|
||||
const containerColor = container === 'running' ? 'green' : container === 'idle' ? 'green' : container === 'none' ? 'gray' : 'gray';
|
||||
|
||||
const fields = [
|
||||
el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'container'), badgeChip(container, { running: 'green', idle: 'green', stopped: 'gray', none: 'gray' })]),
|
||||
field('sessions', String(gs.length)),
|
||||
field('messages', `${msgIn} in · ${msgOut} out`),
|
||||
field('last active', lastActive ? relTime(lastActive) : '—', lastActive ? '' : 'dim'),
|
||||
];
|
||||
if (cfg) {
|
||||
fields.push(field('provider / model', `${cfg.provider ?? 'claude'} / ${cfg.model ?? 'default'}`));
|
||||
fields.push(el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'cli scope'), badgeChip(cfg.cli_scope ?? 'group', { global: 'amber', group: 'green', disabled: 'gray' })]));
|
||||
const pkgs = (cfg.packages_apt?.length ?? 0) + (cfg.packages_npm?.length ?? 0);
|
||||
const mcp = Object.keys(cfg.mcp_servers ?? {}).length;
|
||||
if (pkgs || mcp) fields.push(field('extras', `${pkgs} pkgs · ${mcp} mcp`));
|
||||
}
|
||||
|
||||
return el('div', { class: 'ov-card' }, [
|
||||
el('div', { class: 'ov-head' }, [
|
||||
el('span', { class: `dot ${status}` }),
|
||||
el('span', { class: 'ov-name' }, g.name),
|
||||
el('span', { class: 'ov-folder' }, g.folder),
|
||||
]),
|
||||
el('div', { class: 'ov-fields' }, fields),
|
||||
el('div', { class: 'ov-chans' }, chans.map((c) => el('span', { class: 'badge' }, c))),
|
||||
]);
|
||||
});
|
||||
|
||||
content.replaceChildren(
|
||||
el('h2', { class: 'page-title' }, 'Agents overview'),
|
||||
el('div', { class: 'ov-cards' }, cards),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Activity ----
|
||||
|
||||
function renderActivity() {
|
||||
const content = $('content');
|
||||
const data = state.activity;
|
||||
if (!data) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
|
||||
const { series, sessions } = data;
|
||||
const totalIn = series.reduce((a, d) => a + d.in, 0);
|
||||
const totalOut = series.reduce((a, d) => a + d.out, 0);
|
||||
|
||||
const W = 720, H = 220, padL = 34, padB = 28, padT = 10;
|
||||
const max = Math.max(1, ...series.map((d) => Math.max(d.in, d.out)));
|
||||
const slot = (W - padL) / series.length;
|
||||
const bw = Math.max(3, slot / 2 - 2);
|
||||
const yOf = (vv) => padT + (H - padT - padB) * (1 - vv / max);
|
||||
const chart = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'activity-chart', preserveAspectRatio: 'none' });
|
||||
for (const frac of [0, 0.5, 1]) {
|
||||
const y = yOf(max * frac);
|
||||
chart.append(svg('line', { x1: padL, y1: y, x2: W, y2: y, class: 'grid' }));
|
||||
chart.append(svg('text', { x: padL - 6, y: y + 3, class: 'axis', 'text-anchor': 'end' }, String(Math.round(max * frac))));
|
||||
}
|
||||
series.forEach((d, i) => {
|
||||
const x = padL + i * slot;
|
||||
chart.append(svg('rect', { x: x + 1, y: yOf(d.in), width: bw, height: yOf(0) - yOf(d.in), class: 'bar-in' }, [svg('title', {}, `${d.date}: ${d.in} in`)]));
|
||||
chart.append(svg('rect', { x: x + 1 + bw, y: yOf(d.out), width: bw, height: yOf(0) - yOf(d.out), class: 'bar-out' }, [svg('title', {}, `${d.date}: ${d.out} out`)]));
|
||||
if (i % 2 === 0) chart.append(svg('text', { x: x + bw, y: H - 8, class: 'axis', 'text-anchor': 'middle' }, d.date.slice(5)));
|
||||
});
|
||||
const legend = el('div', { class: 'activity-legend' }, [
|
||||
el('span', {}, [el('span', { class: 'lg in' }), `inbound (${totalIn})`]),
|
||||
el('span', {}, [el('span', { class: 'lg out' }), `outbound (${totalOut})`]),
|
||||
el('span', { class: 'dim' }, `last ${series.length} days`),
|
||||
]);
|
||||
const sessRows = [...sessions].sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')).map((s) => {
|
||||
const groupName = resolveRef('ncl', { ref: 'groups', label: 'name' }, s.agent_group_id) ?? s.agent_group_id;
|
||||
return el('tr', {}, [
|
||||
el('td', {}, groupName),
|
||||
el('td', {}, el('span', { class: 'trunc', title: s.session_id }, s.session_id.slice(0, 22) + '…')),
|
||||
el('td', { class: 'num' }, String(s.in)),
|
||||
el('td', { class: 'num' }, String(s.out)),
|
||||
el('td', {}, s.lastActivity ? el('span', { class: 'reltime', title: s.lastActivity }, relTime(s.lastActivity)) : el('span', { class: 'null' }, '—')),
|
||||
]);
|
||||
});
|
||||
content.replaceChildren(
|
||||
el('h2', { class: 'page-title' }, 'Message activity'),
|
||||
el('div', { class: 'activity-wrap' }, [
|
||||
legend,
|
||||
el('div', { class: 'chart-box' }, chart),
|
||||
el('div', { class: 'table-wrap' }, el('table', { class: 'activity-table' }, [
|
||||
el('thead', {}, el('tr', {}, ['agent', 'session', 'in', 'out', 'last activity'].map((h) => el('th', {}, h)))),
|
||||
el('tbody', {}, sessRows),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Logs (tail of a log file) ----
|
||||
|
||||
function renderLogPage(name) {
|
||||
const content = $('content');
|
||||
const label = state.logs.find((l) => l.name === name)?.label ?? name;
|
||||
const cached = state.logCache.get(name);
|
||||
if (!cached) { content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'empty' }, 'Loading…')); return; }
|
||||
const view = el('div', { class: 'log-view' });
|
||||
for (const line of cached.text.split('\n')) {
|
||||
const lvl = /\bERROR\b/i.test(line) ? 'err' : /\bWARN(ING)?\b/i.test(line) ? 'warn' : '';
|
||||
view.append(el('div', { class: `log-line ${lvl}` }, line || ' '));
|
||||
}
|
||||
content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'log-box' }, view));
|
||||
// follow the tail — scroll to the newest line
|
||||
requestAnimationFrame(() => { const b = content.querySelector('.log-box'); if (b) b.scrollTop = b.scrollHeight; });
|
||||
}
|
||||
|
||||
// ---- Files (doc viewer) ----
|
||||
|
||||
function renderDocs() {
|
||||
const coll = activeCollection();
|
||||
const content = $('content');
|
||||
if (!coll) { content.replaceChildren(el('div', { class: 'empty' }, 'No documents.')); return; }
|
||||
if (!coll.files.length) { content.replaceChildren(el('div', { class: 'empty' }, `No ${coll.label.toLowerCase()}.`)); return; }
|
||||
// display name: drop the group prefix, the `/SKILL.md` tail (show the skill
|
||||
// dir), and the .md extension — leaving e.g. "meeting-tagger" or "2026-06-13-…"
|
||||
const itemName = (label) => {
|
||||
let n = label.includes('/') ? label.split('/').slice(1).join('/').trim() : label;
|
||||
return n.replace(/\/SKILL\.md$/, '').replace(/\.md$/, '') || label;
|
||||
};
|
||||
const newestFirst = coll.name === 'conversations';
|
||||
const groups = new Map();
|
||||
for (const f of coll.files) { if (!groups.has(f.group)) groups.set(f.group, []); groups.get(f.group).push(f); }
|
||||
const toggleGroup = (g) => {
|
||||
state.openDocGroups.has(g) ? state.openDocGroups.delete(g) : state.openDocGroups.add(g);
|
||||
state.renderedSig = null; render();
|
||||
};
|
||||
const list = el('div', { class: 'doc-list' });
|
||||
for (const [group, files] of groups) {
|
||||
const open = state.openDocGroups.has(group);
|
||||
list.append(el('button', { class: 'doc-group-toggle' + (open ? ' open' : ''), onclick: () => toggleGroup(group) }, [
|
||||
el('span', { class: 'chev' }, open ? '▾' : '▸'),
|
||||
el('span', { class: 'g-name' }, group || '—'),
|
||||
el('span', { class: 'g-count' }, String(files.length)),
|
||||
]));
|
||||
if (open) {
|
||||
const ordered = newestFirst ? [...files].reverse() : files;
|
||||
for (const f of ordered) {
|
||||
list.append(el('button', { class: 'doc-item' + (f.path === state.activeDocPath ? ' active' : ''), title: f.path, onclick: () => openDoc(coll.name, f.path) }, itemName(f.label) || f.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
const pane = el('div', { class: 'doc-content' });
|
||||
const cached = state.activeDocPath ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`) : null;
|
||||
if (!state.activeDocPath) pane.append(el('div', { class: 'empty' }, 'Select a document.'));
|
||||
else if (!cached) pane.append(el('div', { class: 'empty' }, 'Loading…'));
|
||||
else if (cached.lang === 'error') pane.append(el('div', { class: 'tab-error' }, cached.content));
|
||||
else if (cached.lang === 'json') {
|
||||
let pretty = cached.content;
|
||||
try { pretty = JSON.stringify(JSON.parse(cached.content), null, 2); } catch { /* keep raw */ }
|
||||
pane.append(el('pre', { class: 'code json' }, pretty));
|
||||
} else if (cached.lang === 'markdown') {
|
||||
const md = el('div', { class: 'markdown' }); md.innerHTML = mdToHtml(cached.content); pane.append(md);
|
||||
} else pane.append(el('pre', { class: 'code' }, cached.content));
|
||||
content.replaceChildren(el('h2', { class: 'page-title' }, coll.label), el('div', { class: 'doc-viewer' }, [list, pane]));
|
||||
}
|
||||
|
||||
// ---- resource table ----
|
||||
|
||||
function renderTable(cliName, resource) {
|
||||
const content = $('content');
|
||||
const cli = state.clis.find((c) => c.name === cliName);
|
||||
if (!cli) { content.replaceChildren(el('div', { class: 'empty' }, 'No such CLI.')); return; }
|
||||
const key = `${cliName}/${resource}`;
|
||||
const snapshot = state.snapshots.get(key);
|
||||
const error = state.errors.get(key);
|
||||
const canDrill = (cli.commands || []).includes('get');
|
||||
const parts = [el('h2', { class: 'page-title' }, resource)];
|
||||
if (cli.help) parts.push(helpPanel(state.helpCache.get(key)));
|
||||
if (error && snapshot) parts.push(el('div', { class: 'stale-note' }, `⚠ live fetch failing — snapshot from ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`));
|
||||
if (!snapshot) {
|
||||
parts.push(error ? el('div', { class: 'tab-error' }, [`Failed to load ${resource}.`, el('pre', {}, error)]) : el('div', { class: 'empty' }, 'Loading…'));
|
||||
content.replaceChildren(...parts); return;
|
||||
}
|
||||
const rows = snapshot.rows;
|
||||
parts.push(summaryBar(resource, rows, cli.summary?.[resource], cli));
|
||||
if (rows.length === 0) { parts.push(el('div', { class: 'empty' }, `No ${resource}.`)); content.replaceChildren(...parts); return; }
|
||||
const columns = [];
|
||||
for (const row of rows) for (const k of Object.keys(row)) if (!columns.includes(k)) columns.push(k);
|
||||
const ctx = { cliName, enrich: cli.enrich?.[resource], badges: cli.badges };
|
||||
const body = rows.map((row) => {
|
||||
const id = row.id; const canRow = canDrill && id != null;
|
||||
return el('tr', { class: canRow ? 'drillable' : '', ...(canRow ? { onclick: () => openDetail(cliName, resource, String(id)) } : {}) },
|
||||
columns.map((c) => buildCell(row[c], c, ctx)));
|
||||
});
|
||||
parts.push(el('div', { class: 'table-wrap' }, el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, columns.map((c) => el('th', {}, c)))),
|
||||
el('tbody', {}, body),
|
||||
])));
|
||||
content.replaceChildren(...parts);
|
||||
}
|
||||
|
||||
// ---- drill-down detail overlay ----
|
||||
|
||||
function renderDetail() {
|
||||
const overlay = $('detail');
|
||||
if (!state.detail) { overlay.hidden = true; overlay.replaceChildren(); return; }
|
||||
overlay.hidden = false;
|
||||
const d = state.detail;
|
||||
const panel = el('div', { class: 'detail-panel' });
|
||||
panel.append(el('div', { class: 'detail-head' }, [
|
||||
el('div', {}, [el('span', { class: 'detail-res' }, d.resource), ' ', el('span', { class: 'detail-id' }, d.id)]),
|
||||
el('button', { class: 'detail-close', onclick: closeDetail, title: 'Close' }, '✕'),
|
||||
]));
|
||||
const sub = el('div', { class: 'detail-body' });
|
||||
if (d.loading) sub.append(el('div', { class: 'empty' }, 'Loading…'));
|
||||
else if (d.error) sub.append(el('div', { class: 'tab-error' }, d.error));
|
||||
else if (d.record) sub.append(el('div', { class: 'kv' }, kvRows(d.record)));
|
||||
if (d.config) {
|
||||
sub.append(el('div', { class: 'detail-section' }, 'Container config'));
|
||||
sub.append(el('div', { class: 'kv' }, kvRows(d.config)));
|
||||
}
|
||||
panel.append(sub);
|
||||
overlay.replaceChildren(panel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
$('pause').addEventListener('click', () => {
|
||||
state.paused = !state.paused;
|
||||
$('pause').textContent = state.paused ? '▶ resume' : '⏸ pause';
|
||||
$('pause').classList.toggle('paused', state.paused);
|
||||
state.renderedSig = null; render();
|
||||
});
|
||||
$('refresh').addEventListener('click', () => { if (!state.refreshing) refresh(true); });
|
||||
$('hamburger').addEventListener('click', () => { state.sidebarOpen = !state.sidebarOpen; state.renderedSig = null; render(); });
|
||||
$('scrim').addEventListener('click', () => { state.sidebarOpen = false; state.renderedSig = null; render(); });
|
||||
$('detail').addEventListener('click', (e) => { if (e.target === $('detail')) closeDetail(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (state.detail) closeDetail(); else if (state.sidebarOpen) { state.sidebarOpen = false; state.renderedSig = null; render(); } } });
|
||||
|
||||
async function tick() {
|
||||
if (!state.paused) { try { await refresh(); } catch { /* keep snapshots; retry next tick */ } }
|
||||
else renderControls();
|
||||
setTimeout(tick, state.refreshSeconds * 1000);
|
||||
}
|
||||
tick();
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#141a24"/>
|
||||
<stop offset="1" stop-color="#0b0e14"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7.5" fill="url(#bg)"/>
|
||||
<rect x="0.5" y="0.5" width="31" height="31" rx="7" fill="none" stroke="#2b3342" stroke-width="1"/>
|
||||
<text x="16.2" y="20.8" font-family="monospace" font-size="15" font-weight="700" letter-spacing="-0.5" text-anchor="middle" fill="#5b9dff">ncl</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>clidash</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="theme-color" content="#0a0c11">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<button id="hamburger" class="hamburger" title="Menu" aria-label="Toggle menu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
|
||||
</button>
|
||||
<div id="scrim" class="scrim" hidden></div>
|
||||
|
||||
<div class="app">
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="brand"><h1>clidash</h1></div>
|
||||
<div class="controls">
|
||||
<button id="refresh" class="refresh" title="Refresh now">↻ refresh</button>
|
||||
<button id="pause" class="pause" title="Pause auto-refresh">⏸ pause</button>
|
||||
</div>
|
||||
<div id="updated" class="updated"></div>
|
||||
<nav id="nav" class="nav"></nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<div id="cmdline" class="cmdline" hidden></div>
|
||||
<section id="content" class="content"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="detail" class="detail-overlay" hidden></div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
// Minimal, dependency-free, XSS-safe markdown → HTML for clidash's file viewer
|
||||
// (SKILL.md / CLAUDE.md). Pure string functions, no DOM — importable in both the
|
||||
// browser (app.js) and node tests.
|
||||
//
|
||||
// Safety model: the ENTIRE source is HTML-escaped first, so no raw markup from a
|
||||
// file can reach innerHTML. Markdown transforms then emit only tags this module
|
||||
// generates. Link hrefs are taken from the URL capture group and gated to an
|
||||
// http(s) scheme, so a `javascript:`/`data:` URL (or one smuggled via link text)
|
||||
// can never become an executable href.
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
|
||||
export function mdToHtml(src) {
|
||||
const lines = escapeHtml(src).split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
const inline = (t) => t
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) =>
|
||||
/^https?:\/\//i.test(url) ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>` : m);
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^```/.test(line)) {
|
||||
const buf = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
|
||||
i++;
|
||||
out.push(`<pre class="code"><code>${buf.join('\n')}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); i++; continue; }
|
||||
if (/^\s*([-*])\s+/.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) {
|
||||
items.push(`<li>${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}</li>`);
|
||||
i++;
|
||||
}
|
||||
out.push(`<ul>${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
|
||||
i++;
|
||||
}
|
||||
out.push(`<ol>${items.join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
|
||||
if (/^\s*>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^\s*>\s?/, ''))}</blockquote>`); i++; continue; }
|
||||
if (line.trim() === '') { i++; continue; }
|
||||
const para = [line];
|
||||
i++;
|
||||
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>)/.test(lines[i])) {
|
||||
para.push(lines[i++]);
|
||||
}
|
||||
out.push(`<p>${inline(para.join(' '))}</p>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "clidash",
|
||||
"short_name": "ncl",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#0a0c11",
|
||||
"background_color": "#0a0c11",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/* clidash — refined terminal-ops console.
|
||||
IBM Plex superfamily (Sans for UI, Mono for the CLI identity), a deep layered
|
||||
dark palette, real depth, and precise micro-interactions. */
|
||||
|
||||
:root {
|
||||
/* layered surfaces */
|
||||
--bg: #0a0c11;
|
||||
--bg-grad: #10141d;
|
||||
--panel: #13171f;
|
||||
--panel-2: #1a1f2a;
|
||||
--border: #222834;
|
||||
--border-strong: #2e3644;
|
||||
/* text */
|
||||
--text: #e8edf5;
|
||||
--dim: #98a2b3;
|
||||
--faint: #5c6675;
|
||||
/* accent + semantics */
|
||||
--accent: #5b9dff;
|
||||
--accent-soft: rgba(91, 157, 255, 0.14);
|
||||
--green: #4cc97a;
|
||||
--amber: #e0a93a;
|
||||
--red: #f76d6d;
|
||||
--purple: #c08cff;
|
||||
--gray: #6b7585;
|
||||
/* shape + motion */
|
||||
--radius: 11px;
|
||||
--radius-sm: 8px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow: 0 6px 22px -8px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 18px 44px -12px rgba(0, 0, 0, 0.6);
|
||||
--ease: 160ms cubic-bezier(0.2, 0.6, 0.2, 1);
|
||||
--font-sans: "IBM Plex Sans", -apple-system, system-ui, Segoe UI, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html { scrollbar-color: var(--border-strong) transparent; }
|
||||
body {
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% -10%, var(--bg-grad) 0%, transparent 55%),
|
||||
var(--bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection { background: var(--accent-soft); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-mono); font-size: 17px; font-weight: 600;
|
||||
letter-spacing: -0.3px; color: var(--text);
|
||||
}
|
||||
h1::before { content: "▍"; color: var(--accent); margin-right: 4px; }
|
||||
|
||||
/* ---- layout ---- */
|
||||
|
||||
.app { display: flex; align-items: stretch; min-height: 100vh; min-height: 100dvh; }
|
||||
|
||||
.sidebar {
|
||||
width: 236px; flex-shrink: 0; height: 100vh; height: 100dvh;
|
||||
position: sticky; top: 0; align-self: flex-start; overflow-y: auto;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 200px), var(--bg);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 18px 12px 40px; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.sidebar .brand { padding: 2px 8px 6px; }
|
||||
.sidebar .controls { display: flex; gap: 8px; padding: 0 4px; }
|
||||
.sidebar .updated { color: var(--faint); font-size: 11.5px; padding: 0 8px; min-height: 16px; font-family: var(--font-mono); }
|
||||
|
||||
.pause, .refresh {
|
||||
flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--dim); padding: 6px 10px; font-size: 12.5px; font-family: var(--font-mono);
|
||||
cursor: pointer; transition: color var(--ease), border-color var(--ease), background var(--ease);
|
||||
}
|
||||
.pause:hover, .refresh:hover { color: var(--text); border-color: var(--border-strong); background: var(--panel-2); }
|
||||
.pause.paused { color: var(--amber); border-color: var(--amber); }
|
||||
.refresh.spinning { color: var(--accent); border-color: var(--accent); animation: pulse 0.85s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
|
||||
.nav-section {
|
||||
color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px;
|
||||
font-weight: 600; margin: 16px 10px 5px; font-family: var(--font-mono);
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.nav-section svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 9px; text-align: left;
|
||||
background: none; border: none; border-radius: var(--radius-sm);
|
||||
color: var(--dim); padding: 7px 10px; font-size: 13.5px; cursor: pointer; width: 100%;
|
||||
font-family: var(--font-sans); position: relative;
|
||||
transition: color var(--ease), background var(--ease);
|
||||
}
|
||||
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; }
|
||||
.nav-item:hover { background: var(--panel); color: var(--text); }
|
||||
.nav-item.active { background: var(--accent-soft); color: var(--text); }
|
||||
.nav-item.active::before {
|
||||
content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 18px; background: var(--accent); border-radius: 0 3px 3px 0;
|
||||
}
|
||||
.nav-item.nav-sub { padding-left: 16px; font-size: 13px; }
|
||||
.nav-item.nav-sub.active { color: var(--accent); }
|
||||
|
||||
.hamburger {
|
||||
display: none; position: fixed; top: 12px; left: 12px; z-index: 60;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--text); width: 40px; height: 40px; cursor: pointer; align-items: center; justify-content: center;
|
||||
}
|
||||
.hamburger svg { width: 20px; height: 20px; }
|
||||
.scrim { display: none; }
|
||||
|
||||
.main { flex: 1; min-width: 0; padding: 26px 28px 64px; max-width: 1500px; }
|
||||
.page-title {
|
||||
font-size: 20px; font-weight: 600; letter-spacing: -0.4px; margin-bottom: 16px;
|
||||
animation: fadeUp 0.3s var(--ease) both;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: rgba(247, 109, 109, 0.1); border: 1px solid rgba(247, 109, 109, 0.4);
|
||||
border-radius: var(--radius-sm); color: var(--red); padding: 9px 14px; font-size: 13.5px; margin-bottom: 16px;
|
||||
}
|
||||
.cmdline {
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--dim);
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 9px 14px; margin-bottom: 18px; overflow-x: auto; white-space: nowrap; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cmdline::first-letter { color: var(--green); }
|
||||
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(7px); } }
|
||||
|
||||
/* ---- overview cards ---- */
|
||||
|
||||
.ov-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; }
|
||||
.ov-card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 40%), var(--panel);
|
||||
border: 1px solid var(--border); border-radius: var(--radius); padding: 17px 19px;
|
||||
box-shadow: var(--shadow); transition: border-color var(--ease), transform var(--ease);
|
||||
animation: fadeUp 0.4s var(--ease) both;
|
||||
}
|
||||
.ov-card:nth-child(2) { animation-delay: 0.05s; }
|
||||
.ov-card:nth-child(3) { animation-delay: 0.1s; }
|
||||
.ov-card:nth-child(4) { animation-delay: 0.15s; }
|
||||
.ov-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
|
||||
.ov-head { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; }
|
||||
.ov-head .ov-name { font-weight: 600; font-size: 15px; }
|
||||
.ov-head .ov-folder { color: var(--faint); font-size: 12px; font-family: var(--font-mono); }
|
||||
.ov-fields { display: flex; flex-direction: column; gap: 7px; }
|
||||
.ov-field { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
|
||||
.ov-field .k { color: var(--dim); }
|
||||
.ov-field .v { color: var(--text); font-variant-numeric: tabular-nums; } .ov-field .v.dim { color: var(--faint); }
|
||||
.ov-chans { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
||||
|
||||
.dot, .ov-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.green { background: var(--green); box-shadow: 0 0 8px -1px var(--green); }
|
||||
.dot.amber { background: var(--amber); box-shadow: 0 0 8px -1px var(--amber); }
|
||||
.dot.red { background: var(--red); box-shadow: 0 0 8px -1px var(--red); }
|
||||
.dot.gray { background: var(--gray); }
|
||||
|
||||
.badge {
|
||||
font-size: 11.5px; border: 1px solid var(--border-strong); border-radius: 99px;
|
||||
padding: 2px 10px; color: var(--dim); background: var(--panel-2); font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- status badges + enriched cells ---- */
|
||||
.badge-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
|
||||
.badge-status .dot { width: 7px; height: 7px; }
|
||||
.badge-status.green { color: var(--green); } .badge-status.amber { color: var(--amber); }
|
||||
.badge-status.red { color: var(--red); } .badge-status.gray { color: var(--gray); }
|
||||
td.enriched span:first-child { color: var(--text); }
|
||||
td.enriched .raw-id { display: block; color: var(--faint); font-size: 11px; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- summary bar ---- */
|
||||
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 2px 0 16px; font-size: 13px; }
|
||||
.summary-bar .sum-count { color: var(--text); font-weight: 600; }
|
||||
.summary-bar .sum-sep { color: var(--border-strong); }
|
||||
.summary-bar .sum-chip { color: var(--dim); font-family: var(--font-mono); font-size: 12px; }
|
||||
|
||||
/* ---- per-resource help panel ---- */
|
||||
.help-panel { margin-bottom: 18px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; box-shadow: var(--shadow-sm); }
|
||||
.help-head { color: var(--dim); font-size: 13px; line-height: 1.6; }
|
||||
.help-head.dim { color: var(--faint); }
|
||||
.help-more { margin-top: 9px; }
|
||||
.help-more > summary { cursor: pointer; color: var(--accent); font-size: 12px; list-style: none; user-select: none; font-family: var(--font-mono); }
|
||||
.help-more > summary::-webkit-details-marker { display: none; }
|
||||
.help-more > summary::before { content: "▸ "; }
|
||||
.help-more[open] > summary::before { content: "▾ "; }
|
||||
.help-text {
|
||||
margin: 9px 0 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 13px 15px; font-size: 12px; line-height: 1.6; color: var(--dim);
|
||||
white-space: pre-wrap; overflow-x: auto; font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- document viewer ---- */
|
||||
.doc-viewer { display: grid; grid-template-columns: 264px 1fr; gap: 20px; align-items: start; }
|
||||
.doc-list { display: flex; flex-direction: column; gap: 2px; max-height: 76vh; overflow-y: auto; padding-right: 4px; }
|
||||
.doc-group-toggle {
|
||||
flex-shrink: 0; display: flex; align-items: center; gap: 7px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
|
||||
font-weight: 600; font-family: var(--font-mono); margin: 12px 0 4px; padding: 5px 8px;
|
||||
border-radius: var(--radius-sm); transition: color var(--ease), background var(--ease);
|
||||
}
|
||||
.doc-group-toggle:first-child { margin-top: 0; }
|
||||
.doc-group-toggle:hover { color: var(--text); background: var(--panel); }
|
||||
.doc-group-toggle.open { color: var(--text); }
|
||||
.doc-group-toggle .chev { color: var(--accent); width: 10px; }
|
||||
.doc-group-toggle .g-name { flex: 1; }
|
||||
.doc-group-toggle .g-count {
|
||||
color: var(--faint); background: var(--panel-2); border-radius: 99px;
|
||||
padding: 1px 8px; font-size: 10.5px; letter-spacing: 0;
|
||||
}
|
||||
.doc-item {
|
||||
flex-shrink: 0; text-align: left; background: none; border: none; border-radius: var(--radius-sm);
|
||||
color: var(--dim); padding: 6px 11px; font-size: 13px; line-height: 1.5; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ease), background var(--ease);
|
||||
}
|
||||
.doc-item:hover { background: var(--panel); color: var(--text); }
|
||||
.doc-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.doc-content {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px 26px; min-height: 220px; max-height: 78vh; overflow-y: auto; box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.markdown { font-size: 14px; line-height: 1.7; color: var(--text); }
|
||||
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 20px 0 8px; line-height: 1.3; font-family: var(--font-mono); }
|
||||
.markdown h1 { font-size: 21px; } .markdown h2 { font-size: 17px; color: var(--accent); } .markdown h3 { font-size: 15px; }
|
||||
.markdown h1:first-child, .markdown h2:first-child { margin-top: 0; }
|
||||
.markdown p { color: var(--text); margin: 8px 0; }
|
||||
.markdown ul, .markdown ol { margin: 8px 0 8px 22px; }
|
||||
.markdown li { margin: 3px 0; }
|
||||
.markdown a { color: var(--accent); }
|
||||
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
|
||||
.markdown blockquote { border-left: 3px solid var(--border-strong); padding-left: 12px; color: var(--dim); margin: 8px 0; }
|
||||
.markdown code { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-size: 12.5px; font-family: var(--font-mono); }
|
||||
.markdown pre.code, .doc-content pre.code {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 14px; overflow-x: auto; font-size: 12.5px; line-height: 1.6; margin: 12px 0; font-family: var(--font-mono);
|
||||
}
|
||||
.markdown pre.code code { background: none; border: none; padding: 0; }
|
||||
.doc-content pre.json { color: var(--text); white-space: pre; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- activity ---- */
|
||||
.activity-wrap { max-width: 780px; }
|
||||
.activity-legend { display: flex; gap: 18px; align-items: center; margin-bottom: 12px; font-size: 13px; color: var(--dim); }
|
||||
.activity-legend .lg { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
|
||||
.activity-legend .lg.in { background: var(--accent); } .activity-legend .lg.out { background: var(--purple); }
|
||||
.chart-box { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; box-shadow: var(--shadow); }
|
||||
.activity-chart { width: 100%; height: 220px; display: block; }
|
||||
.activity-chart .bar-in { fill: var(--accent); } .activity-chart .bar-out { fill: var(--purple); }
|
||||
.activity-chart .grid { stroke: var(--border); stroke-width: 1; }
|
||||
.activity-chart .axis { fill: var(--faint); font-size: 9px; font-family: var(--font-mono); }
|
||||
.activity-table td.num, .activity-table th:nth-child(3), .activity-table th:nth-child(4) { text-align: right; }
|
||||
.activity-table td.num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ---- log viewer ---- */
|
||||
.log-box {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
max-height: 78vh; overflow: auto; padding: 12px 0; box-shadow: var(--shadow);
|
||||
}
|
||||
.log-view { font-family: var(--font-mono); font-size: 12px; line-height: 1.65; min-width: max-content; }
|
||||
.log-line { padding: 0 16px; white-space: pre; color: var(--dim); }
|
||||
.log-line:hover { background: var(--panel); }
|
||||
.log-line.err { color: var(--red); background: rgba(247, 109, 109, 0.06); }
|
||||
.log-line.warn { color: var(--amber); }
|
||||
|
||||
/* ---- tables ---- */
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
th { color: var(--dim); font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.6px; position: sticky; top: 0; background: var(--panel-2); font-family: var(--font-mono); }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
td.null { color: var(--faint); font-style: italic; }
|
||||
tr.drillable { cursor: pointer; transition: background var(--ease); }
|
||||
tr.drillable:hover td { background: var(--panel); }
|
||||
.reltime .abs { color: var(--faint); font-size: 11.5px; margin-left: 6px; font-family: var(--font-mono); }
|
||||
td .trunc { cursor: pointer; border-bottom: 1px dotted var(--faint); }
|
||||
.stale-note { color: var(--amber); font-size: 12.5px; margin-bottom: 8px; }
|
||||
.empty, .tab-error { color: var(--dim); padding: 26px 2px; font-size: 14px; }
|
||||
.tab-error { color: var(--red); }
|
||||
.tab-error pre { margin-top: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; color: var(--dim); font-size: 12px; overflow-x: auto; white-space: pre-wrap; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- drill-down detail overlay ---- */
|
||||
.detail-overlay { position: fixed; inset: 0; background: rgba(2, 4, 8, 0.62); display: flex; justify-content: flex-end; z-index: 50; backdrop-filter: blur(2px); animation: fade 0.18s ease; }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
.detail-panel { width: min(580px, 100%); height: 100%; background: var(--bg); border-left: 1px solid var(--border-strong); overflow-y: auto; box-shadow: var(--shadow-lg); animation: slideIn 0.22s var(--ease); }
|
||||
@keyframes slideIn { from { transform: translateX(24px); opacity: 0.6; } }
|
||||
.detail-head { display: flex; justify-content: space-between; align-items: center; padding: 17px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); }
|
||||
.detail-res { font-weight: 600; font-size: 15px; }
|
||||
.detail-id { color: var(--dim); font-family: var(--font-mono); font-size: 13px; }
|
||||
.detail-close { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--dim); width: 30px; height: 30px; cursor: pointer; font-size: 14px; transition: color var(--ease), border-color var(--ease); }
|
||||
.detail-close:hover { color: var(--text); border-color: var(--accent); }
|
||||
.detail-body { padding: 17px 22px 44px; }
|
||||
.detail-section { margin: 24px 0 8px; color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; border-top: 1px solid var(--border); padding-top: 17px; font-family: var(--font-mono); }
|
||||
.kv { display: flex; flex-direction: column; gap: 2px; }
|
||||
.kv-row { display: grid; grid-template-columns: 168px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.kv-key { color: var(--dim); font-family: var(--font-mono); font-size: 12.5px; }
|
||||
.kv-json { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; font-size: 12px; overflow-x: auto; margin: 0; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- mobile ---- */
|
||||
@media (max-width: 640px) {
|
||||
.hamburger { display: flex; }
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; z-index: 70; transform: translateX(-100%);
|
||||
transition: transform 0.22s var(--ease); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.scrim { display: block; position: fixed; inset: 0; background: rgba(2, 4, 8, 0.55); z-index: 65; backdrop-filter: blur(1px); }
|
||||
.scrim[hidden] { display: none; }
|
||||
.main { padding: 58px 16px 48px; }
|
||||
.ov-cards { grid-template-columns: 1fr; }
|
||||
.doc-viewer { grid-template-columns: 1fr; gap: 12px; }
|
||||
.doc-list { max-height: 220px; flex-direction: row; flex-wrap: wrap; }
|
||||
.doc-content { max-height: none; padding: 18px; }
|
||||
.detail-panel { width: 100%; }
|
||||
.kv-row { grid-template-columns: 1fr; gap: 2px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// clidash — CLI-agnostic read-only web dashboard.
|
||||
// Node built-ins only. All per-CLI knowledge lives in clidash.config.json;
|
||||
// the only per-CLI code is optional view plugins (views/) and discovery
|
||||
// parsers (parsers.js).
|
||||
//
|
||||
// Security model: the server can only exec the configured argv templates.
|
||||
// `{resource}` is the sole substitution and is validated against the
|
||||
// discovered/static resource set before exec. execFile, never a shell.
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve, sep, basename } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { discoveryParsers, parseOutput, unwrapPath } from './parsers.js';
|
||||
import { globFiles, describeFile, resolveDoc } from './docs.js';
|
||||
import { collectActivity } from './activity.js';
|
||||
import { tailFile } from './logs.js';
|
||||
|
||||
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const MAX_DOC_BYTES = 2 * 1024 * 1024; // cap a single served document at 2 MB
|
||||
|
||||
const DEFAULTS = {
|
||||
bind: '127.0.0.1',
|
||||
port: 4690,
|
||||
refreshSeconds: 60,
|
||||
execTimeoutMs: 10_000,
|
||||
discoveryTtlMs: 60_000,
|
||||
};
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
};
|
||||
|
||||
export function createApp(userConfig) {
|
||||
const config = { ...DEFAULTS, ...userConfig };
|
||||
const publicDir = resolve(config.publicDir ?? join(MODULE_DIR, 'public'));
|
||||
const viewsDir = resolve(config.viewsDir ?? join(MODULE_DIR, 'views'));
|
||||
|
||||
// Human-readable form of a command, for display in the UI ("the command run").
|
||||
const displayCmd = (bin, args) => `${basename(bin)} ${args.join(' ')}`;
|
||||
|
||||
// ---- exec --------------------------------------------------------------
|
||||
|
||||
function execCli(cliCfg, args, label) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
execFile(cliCfg.bin, args, {
|
||||
cwd: cliCfg.cwd,
|
||||
timeout: config.execTimeoutMs,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: { ...process.env, ...cliCfg.env },
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const timedOut = error.killed || error.signal === 'SIGTERM';
|
||||
const detail = stderr.trim() || error.message;
|
||||
const msg = timedOut
|
||||
? `${label} timed out after ${config.execTimeoutMs}ms`
|
||||
: `${label} failed: ${detail}`;
|
||||
rejectPromise(new Error(msg));
|
||||
return;
|
||||
}
|
||||
resolvePromise(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- resource discovery (cached, coalesced, keeps last good) -----------
|
||||
|
||||
const discoveryCache = new Map(); // cli -> { at, resources }
|
||||
const discoveryInflight = new Map(); // cli -> Promise
|
||||
|
||||
async function discoverResources(cliName) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
if (cliCfg.resources) {
|
||||
return cliCfg.resources.map((name) =>
|
||||
typeof name === 'string' ? { name, description: '' } : name,
|
||||
);
|
||||
}
|
||||
const cached = discoveryCache.get(cliName);
|
||||
if (cached && Date.now() - cached.at < config.discoveryTtlMs) return cached.resources;
|
||||
if (discoveryInflight.has(cliName)) return discoveryInflight.get(cliName);
|
||||
|
||||
const parser = discoveryParsers[cliCfg.discover.parser];
|
||||
if (!parser) throw new Error(`Unknown discovery parser: ${cliCfg.discover.parser}`);
|
||||
const promise = execCli(cliCfg, cliCfg.discover.args, `${cliName} discovery`)
|
||||
.then((stdout) => {
|
||||
const resources = parser(stdout);
|
||||
discoveryCache.set(cliName, { at: Date.now(), resources });
|
||||
return resources;
|
||||
})
|
||||
.finally(() => discoveryInflight.delete(cliName));
|
||||
discoveryInflight.set(cliName, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- row fetching (coalesced per cli+resource) --------------------------
|
||||
|
||||
const listInflight = new Map(); // "cli\0resource" -> Promise
|
||||
|
||||
async function fetchRows(cliName, resourceName) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
const resources = await discoverResources(cliName);
|
||||
if (!resources.some((r) => r.name === resourceName)) {
|
||||
const err = new Error(`Unknown resource "${resourceName}" for CLI "${cliName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const key = `${cliName}\0${resourceName}`;
|
||||
if (listInflight.has(key)) return listInflight.get(key);
|
||||
|
||||
// {resource} may appear as a whole arg or inside one (e.g. an ssh remote
|
||||
// command). Safe either way — the value is allowlist-validated above.
|
||||
const args = cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName));
|
||||
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} list`)
|
||||
.then((stdout) => {
|
||||
const parsed = parseOutput(stdout, cliCfg.output ?? 'json');
|
||||
const rows = unwrapPath(parsed, cliCfg.unwrap);
|
||||
if (!Array.isArray(rows)) {
|
||||
const err = new Error(`${cliName} ${resourceName}: expected an array of rows`);
|
||||
err.raw = stdout;
|
||||
throw err;
|
||||
}
|
||||
return rows;
|
||||
})
|
||||
.finally(() => listInflight.delete(key));
|
||||
listInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- detail commands (drill-down: get, config-get, …) -------------------
|
||||
|
||||
const cmdInflight = new Map();
|
||||
const ID_RE = /^[A-Za-z0-9:_.-]+$/; // ncl ids / uuids; no shell metas (and execFile never shells)
|
||||
|
||||
async function runCommand(cliName, cmdName, resourceName, id) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
const template = cliCfg.commands?.[cmdName];
|
||||
if (!template) {
|
||||
const err = new Error(`Unknown command "${cmdName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const needsResource = template.includes('{resource}');
|
||||
if (needsResource) {
|
||||
const resources = await discoverResources(cliName);
|
||||
if (!resources.some((r) => r.name === resourceName)) {
|
||||
const err = new Error(`Unknown resource "${resourceName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (template.includes('{id}') && !ID_RE.test(id ?? '')) {
|
||||
const err = new Error('Invalid id');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const key = `${cliName}\0${cmdName}\0${resourceName}\0${id}`;
|
||||
if (cmdInflight.has(key)) return cmdInflight.get(key);
|
||||
const args = template.map((a) => a.replaceAll('{resource}', resourceName ?? '').replaceAll('{id}', id ?? ''));
|
||||
const promise = execCli(cliCfg, args, `${cliName} ${cmdName}`)
|
||||
.then((stdout) => unwrapPath(parseOutput(stdout, cliCfg.output ?? 'json'), cliCfg.unwrap))
|
||||
.finally(() => cmdInflight.delete(key));
|
||||
cmdInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- per-resource help (raw text from `<cli> <resource> help`) -----------
|
||||
|
||||
const helpInflight = new Map();
|
||||
async function runHelp(cliName, resourceName) {
|
||||
const cliCfg = config.clis[cliName];
|
||||
if (!cliCfg.help) { const e = new Error(`No help for "${cliName}"`); e.statusCode = 404; throw e; }
|
||||
const resources = await discoverResources(cliName);
|
||||
if (!resources.some((r) => r.name === resourceName)) {
|
||||
const e = new Error(`Unknown resource "${resourceName}"`); e.statusCode = 404; throw e;
|
||||
}
|
||||
const key = `${cliName}\0${resourceName}`;
|
||||
if (helpInflight.has(key)) return helpInflight.get(key);
|
||||
const args = cliCfg.help.map((a) => a.replaceAll('{resource}', resourceName));
|
||||
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} help`).finally(() => helpInflight.delete(key));
|
||||
helpInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- view plugins --------------------------------------------------------
|
||||
|
||||
async function listViews(cliName) {
|
||||
try {
|
||||
const files = await readdir(viewsDir);
|
||||
return files
|
||||
.filter((f) => f.startsWith(`${cliName}-`) && f.endsWith('.js'))
|
||||
.map((f) => f.slice(cliName.length + 1, -3));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function runView(cliName, viewName) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(viewName)) {
|
||||
const err = new Error(`Invalid view name`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const file = join(viewsDir, `${cliName}-${viewName}.js`);
|
||||
let mod;
|
||||
try {
|
||||
mod = await import(pathToFileURL(file).href);
|
||||
} catch (e) {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND') {
|
||||
const err = new Error(`No view "${viewName}" for CLI "${cliName}"`);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return mod.default({ fetch: (resource) => fetchRows(cliName, resource) });
|
||||
}
|
||||
|
||||
// ---- http ----------------------------------------------------------------
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function sendError(res, err) {
|
||||
const status = err.statusCode ?? 502;
|
||||
const body = { ok: false, error: err.message };
|
||||
if (err.raw !== undefined) body.raw = String(err.raw).slice(0, 64 * 1024);
|
||||
sendJson(res, status, body);
|
||||
}
|
||||
|
||||
async function serveStatic(res, urlPath) {
|
||||
const relative = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
|
||||
const file = resolve(publicDir, relative);
|
||||
if (file !== publicDir && !file.startsWith(publicDir + sep)) {
|
||||
sendJson(res, 403, { ok: false, error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await readFile(file);
|
||||
const ext = file.slice(file.lastIndexOf('.'));
|
||||
// always revalidate so a redeploy is picked up immediately (no stale JS/CSS)
|
||||
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
||||
res.end(content);
|
||||
} catch {
|
||||
sendJson(res, 404, { ok: false, error: 'Not found' });
|
||||
}
|
||||
}
|
||||
|
||||
return createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'GET') {
|
||||
sendJson(res, 405, { ok: false, error: 'Read-only dashboard: GET only' });
|
||||
return;
|
||||
}
|
||||
const urlPath = req.url.split('?')[0];
|
||||
const segments = urlPath.split('/').map((s) => decodeURIComponent(s));
|
||||
|
||||
if (urlPath === '/api/clis') {
|
||||
const clis = await Promise.all(Object.keys(config.clis).map(async (name) => {
|
||||
const entry = {
|
||||
name,
|
||||
refreshSeconds: config.refreshSeconds,
|
||||
views: await listViews(name),
|
||||
commands: Object.keys(config.clis[name].commands ?? {}),
|
||||
enrich: config.clis[name].enrich ?? null,
|
||||
badges: config.clis[name].badges ?? null,
|
||||
summary: config.clis[name].summary ?? null,
|
||||
help: !!config.clis[name].help,
|
||||
};
|
||||
try {
|
||||
entry.resources = await discoverResources(name);
|
||||
} catch (e) {
|
||||
// keep last good discovery (≤TTL old) if we have one; always surface the error
|
||||
entry.resources = discoveryCache.get(name)?.resources ?? [];
|
||||
entry.error = e.message;
|
||||
}
|
||||
return entry;
|
||||
}));
|
||||
sendJson(res, 200, { clis });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'r' && segments.length === 5) {
|
||||
const [, , , cliName, resourceName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const rows = await fetchRows(cliName, resourceName);
|
||||
const cliCfg = config.clis[cliName];
|
||||
const command = displayCmd(cliCfg.bin, cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName)));
|
||||
sendJson(res, 200, { ok: true, rows, command, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'cmd' && segments.length === 5) {
|
||||
const [, , , cliName, cmdName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const q = new URL(req.url, 'http://localhost').searchParams;
|
||||
const data = await runCommand(cliName, cmdName, q.get('resource'), q.get('id'));
|
||||
const tmpl = config.clis[cliName].commands?.[cmdName] ?? [];
|
||||
const command = displayCmd(config.clis[cliName].bin,
|
||||
tmpl.map((a) => a.replaceAll('{resource}', q.get('resource') ?? '').replaceAll('{id}', q.get('id') ?? '')));
|
||||
sendJson(res, 200, { ok: true, data, command, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'help' && segments.length === 5) {
|
||||
const [, , , cliName, resourceName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const text = await runHelp(cliName, resourceName);
|
||||
sendJson(res, 200, { ok: true, text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[1] === 'api' && segments[2] === 'view' && segments.length === 5) {
|
||||
const [, , , cliName, viewName] = segments;
|
||||
if (!config.clis[cliName]) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
|
||||
return;
|
||||
}
|
||||
const result = await runView(cliName, viewName);
|
||||
sendJson(res, 200, { ok: true, result, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Log tails (allowlisted files under logs.dir).
|
||||
if (urlPath === '/api/logs') {
|
||||
sendJson(res, 200, { files: (config.logs?.files ?? []).map((f) => ({ name: f.name, label: f.label ?? f.name })) });
|
||||
return;
|
||||
}
|
||||
if (segments[1] === 'api' && segments[2] === 'log' && segments.length === 4) {
|
||||
const name = segments[3];
|
||||
const file = config.logs?.files?.find((f) => f.name === name);
|
||||
if (!file) { sendJson(res, 404, { ok: false, error: `Unknown log "${name}"` }); return; }
|
||||
const lines = config.logs.tailLines ?? 400;
|
||||
const { text } = await tailFile(join(config.logs.dir, name), lines);
|
||||
sendJson(res, 200, { ok: true, text, command: `tail -n ${lines} ${join(config.logs.dir, name)}`, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Message activity (read per-session DBs; ncl has no messages resource).
|
||||
if (urlPath === '/api/activity') {
|
||||
if (!config.activity) { sendJson(res, 200, { ok: true, configured: false }); return; }
|
||||
const days = config.activity.days ?? 14;
|
||||
const { sessions, series } = collectActivity(config.activity.sessionsRoot, days, new Date());
|
||||
const command = `node:sqlite · ${config.activity.sessionsRoot}/*/*/{inbound,outbound}.db (last ${days}d)`;
|
||||
sendJson(res, 200, { ok: true, configured: true, sessions, series, command, fetchedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-only file viewer (skills, CLAUDE.md, profiles, conversations).
|
||||
if (urlPath === '/api/docs') {
|
||||
const docs = config.docs;
|
||||
const collections = (docs?.collections ?? []).map((coll) => ({
|
||||
name: coll.name,
|
||||
label: coll.label ?? coll.name,
|
||||
lang: coll.lang ?? 'text',
|
||||
files: globFiles(docs.root, coll.patterns, docs.deny ?? []).map((path) => ({
|
||||
path,
|
||||
...describeFile(path),
|
||||
})),
|
||||
}));
|
||||
sendJson(res, 200, { collections });
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath === '/api/doc') {
|
||||
const docs = config.docs;
|
||||
const query = new URL(req.url, 'http://localhost').searchParams;
|
||||
const collName = query.get('c');
|
||||
const relPath = query.get('p') ?? '';
|
||||
const collection = docs?.collections?.find((c) => c.name === collName);
|
||||
if (!collection) {
|
||||
sendJson(res, 404, { ok: false, error: `Unknown collection "${collName}"` });
|
||||
return;
|
||||
}
|
||||
let abs;
|
||||
try {
|
||||
abs = resolveDoc(docs.root, collection, relPath, docs.deny ?? []);
|
||||
} catch {
|
||||
sendJson(res, 404, { ok: false, error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
const content = await readFile(abs, 'utf8');
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
path: relPath,
|
||||
lang: collection.lang ?? 'text',
|
||||
content: content.length > MAX_DOC_BYTES ? content.slice(0, MAX_DOC_BYTES) : content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath.startsWith('/api/')) {
|
||||
sendJson(res, 404, { ok: false, error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(res, urlPath);
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- standalone entry point ------------------------------------------------
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
||||
if (isMain) {
|
||||
const configPath = process.env.CLIDASH_CONFIG ?? join(MODULE_DIR, 'clidash.config.json');
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
||||
if (process.env.PORT) config.port = Number(process.env.PORT);
|
||||
if (process.env.BIND) config.bind = process.env.BIND;
|
||||
const finalConfig = { ...DEFAULTS, ...config };
|
||||
const server = createApp(finalConfig);
|
||||
server.listen(finalConfig.port, finalConfig.bind, () => {
|
||||
console.log(`clidash listening on http://${finalConfig.bind}:${finalConfig.port}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
let root;
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
|
||||
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
|
||||
const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]);
|
||||
});
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp({ port: 0, bind: '127.0.0.1', clis: {}, ...config });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/activity: returns per-session totals + a daily series', async () => {
|
||||
await withServer({ activity: { sessionsRoot: root, days: 14 } }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/activity`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.configured, true);
|
||||
assert.equal(body.series.length, 14);
|
||||
assert.equal(body.sessions[0].in, 2);
|
||||
assert.equal(body.sessions[0].out, 1);
|
||||
assert.equal(body.series.at(-1).in, 2); // today
|
||||
assert.equal(body.series.at(-1).out, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/activity: not configured → configured:false, no crash', async () => {
|
||||
await withServer({}, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/activity`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.configured, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { collectActivity } from '../activity.js';
|
||||
|
||||
let root;
|
||||
const NOW = new Date('2026-06-14T12:00:00Z');
|
||||
|
||||
function makeDb(path, table, timestamps) {
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec(`CREATE TABLE ${table} (id TEXT, timestamp TEXT)`);
|
||||
const ins = db.prepare(`INSERT INTO ${table} (id, timestamp) VALUES (?, ?)`);
|
||||
timestamps.forEach((t, i) => ins.run(String(i), t));
|
||||
db.close();
|
||||
}
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-act-'));
|
||||
// session 1 (group ag-1): 3 inbound across 2 days, 2 outbound today
|
||||
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
|
||||
makeDb(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in',
|
||||
['2026-06-14 09:01:23', '2026-06-14 10:00:00', '2026-06-13 08:00:00']);
|
||||
makeDb(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out',
|
||||
['2026-06-14 09:05:00', '2026-06-14 10:05:00']);
|
||||
// session 2 (group ag-2): 1 inbound 20 days ago (outside 14d window), 0 outbound
|
||||
mkdirSync(join(root, 'ag-2', 'sess-2'), { recursive: true });
|
||||
makeDb(join(root, 'ag-2', 'sess-2', 'inbound.db'), 'messages_in', ['2026-05-25 08:00:00']);
|
||||
makeDb(join(root, 'ag-2', 'sess-2', 'outbound.db'), 'messages_out', []);
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
test('collectActivity: per-session in/out totals + last activity', () => {
|
||||
const { sessions } = collectActivity(root, 14, NOW);
|
||||
const s1 = sessions.find((s) => s.session_id === 'sess-1');
|
||||
assert.equal(s1.agent_group_id, 'ag-1');
|
||||
assert.equal(s1.in, 3);
|
||||
assert.equal(s1.out, 2);
|
||||
assert.equal(s1.lastActivity, '2026-06-14T10:05:00Z'); // normalized to ISO
|
||||
const s2 = sessions.find((s) => s.session_id === 'sess-2');
|
||||
assert.equal(s2.in, 1);
|
||||
assert.equal(s2.out, 0);
|
||||
});
|
||||
|
||||
test('collectActivity: series has one bucket per day for `days`, newest last', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
assert.equal(series.length, 14);
|
||||
assert.equal(series[0].date, '2026-06-01');
|
||||
assert.equal(series[13].date, '2026-06-14');
|
||||
});
|
||||
|
||||
test('collectActivity: counts land in the right day buckets', () => {
|
||||
const { series } = collectActivity(root, 14, NOW);
|
||||
const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
|
||||
assert.equal(byDate['2026-06-14'].in, 2);
|
||||
assert.equal(byDate['2026-06-14'].out, 2);
|
||||
assert.equal(byDate['2026-06-13'].in, 1);
|
||||
assert.equal(byDate['2026-06-13'].out, 0);
|
||||
});
|
||||
|
||||
test('collectActivity: messages outside the window are counted in totals but not the series', () => {
|
||||
const { series, sessions } = collectActivity(root, 14, NOW);
|
||||
const total = series.reduce((a, d) => a + d.in + d.out, 0);
|
||||
assert.equal(total, 5); // the 20-day-old message is excluded from series
|
||||
assert.equal(sessions.find((s) => s.session_id === 'sess-2').in, 1); // but still in the total count
|
||||
});
|
||||
|
||||
test('collectActivity: a dir with no message DBs is not a session (skipped)', () => {
|
||||
mkdirSync(join(root, 'ag-1', '.claude-shared'), { recursive: true }); // scaffolding, no db files
|
||||
const { sessions } = collectActivity(root, 14, NOW);
|
||||
assert.ok(!sessions.some((s) => s.session_id === '.claude-shared'));
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clidash-cmd-'));
|
||||
after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
function cli(extra = {}) {
|
||||
return {
|
||||
bin: process.execPath,
|
||||
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
|
||||
list: [STUB, '{resource}', 'list', '--json'],
|
||||
output: 'json',
|
||||
unwrap: 'data',
|
||||
commands: {
|
||||
get: [STUB, '{resource}', 'get', '{id}', '--json'],
|
||||
'config-get': [STUB, 'groups', 'config', 'get', '--id', '{id}', '--json'],
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(clis, fn, extra = {}) {
|
||||
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis, ...extra });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/cmd: runs an allowlisted command with {resource} + {id}', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=sess-123`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.data.id, 'sessions-detail');
|
||||
assert.match(body.data.args, /sessions get sess-123/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: config-get needs no resource', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/cmd/ncl/config-get?id=ag-1`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.match(body.data.args, /groups config get --id ag-1/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: unknown command name → 404 (allowlist)', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const res = await fetch(`${base}/api/cmd/ncl/delete?resource=groups&id=ag-1`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: a {resource} not in the discovered set is rejected without exec', async () => {
|
||||
const countFile = join(tmp, 'cmd-count.txt');
|
||||
const c = cli();
|
||||
c.env = { STUB_COUNT_FILE: countFile };
|
||||
await withServer({ ncl: c }, async (base) => {
|
||||
const res = await fetch(`${base}/api/cmd/ncl/get?resource=evil&id=x`);
|
||||
assert.equal(res.status, 404);
|
||||
// only discovery ran, never a get for the bogus resource
|
||||
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
|
||||
assert.deepEqual(calls, ['help']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: an id with illegal characters is rejected', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const res = await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=${encodeURIComponent('a b;rm -rf')}`);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: unknown cli → 404', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/cmd/nope/get?resource=sessions&id=x`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/cmd: a cli without a commands map → 404', async () => {
|
||||
const c = cli();
|
||||
delete c.commands;
|
||||
await withServer({ ncl: c }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=x`)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../public/style.css', import.meta.url)), 'utf8');
|
||||
|
||||
// Regression: the `hidden` attribute must override author `display` rules.
|
||||
// `.detail-overlay` and `.cli-switcher` set `display:flex`, which beats the
|
||||
// browser's default `[hidden]{display:none}` — without this reset a hidden
|
||||
// overlay stays on top of the page and silently eats every click.
|
||||
test('style.css forces [hidden] to display:none with !important', () => {
|
||||
assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important;?\s*\}/);
|
||||
});
|
||||
|
||||
// Guard the premise: if these stop using display:flex the reset is less load-
|
||||
// bearing, but this documents WHY the reset exists.
|
||||
test('the overlays that motivated the reset still use display:flex', () => {
|
||||
assert.match(css, /\.detail-overlay\s*\{[^}]*display:\s*flex/);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
let root;
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-docsrv-'));
|
||||
const w = (rel, body) => {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
};
|
||||
w('groups/alpha/skills/tagger/SKILL.md', '# tagger\nhello');
|
||||
w('container/skills/welcome/SKILL.md', '# welcome');
|
||||
w('groups/alpha/profile.json', '{"name":"Alpha"}');
|
||||
w('groups/alpha/.env', 'SECRET=nope');
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
function docsConfig() {
|
||||
return {
|
||||
port: 0,
|
||||
bind: '127.0.0.1',
|
||||
clis: {},
|
||||
docs: {
|
||||
root,
|
||||
deny: ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'],
|
||||
collections: [
|
||||
{ name: 'skills', label: 'Skills', lang: 'markdown', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] },
|
||||
{ name: 'profiles', label: 'Profiles', lang: 'json', patterns: ['groups/*/profile.json'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp(config);
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
}
|
||||
|
||||
test('/api/docs: lists collections with their files', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/docs`)).json();
|
||||
const skills = body.collections.find((c) => c.name === 'skills');
|
||||
assert.equal(skills.label, 'Skills');
|
||||
assert.equal(skills.lang, 'markdown');
|
||||
const paths = skills.files.map((f) => f.path);
|
||||
assert.ok(paths.includes('groups/alpha/skills/tagger/SKILL.md'));
|
||||
assert.ok(paths.includes('container/skills/welcome/SKILL.md'));
|
||||
// each file carries a readable label + group
|
||||
const f = skills.files.find((x) => x.path.includes('tagger'));
|
||||
assert.equal(f.group, 'alpha');
|
||||
assert.match(f.label, /tagger/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: returns file content + lang', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const url = `${base}/api/doc?c=skills&p=${encodeURIComponent('groups/alpha/skills/tagger/SKILL.md')}`;
|
||||
const body = await (await fetch(url)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.lang, 'markdown');
|
||||
assert.match(body.content, /# tagger/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: a denied file is not readable even though it sits under root', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
// .env is excluded by the deny-list and not in any collection pattern
|
||||
const coll = docsConfig();
|
||||
coll.docs.collections.push({ name: 'all', label: 'All', lang: 'text', patterns: ['groups/*/*'] });
|
||||
await withServer(coll, async (base2) => {
|
||||
const res = await fetch(`${base2}/api/doc?c=all&p=${encodeURIComponent('groups/alpha/.env')}`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).ok, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: path traversal is rejected', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const res = await fetch(`${base}/api/doc?c=skills&p=${encodeURIComponent('../../../../etc/passwd')}`);
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal((await res.json()).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/doc: unknown collection → 404', async () => {
|
||||
await withServer(docsConfig(), async (base) => {
|
||||
const res = await fetch(`${base}/api/doc?c=nope&p=x`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/docs: absent docs config → empty collections, no crash', async () => {
|
||||
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/docs`)).json();
|
||||
assert.deepEqual(body.collections, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { globFiles, describeFile, resolveDoc } from '../docs.js';
|
||||
|
||||
let root;
|
||||
|
||||
before(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'clidash-docs-'));
|
||||
const w = (rel, body = 'x') => {
|
||||
const abs = join(root, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
};
|
||||
w('groups/alpha/skills/example-skill/SKILL.md', '# example-skill\nbody');
|
||||
w('groups/alpha/skills/tagger/SKILL.md');
|
||||
w('groups/alpha/CLAUDE.md', '# Alpha');
|
||||
w('groups/alpha/CLAUDE.local.md');
|
||||
w('groups/alpha/profile.json', '{"name":"Alpha"}');
|
||||
w('groups/alpha/conversations/2026-06-01.md');
|
||||
w('groups/bravo/skills/tagger/SKILL.md');
|
||||
w('groups/bravo/profile.json');
|
||||
w('container/skills/agent-browser/SKILL.md');
|
||||
w('container/skills/welcome/SKILL.md');
|
||||
// things that must NEVER be served
|
||||
w('groups/alpha/.env', 'SECRET=1');
|
||||
w('groups/alpha/skills/example-skill/node_modules/dep/SKILL.md');
|
||||
w('groups/alpha/notion-token.txt', 'ntn_xxx');
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const DENY = ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'];
|
||||
|
||||
// --------------------------------------------------------------- globFiles
|
||||
|
||||
test('globFiles: matches a nested *-segment pattern', () => {
|
||||
const files = globFiles(root, ['groups/*/skills/*/SKILL.md'], DENY);
|
||||
assert.deepEqual(files, [
|
||||
'groups/alpha/skills/example-skill/SKILL.md',
|
||||
'groups/alpha/skills/tagger/SKILL.md',
|
||||
'groups/bravo/skills/tagger/SKILL.md',
|
||||
]);
|
||||
});
|
||||
|
||||
test('globFiles: multiple patterns union, sorted', () => {
|
||||
const files = globFiles(root, ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'], DENY);
|
||||
assert.ok(files.includes('container/skills/agent-browser/SKILL.md'));
|
||||
assert.ok(files.includes('groups/alpha/skills/example-skill/SKILL.md'));
|
||||
});
|
||||
|
||||
test('globFiles: wildcard inside a filename segment', () => {
|
||||
const files = globFiles(root, ['groups/*/CLAUDE*.md'], DENY);
|
||||
assert.deepEqual(files, ['groups/alpha/CLAUDE.local.md', 'groups/alpha/CLAUDE.md']);
|
||||
});
|
||||
|
||||
test('globFiles: deny list excludes node_modules and secret-ish files', () => {
|
||||
const files = globFiles(root, ['groups/*/skills/*/**', 'groups/*/*'], DENY);
|
||||
assert.ok(!files.some((f) => f.includes('node_modules')));
|
||||
assert.ok(!files.some((f) => f.endsWith('.env')));
|
||||
assert.ok(!files.some((f) => f.includes('token')));
|
||||
});
|
||||
|
||||
test('globFiles: no match returns empty array', () => {
|
||||
assert.deepEqual(globFiles(root, ['nope/*/x.md'], DENY), []);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- describeFile
|
||||
|
||||
test('describeFile: per-group skill → group + readable label', () => {
|
||||
const d = describeFile('groups/alpha/skills/tagger/SKILL.md');
|
||||
assert.equal(d.group, 'alpha');
|
||||
assert.match(d.label, /alpha/);
|
||||
assert.match(d.label, /tagger/);
|
||||
});
|
||||
|
||||
test('describeFile: container skill → shared', () => {
|
||||
const d = describeFile('container/skills/agent-browser/SKILL.md');
|
||||
assert.equal(d.group, 'shared');
|
||||
assert.match(d.label, /agent-browser/);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- resolveDoc
|
||||
|
||||
const SKILLS = { name: 'skills', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] };
|
||||
|
||||
test('resolveDoc: returns an absolute path for an allowed file', () => {
|
||||
const abs = resolveDoc(root, SKILLS, 'groups/alpha/skills/example-skill/SKILL.md', DENY);
|
||||
assert.ok(abs.endsWith('/groups/alpha/skills/example-skill/SKILL.md'));
|
||||
assert.ok(abs.startsWith(root));
|
||||
});
|
||||
|
||||
test('resolveDoc: rejects a path not matching the collection patterns', () => {
|
||||
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/profile.json', DENY), /not allowed/i);
|
||||
});
|
||||
|
||||
test('resolveDoc: rejects path traversal', () => {
|
||||
assert.throws(() => resolveDoc(root, SKILLS, '../../etc/passwd', DENY), /not allowed/i);
|
||||
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/skills/../../../.env', DENY), /not allowed/i);
|
||||
});
|
||||
|
||||
test('resolveDoc: rejects an absolute path', () => {
|
||||
assert.throws(() => resolveDoc(root, SKILLS, '/etc/passwd', DENY), /not allowed/i);
|
||||
});
|
||||
|
||||
test('resolveDoc: a denied file is not resolvable even if pattern-shaped', () => {
|
||||
const coll = { name: 'all', patterns: ['groups/*/*'] };
|
||||
assert.throws(() => resolveDoc(root, coll, 'groups/alpha/.env', DENY), /not allowed/i);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
Resources:
|
||||
approvals Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.
|
||||
verbs: list, get
|
||||
destinations Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.
|
||||
verbs: list, add, remove
|
||||
dropped-messages Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).
|
||||
verbs: list
|
||||
groups Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.
|
||||
verbs: list, get, create, update, delete, restart, config get, config update, config add-mcp-server, config remove-mcp-server, config add-package, config remove-package
|
||||
members Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".
|
||||
verbs: list, add, remove
|
||||
messaging-groups Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.
|
||||
verbs: list, get, create, update, delete
|
||||
roles User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).
|
||||
verbs: list, grant, revoke
|
||||
sessions Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.
|
||||
verbs: list, get
|
||||
user-dms User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.
|
||||
verbs: list
|
||||
users User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).
|
||||
verbs: list, get, create, update
|
||||
wirings Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.
|
||||
verbs: list, get, create, update, delete
|
||||
|
||||
Commands:
|
||||
help List available resources and commands.
|
||||
|
||||
Run `ncl <resource> help` for detailed field information.
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
// Stub CLI for clidash tests. Impersonates ncl (envelope json) or a
|
||||
// jsonlines CLI, with failure/slowness/garbage modes driven by env vars.
|
||||
import { readFileSync, appendFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (process.env.STUB_COUNT_FILE) {
|
||||
appendFileSync(process.env.STUB_COUNT_FILE, args.join(' ') + '\n');
|
||||
}
|
||||
|
||||
const sleepMs = Number(process.env.STUB_SLEEP_MS || 0);
|
||||
|
||||
setTimeout(() => {
|
||||
if (process.env.STUB_FAIL) {
|
||||
process.stderr.write('boom: socket down\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (args[0] === 'help') {
|
||||
process.stdout.write(
|
||||
readFileSync(fileURLToPath(new URL('./ncl-help.txt', import.meta.url)), 'utf8'),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[1] === 'help') { // `<resource> help` → raw per-resource help text
|
||||
process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n list\n get <id>\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.env.STUB_RAW) {
|
||||
process.stdout.write(process.env.STUB_RAW + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const resource = args[0];
|
||||
// `get`/detail commands → single-object envelope
|
||||
if (args.includes('get') || args.includes('config')) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
id: 'req-1', ok: true,
|
||||
data: { id: `${resource}-detail`, args: args.join(' '), extra: 'field' },
|
||||
}) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.env.STUB_JSONLINES) {
|
||||
process.stdout.write(JSON.stringify({ id: `${resource}-1`, name: 'row one' }) + '\n');
|
||||
process.stdout.write(JSON.stringify({ id: `${resource}-2`, name: 'row two' }) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({
|
||||
id: 'req-1',
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: `${resource}-1`, name: 'row one' },
|
||||
{ id: `${resource}-2`, name: 'row two' },
|
||||
],
|
||||
}) + '\n');
|
||||
process.exit(0);
|
||||
}, sleepMs);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
|
||||
|
||||
function cli(extra = {}) {
|
||||
return {
|
||||
bin: process.execPath,
|
||||
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
|
||||
list: [STUB, '{resource}', 'list', '--json'],
|
||||
output: 'json', unwrap: 'data',
|
||||
help: [STUB, '{resource}', 'help'],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(clis, fn) {
|
||||
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/help: returns raw per-resource help text', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/help/ncl/sessions`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.match(body.text, /sessions: help for sessions/);
|
||||
assert.match(body.text, /Verbs:/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/help: undiscovered resource → 404', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/help/ncl/evil`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/help: a cli without a help template → 404', async () => {
|
||||
const c = cli(); delete c.help;
|
||||
await withServer({ ncl: c }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/help/ncl/sessions`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/help: unknown cli → 404', async () => {
|
||||
await withServer({ ncl: cli() }, async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/help/nope/sessions`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/clis: reports help availability per cli', async () => {
|
||||
const noHelp = cli(); delete noHelp.help;
|
||||
await withServer({ ncl: cli(), docker: noHelp }, async (base) => {
|
||||
const body = await (await fetch(`${base}/api/clis`)).json();
|
||||
assert.equal(body.clis.find((c) => c.name === 'ncl').help, true);
|
||||
assert.equal(body.clis.find((c) => c.name === 'docker').help, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { tailFile } from '../logs.js';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
let dir;
|
||||
before(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'clidash-logs-'));
|
||||
// 10 lines, some with ANSI color codes
|
||||
const lines = Array.from({ length: 10 }, (_, i) =>
|
||||
`[12:00:0${i}] \x1b[32mINFO\x1b[39m line ${i}`);
|
||||
writeFileSync(join(dir, 'app.log'), lines.join('\n') + '\n');
|
||||
writeFileSync(join(dir, 'error.log'), 'boom\n');
|
||||
});
|
||||
after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
test('tailFile: returns the last N lines, ANSI stripped, no trailing blank', async () => {
|
||||
const { lines, text } = await tailFile(join(dir, 'app.log'), 3);
|
||||
assert.equal(lines.length, 3);
|
||||
assert.deepEqual(lines, ['[12:00:07] INFO line 7', '[12:00:08] INFO line 8', '[12:00:09] INFO line 9']);
|
||||
assert.ok(!text.includes('\x1b'));
|
||||
});
|
||||
|
||||
test('tailFile: maxLines larger than file returns all lines', async () => {
|
||||
const { lines } = await tailFile(join(dir, 'app.log'), 100);
|
||||
assert.equal(lines.length, 10);
|
||||
});
|
||||
|
||||
// ---- server endpoints ----
|
||||
|
||||
function cfg() {
|
||||
return {
|
||||
port: 0, bind: '127.0.0.1', clis: {},
|
||||
logs: { dir, tailLines: 5, files: [{ name: 'app.log', label: 'app' }, { name: 'error.log', label: 'errors' }] },
|
||||
};
|
||||
}
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp(config);
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
|
||||
}
|
||||
|
||||
test('/api/logs: lists the configured log files', async () => {
|
||||
await withServer(cfg(), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/logs`)).json();
|
||||
assert.deepEqual(body.files.map((f) => f.name), ['app.log', 'error.log']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/logs: absent logs config → empty list', async () => {
|
||||
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
|
||||
assert.deepEqual((await (await fetch(`${base}/api/logs`)).json()).files, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/log: returns the tail text + a tail command', async () => {
|
||||
await withServer(cfg(), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/log/app.log`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.match(body.text, /line 9$/);
|
||||
assert.equal(body.text.split('\n').length, 5); // tailLines
|
||||
assert.match(body.command, /tail -n 5 .*app\.log/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/log: a name not in the allowlist is rejected (no traversal)', async () => {
|
||||
await withServer(cfg(), async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/log/${encodeURIComponent('../../etc/passwd')}`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/api/log/secrets.log`)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { escapeHtml, mdToHtml } from '../public/md.js';
|
||||
|
||||
// ---- escaping -------------------------------------------------------------
|
||||
|
||||
test('escapeHtml: neutralizes all HTML metacharacters', () => {
|
||||
assert.equal(escapeHtml(`<script>"&'`), '<script>"&'');
|
||||
});
|
||||
|
||||
test('mdToHtml: raw HTML in source is escaped, never passed through', () => {
|
||||
const html = mdToHtml('a <script>alert(1)</script> b');
|
||||
assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('<script>'));
|
||||
});
|
||||
|
||||
// ---- the security-sensitive part: links -----------------------------------
|
||||
|
||||
test('mdToHtml: link href comes from the URL, label from the text', () => {
|
||||
const html = mdToHtml('see [the docs](https://example.com/x)');
|
||||
assert.match(html, /<a href="https:\/\/example\.com\/x" target="_blank" rel="noopener noreferrer">the docs<\/a>/);
|
||||
});
|
||||
|
||||
test('mdToHtml: javascript: smuggled in link TEXT stays inert (never an href)', () => {
|
||||
const html = mdToHtml('[javascript:alert(1)](https://safe.com)');
|
||||
// href is the safe URL; the js string is only visible label text
|
||||
assert.match(html, /href="https:\/\/safe\.com"/);
|
||||
assert.ok(!/href="javascript:/i.test(html));
|
||||
});
|
||||
|
||||
test('mdToHtml: a non-http(s) URL is not turned into a link', () => {
|
||||
// javascript:/data: never match the (https?:...) capture, so the literal
|
||||
// (escaped) markdown is left as-is — no anchor, no executable href.
|
||||
const html = mdToHtml('[click](javascript:alert(1))');
|
||||
assert.ok(!/<a /.test(html));
|
||||
assert.ok(!/href="javascript:/i.test(html));
|
||||
});
|
||||
|
||||
test('mdToHtml: an attribute-breakout attempt in the URL cannot escape the href', () => {
|
||||
// The double-quote is escaped to " before the regex runs, so it can never
|
||||
// close an attribute. (Here the URL also has a space, so no anchor even forms.)
|
||||
// The security property: no REAL attribute (with a literal quote) is injected.
|
||||
const html = mdToHtml('[x](https://a" onmouseover="alert(1))');
|
||||
assert.ok(!/<a/.test(html), 'malformed link must not produce an anchor');
|
||||
assert.ok(!/onmouseover="/.test(html), 'no real (unescaped-quote) attribute injected');
|
||||
});
|
||||
|
||||
test('mdToHtml: an escaped quote inside a matched URL stays inside the href, inert', () => {
|
||||
// Even when a URL matches, any " in it is already " (an entity), which
|
||||
// does not terminate an HTML attribute value — so no breakout.
|
||||
const html = mdToHtml('[x](https://a"onmouseover=alert)');
|
||||
assert.ok(!/onmouseover="/.test(html));
|
||||
if (/<a/.test(html)) assert.match(html, /href="https:\/\/a"onmouseover=alert"/);
|
||||
});
|
||||
|
||||
// ---- basic rendering sanity ----------------------------------------------
|
||||
|
||||
test('mdToHtml: headings, code fences, lists render', () => {
|
||||
const html = mdToHtml('# Title\n\n```\ncode\n```\n\n- a\n- b');
|
||||
assert.match(html, /<h1>Title<\/h1>/);
|
||||
assert.match(html, /<pre class="code"><code>code<\/code><\/pre>/);
|
||||
assert.match(html, /<ul><li>a<\/li><li>b<\/li><\/ul>/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import overview from '../views/ncl-overview.js';
|
||||
|
||||
const minutesAgo = (m) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
|
||||
// Shapes mirror real `ncl <resource> list --json` output.
|
||||
function makeFixtures({ alphaLastActive, bravoLastActive }) {
|
||||
return {
|
||||
groups: [
|
||||
{ id: 'ag-1', name: 'Alpha', folder: 'alpha', created_at: '2026-05-31T11:14:48.793Z' },
|
||||
{ id: 'ag-2', name: 'Bravo Team', folder: 'bravo', created_at: '2026-05-31T11:14:48.796Z' },
|
||||
{ id: 'ag-3', name: 'Orphan', folder: 'orphan', created_at: '2026-05-31T11:14:48.799Z' },
|
||||
],
|
||||
sessions: [
|
||||
{ id: 'sess-1', agent_group_id: 'ag-1', messaging_group_id: 'mg-1', thread_id: null, status: 'active', container_status: 'stopped', last_active: alphaLastActive, created_at: '2026-05-31T11:14:51.911Z' },
|
||||
{ id: 'sess-2', agent_group_id: 'ag-2', messaging_group_id: 'mg-2', thread_id: null, status: 'active', container_status: 'running', last_active: bravoLastActive, created_at: '2026-05-31T11:14:51.973Z' },
|
||||
],
|
||||
'messaging-groups': [
|
||||
{ id: 'mg-1', channel_type: 'telegram', platform_id: 'telegram:1', name: 'Alpha', is_group: 0 },
|
||||
{ id: 'mg-2', channel_type: 'telegram', platform_id: 'telegram:2', name: 'Bravo Team', is_group: 0 },
|
||||
],
|
||||
wirings: [
|
||||
{ id: 'mga-1', messaging_group_id: 'mg-1', agent_group_id: 'ag-1', session_mode: 'shared' },
|
||||
{ id: 'mga-2', messaging_group_id: 'mg-2', agent_group_id: 'ag-2', session_mode: 'shared' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fetchFrom(fixtures) {
|
||||
return async (resource) => {
|
||||
if (!(resource in fixtures)) throw new Error(`unexpected fetch: ${resource}`);
|
||||
return fixtures[resource];
|
||||
};
|
||||
}
|
||||
|
||||
test('overview: one card per agent group with joined session + wiring data', async () => {
|
||||
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
|
||||
const result = await overview({ fetch: fetchFrom(fixtures) });
|
||||
assert.equal(result.cards.length, 3);
|
||||
|
||||
const alpha = result.cards.find((c) => c.title === 'Alpha');
|
||||
assert.equal(alpha.subtitle, 'alpha');
|
||||
assert.equal(alpha.fields.container, 'stopped');
|
||||
assert.equal(alpha.fields.sessions, 1);
|
||||
assert.deepEqual(alpha.badges, ['telegram: Alpha']);
|
||||
|
||||
const bravo = result.cards.find((c) => c.title === 'Bravo Team');
|
||||
assert.equal(bravo.fields.container, 'running');
|
||||
assert.deepEqual(bravo.badges, ['telegram: Bravo Team']);
|
||||
});
|
||||
|
||||
test('overview: staleness thresholds — green <15m, amber <2h, red older, gray never', async () => {
|
||||
const fixtures = makeFixtures({ alphaLastActive: minutesAgo(5), bravoLastActive: minutesAgo(30) });
|
||||
const result = await overview({ fetch: fetchFrom(fixtures) });
|
||||
assert.equal(result.cards.find((c) => c.title === 'Alpha').status, 'green');
|
||||
assert.equal(result.cards.find((c) => c.title === 'Bravo Team').status, 'amber');
|
||||
assert.equal(result.cards.find((c) => c.title === 'Orphan').status, 'gray');
|
||||
|
||||
const stale = makeFixtures({ alphaLastActive: minutesAgo(300), bravoLastActive: minutesAgo(30) });
|
||||
const result2 = await overview({ fetch: fetchFrom(stale) });
|
||||
assert.equal(result2.cards.find((c) => c.title === 'Alpha').status, 'red');
|
||||
});
|
||||
|
||||
test('overview: last_active is exposed for relative-time rendering', async () => {
|
||||
const ts = minutesAgo(5);
|
||||
const fixtures = makeFixtures({ alphaLastActive: ts, bravoLastActive: minutesAgo(30) });
|
||||
const result = await overview({ fetch: fetchFrom(fixtures) });
|
||||
assert.equal(result.cards.find((c) => c.title === 'Alpha').fields['last active'], ts);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { discoveryParsers, parseOutput, unwrapPath } from '../parsers.js';
|
||||
|
||||
const fixture = readFileSync(
|
||||
fileURLToPath(new URL('./fixtures/ncl-help.txt', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- ncl-help
|
||||
|
||||
test('ncl-help: parses all listable resources from real captured output', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
assert.deepEqual(
|
||||
resources.map((r) => r.name),
|
||||
[
|
||||
'approvals', 'destinations', 'dropped-messages', 'groups', 'members',
|
||||
'messaging-groups', 'roles', 'sessions', 'user-dms', 'users', 'wirings',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('ncl-help: every parsed resource has a non-empty description and a list verb', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
for (const r of resources) {
|
||||
assert.ok(r.description.length > 0, `${r.name} has empty description`);
|
||||
assert.ok(r.verbs.includes('list'), `${r.name} missing list verb`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ncl-help: parses verbs correctly, including multi-word verbs', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
const groups = resources.find((r) => r.name === 'groups');
|
||||
assert.deepEqual(groups.verbs, [
|
||||
'list', 'get', 'create', 'update', 'delete', 'restart',
|
||||
'config get', 'config update', 'config add-mcp-server',
|
||||
'config remove-mcp-server', 'config add-package', 'config remove-package',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ncl-help: excludes resources without a list verb', () => {
|
||||
const input = [
|
||||
'Resources:',
|
||||
' alpha Has list.',
|
||||
' verbs: list, get',
|
||||
' beta No list here.',
|
||||
' verbs: grant, revoke',
|
||||
'',
|
||||
].join('\n');
|
||||
const resources = discoveryParsers['ncl-help'](input);
|
||||
assert.deepEqual(resources.map((r) => r.name), ['alpha']);
|
||||
});
|
||||
|
||||
test('ncl-help: ignores the Commands section (help is not a resource)', () => {
|
||||
const resources = discoveryParsers['ncl-help'](fixture);
|
||||
assert.ok(!resources.some((r) => r.name === 'help'));
|
||||
});
|
||||
|
||||
test('ncl-help: throws loudly on unrecognized format', () => {
|
||||
assert.throws(() => discoveryParsers['ncl-help']('totally not help output'), /Resources/);
|
||||
assert.throws(() => discoveryParsers['ncl-help'](''), /Resources/);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- parseOutput
|
||||
|
||||
test('parseOutput json: parses a single document', () => {
|
||||
assert.deepEqual(parseOutput('{"a": 1}', 'json'), { a: 1 });
|
||||
});
|
||||
|
||||
test('parseOutput json: throws on malformed input with raw output preserved', () => {
|
||||
assert.throws(() => parseOutput('not json', 'json'), (err) => {
|
||||
assert.match(err.message, /JSON/i);
|
||||
assert.equal(err.raw, 'not json');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('parseOutput jsonlines: one object per line, blank lines skipped', () => {
|
||||
const text = '{"id":1}\n\n{"id":2}\n{"id":3}\n';
|
||||
assert.deepEqual(parseOutput(text, 'jsonlines'), [{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
});
|
||||
|
||||
test('parseOutput jsonlines: throws on a malformed line', () => {
|
||||
assert.throws(() => parseOutput('{"ok":1}\ngarbage\n', 'jsonlines'), /line 2/i);
|
||||
});
|
||||
|
||||
test('parseOutput: rejects unknown format', () => {
|
||||
assert.throws(() => parseOutput('{}', 'xml'), /format/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- unwrapPath
|
||||
|
||||
test('unwrapPath: extracts the ncl {id, ok, data} envelope', () => {
|
||||
const doc = { id: 'x', ok: true, data: [{ id: 'sess-1' }] };
|
||||
assert.deepEqual(unwrapPath(doc, 'data'), [{ id: 'sess-1' }]);
|
||||
});
|
||||
|
||||
test('unwrapPath: supports nested dot paths', () => {
|
||||
assert.deepEqual(unwrapPath({ a: { b: [1, 2] } }, 'a.b'), [1, 2]);
|
||||
});
|
||||
|
||||
test('unwrapPath: throws when the path is missing', () => {
|
||||
assert.throws(() => unwrapPath({ ok: true }, 'data'), /data/);
|
||||
});
|
||||
|
||||
test('unwrapPath: no path returns the value unchanged', () => {
|
||||
const rows = [{ id: 1 }];
|
||||
assert.equal(unwrapPath(rows, undefined), rows);
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createApp } from '../server.js';
|
||||
|
||||
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'clidash-test-'));
|
||||
|
||||
function stubCli(extra = {}) {
|
||||
return {
|
||||
bin: process.execPath,
|
||||
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
|
||||
list: [STUB, '{resource}', 'list', '--json'],
|
||||
output: 'json',
|
||||
unwrap: 'data',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfig(clis, extra = {}) {
|
||||
return { port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, refreshSeconds: 10, clis, ...extra };
|
||||
}
|
||||
|
||||
async function withServer(config, fn) {
|
||||
const server = createApp(config);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
after(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
// ----------------------------------------------------------------- /api/clis
|
||||
|
||||
test('/api/clis: lists configured CLIs with discovered resources', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/api/clis`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.clis.length, 1);
|
||||
assert.equal(body.clis[0].name, 'stub');
|
||||
assert.equal(body.clis[0].refreshSeconds, 10);
|
||||
const names = body.clis[0].resources.map((r) => r.name);
|
||||
assert.ok(names.includes('sessions'));
|
||||
assert.ok(names.includes('groups'));
|
||||
assert.equal(names.length, 11);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/clis: static resource list needs no discovery', async () => {
|
||||
const cli = stubCli({ resources: ['alpha', 'beta'] });
|
||||
delete cli.discover;
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/clis`)).json();
|
||||
assert.deepEqual(body.clis[0].resources.map((r) => r.name), ['alpha', 'beta']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/clis: discovery failure reports a loud error', async () => {
|
||||
const cli = stubCli();
|
||||
cli.env = { STUB_FAIL: '1' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/clis`)).json();
|
||||
assert.equal(body.clis[0].resources.length, 0);
|
||||
assert.match(body.clis[0].error, /boom/);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ /api/r/cli/res
|
||||
|
||||
test('/api/r: returns unwrapped rows with fetchedAt', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/stub/sessions`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.rows.map((r) => r.id), ['sessions-1', 'sessions-2']);
|
||||
assert.ok(body.fetchedAt);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: rejects a resource not in the discovered set without exec', async () => {
|
||||
const countFile = join(tmp, 'count-reject.txt');
|
||||
const cli = stubCli();
|
||||
cli.env = { STUB_COUNT_FILE: countFile };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/stub/evil%20--rm`);
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
// only the discovery exec ran — never a list exec for the bogus resource
|
||||
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
|
||||
assert.deepEqual(calls, ['help']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: unknown cli → 404', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/nope/sessions`);
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: jsonlines CLI with static resources works', async () => {
|
||||
const cli = {
|
||||
bin: process.execPath,
|
||||
resources: ['ps'],
|
||||
list: [STUB, '{resource}'],
|
||||
output: 'jsonlines',
|
||||
env: { STUB_JSONLINES: '1' },
|
||||
};
|
||||
await withServer(makeConfig({ docker: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/docker/ps`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.rows.map((r) => r.id), ['ps-1', 'ps-2']);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: exec failure returns ok:false with stderr', async () => {
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_FAIL: '1' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const res = await fetch(`${base}/api/r/stub/sessions`);
|
||||
assert.equal(res.status, 502);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.error, /boom: socket down/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: exec timeout returns ok:false naming the resource', async () => {
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_SLEEP_MS: '5000' };
|
||||
await withServer(makeConfig({ stub: cli }, { execTimeoutMs: 200 }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.error, /sessions/);
|
||||
assert.match(body.error, /timed out/i);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: malformed CLI output returns the raw output', async () => {
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_RAW: 'this is not json' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.match(body.raw, /this is not json/);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: concurrent requests for the same resource coalesce into one exec', async () => {
|
||||
const countFile = join(tmp, 'count-coalesce.txt');
|
||||
const cli = stubCli({ resources: ['sessions'] });
|
||||
delete cli.discover;
|
||||
cli.env = { STUB_COUNT_FILE: countFile, STUB_SLEEP_MS: '150' };
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const bodies = await Promise.all(
|
||||
Array.from({ length: 5 }, () => fetch(`${base}/api/r/stub/sessions`).then((r) => r.json())),
|
||||
);
|
||||
for (const body of bodies) assert.equal(body.ok, true);
|
||||
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- /api/view
|
||||
|
||||
test('/api/view: runs a view plugin with a bound fetch helper', async () => {
|
||||
const viewsDir = join(tmp, 'views');
|
||||
writeFileSync(join(viewsDir, '..', 'placeholder'), ''); // ensure tmp exists
|
||||
const { mkdirSync } = await import('node:fs');
|
||||
mkdirSync(viewsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(viewsDir, 'stub-overview.js'),
|
||||
'export default async function ({ fetch }) {\n' +
|
||||
' const rows = await fetch("sessions");\n' +
|
||||
' return { count: rows.length, first: rows[0].id };\n' +
|
||||
'}\n',
|
||||
);
|
||||
await withServer(makeConfig({ stub: stubCli() }, { viewsDir }), async (base) => {
|
||||
const res = await fetch(`${base}/api/view/stub/overview`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.deepEqual(body.result, { count: 2, first: 'sessions-1' });
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/view: missing view → 404; bad view name → 404', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }, { viewsDir: join(tmp, 'views') }), async (base) => {
|
||||
assert.equal((await fetch(`${base}/api/view/stub/nope`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/api/view/stub/..%2F..%2Fserver`)).status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- static files
|
||||
|
||||
test('GET /: serves the dashboard index.html', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get('content-type'), /text\/html/);
|
||||
assert.match(await res.text(), /clidash/i);
|
||||
});
|
||||
});
|
||||
|
||||
test('static: path traversal outside public/ is rejected', async () => {
|
||||
await withServer(makeConfig({ stub: stubCli() }), async (base) => {
|
||||
const res = await fetch(`${base}/..%2Fserver.js`);
|
||||
assert.notEqual(res.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
test('/api/r: {resource} substitutes inside a larger argv string (ssh-remote pattern)', async () => {
|
||||
const cli = {
|
||||
bin: process.execPath,
|
||||
resources: ['sessions'],
|
||||
list: [STUB, 'wrapped-{resource}-arg', 'list'],
|
||||
output: 'json',
|
||||
unwrap: 'data',
|
||||
env: { STUB_COUNT_FILE: join(tmp, 'count-embed.txt') },
|
||||
};
|
||||
await withServer(makeConfig({ stub: cli }), async (base) => {
|
||||
const body = await (await fetch(`${base}/api/r/stub/sessions`)).json();
|
||||
assert.equal(body.ok, true);
|
||||
const calls = readFileSync(join(tmp, 'count-embed.txt'), 'utf8').trim();
|
||||
assert.equal(calls, 'wrapped-sessions-arg list');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test against a running clidash instance (run on the VM after deploy).
|
||||
# Usage: ./test/smoke.sh [base-url] (default http://127.0.0.1:4690)
|
||||
set -euo pipefail
|
||||
BASE="${1:-http://127.0.0.1:4690}"
|
||||
|
||||
check() {
|
||||
local label="$1" url="$2" pattern="$3"
|
||||
if curl -fsS --max-time 15 "$url" | grep -q "$pattern"; then
|
||||
echo "OK $label"
|
||||
else
|
||||
echo "FAIL $label ($url did not match $pattern)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check "/api/clis" "$BASE/api/clis" '"resources"'
|
||||
check "/api/r/ncl/sessions" "$BASE/api/r/ncl/sessions" '"ok":true'
|
||||
check "/api/view/ncl/overview" "$BASE/api/view/ncl/overview" '"ok":true'
|
||||
check "GET / (static UI)" "$BASE/" 'clidash'
|
||||
echo "smoke: all good"
|
||||
@@ -0,0 +1,60 @@
|
||||
// Curated "Agents overview" view for ncl: joins groups + sessions +
|
||||
// messaging-groups + wirings into per-agent cards. Returns the generic
|
||||
// card shape the frontend renders, so the UI itself stays CLI-agnostic:
|
||||
// { title, cards: [{ title, subtitle, status, fields, badges }] }
|
||||
// status: green <15m since last_active, amber <2h, red older, gray never.
|
||||
|
||||
const GREEN_MAX_MIN = 15;
|
||||
const AMBER_MAX_MIN = 120;
|
||||
|
||||
function staleness(lastActive) {
|
||||
if (!lastActive) return 'gray';
|
||||
const ageMin = (Date.now() - new Date(lastActive).getTime()) / 60_000;
|
||||
if (ageMin < GREEN_MAX_MIN) return 'green';
|
||||
if (ageMin < AMBER_MAX_MIN) return 'amber';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export default async function overview({ fetch }) {
|
||||
const [groups, sessions, messagingGroups, wirings] = await Promise.all([
|
||||
fetch('groups'),
|
||||
fetch('sessions'),
|
||||
fetch('messaging-groups'),
|
||||
fetch('wirings'),
|
||||
]);
|
||||
|
||||
const mgById = new Map(messagingGroups.map((mg) => [mg.id, mg]));
|
||||
|
||||
const cards = groups.map((group) => {
|
||||
const groupSessions = sessions.filter((s) => s.agent_group_id === group.id);
|
||||
const lastActive = groupSessions
|
||||
.map((s) => s.last_active)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
const container = groupSessions.some((s) => s.container_status === 'running')
|
||||
? 'running'
|
||||
: groupSessions[0]?.container_status ?? 'none';
|
||||
|
||||
const badges = wirings
|
||||
.filter((w) => w.agent_group_id === group.id)
|
||||
.map((w) => {
|
||||
const mg = mgById.get(w.messaging_group_id);
|
||||
return mg ? `${mg.channel_type}: ${mg.name ?? mg.platform_id}` : w.messaging_group_id;
|
||||
});
|
||||
|
||||
return {
|
||||
title: group.name,
|
||||
subtitle: group.folder,
|
||||
status: staleness(lastActive),
|
||||
fields: {
|
||||
container,
|
||||
sessions: groupSessions.length,
|
||||
'last active': lastActive,
|
||||
},
|
||||
badges,
|
||||
};
|
||||
});
|
||||
|
||||
return { title: 'Agents overview', cards };
|
||||
}
|
||||
@@ -9,13 +9,13 @@ Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container i
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider (`AGENT_PROVIDER=claude`). Confirm the provider before applying:
|
||||
mnemon hooks fire only under `--target claude-code`. Use this skill on agent groups that run the default Claude provider. The provider is the materialized `provider` key in each group's `container.json` (absent or `claude` = default Claude provider). Confirm it before applying:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
grep -H '"provider"' groups/*/container.json 2>/dev/null # no match, or "provider": "claude" = Claude
|
||||
```
|
||||
|
||||
If a group uses a different provider (e.g. `AGENT_PROVIDER=opencode`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
If a group sets a different provider (e.g. `"provider": "opencode"`), it spawns its own process and never invokes the `claude` CLI, so the hooks registered by `mnemon setup` do not run for that group.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: add-opencode
|
||||
description: Use OpenCode as an agent provider (AGENT_PROVIDER=opencode). OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per-session and per-group via agent_provider; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
description: Use OpenCode as an agent provider. OpenRouter, OpenAI, Google, DeepSeek, etc. via OpenCode config — not the Anthropic Agent SDK. Per group via `ncl groups config update --provider opencode`; host passes OPENCODE_* and XDG mount when spawning containers.
|
||||
---
|
||||
|
||||
# OpenCode agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `mock`).
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected per agent group by the **`provider`** key in that group's `container.json` (materialized from the `container_configs` table) — set it with `ncl groups config update --provider opencode`. Default is `claude`.
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the OpenCode provider files in from the `providers` branch, wires them into the host and container barrels, installs dependencies, and rebuilds the image.
|
||||
|
||||
@@ -148,7 +148,7 @@ done
|
||||
|
||||
Set model/provider strings in the form OpenCode expects (often `provider/model-id`). **Put comments on their own lines** — a `#` inside a value is kept verbatim and breaks model IDs.
|
||||
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the group still needs `provider` set to `opencode` (see [Select the provider](#select-the-provider) below).
|
||||
|
||||
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
|
||||
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
|
||||
@@ -215,7 +215,7 @@ OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api-key`** header. If OneCLI injects **`Authorization: Bearer …`** only, Zen often returns **401 / "Missing API key"** even though the gateway is working.
|
||||
|
||||
**Naming:** NanoClaw **`AGENT_PROVIDER=opencode`** (DB `agent_provider`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
**Naming:** NanoClaw's **`provider: opencode`** (the `container.json` key, set via `ncl groups config update --provider opencode`) means "run the **OpenCode agent provider**." Separately, **`OPENCODE_PROVIDER=opencode`** in `.env` is OpenCode's **Zen provider id** inside the OpenCode config (see [Zen docs](https://opencode.ai/docs/zen/)).
|
||||
|
||||
**Host `.env` (typical Zen shape):**
|
||||
|
||||
@@ -236,9 +236,16 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
--header-name "x-api-key" --value-format "{value}"
|
||||
```
|
||||
|
||||
### Per group / per session
|
||||
### Select the provider
|
||||
|
||||
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'`.
|
||||
Per group, from the host:
|
||||
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider opencode
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
`ncl groups config update --provider` writes the `provider` value into the `container_configs` table; the host materializes it into `groups/<folder>/container.json` at spawn time and the in-container runner reads `provider` from there (defaulting to `claude`). The restart picks up the change. Switching is an operator action — run it from the host. Memory does NOT carry over automatically between providers — run `/migrate-memory` to carry it across.
|
||||
|
||||
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.
|
||||
|
||||
@@ -250,6 +257,6 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, set `agent_provider = 'opencode'` (or `"provider": "opencode"` in the group's `container.json`) on a test group, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
The registration and Dockerfile guards in step 7 verify the wiring. To confirm an end-to-end round-trip, switch a test group with `ncl groups config update --id <group-id> --provider opencode && ncl groups restart --id <group-id>`, register the matching provider key in OneCLI, and send a message. A clean exchange returns the model's reply with no `Unknown provider: opencode` error and no UUID/session warnings in the logs.
|
||||
|
||||
To remove this provider, see [REMOVE.md](REMOVE.md).
|
||||
|
||||
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
|
||||
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
||||
```
|
||||
|
||||
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
|
||||
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
|
||||
|
||||
## Add Directories
|
||||
|
||||
Ask which directories the user wants agents to access. For each path:
|
||||
- Validate the path exists
|
||||
- Ask if it should be read-only for non-main agents (default: yes)
|
||||
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
|
||||
|
||||
Build the JSON config and write it:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
Use `--force` to overwrite the existing config.
|
||||
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
|
||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
|
||||
```
|
||||
|
||||
## Reset to Empty
|
||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automatically — no service restart needed.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
To apply the new config to a group that already has a running container, restart just that group:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
@@ -71,7 +71,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/modules/approvals/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/modules/permissions/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
@@ -79,8 +79,8 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills) — agent-runner source is a shared read-only mount, not copied per group |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
| `nanoclaw.sh --uninstall` + `setup/uninstall/` | Uninstall this copy only (slug-scoped): service, containers + image, `data/`, `logs/`, `groups/`, this copy's OneCLI agents. Confirms per group; `--dry-run` previews, `--yes` skips prompts. Other copies and the shared OneCLI app are untouched. Bypasses bootstrap entirely; `uninstall.sh` is a pointer that execs it. |
|
||||
@@ -182,7 +182,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. a `scripts/` CLI or helper).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `agent-browser`, `frontend-engineer`, `onecli-gateway`, `self-customize`, `slack-formatting`, `vercel-cli`, `welcome`, `whatsapp-formatting`).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
|
||||
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||
|
||||
@@ -165,7 +165,7 @@ Key files:
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export function openInboundDb(): Database {
|
||||
// so the singleton survives for the rest of the test.
|
||||
if (_testMode && _inbound) {
|
||||
const db = _inbound;
|
||||
return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database;
|
||||
return {
|
||||
prepare: (sql: string) => db.prepare(sql),
|
||||
exec: (sql: string) => db.exec(sql),
|
||||
close: () => {},
|
||||
} as unknown as Database;
|
||||
}
|
||||
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
@@ -260,11 +264,3 @@ export function closeSessionDb(): void {
|
||||
_outbound?.close();
|
||||
_outbound = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInboundDb() / getOutboundDb() instead.
|
||||
* Kept for backward compatibility during migration.
|
||||
*/
|
||||
export function getSessionDb(): Database {
|
||||
return getInboundDb();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
getInboundDb,
|
||||
getOutboundDb,
|
||||
getSessionDb,
|
||||
initTestSessionDb,
|
||||
closeSessionDb,
|
||||
touchHeartbeat,
|
||||
|
||||
@@ -56,7 +56,7 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
|
||||
* to skip messages already picked up by this or a previous container run.
|
||||
*
|
||||
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
|
||||
* Returns the most recent `maxMessagesPerPrompt` pending rows in
|
||||
* chronological order, regardless of their `trigger` flag: accumulated
|
||||
* context (trigger=0) rides along with the wake-eligible rows so the agent
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
@@ -163,4 +163,3 @@ export function findQuestionResponse(questionId: string): MessageInRow | undefin
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,47 @@ export function setContinuation(providerName: string, id: string): void {
|
||||
export function clearContinuation(providerName: string): void {
|
||||
deleteValue(continuationKey(providerName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The a2a reply stamp: the id of the first inbound message in the batch the
|
||||
* agent is currently processing. The poll loop publishes it at batch start;
|
||||
* MCP tools (`send_message`, `send_file`) read it and stamp it onto outbound
|
||||
* rows so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This lives in outbound.db rather than module state because the MCP server
|
||||
* runs as a separate stdio subprocess from the poll loop — module state set
|
||||
* by the poll loop is invisible to it. Both processes open outbound.db
|
||||
* (journal_mode=DELETE + busy_timeout make intra-container access safe).
|
||||
*/
|
||||
const IN_REPLY_TO_KEY = 'current_in_reply_to';
|
||||
|
||||
/**
|
||||
* Ignore a stamp older than this. The poll loop clears the stamp in a
|
||||
* finally, but a container killed mid-batch (SIGKILL) can leave one behind;
|
||||
* the guard stops a later out-of-batch read from picking up a dead stamp.
|
||||
* Generous so a long-running batch's late sends still stamp correctly.
|
||||
*/
|
||||
const IN_REPLY_TO_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
if (id === null) {
|
||||
clearCurrentInReplyTo();
|
||||
return;
|
||||
}
|
||||
setValue(IN_REPLY_TO_KEY, id);
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
deleteValue(IN_REPLY_TO_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
const row = getOutboundDb()
|
||||
.prepare('SELECT value, updated_at FROM session_state WHERE key = ?')
|
||||
.get(IN_REPLY_TO_KEY) as { value: string; updated_at: string } | undefined;
|
||||
if (!row) return null;
|
||||
const age = Date.now() - new Date(row.updated_at).getTime();
|
||||
if (!Number.isFinite(age) || age > IN_REPLY_TO_MAX_AGE_MS) return null;
|
||||
return row.value;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,31 @@
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*
|
||||
* The stamp is published through session_state in outbound.db, not module
|
||||
* state — the MCP server runs as a separate stdio subprocess from the poll
|
||||
* loop, so it can only see the stamp through the shared DB. These tests seed
|
||||
* it the same way the poll-loop process does (a direct DB write) rather than
|
||||
* via any in-memory helper, so they exercise the real process boundary.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
/**
|
||||
* Publish the a2a reply stamp the way the poll loop does: a direct write to
|
||||
* session_state in outbound.db. `ageMs` back-dates updated_at to exercise the
|
||||
* staleness guard MCP tools apply when reading it.
|
||||
*/
|
||||
function publishInReplyTo(id: string, ageMs = 0): void {
|
||||
const updatedAt = new Date(Date.now() - ageMs).toISOString();
|
||||
getOutboundDb()
|
||||
.prepare('INSERT OR REPLACE INTO session_state (key, value, updated_at) VALUES (?, ?, ?)')
|
||||
.run('current_in_reply_to', id, updatedAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
@@ -24,13 +41,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
it('stamps the batch in_reply_to (published via the DB) on outbound rows', async () => {
|
||||
publishInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
@@ -40,7 +56,17 @@ describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
// Nothing published to session_state — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stale stamp left behind by a killed container', async () => {
|
||||
publishInReplyTo('inbound-msg-1', 60 * 60 * 1000); // an hour old
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getCurrentInReplyTo } from '../db/session-state.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -2,8 +2,13 @@ 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 { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
clearCurrentInReplyTo,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
setCurrentInReplyTo,
|
||||
} from './db/session-state.js';
|
||||
import {
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
|
||||
@@ -3,4 +3,3 @@
|
||||
// level. Skills add a new provider by appending one import line below.
|
||||
|
||||
import './claude.js';
|
||||
import './mock.js';
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# Apple Container Networking Setup (macOS 26)
|
||||
|
||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
||||
|
||||
## Quick Setup
|
||||
|
||||
Run these two commands (requires `sudo`):
|
||||
|
||||
```bash
|
||||
# 1. Enable IP forwarding so the host routes container traffic
|
||||
sudo sysctl -w net.inet.ip.forwarding=1
|
||||
|
||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
||||
|
||||
## Making It Persistent
|
||||
|
||||
These settings reset on reboot. To make them permanent:
|
||||
|
||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
||||
```
|
||||
net.inet.ip.forwarding=1
|
||||
```
|
||||
|
||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
||||
```
|
||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
||||
```
|
||||
|
||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
||||
|
||||
## IPv6 DNS Issue
|
||||
|
||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
||||
|
||||
The container image and runner are configured to prefer IPv4 via:
|
||||
```
|
||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
```
|
||||
|
||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check IP forwarding is enabled
|
||||
sysctl net.inet.ip.forwarding
|
||||
# Expected: net.inet.ip.forwarding: 1
|
||||
|
||||
# Test container internet access
|
||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
||||
# Expected: 404
|
||||
|
||||
# Check bridge interface (only exists when a container is running)
|
||||
ifconfig bridge100
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Container VM (192.168.64.x)
|
||||
│
|
||||
├── eth0 → gateway 192.168.64.1
|
||||
│
|
||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
||||
│
|
||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
||||
│
|
||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
||||
│
|
||||
en0 (your WiFi/Ethernet) → Internet
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
||||
@@ -6,8 +6,5 @@ The files in this directory are original design documents and developer referenc
|
||||
|
||||
| This directory | Documentation site |
|
||||
|---|---|
|
||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
@@ -1,70 +1,106 @@
|
||||
# NanoClaw Security Model
|
||||
|
||||
> The canonical, continuously-verified version of this model lives at
|
||||
> [docs.nanoclaw.dev/concepts/security](https://docs.nanoclaw.dev/concepts/security).
|
||||
> This in-repo copy can drift; if the two disagree, verify against
|
||||
> `src/container-runner.ts` (`buildMounts`).
|
||||
|
||||
## Trust Model
|
||||
|
||||
Privilege is **user-level**, persisted in the `user_roles` table (owner /
|
||||
admin, global or scoped to an agent group) plus `agent_group_members` (the
|
||||
unprivileged access gate).
|
||||
|
||||
| Entity | Trust Level | Rationale |
|
||||
|--------|-------------|-----------|
|
||||
| Main group | Trusted | Private self-chat, admin control |
|
||||
| Non-main groups | Untrusted | Other users may be malicious |
|
||||
| Container agents | Sandboxed | Isolated execution environment |
|
||||
| Incoming messages | User input | Potential prompt injection |
|
||||
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
|
||||
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
### 1. Container Isolation (Primary Boundary)
|
||||
|
||||
Agents execute in containers (lightweight Linux VMs), providing:
|
||||
- **Process isolation** - Container processes cannot affect the host
|
||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
||||
Agents execute in containers (Docker), providing:
|
||||
- **Process isolation** — container processes cannot affect the host
|
||||
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
|
||||
|
||||
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
|
||||
This is the primary security boundary. Rather than relying on application-level
|
||||
permission checks, the attack surface is limited by what's mounted.
|
||||
|
||||
### 2. Mount Security
|
||||
|
||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
||||
- Outside project root
|
||||
- Never mounted into containers
|
||||
- Cannot be modified by agents
|
||||
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||
spawn. For the default (Claude) provider these are:
|
||||
|
||||
**Default Blocked Patterns:**
|
||||
| Container path | Host source | Mode | Purpose |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
|
||||
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
|
||||
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
|
||||
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
|
||||
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
|
||||
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
|
||||
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
|
||||
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
|
||||
| `/app/skills` | `container/skills/` | RO | Shared container skills |
|
||||
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
|
||||
|
||||
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
|
||||
**nested read-only mounts on top of the read-write group dir** — the agent can
|
||||
read its config but cannot modify it. The project root is **never mounted**: the
|
||||
container only ever sees the paths above plus any provider-contributed mounts
|
||||
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
|
||||
`package.json`) is not reachable.
|
||||
|
||||
**Additional-mount allowlist** — extra mounts from a group's container config
|
||||
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
|
||||
which is:
|
||||
- Outside the project root
|
||||
- Never mounted into containers
|
||||
- Not modifiable by agents
|
||||
|
||||
Its schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedRoots": [
|
||||
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
|
||||
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
|
||||
],
|
||||
"blockedPatterns": ["password", "secret", "token"]
|
||||
}
|
||||
```
|
||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
||||
|
||||
**Default blocked patterns** (merged with any in the file):
|
||||
```
|
||||
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
|
||||
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
|
||||
private_key, .secret
|
||||
```
|
||||
|
||||
**Protections:**
|
||||
- Symlink resolution before validation (prevents traversal attacks)
|
||||
- Container path validation (rejects `..` and absolute paths)
|
||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
||||
|
||||
**Read-Only Project Root:**
|
||||
|
||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
||||
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
|
||||
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
|
||||
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
|
||||
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
|
||||
|
||||
### 3. Session Isolation
|
||||
|
||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
||||
- Groups cannot see other groups' conversation history
|
||||
- Session data includes full message history and file contents read
|
||||
- Prevents cross-group information disclosure
|
||||
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||
- Different agent groups cannot see each other's conversation history or files.
|
||||
- A group's sessions share that group's memory but keep separate message DBs.
|
||||
|
||||
### 4. IPC Authorization
|
||||
This prevents cross-group information disclosure.
|
||||
|
||||
Messages and task operations are verified against group identity:
|
||||
|
||||
| Operation | Main Group | Non-Main Group |
|
||||
|-----------|------------|----------------|
|
||||
| Send message to own chat | ✓ | ✓ |
|
||||
| Send message to other chats | ✓ | ✗ |
|
||||
| Schedule task for self | ✓ | ✓ |
|
||||
| Schedule task for others | ✓ | ✗ |
|
||||
| View all tasks | ✓ | Own only |
|
||||
| Manage other groups | ✓ | ✗ |
|
||||
|
||||
### 5. Credential Isolation (OneCLI Agent Vault)
|
||||
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||
|
||||
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
||||
|
||||
@@ -77,13 +113,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
|
||||
**Per-agent policies:**
|
||||
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
||||
|
||||
**NOT Mounted:**
|
||||
- Channel auth sessions (`store/auth/`) — host only
|
||||
- Mount allowlist — external, never mounted
|
||||
- Any credentials matching blocked patterns
|
||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
||||
**Never on the container filesystem:**
|
||||
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||
|
||||
### 6. Egress Lockdown (Forced Proxy)
|
||||
### 5. Egress Lockdown (Forced Proxy)
|
||||
|
||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||
@@ -111,31 +146,42 @@ no `host-gateway` route).
|
||||
exception: a heal failure there is logged but not fatal, since already-running
|
||||
agents stay on the internal net (no leak) until the gateway returns.
|
||||
|
||||
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
|
||||
the agent reaches the OneCLI gateway over the host-gateway path and outbound
|
||||
traffic is not confined to the internal network.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
|
||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||
|
||||
These variables are read from the **host process** environment (the service's
|
||||
environment / `.env`), not from inside the container. The agent container is
|
||||
started with only `TZ` and any provider-declared variables — host environment
|
||||
variables, including secrets, are never forwarded into the agent.
|
||||
|
||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
|
||||
## Privilege Comparison
|
||||
## Resource Limits
|
||||
|
||||
| Capability | Main Group | Non-Main Group |
|
||||
|------------|------------|----------------|
|
||||
| Project root access | `/workspace/project` (ro) | None |
|
||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
||||
| Additional mounts | Configurable | Read-only unless allowed |
|
||||
| Network access | Unrestricted | Unrestricted |
|
||||
| MCP tools | All | All |
|
||||
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
|
||||
agent is not throttled unless the operator configures a limit:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||
|
||||
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
|
||||
the host having no swap (a deployment concern). On a swapless host a runaway is
|
||||
OOM-killed at the limit.
|
||||
|
||||
## Security Architecture Diagram
|
||||
|
||||
@@ -149,7 +195,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ HOST PROCESS (TRUSTED) │
|
||||
│ • Message routing │
|
||||
│ • IPC authorization │
|
||||
│ • Role / access checks (user_roles, agent_group_members) │
|
||||
│ • Mount validation (external allowlist) │
|
||||
│ • Container lifecycle │
|
||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Specification
|
||||
|
||||
> **⚠️ Historical v1 spec.** This document describes the original NanoClaw v1 architecture — the single `store/messages.db`, the file-based IPC watcher, the `task-scheduler.ts` loop, the `MAX_CONCURRENT_CONTAINERS` cap, and the `groups/{channel}_{name}/` folder convention. **None of these exist in v2.** v2 replaced them with the two-DB session split (`inbound.db`/`outbound.db`), the entity model (users → messaging groups → agent groups → sessions), and the system-action delivery path. Kept for reference only. For the current architecture start at [architecture.md](architecture.md) and the root [CLAUDE.md](../CLAUDE.md); the v1→v2 diff is in [v1-to-v2-changes.md](v1-to-v2-changes.md).
|
||||
|
||||
A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
|
||||
|
||||
---
|
||||
|
||||
@@ -596,7 +596,7 @@ Schedule a one-shot or recurring task.
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: write a `messages_in` row (to self) with `kind: 'task'`, `process_after`, and optionally `recurrence`. The host sweep picks it up when due.
|
||||
Implementation: the container can't write host-owned `inbound.db`, so this writes a `messages_out` row with `kind: 'system'` and `action: 'schedule_task'` (`container/agent-runner/src/mcp-tools/scheduling.ts`). During delivery the host's action handler (`src/modules/scheduling/actions.ts` → `insertTask()` in `src/modules/scheduling/db.ts`) inserts the `kind: 'task'` row into `inbound.db` with `process_after` and optionally `recurrence`. The host sweep picks it up when due.
|
||||
|
||||
#### list_tasks
|
||||
|
||||
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
|
||||
}
|
||||
```
|
||||
|
||||
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
|
||||
Implementation: a read, not a write — the container may read the read-only `inbound.db` mount directly. Returns one row per series (the live pending/paused occurrence): `SELECT series_id AS id, ... FROM messages_in WHERE kind = 'task' AND status IN ('pending','paused') GROUP BY series_id`. See `container/agent-runner/src/mcp-tools/scheduling.ts`.
|
||||
|
||||
#### cancel_task / pause_task / resume_task / update_task
|
||||
|
||||
@@ -625,7 +625,7 @@ Modify a scheduled task.
|
||||
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
|
||||
```
|
||||
|
||||
Implementation: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
Implementation: all four are sent as system actions (`messages_out`, `kind: 'system'`, `action: 'cancel_task' | 'pause_task' | 'resume_task' | 'update_task'`) — the container never writes `inbound.db`. The host's handlers in `src/modules/scheduling/actions.ts` apply the change against `inbound.db` via `src/modules/scheduling/db.ts`: cancel/pause/resume flip status on the live row(s); update_task reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
|
||||
|
||||
#### register_agent_group
|
||||
|
||||
@@ -712,9 +712,9 @@ These are ephemeral to the container's lifetime. When the container is killed an
|
||||
|
||||
The agent-runner receives configuration via:
|
||||
|
||||
- **Environment variables:** `AGENT_PROVIDER` (claude/codex/opencode), `NANOCLAW_ADMIN_USER_ID`, provider-specific vars (API keys, model overrides), `TZ`
|
||||
- **`container.json`:** The provider name, model, assistant name, MCP servers, and other NanoClaw config are read from `/workspace/agent/container.json` (materialized by the host from the `container_configs` table), not from environment variables. See `container/agent-runner/src/config.ts`.
|
||||
- **Environment variables:** provider-specific vars only (API keys, model overrides), `TZ`.
|
||||
- **Fixed mount paths:** Session DB at `/workspace/session.db`. Agent group folder at `/workspace/agent/`. System prompt from `/workspace/agent/CLAUDE.md` and `/workspace/global/CLAUDE.md`.
|
||||
- **Optional startup config:** Some config may be passed as a JSON file at a fixed path (e.g., `/workspace/config.json`) for things like the session ID to resume, assistant name, and admin user ID. This avoids overloading environment variables.
|
||||
|
||||
The agent-runner reads config, creates the provider, and enters the poll loop. No stdin, no initial prompt — messages are already in the session DB.
|
||||
|
||||
@@ -731,7 +731,7 @@ function createProvider(name: ProviderName, config: ProviderConfig): AgentProvid
|
||||
}
|
||||
```
|
||||
|
||||
The provider name comes from the container's environment (`AGENT_PROVIDER` env var), set by the host based on `agent_groups.agent_provider` or `sessions.agent_provider`.
|
||||
The provider name comes from the `provider` key in `/workspace/agent/container.json` (defaulting to `'claude'`), which the host materializes from the `container_configs` table — set it with `ncl groups config update --provider`. It is not an environment variable.
|
||||
|
||||
`ProviderConfig` contains provider-specific settings (API keys, model overrides, etc.) passed via environment variables — not via the interface. Each provider reads what it needs from `env`.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ flowchart TB
|
||||
subgraph Session["Per-Session Container (Docker / Apple Container)"]
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
|
||||
Provider["Agent providers<br/>(claude, opencode; todo: codex)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# NanoClaw Architecture (Draft)
|
||||
|
||||
> **Draft — design intent, not a line-by-line spec.** Some passages predate the current implementation and can drift from it. The root [CLAUDE.md](../CLAUDE.md) and the cited source files (`src/`, `container/agent-runner/src/`) are the source of truth; when this doc and the code disagree, trust the code. Notably, scheduling MCP tools do **not** write `inbound.db` directly — they emit `messages_out` system actions that the host applies (see [agent-runner-details.md](agent-runner-details.md) and `src/modules/scheduling/`).
|
||||
|
||||
## Core Idea
|
||||
|
||||
Each agent session has a mounted SQLite DB. The DB is the one and only IO mechanism between host and container. No IPC files, no stdin piping. Two tables: messages_in (host → agent-runner) and messages_out (agent-runner → host). Everything is a message.
|
||||
@@ -128,7 +130,6 @@ Non-Chat-SDK channels (WhatsApp via Baileys, Gmail, custom integrations) impleme
|
||||
The host is an orchestrator:
|
||||
1. **Spawn** — when wakeUpAgent is called and no container exists for the session
|
||||
2. **Idle kill** — when a container has no unprocessed messages for some timeout period
|
||||
3. **Limits** — MAX_CONCURRENT_CONTAINERS caps active containers
|
||||
|
||||
When a container spins up, the agent-runner immediately starts polling its session DB. Messages are already there waiting.
|
||||
|
||||
@@ -244,7 +245,7 @@ One-shot and recurring tasks use the same tables — no separate scheduler.
|
||||
|
||||
**Active container poll** (~1s) checks the same conditions but only for sessions with running containers.
|
||||
|
||||
**Agent-runner creates schedules** by writing messages_in (to itself) or messages_out (reminders/notifications) with `process_after` and optionally `recurrence`.
|
||||
**Agent-runner creates schedules** by emitting a `messages_out` row with `kind: 'system'` and an `action` (`schedule_task`, `cancel_task`, …) — it cannot write host-owned `inbound.db` directly. The host applies the action during delivery (`src/modules/scheduling/actions.ts`), inserting/updating the `kind: 'task'` `messages_in` row with `process_after` and optionally `recurrence`.
|
||||
|
||||
### messages_in content by kind
|
||||
|
||||
@@ -555,7 +556,7 @@ const DISCORD_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||
const GMAIL_CREDS = process.env.GMAIL_CREDENTIALS_PATH;
|
||||
```
|
||||
|
||||
Shared config (DATA_DIR, TIMEZONE, MAX_CONCURRENT_CONTAINERS) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
|
||||
Shared config (DATA_DIR, TIMEZONE) stays in `config.ts`. Channel/skill-specific config stays in the module that uses it.
|
||||
|
||||
### Code Style
|
||||
|
||||
@@ -829,7 +830,7 @@ Mixed batches (e.g., a chat message + a system result both pending) are combined
|
||||
|
||||
### MCP Tools
|
||||
|
||||
MCP tools write directly to the session DB.
|
||||
MCP tools write to the container's own `outbound.db`. Anything that needs a change in host-owned `inbound.db` (schedule/cancel/pause/resume/update a task, register a group) is emitted as a `kind: 'system'` `messages_out` action that the host applies during delivery — the container never writes `inbound.db`.
|
||||
|
||||
**Core tools:**
|
||||
|
||||
@@ -837,9 +838,9 @@ MCP tools write directly to the session DB.
|
||||
|------|-------------|
|
||||
| `send_message` | Write `messages_out` row, `kind: 'chat'` |
|
||||
| `send_file` | Move file to `outbox/{msg_id}/`, write `messages_out` with filenames |
|
||||
| `schedule_task` | Write `messages_in` row (to self) with `process_after` + `recurrence`. Or `messages_out` with `deliver_after` for outbound reminders. |
|
||||
| `list_tasks` | Query `messages_in WHERE recurrence IS NOT NULL` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Modify `messages_in` rows (update status, clear/set recurrence) |
|
||||
| `schedule_task` | Write `messages_out`, `kind: 'system'`, `action: 'schedule_task'`; host inserts the `kind: 'task'` `messages_in` row with `process_after` + optional `recurrence` |
|
||||
| `list_tasks` | Read `messages_in` (read-only mount) — one row per series: `kind = 'task' AND status IN ('pending','paused') GROUP BY series_id` |
|
||||
| `pause_task` / `resume_task` / `cancel_task` | Write `messages_out`, `kind: 'system'`, matching `action`; host updates the live `messages_in` row(s) |
|
||||
| `register_agent_group` | Write `messages_out`, `kind: 'system'`, `action: 'register_agent_group'` |
|
||||
|
||||
**New tools:**
|
||||
|
||||
@@ -17,7 +17,7 @@ data/v2-sessions/<agent_group_id>/<session_id>/
|
||||
outbox/<message_id>/ ← attachments the agent produced
|
||||
```
|
||||
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`, `agent-runner-src/`) that is shared across every session of that agent group.
|
||||
One session = one folder = one pair of DBs. The `agent_group_id` parent directory also holds per-group state (`.claude-shared/`) that is shared across every session of that agent group. (The agent-runner source is not copied per group — it's a shared read-only mount from `container/agent-runner/src` into every container; see `src/container-runner.ts`.)
|
||||
|
||||
Path helpers in `src/session-manager.ts`: `sessionDir()`, `inboundDbPath()`, `outboundDbPath()`, `heartbeatPath()`.
|
||||
|
||||
@@ -55,7 +55,7 @@ CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
|
||||
Content shapes: see [api-details.md §Session DB Schema Details](api-details.md#session-db-schema-details).
|
||||
|
||||
**Writers (host):** `insertMessage()`, `insertTask()`, `insertRecurrence()` — all in `src/db/session-db.ts`. Each calls `nextEvenSeq()`.
|
||||
**Writers (host):** `insertMessage()` (and `nextEvenSeq()`) in `src/db/session-db.ts`; `insertTask()` and `insertRecurrence()` in `src/modules/scheduling/db.ts`. Each calls `nextEvenSeq()`.
|
||||
**Reader (container):** `container/agent-runner/src/db/messages-in.ts` — polls `status='pending' AND (process_after IS NULL OR process_after <= now)`.
|
||||
|
||||
### 2.2 `delivered`
|
||||
|
||||
@@ -35,7 +35,6 @@ data/
|
||||
v2-sessions/
|
||||
<agent_group_id>/
|
||||
.claude-shared/ ← shared Claude state for the agent group
|
||||
agent-runner-src/ ← per-group agent-runner overlay
|
||||
<session_id>/
|
||||
inbound.db ← host writes, container reads
|
||||
outbound.db ← container writes, host reads
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
# Running NanoClaw in Docker Sandboxes (Manual Setup)
|
||||
|
||||
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host (macOS / Windows WSL)
|
||||
└── Docker Sandbox (micro VM with isolated kernel)
|
||||
├── NanoClaw process (Node.js)
|
||||
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
|
||||
│ └── Container spawner → nested Docker daemon
|
||||
└── Docker-in-Docker
|
||||
└── nanoclaw-agent containers
|
||||
└── Claude Agent SDK
|
||||
```
|
||||
|
||||
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
|
||||
|
||||
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
|
||||
|
||||
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker Desktop v4.40+** with Sandbox support
|
||||
- **Anthropic API key** (the sandbox proxy manages injection)
|
||||
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
|
||||
- For **WhatsApp**: a phone with WhatsApp installed
|
||||
|
||||
Verify sandbox support:
|
||||
```bash
|
||||
docker sandbox version
|
||||
```
|
||||
|
||||
## Step 1: Create the Sandbox
|
||||
|
||||
On your host machine:
|
||||
|
||||
```bash
|
||||
# Create a workspace directory
|
||||
mkdir -p ~/nanoclaw-workspace
|
||||
|
||||
# Create a shell sandbox with the workspace mounted
|
||||
docker sandbox create shell ~/nanoclaw-workspace
|
||||
```
|
||||
|
||||
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
|
||||
|
||||
```bash
|
||||
docker sandbox network proxy shell-nanoclaw-workspace \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
Telegram does not need proxy bypass.
|
||||
|
||||
Enter the sandbox:
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
```
|
||||
|
||||
## Step 2: Install Prerequisites
|
||||
|
||||
Inside the sandbox:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y build-essential python3
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
## Step 3: Clone and Install NanoClaw
|
||||
|
||||
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
|
||||
|
||||
```bash
|
||||
# Clone to home first (virtiofs can corrupt git pack files during clone)
|
||||
cd ~
|
||||
git clone https://github.com/nanocoai/nanoclaw.git
|
||||
|
||||
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
|
||||
WORKSPACE=/Users/you/nanoclaw-workspace
|
||||
|
||||
# Move into workspace so DinD mounts work
|
||||
mv nanoclaw "$WORKSPACE/nanoclaw"
|
||||
cd "$WORKSPACE/nanoclaw"
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
pnpm install https-proxy-agent
|
||||
```
|
||||
|
||||
## Step 4: Apply Proxy and Sandbox Patches
|
||||
|
||||
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
|
||||
|
||||
### 4a. Dockerfile — proxy args for container image build
|
||||
|
||||
`pnpm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
|
||||
|
||||
Add these lines after the `FROM` line:
|
||||
|
||||
```dockerfile
|
||||
# Accept proxy build args
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
ARG no_proxy
|
||||
ARG NODE_EXTRA_CA_CERTS
|
||||
ARG npm_config_strict_ssl=true
|
||||
RUN npm config set strict-ssl ${npm_config_strict_ssl}
|
||||
```
|
||||
|
||||
And after the `RUN pnpm install` line:
|
||||
|
||||
```dockerfile
|
||||
RUN npm config set strict-ssl true
|
||||
```
|
||||
|
||||
### 4b. Build script — forward proxy args
|
||||
|
||||
Patch `container/build.sh` to pass proxy env vars to `docker build`:
|
||||
|
||||
Add these `--build-arg` flags to the `docker build` command:
|
||||
|
||||
```bash
|
||||
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
|
||||
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
|
||||
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
|
||||
--build-arg npm_config_strict_ssl=false \
|
||||
```
|
||||
|
||||
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
|
||||
|
||||
Three changes to `src/container-runner.ts`:
|
||||
|
||||
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
|
||||
|
||||
```typescript
|
||||
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
|
||||
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
|
||||
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
|
||||
// Use emptyEnvPath instead of '/dev/null' in the mount
|
||||
```
|
||||
|
||||
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
|
||||
|
||||
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
|
||||
|
||||
```typescript
|
||||
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
|
||||
if (caCertSrc) {
|
||||
const certDir = path.join(DATA_DIR, 'ca-cert');
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
|
||||
// Mount: certDir -> /workspace/ca-cert (read-only)
|
||||
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
|
||||
}
|
||||
```
|
||||
|
||||
### 4d. Container runtime — prevent self-termination
|
||||
|
||||
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
|
||||
|
||||
```typescript
|
||||
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
|
||||
```
|
||||
|
||||
### 4e. Credential proxy — route through MITM proxy
|
||||
|
||||
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
|
||||
|
||||
```typescript
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
||||
// Pass upstreamAgent to https.request() options
|
||||
```
|
||||
|
||||
### 4f. Setup script — proxy build args
|
||||
|
||||
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
|
||||
|
||||
## Step 5: Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
bash container/build.sh
|
||||
```
|
||||
|
||||
## Step 6: Add a Channel
|
||||
|
||||
### Telegram
|
||||
|
||||
```bash
|
||||
# Apply the Telegram skill
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
||||
|
||||
# Rebuild after applying the skill
|
||||
pnpm run build
|
||||
|
||||
# Configure .env
|
||||
cat > .env << EOF
|
||||
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# Register your chat
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "tg:<your-chat-id>" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "telegram_main" \
|
||||
--channel telegram \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**To find your chat ID:** Send any message to your bot, then:
|
||||
```bash
|
||||
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
|
||||
|
||||
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
|
||||
|
||||
```bash
|
||||
# Apply the WhatsApp skill
|
||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
|
||||
|
||||
# Rebuild
|
||||
pnpm run build
|
||||
|
||||
# Configure .env
|
||||
cat > .env << EOF
|
||||
ASSISTANT_NAME=nanoclaw
|
||||
ANTHROPIC_API_KEY=proxy-managed
|
||||
EOF
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
|
||||
# Authenticate (choose one):
|
||||
|
||||
# QR code — scan with WhatsApp camera:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
|
||||
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
|
||||
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
|
||||
|
||||
# Register your chat (JID = your phone number + @s.whatsapp.net)
|
||||
pnpm exec tsx setup/index.ts --step register \
|
||||
--jid "<phone>@s.whatsapp.net" \
|
||||
--name "My Chat" \
|
||||
--trigger "@nanoclaw" \
|
||||
--folder "whatsapp_main" \
|
||||
--channel whatsapp \
|
||||
--assistant-name "nanoclaw" \
|
||||
--is-main \
|
||||
--no-trigger-required
|
||||
```
|
||||
|
||||
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
|
||||
|
||||
### Both Channels
|
||||
|
||||
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
|
||||
|
||||
## Step 7: Run
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
|
||||
|
||||
## Networking Details
|
||||
|
||||
### How the proxy works
|
||||
|
||||
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
|
||||
|
||||
```
|
||||
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
|
||||
```
|
||||
|
||||
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
|
||||
|
||||
### Shared paths for DinD mounts
|
||||
|
||||
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
|
||||
- `/dev/null` → replace with an empty file in the project dir
|
||||
- `/usr/local/share/ca-certificates/` → copy cert to project dir
|
||||
- `/home/agent/` → clone to workspace instead
|
||||
|
||||
### Git clone and virtiofs
|
||||
|
||||
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN
|
||||
```bash
|
||||
npm config set strict-ssl false
|
||||
```
|
||||
|
||||
### Container build fails with proxy errors
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg http_proxy=$http_proxy \
|
||||
--build-arg https_proxy=$https_proxy \
|
||||
-t nanoclaw-agent:latest container/
|
||||
```
|
||||
|
||||
### Agent containers fail with "path not shared"
|
||||
All bind-mounted paths must be under the workspace directory. Check:
|
||||
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
|
||||
- Is the CA cert copied to the project root?
|
||||
- Has the empty `.env` shadow file been created?
|
||||
|
||||
### Agent containers can't reach Anthropic API
|
||||
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
|
||||
|
||||
### WhatsApp error 405
|
||||
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
|
||||
|
||||
### WhatsApp "Connection failed" immediately
|
||||
Proxy bypass not configured. From the **host**, run:
|
||||
```bash
|
||||
docker sandbox network proxy <sandbox-name> \
|
||||
--bypass-host web.whatsapp.com \
|
||||
--bypass-host "*.whatsapp.com" \
|
||||
--bypass-host "*.whatsapp.net"
|
||||
```
|
||||
|
||||
### Telegram bot doesn't receive messages
|
||||
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
|
||||
2. Check Group Privacy is disabled in @BotFather if using in groups
|
||||
|
||||
### Git clone fails with "inflate: data stream error"
|
||||
Clone to a non-workspace path first, then move:
|
||||
```bash
|
||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
```
|
||||
|
||||
### WhatsApp QR code doesn't display
|
||||
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
|
||||
```bash
|
||||
docker sandbox run shell-nanoclaw-workspace
|
||||
# Then inside:
|
||||
pnpm exec tsx src/whatsapp-auth.ts
|
||||
```
|
||||
@@ -1,166 +0,0 @@
|
||||
# Main
|
||||
|
||||
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
- Answer questions and have conversations
|
||||
- Search the web and fetch content from URLs
|
||||
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
|
||||
- Read and write files in your workspace
|
||||
- Run bash commands in your sandbox
|
||||
- Schedule tasks to run later or on a recurring basis
|
||||
- Send messages back to the chat
|
||||
|
||||
## Communication
|
||||
|
||||
Be concise — every message costs the reader's attention.
|
||||
|
||||
### Destinations
|
||||
|
||||
Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `<message to="name">...</message>` block:
|
||||
|
||||
```
|
||||
<message to="family">On my way home, 15 minutes</message>
|
||||
<message to="worker-1">kick off the pipeline</message>
|
||||
```
|
||||
|
||||
Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name.
|
||||
|
||||
### Mid-turn updates
|
||||
|
||||
Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
|
||||
|
||||
- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response.
|
||||
- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message.
|
||||
- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
|
||||
|
||||
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
|
||||
|
||||
**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did.
|
||||
|
||||
### Internal thoughts
|
||||
|
||||
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `<message>` blocks is also treated as scratchpad. With a single destination, only explicit `<internal>` tags are scratchpad; the rest of your response is sent.
|
||||
|
||||
```
|
||||
<internal>Compiled all three reports, ready to summarize.</internal>
|
||||
|
||||
Here are the key findings from the research…
|
||||
```
|
||||
|
||||
### Sub-agents and teammates
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
|
||||
|
||||
## Your Workspace
|
||||
|
||||
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
|
||||
|
||||
## Memory
|
||||
|
||||
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
|
||||
|
||||
When you learn something important:
|
||||
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
|
||||
- Split files larger than 500 lines into folders
|
||||
- Keep an index in your memory for the files you create
|
||||
|
||||
## Message Formatting
|
||||
|
||||
Format messages based on the channel you're responding to. Check your group folder name:
|
||||
|
||||
### Slack channels (folder starts with `slack_`)
|
||||
|
||||
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
|
||||
- `*bold*` (single asterisks)
|
||||
- `_italic_` (underscores)
|
||||
- `<https://url|link text>` for links (NOT `[text](url)`)
|
||||
- `•` bullets (no numbered lists)
|
||||
- `:emoji:` shortcodes
|
||||
- `>` for block quotes
|
||||
- No `##` headings — use `*Bold text*` instead
|
||||
|
||||
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
|
||||
|
||||
- `*bold*` (single asterisks, NEVER **double**)
|
||||
- `_italic_` (underscores)
|
||||
- `•` bullet points
|
||||
- ` ``` ` code blocks
|
||||
|
||||
No `##` headings. No `[links](url)`. No `**double stars**`.
|
||||
|
||||
### Discord channels (folder starts with `discord_`)
|
||||
|
||||
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
|
||||
|
||||
---
|
||||
|
||||
## Installing Packages & Tools
|
||||
|
||||
Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools:
|
||||
|
||||
1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
|
||||
2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes.
|
||||
|
||||
Example flow:
|
||||
```
|
||||
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
|
||||
# → Admin gets an approval card → approves
|
||||
request_rebuild({ reason: "Apply ffmpeg + transformers" })
|
||||
# → Admin approves → image rebuilt with the packages
|
||||
```
|
||||
|
||||
**When to use this vs workspace pnpm install:**
|
||||
- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies
|
||||
- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH
|
||||
|
||||
### MCP Servers
|
||||
|
||||
Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
|
||||
|
||||
```
|
||||
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
|
||||
request_rebuild({ reason: "Add memory MCP server" })
|
||||
```
|
||||
|
||||
## Task Scripts
|
||||
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
|
||||
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows.
|
||||
|
||||
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
|
||||
|
||||
### How it works
|
||||
|
||||
1. You provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first (30-second timeout)
|
||||
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script in your sandbox to verify it works:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
|
||||
|
||||
- Explain that each wake-up uses API credits and risks rate limits
|
||||
- Suggest restructuring with a script that checks the condition first
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
@@ -1,312 +0,0 @@
|
||||
@./.claude-global.md
|
||||
# Main
|
||||
|
||||
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
|
||||
|
||||
## What You Can Do
|
||||
|
||||
- Answer questions and have conversations
|
||||
- Search the web and fetch content from URLs
|
||||
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
|
||||
- Read and write files in your workspace
|
||||
- Run bash commands in your sandbox
|
||||
- Schedule tasks to run later or on a recurring basis
|
||||
- Send messages back to the chat
|
||||
|
||||
## Communication
|
||||
|
||||
Your output is sent to the user or group.
|
||||
|
||||
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
|
||||
|
||||
### Internal thoughts
|
||||
|
||||
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
|
||||
|
||||
```
|
||||
<internal>Compiled all three reports, ready to summarize.</internal>
|
||||
|
||||
Here are the key findings from the research...
|
||||
```
|
||||
|
||||
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
|
||||
|
||||
### Sub-agents and teammates
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
|
||||
|
||||
## Memory
|
||||
|
||||
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
|
||||
|
||||
When you learn something important:
|
||||
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
|
||||
- Split files larger than 500 lines into folders
|
||||
- Keep an index in your memory for the files you create
|
||||
|
||||
## Message Formatting
|
||||
|
||||
Format messages based on the channel. Check the group folder name prefix:
|
||||
|
||||
### Slack channels (folder starts with `slack_`)
|
||||
|
||||
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
|
||||
- `*bold*` (single asterisks)
|
||||
- `_italic_` (underscores)
|
||||
- `<https://url|link text>` for links (NOT `[text](url)`)
|
||||
- `•` bullets (no numbered lists)
|
||||
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
|
||||
- `>` for block quotes
|
||||
- No `##` headings — use `*Bold text*` instead
|
||||
|
||||
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
|
||||
|
||||
- `*bold*` (single asterisks, NEVER **double**)
|
||||
- `_italic_` (underscores)
|
||||
- `•` bullet points
|
||||
- ` ``` ` code blocks
|
||||
|
||||
No `##` headings. No `[links](url)`. No `**double stars**`.
|
||||
|
||||
### Discord (folder starts with `discord_`)
|
||||
|
||||
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
|
||||
|
||||
---
|
||||
|
||||
## Admin Context
|
||||
|
||||
This is the **main channel**, which has elevated privileges.
|
||||
|
||||
## Authentication
|
||||
|
||||
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
|
||||
|
||||
## Container Mounts
|
||||
|
||||
Main has read-only access to the project, read-write access to the store (SQLite DB), and read-write access to its group folder:
|
||||
|
||||
| Container Path | Host Path | Access |
|
||||
|----------------|-----------|--------|
|
||||
| `/workspace/project` | Project root | read-only |
|
||||
| `/workspace/project/store` | `store/` | read-write |
|
||||
| `/workspace/group` | `groups/main/` | read-write |
|
||||
|
||||
Key paths inside the container:
|
||||
- `/workspace/project/store/messages.db` - SQLite database (read-write)
|
||||
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
|
||||
- `/workspace/project/groups/` - All group folders
|
||||
|
||||
---
|
||||
|
||||
## Managing Groups
|
||||
|
||||
### Finding Available Groups
|
||||
|
||||
Available groups are provided in `/workspace/ipc/available_groups.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"jid": "120363336345536173@g.us",
|
||||
"name": "Family Chat",
|
||||
"lastActivity": "2026-01-31T12:00:00.000Z",
|
||||
"isRegistered": false
|
||||
}
|
||||
],
|
||||
"lastSync": "2026-01-31T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
|
||||
|
||||
If a group the user mentions isn't in the list, request a fresh sync:
|
||||
|
||||
```bash
|
||||
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
|
||||
```
|
||||
|
||||
Then wait a moment and re-read `available_groups.json`.
|
||||
|
||||
**Fallback**: Query the SQLite database directly:
|
||||
|
||||
```bash
|
||||
sqlite3 /workspace/project/store/messages.db "
|
||||
SELECT jid, name, last_message_time
|
||||
FROM chats
|
||||
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
|
||||
ORDER BY last_message_time DESC
|
||||
LIMIT 10;
|
||||
"
|
||||
```
|
||||
|
||||
### Registered Groups Config
|
||||
|
||||
Groups are registered in the SQLite `registered_groups` table:
|
||||
|
||||
```json
|
||||
{
|
||||
"1234567890-1234567890@g.us": {
|
||||
"name": "Family Chat",
|
||||
"folder": "whatsapp_family-chat",
|
||||
"trigger": "@Andy",
|
||||
"added_at": "2024-01-31T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
|
||||
- **name**: Display name for the group
|
||||
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
|
||||
- **trigger**: The trigger word (usually same as global, but could differ)
|
||||
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
|
||||
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
|
||||
- **added_at**: ISO timestamp when registered
|
||||
|
||||
### Trigger Behavior
|
||||
|
||||
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
|
||||
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
|
||||
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
|
||||
|
||||
### Adding a Group
|
||||
|
||||
1. Query the database to find the group's JID
|
||||
2. Ask the user whether the group should require a trigger word before registering
|
||||
3. Use the `register_group` MCP tool with the JID, name, folder, trigger, and the chosen `requiresTrigger` setting
|
||||
4. Optionally include `containerConfig` for additional mounts
|
||||
5. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
|
||||
6. Optionally create an initial `CLAUDE.md` for the group
|
||||
|
||||
Folder naming convention — channel prefix with underscore separator:
|
||||
- WhatsApp "Family Chat" → `whatsapp_family-chat`
|
||||
- Telegram "Dev Team" → `telegram_dev-team`
|
||||
- Discord "General" → `discord_general`
|
||||
- Slack "Engineering" → `slack_engineering`
|
||||
- Use lowercase, hyphens for the group name part
|
||||
|
||||
#### Adding Additional Directories for a Group
|
||||
|
||||
Groups can have extra directories mounted. Add `containerConfig` to their entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"1234567890@g.us": {
|
||||
"name": "Dev Team",
|
||||
"folder": "dev-team",
|
||||
"trigger": "@Andy",
|
||||
"added_at": "2026-01-31T12:00:00Z",
|
||||
"containerConfig": {
|
||||
"additionalMounts": [
|
||||
{
|
||||
"hostPath": "~/projects/webapp",
|
||||
"containerPath": "webapp",
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The directory will appear at `/workspace/extra/webapp` in that group's container.
|
||||
|
||||
#### Sender Allowlist
|
||||
|
||||
After registering a group, explain the sender allowlist feature to the user:
|
||||
|
||||
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
|
||||
>
|
||||
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
|
||||
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
|
||||
>
|
||||
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
|
||||
|
||||
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
|
||||
|
||||
```json
|
||||
{
|
||||
"default": { "allow": "*", "mode": "trigger" },
|
||||
"chats": {
|
||||
"<chat-jid>": {
|
||||
"allow": ["sender-id-1", "sender-id-2"],
|
||||
"mode": "trigger"
|
||||
}
|
||||
},
|
||||
"logDenied": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
|
||||
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
|
||||
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
|
||||
|
||||
### Removing a Group
|
||||
|
||||
1. Read `/workspace/project/data/registered_groups.json`
|
||||
2. Remove the entry for that group
|
||||
3. Write the updated JSON back
|
||||
4. The group folder and its files remain (don't delete them)
|
||||
|
||||
### Listing Groups
|
||||
|
||||
Read `/workspace/project/data/registered_groups.json` and format it nicely.
|
||||
|
||||
---
|
||||
|
||||
## Global Memory
|
||||
|
||||
You can read and write to `/workspace/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
|
||||
|
||||
---
|
||||
|
||||
## Scheduling for Other Groups
|
||||
|
||||
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
|
||||
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
|
||||
|
||||
The task will run in that group's context with access to their files and memory.
|
||||
|
||||
---
|
||||
|
||||
## Task Scripts
|
||||
|
||||
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
|
||||
|
||||
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
|
||||
|
||||
### How it works
|
||||
|
||||
1. You provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first (30-second timeout)
|
||||
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script in your sandbox to verify it works:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
|
||||
|
||||
- Explain that each wake-up uses API credits and risks rate limits
|
||||
- Suggest restructuring with a script that checks the condition first
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.1.24",
|
||||
"version": "2.1.38",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -21,7 +21,6 @@
|
||||
"setup:auto": "tsx setup/auto.ts",
|
||||
"ncl": "tsx src/cli/client.ts",
|
||||
"chat": "tsx scripts/chat.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -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="204k tokens, 102% of context window">
|
||||
<title>204k tokens, 102% 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="208k tokens, 104% of context window">
|
||||
<title>208k tokens, 104% 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">204k</text>
|
||||
<text x="71" y="14">204k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
|
||||
<text x="71" y="14">208k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -33,7 +33,9 @@ db.exec(`
|
||||
`);
|
||||
|
||||
// Insert test message
|
||||
db.prepare(`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`).run(
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`,
|
||||
).run(
|
||||
'test-1',
|
||||
JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }),
|
||||
);
|
||||
@@ -44,7 +46,7 @@ db.close();
|
||||
process.env.SESSION_DB_PATH = DB_PATH;
|
||||
process.env.AGENT_PROVIDER = 'claude';
|
||||
|
||||
const { getSessionDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||
const { getInboundDb, closeSessionDb } = await import('../container/agent-runner/src/db/connection.js');
|
||||
const { getUndeliveredMessages } = await import('../container/agent-runner/src/db/messages-out.js');
|
||||
const { getPendingMessages } = await import('../container/agent-runner/src/db/messages-in.js');
|
||||
const { createProvider } = await import('../container/agent-runner/src/providers/factory.js');
|
||||
|
||||
@@ -71,7 +71,7 @@ import { routeInbound } from '../src/router.js';
|
||||
import { setDeliveryAdapter, startActiveDeliveryPoll, stopDeliveryPolls } from '../src/delivery.js';
|
||||
import { getChannelAdapter, registerChannelAdapter, initChannelAdapters } from '../src/channels/channel-registry.js';
|
||||
import { findSession } from '../src/db/sessions.js';
|
||||
import { sessionDbPath } from '../src/session-manager.js';
|
||||
import { inboundDbPath } from '../src/session-manager.js';
|
||||
import type { ChannelAdapter, ChannelSetup, OutboundMessage } from '../src/channels/adapter.js';
|
||||
|
||||
// Track delivered messages
|
||||
@@ -99,7 +99,9 @@ const mockAdapter: ChannelAdapter = {
|
||||
|
||||
async setTyping() {},
|
||||
async teardown() {},
|
||||
isConnected() { return true; },
|
||||
isConnected() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// Register mock adapter
|
||||
@@ -179,15 +181,19 @@ console.log(`✓ Container status: ${session.container_status}`);
|
||||
import { execSync } from 'child_process';
|
||||
const checkContainerLogs = () => {
|
||||
try {
|
||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"').toString().trim();
|
||||
const containers = execSync('docker ps -a --filter name=nanoclaw-v2-test-channel --format "{{.Names}}"')
|
||||
.toString()
|
||||
.trim();
|
||||
for (const name of containers.split('\n').filter(Boolean)) {
|
||||
console.log(`\nContainer logs (${name}):`);
|
||||
console.log(execSync(`docker logs ${name} 2>&1`).toString());
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const sessDbPath = sessionDbPath('ag-chan', session.id);
|
||||
const sessDbPath = inboundDbPath('ag-chan', session.id);
|
||||
console.log(`✓ Session DB: ${sessDbPath}`);
|
||||
|
||||
// --- Step 4: Wait for delivery through mock adapter ---
|
||||
@@ -210,7 +216,9 @@ await new Promise<void>((resolve) => {
|
||||
console.log(` messages_out rows: ${out.length}`);
|
||||
if (out.length > 0) console.log(' (messages exist but delivery failed)');
|
||||
db.close();
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
checkContainerLogs();
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
|
||||
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# PUBLIC_KEY to .env, and restart the service. Non-interactive —
|
||||
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
|
||||
# server-invite step live in setup/channels/discord.ts. Credentials come in via
|
||||
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
|
||||
@@ -105,10 +105,6 @@ upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
|
||||
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
|
||||
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the iMessage adapter, persist mode/creds to .env + data/env/env,
|
||||
# Install the iMessage adapter, persist mode/creds to .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:
|
||||
@@ -135,10 +135,6 @@ else
|
||||
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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific
|
||||
# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to
|
||||
# .env + data/env/env, and restart the service. Non-interactive — the
|
||||
# .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, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET.
|
||||
@@ -108,10 +108,6 @@ if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then
|
||||
upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET"
|
||||
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 credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
|
||||
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# _TYPE to .env, and restart the service. Non-interactive —
|
||||
# the operator-facing Azure portal walkthroughs live in
|
||||
# setup/channels/teams.ts. Credentials come in via env vars:
|
||||
# TEAMS_APP_ID (required)
|
||||
@@ -114,10 +114,6 @@ if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
|
||||
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 credentials…"
|
||||
# shellcheck source=setup/lib/install-slug.sh
|
||||
source "$PROJECT_ROOT/setup/lib/install-slug.sh"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
|
||||
# Install the Telegram adapter, persist the bot token to .env,
|
||||
# restart the service, and open the bot's chat page in the local Telegram
|
||||
# client. Non-interactive — the operator-facing "Create a bot" instructions
|
||||
# and token paste live in setup/auto.ts. The token comes in via the
|
||||
@@ -134,10 +134,6 @@ if echo "$INFO" | grep -q '"ok":true'; then
|
||||
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
|
||||
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
|
||||
# surprise the user. Keeping it out of here means this script stays pure
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 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).
|
||||
* 4. Persist SIGNAL_ACCOUNT to .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
|
||||
@@ -333,7 +333,7 @@ async function renderQr(url: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */
|
||||
/** Persist SIGNAL_ACCOUNT to .env. */
|
||||
function writeSignalAccount(account: string): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
@@ -353,10 +353,6 @@ function writeSignalAccount(account: string): void {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env. */
|
||||
function writeAssistantHasOwnNumber(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
@@ -452,11 +452,6 @@ function writeAssistantHasOwnNumber(): void {
|
||||
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
// Container reads from data/env/env.
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
|
||||
@@ -116,15 +116,6 @@ function main(): void {
|
||||
channelsProcessed++;
|
||||
}
|
||||
|
||||
// Sync to data/env/env
|
||||
if (fs.existsSync(v2EnvPath)) {
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
console.log(`OK:channels=${channelsProcessed},env_keys=${envKeysCopied},files=${filesCopied}`);
|
||||
if (missing.length > 0) {
|
||||
console.log(`MISSING:${missing.join(',')}`);
|
||||
|
||||
@@ -65,15 +65,6 @@ function main(): void {
|
||||
fs.writeFileSync(v2EnvPath, result);
|
||||
}
|
||||
|
||||
// Sync to data/env/env (container reads from here)
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
try {
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(v2EnvPath, path.join(containerEnvDir, 'env'));
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
console.log(`OK:copied=${copied.length},skipped=${skipped.length}`);
|
||||
if (copied.length > 0) console.log(`COPIED:${copied.join(',')}`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Step: set-env — Write or update a KEY=VALUE in .env, with optional sync to
|
||||
* data/env/env (the container-mounted copy).
|
||||
* Step: set-env — Write or update a KEY=VALUE in .env.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx setup/index.ts --step set-env -- \
|
||||
* --key TELEGRAM_BOT_TOKEN --value "<token>" [--sync-container]
|
||||
* --key TELEGRAM_BOT_TOKEN --value "<token>"
|
||||
*
|
||||
* Exists so channel-install flows don't have to invent grep/sed/rm pipelines
|
||||
* (which can't be allowlisted tightly — sed can read any file, and each
|
||||
@@ -21,7 +20,6 @@ import { emitStatus } from './status.js';
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const keyIdx = args.indexOf('--key');
|
||||
const valueIdx = args.indexOf('--value');
|
||||
const syncContainer = args.includes('--sync-container');
|
||||
|
||||
if (keyIdx === -1 || !args[keyIdx + 1]) {
|
||||
throw new Error('--key <KEY> is required');
|
||||
@@ -59,19 +57,9 @@ export async function run(args: string[]): Promise<void> {
|
||||
fs.writeFileSync(envFile, content);
|
||||
log.info('Updated .env', { key, existed });
|
||||
|
||||
let synced = false;
|
||||
if (syncContainer) {
|
||||
const dataEnvDir = path.join(projectRoot, 'data', 'env');
|
||||
fs.mkdirSync(dataEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envFile, path.join(dataEnvDir, 'env'));
|
||||
synced = true;
|
||||
log.info('Synced .env to container mount', { path: 'data/env/env' });
|
||||
}
|
||||
|
||||
emitStatus('SET_ENV', {
|
||||
KEY: key,
|
||||
EXISTED: existed,
|
||||
SYNCED_TO_CONTAINER: synced,
|
||||
STATUS: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeOption, normalizeOptions } from './ask-question.js';
|
||||
|
||||
describe('normalizeOption — style whitelist', () => {
|
||||
// The style value flows straight into the Chat SDK Button() and from there
|
||||
// into Slack Block Kit. Slack rejects the *entire* message with
|
||||
// invalid_blocks if a button carries an unknown style, which in the
|
||||
// approval flow means the card never renders — an effective auto-deny.
|
||||
// So anything outside the whitelist must drop to undefined here.
|
||||
|
||||
it.each(['primary', 'danger', 'default'] as const)('passes through the known style %j', (style) => {
|
||||
expect(normalizeOption({ label: 'Approve', style }).style).toBe(style);
|
||||
});
|
||||
|
||||
it('drops unknown style strings to undefined', () => {
|
||||
for (const bad of ['success', 'warning', 'PRIMARY', 'Danger', ' primary', 'primary ', '', 'red']) {
|
||||
const opt = normalizeOption({ label: 'Approve', style: bad as never });
|
||||
expect(opt.style, `style ${JSON.stringify(bad)} should be dropped`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops non-string style values to undefined', () => {
|
||||
for (const bad of [1, true, null, {}, ['primary']]) {
|
||||
const opt = normalizeOption({ label: 'Approve', style: bad as never });
|
||||
expect(opt.style, `style ${JSON.stringify(bad)} should be dropped`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves style undefined when the object option omits it', () => {
|
||||
expect(normalizeOption({ label: 'Approve' }).style).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives string-shorthand options no style', () => {
|
||||
const opt = normalizeOption('Approve');
|
||||
expect(opt).toEqual({ label: 'Approve', selectedLabel: 'Approve', value: 'Approve' });
|
||||
expect('style' in opt && opt.style !== undefined).toBe(false);
|
||||
});
|
||||
|
||||
it('style coexists with the label/selectedLabel/value defaulting', () => {
|
||||
// Defaults still fill in around an explicit style…
|
||||
expect(normalizeOption({ label: 'Approve', style: 'primary' })).toEqual({
|
||||
label: 'Approve',
|
||||
selectedLabel: 'Approve',
|
||||
value: 'Approve',
|
||||
style: 'primary',
|
||||
});
|
||||
// …and explicit fields are untouched by the style whitelist.
|
||||
expect(normalizeOption({ label: 'Deny', selectedLabel: 'Denied', value: 'deny-1', style: 'danger' })).toEqual({
|
||||
label: 'Deny',
|
||||
selectedLabel: 'Denied',
|
||||
value: 'deny-1',
|
||||
style: 'danger',
|
||||
});
|
||||
// An invalid style must not disturb the rest of the normalization.
|
||||
expect(normalizeOption({ label: 'Deny', value: 'deny-1', style: 'bogus' as never })).toEqual({
|
||||
label: 'Deny',
|
||||
selectedLabel: 'Deny',
|
||||
value: 'deny-1',
|
||||
style: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeOptions', () => {
|
||||
it('normalizes mixed string and object options, preserving order and per-option styles', () => {
|
||||
const out = normalizeOptions([
|
||||
'Skip',
|
||||
{ label: 'Approve', style: 'primary' },
|
||||
{ label: 'Deny', style: 'danger' },
|
||||
{ label: 'Later', style: 'lime' as never },
|
||||
]);
|
||||
expect(out.map((o) => o.label)).toEqual(['Skip', 'Approve', 'Deny', 'Later']);
|
||||
expect(out.map((o) => o.style)).toEqual([undefined, 'primary', 'danger', undefined]);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,15 @@
|
||||
* and rendering.
|
||||
*/
|
||||
|
||||
/** Chat SDK button styles — Slack maps primary→green, danger→red; platforms
|
||||
* without button colors (Telegram) ignore it. */
|
||||
export type OptionStyle = 'primary' | 'danger' | 'default';
|
||||
|
||||
export interface OptionInput {
|
||||
label: string;
|
||||
selectedLabel?: string;
|
||||
value?: string;
|
||||
style?: OptionStyle;
|
||||
}
|
||||
|
||||
export type RawOption = string | OptionInput;
|
||||
@@ -19,6 +24,7 @@ export interface NormalizedOption {
|
||||
label: string;
|
||||
selectedLabel: string;
|
||||
value: string;
|
||||
style?: OptionStyle;
|
||||
}
|
||||
|
||||
export function normalizeOption(raw: RawOption): NormalizedOption {
|
||||
@@ -30,6 +36,7 @@ export function normalizeOption(raw: RawOption): NormalizedOption {
|
||||
label,
|
||||
selectedLabel: raw.selectedLabel ?? label,
|
||||
value: raw.value ?? label,
|
||||
style: raw.style === 'primary' || raw.style === 'danger' || raw.style === 'default' ? raw.style : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,77 @@ describe('createChatSdkBridge.setup — webhook route and state namespace', () =
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge.deliver — ask_question cards (button styles)', () => {
|
||||
// Approval cards color their buttons (Slack: primary→green, danger→red).
|
||||
// The bridge must forward the normalized option style into Button() and
|
||||
// omit it when unset — an invalid style surviving to Block Kit would fail
|
||||
// the whole card with invalid_blocks (effective auto-deny).
|
||||
|
||||
interface CapturedButton {
|
||||
type?: string;
|
||||
id?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
function buttonsFrom(calls: PostCall[]): CapturedButton[] {
|
||||
const msg = calls[0].message as {
|
||||
card?: { children?: Array<{ type?: string; children?: CapturedButton[] }> };
|
||||
};
|
||||
const actionsRow = msg.card?.children?.find((c) => c.type === 'actions');
|
||||
expect(actionsRow).toBeDefined();
|
||||
return actionsRow?.children ?? [];
|
||||
}
|
||||
|
||||
it('passes each option style through to the Button, and omits it when unset', async () => {
|
||||
const { calls, postMessage } = makePostCapture();
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: stubAdapter({ postMessage }),
|
||||
supportsThreads: false,
|
||||
});
|
||||
await bridge.deliver('slack:C1', null, {
|
||||
kind: 'chat-sdk',
|
||||
content: {
|
||||
type: 'ask_question',
|
||||
questionId: 'q-1',
|
||||
title: 'Approval needed',
|
||||
question: 'Allow the tool call?',
|
||||
options: [
|
||||
{ label: 'Approve', style: 'primary' },
|
||||
{ label: 'Deny', style: 'danger' },
|
||||
'Skip', // string shorthand — never styled
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
const buttons = buttonsFrom(calls);
|
||||
expect(buttons.map((b) => b.label)).toEqual(['Approve', 'Deny', 'Skip']);
|
||||
expect(buttons.map((b) => b.style)).toEqual(['primary', 'danger', undefined]);
|
||||
});
|
||||
|
||||
it('drops invalid styles before they reach the Button (delivery goes through normalizeOptions)', async () => {
|
||||
const { calls, postMessage } = makePostCapture();
|
||||
const bridge = createChatSdkBridge({
|
||||
adapter: stubAdapter({ postMessage }),
|
||||
supportsThreads: false,
|
||||
});
|
||||
await bridge.deliver('slack:C1', null, {
|
||||
kind: 'chat-sdk',
|
||||
content: {
|
||||
type: 'ask_question',
|
||||
questionId: 'q-2',
|
||||
title: 'Approval needed',
|
||||
question: 'Allow the tool call?',
|
||||
options: [{ label: 'Approve', style: 'chartreuse' }],
|
||||
},
|
||||
});
|
||||
const buttons = buttonsFrom(calls);
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0].style).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChatSdkBridge.deliver — display cards (send_card)', () => {
|
||||
// The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`.
|
||||
// Before this branch existed the bridge silently dropped them: cards have no
|
||||
|
||||
@@ -439,7 +439,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// 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) }),
|
||||
Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx), style: opt.style }),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
*
|
||||
* Runs on every spawn from `container-runner.buildMounts()`. Deterministic —
|
||||
* same inputs produce the same CLAUDE.md, and stale fragments are pruned.
|
||||
*
|
||||
* See `docs/claude-md-composition.md` for the full design.
|
||||
* The composition order and fragment sources are documented inline above.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -27,7 +27,7 @@ register({
|
||||
if (cliScope === 'group') {
|
||||
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
|
||||
}
|
||||
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
|
||||
const commands = listCommands().filter((c) => !c.resource);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
|
||||
@@ -10,14 +10,13 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import { getDb } from '../db/connection.js';
|
||||
import { register } from './registry.js';
|
||||
import type { Access } from './registry.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
|
||||
export interface ColumnDef {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'json';
|
||||
|
||||
@@ -2,6 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
const approvalState = vi.hoisted(() => ({
|
||||
requestApproval: vi.fn(),
|
||||
approvalHandler: null as
|
||||
| null
|
||||
| ((args: {
|
||||
session: unknown;
|
||||
payload: Record<string, unknown>;
|
||||
userId: string;
|
||||
notify: (text: string) => void;
|
||||
}) => Promise<void>),
|
||||
registerApprovalHandler: vi.fn(
|
||||
(
|
||||
action: string,
|
||||
handler: (args: {
|
||||
session: unknown;
|
||||
payload: Record<string, unknown>;
|
||||
userId: string;
|
||||
notify: (text: string) => void;
|
||||
}) => Promise<void>,
|
||||
) => {
|
||||
if (action === 'cli_command') approvalState.approvalHandler = handler;
|
||||
},
|
||||
),
|
||||
observedContexts: [] as CallerContext[],
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
@@ -29,8 +55,8 @@ vi.mock('./crud.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../modules/approvals/index.js', () => ({
|
||||
registerApprovalHandler: vi.fn(),
|
||||
requestApproval: vi.fn(),
|
||||
registerApprovalHandler: approvalState.registerApprovalHandler,
|
||||
requestApproval: approvalState.requestApproval,
|
||||
}));
|
||||
|
||||
// Register a test command so dispatch has something to find
|
||||
@@ -98,6 +124,18 @@ register({
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'approval-context-command',
|
||||
description: 'approval command that records caller context',
|
||||
resource: 'groups',
|
||||
access: 'approval',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (_args, ctx) => {
|
||||
approvalState.observedContexts.push(ctx);
|
||||
return { caller: ctx.caller };
|
||||
},
|
||||
});
|
||||
|
||||
// Commands that return data shaped like real resources (for post-handler filtering tests)
|
||||
register({
|
||||
name: 'groups-list-data',
|
||||
@@ -147,11 +185,22 @@ register({
|
||||
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
|
||||
});
|
||||
|
||||
// Echoes args back — used to assert dash-joined positional id resolution.
|
||||
register({
|
||||
name: 'groups-get',
|
||||
description: 'test command (groups get)',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
approvalState.observedContexts.length = 0;
|
||||
// Default: the four CLI-whitelisted resources with their real scopeFields.
|
||||
const scopeFields: Record<string, string> = {
|
||||
groups: 'id',
|
||||
@@ -391,6 +440,39 @@ describe('CLI scope enforcement', () => {
|
||||
expect(mockGetContainerConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('approval replay preserves the original agent caller context', async () => {
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetSession.mockReturnValue({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' });
|
||||
mockGetAgentGroup.mockReturnValue({ id: 'g1', name: 'Group One' });
|
||||
|
||||
const ctx = agentCtx();
|
||||
const resp = await dispatch({ id: '1', command: 'approval-context-command', args: {} }, ctx);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
|
||||
const approval = approvalState.requestApproval.mock.calls[0][0] as { payload: Record<string, unknown> };
|
||||
expect(approval.payload).toEqual({
|
||||
frame: {
|
||||
id: '1',
|
||||
command: 'approval-context-command',
|
||||
args: { agent_group_id: 'g1', group: 'g1', id: 'g1' },
|
||||
},
|
||||
callerContext: ctx,
|
||||
});
|
||||
|
||||
expect(approvalState.approvalHandler).toBeTypeOf('function');
|
||||
await approvalState.approvalHandler!({
|
||||
session: { id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' },
|
||||
payload: approval.payload,
|
||||
userId: 'telegram:admin',
|
||||
notify: vi.fn(),
|
||||
});
|
||||
|
||||
expect(approvalState.observedContexts).toEqual([ctx]);
|
||||
expect(approvalState.requestApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- Post-handler filtering ---
|
||||
|
||||
it('group: groups list filters out other groups', async () => {
|
||||
@@ -512,3 +594,19 @@ describe('CLI scope enforcement', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Dash-joined positional id resolution (generated ids contain dashes) ---
|
||||
|
||||
describe('dash-joined positional id resolution', () => {
|
||||
it('resolves `groups-get-<uuid-with-dashes>` to (groups get, id=<uuid>)', async () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
const resp = await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
const data = resp.data as { echo: Record<string, unknown> };
|
||||
expect(data.echo.id).toBe(uuid);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,22 +14,36 @@ import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './fr
|
||||
import { getResource } from './crud.js';
|
||||
import { lookup } from './registry.js';
|
||||
|
||||
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
|
||||
type DispatchOptions = {
|
||||
/** True when a command is being replayed after approval. */
|
||||
approved?: boolean;
|
||||
};
|
||||
|
||||
export async function dispatch(
|
||||
req: RequestFrame,
|
||||
ctx: CallerContext,
|
||||
opts: DispatchOptions = {},
|
||||
): Promise<ResponseFrame> {
|
||||
let cmd = lookup(req.command);
|
||||
|
||||
// Fallback: if the full command isn't registered, trim the last
|
||||
// dash-segment and treat it as the target ID. This lets clients join
|
||||
// all positional args with dashes (e.g. `ncl groups get abc123`
|
||||
// → command "groups-get-abc123" → trim → "groups-get" + id "abc123").
|
||||
// Fallback: if the full command isn't registered, split the dash-joined
|
||||
// command and treat the longest registered prefix as the command, with the
|
||||
// re-joined remainder as the target ID. Clients join all positional args
|
||||
// with dashes (e.g. `ncl groups get abc123` → command "groups-get-abc123"),
|
||||
// and generated ids (UUIDs, `sess-…`, `appr-…`) themselves contain dashes,
|
||||
// so trimming a single trailing segment isn't enough — walk prefixes from
|
||||
// longest to shortest so `groups-get-<uuid-with-dashes>` still resolves to
|
||||
// "groups-get" + id "<uuid-with-dashes>".
|
||||
if (!cmd) {
|
||||
const idx = req.command.lastIndexOf('-');
|
||||
if (idx > 0) {
|
||||
const shortened = req.command.slice(0, idx);
|
||||
const tail = req.command.slice(idx + 1);
|
||||
const parts = req.command.split('-');
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const shortened = parts.slice(0, i).join('-');
|
||||
const fallback = lookup(shortened);
|
||||
if (fallback) {
|
||||
const tail = parts.slice(i).join('-');
|
||||
cmd = fallback;
|
||||
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +115,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval') {
|
||||
if (ctx.caller !== 'host' && cmd.access === 'approval' && !opts.approved) {
|
||||
const session = getSession(ctx.sessionId);
|
||||
if (!session) {
|
||||
return err(req.id, 'handler-error', 'Session not found.');
|
||||
@@ -117,7 +131,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
session,
|
||||
agentName,
|
||||
action: 'cli_command',
|
||||
payload: { frame: { id: req.id, command: req.command, args: req.args } },
|
||||
payload: { frame: { id: req.id, command: req.command, args: req.args }, callerContext: ctx },
|
||||
title: `CLI: ${req.command}`,
|
||||
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
|
||||
});
|
||||
@@ -178,9 +192,10 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
}
|
||||
}
|
||||
|
||||
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
|
||||
registerApprovalHandler('cli_command', async ({ payload, notify }) => {
|
||||
const frame = payload.frame as RequestFrame;
|
||||
const response = await dispatch(frame, { caller: 'host' });
|
||||
const callerContext = parseCallerContext(payload.callerContext) ?? { caller: 'host' };
|
||||
const response = await dispatch(frame, callerContext, { approved: true });
|
||||
|
||||
if (response.ok) {
|
||||
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||
@@ -190,6 +205,26 @@ registerApprovalHandler('cli_command', async ({ session, payload, userId, notify
|
||||
}
|
||||
});
|
||||
|
||||
function parseCallerContext(value: unknown): CallerContext | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.caller === 'host') return { caller: 'host' };
|
||||
if (
|
||||
record.caller === 'agent' &&
|
||||
typeof record.sessionId === 'string' &&
|
||||
typeof record.agentGroupId === 'string' &&
|
||||
typeof record.messagingGroupId === 'string'
|
||||
) {
|
||||
return {
|
||||
caller: 'agent',
|
||||
sessionId: record.sessionId,
|
||||
agentGroupId: record.agentGroupId,
|
||||
messagingGroupId: record.messagingGroupId,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
|
||||
return { id, ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
export type RequestFrame = {
|
||||
/** Correlation key set by the client. */
|
||||
id: string;
|
||||
/** Registry name, e.g. "list-groups". */
|
||||
/** Registry name, e.g. "groups-list". */
|
||||
command: string;
|
||||
/** Command-specific. Each command's parseArgs validates. */
|
||||
args: Record<string, unknown>;
|
||||
@@ -24,10 +24,8 @@ export type ResponseFrame =
|
||||
export type ErrorCode =
|
||||
| 'unknown-command'
|
||||
| 'invalid-args'
|
||||
| 'permission-denied'
|
||||
| 'forbidden'
|
||||
| 'approval-pending'
|
||||
| 'not-found'
|
||||
| 'handler-error'
|
||||
| 'transport-error';
|
||||
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
/**
|
||||
* Command registry — single source of truth for what `ncl` can do.
|
||||
*
|
||||
* Each command file under `commands/` calls `register()` at top level,
|
||||
* and `commands/index.ts` imports them all for side effects so the
|
||||
* registry is populated before the host's CLI server accepts connections.
|
||||
* Most commands come from resource modules under `resources/`, which call
|
||||
* `registerResource()` (one `register()` per CRUD verb); the top-level `help`
|
||||
* command and the per-resource help commands register directly. The barrel
|
||||
* `commands/index.ts` imports the resource barrel for its side effects and then
|
||||
* registers the help commands, so the registry is populated before the host's
|
||||
* CLI server accepts connections.
|
||||
*/
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
export type Access = 'open' | 'approval' | 'hidden';
|
||||
export type Access = 'open' | 'approval';
|
||||
|
||||
export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
name: string;
|
||||
description: string;
|
||||
access: Access;
|
||||
/** Resource this command belongs to (for help grouping). */
|
||||
/**
|
||||
* The group-scope whitelist key. Under `cli_scope: 'group'` the dispatcher
|
||||
* only lets an agent run commands whose `resource` is on the whitelist
|
||||
* (`groups`, `sessions`, `destinations`, `members`); it also drives help
|
||||
* grouping. Omitting `resource` exempts the command from the whitelist —
|
||||
* that's how general commands like `help` stay reachable in group scope.
|
||||
*/
|
||||
resource?: string;
|
||||
/**
|
||||
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Tests for the host-side command gate — filtered commands are dropped
|
||||
* before reaching the container, and admin commands are gated against
|
||||
* the user_roles table.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { gateCommand } from './command-gate.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { createUser } from './modules/permissions/db/users.js';
|
||||
import { grantRole } from './modules/permissions/db/user-roles.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function seedAgentGroup(id: string): void {
|
||||
createAgentGroup({ id, name: id.toUpperCase(), folder: id, agent_provider: null, created_at: now() });
|
||||
}
|
||||
|
||||
function seedUser(id: string): void {
|
||||
createUser({ id, kind: 'telegram', display_name: null, created_at: now() });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
seedAgentGroup('ag-1');
|
||||
seedAgentGroup('ag-2');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
describe('filtered commands', () => {
|
||||
it('drops /start before it reaches the container', () => {
|
||||
expect(gateCommand('/start', 'telegram:1', 'ag-1')).toEqual({ action: 'filter' });
|
||||
});
|
||||
|
||||
it('drops /start regardless of sender', () => {
|
||||
expect(gateCommand('/start', null, 'ag-1')).toEqual({ action: 'filter' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin gating goes through roles', () => {
|
||||
it('denies an admin command from a non-admin user', () => {
|
||||
expect(gateCommand('/clear', 'telegram:nobody', 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
|
||||
});
|
||||
|
||||
it('denies an admin command with no sender', () => {
|
||||
expect(gateCommand('/clear', null, 'ag-1')).toEqual({ action: 'deny', command: '/clear' });
|
||||
});
|
||||
|
||||
it('allows an admin command from an owner', () => {
|
||||
seedUser('telegram:owner');
|
||||
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
|
||||
expect(gateCommand('/clear', 'telegram:owner', 'ag-1')).toEqual({ action: 'pass' });
|
||||
});
|
||||
|
||||
it('allows an admin command from a scoped admin of the group', () => {
|
||||
seedUser('telegram:admin');
|
||||
grantRole({
|
||||
user_id: 'telegram:admin',
|
||||
role: 'admin',
|
||||
agent_group_id: 'ag-1',
|
||||
granted_by: null,
|
||||
granted_at: now(),
|
||||
});
|
||||
expect(gateCommand('/clear', 'telegram:admin', 'ag-1')).toEqual({ action: 'pass' });
|
||||
expect(gateCommand('/clear', 'telegram:admin', 'ag-2')).toEqual({ action: 'deny', command: '/clear' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normal messages pass through', () => {
|
||||
it('passes a plain message', () => {
|
||||
expect(gateCommand('hello there', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
|
||||
});
|
||||
|
||||
it('passes an unknown slash command', () => {
|
||||
expect(gateCommand('/whatever', 'telegram:1', 'ag-1')).toEqual({ action: 'pass' });
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,11 @@
|
||||
* "Permission denied" response written directly to messages_out
|
||||
* - Normal messages: pass through unchanged
|
||||
*/
|
||||
import { getDb, hasTable } from './db/connection.js';
|
||||
import { hasAdminPrivilege } from './modules/permissions/db/user-roles.js';
|
||||
|
||||
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
|
||||
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const FILTERED_COMMANDS = new Set(['/start', '/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
|
||||
|
||||
/**
|
||||
@@ -48,16 +48,5 @@ export function gateCommand(content: string, userId: string | null, agentGroupId
|
||||
|
||||
function isAdmin(userId: string | null, agentGroupId: string): boolean {
|
||||
if (!userId) return false;
|
||||
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
|
||||
const db = getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM user_roles
|
||||
WHERE user_id = ?
|
||||
AND (role = 'owner' OR role = 'admin')
|
||||
AND (agent_group_id IS NULL OR agent_group_id = ?)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(userId, agentGroupId);
|
||||
return row != null;
|
||||
return hasAdminPrivilege(userId, agentGroupId);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,18 @@ import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from
|
||||
import { isValidTimezone } from './timezone.js';
|
||||
|
||||
// Read config values from .env (falls back to process.env).
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'ASSISTANT_HAS_OWN_NUMBER',
|
||||
'ONECLI_URL',
|
||||
'ONECLI_API_KEY',
|
||||
'TZ',
|
||||
'CONTAINER_CPU_LIMIT',
|
||||
'CONTAINER_MEMORY_LIMIT',
|
||||
'NANOCLAW_EGRESS_LOCKDOWN',
|
||||
'NANOCLAW_EGRESS_NETWORK',
|
||||
'ONECLI_GATEWAY_CONTAINER',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||
@@ -37,35 +48,21 @@ export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContaine
|
||||
// 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;
|
||||
export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API_KEY;
|
||||
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
|
||||
// Per-container resource caps, passed through to `docker run`. Default empty =
|
||||
// no flag added = today's unbounded behavior (don't OOM existing OSS workloads).
|
||||
// Operators opt in: CONTAINER_CPU_LIMIT=2, CONTAINER_MEMORY_LIMIT=8g.
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || '';
|
||||
export const CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || envConfig.CONTAINER_CPU_LIMIT || '';
|
||||
export const CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || envConfig.CONTAINER_MEMORY_LIMIT || '';
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function buildTriggerPattern(trigger: string): RegExp {
|
||||
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
|
||||
}
|
||||
|
||||
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
|
||||
|
||||
export function getTriggerPattern(trigger?: string): RegExp {
|
||||
const normalizedTrigger = trigger?.trim();
|
||||
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
|
||||
}
|
||||
|
||||
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
|
||||
// Egress lockdown — force all agent traffic through the OneCLI gateway on a
|
||||
// no-internet Docker network. Off by default; consumed by src/egress-lockdown.ts.
|
||||
export const EGRESS_LOCKDOWN = (process.env.NANOCLAW_EGRESS_LOCKDOWN || envConfig.NANOCLAW_EGRESS_LOCKDOWN) === 'true';
|
||||
export const EGRESS_NETWORK =
|
||||
process.env.NANOCLAW_EGRESS_NETWORK || envConfig.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress';
|
||||
export const ONECLI_GATEWAY_CONTAINER =
|
||||
process.env.ONECLI_GATEWAY_CONTAINER || envConfig.ONECLI_GATEWAY_CONTAINER || 'onecli';
|
||||
|
||||
// Timezone for scheduled tasks, message formatting, etc.
|
||||
// Validates each candidate is a real IANA identifier before accepting.
|
||||
|
||||
@@ -73,8 +73,12 @@ describe('per-container resource limits (structural)', () => {
|
||||
|
||||
it('defaults both knobs to empty string in config (no flag = unbounded)', () => {
|
||||
const cfg = fs.readFileSync(path.join(process.cwd(), 'src', 'config.ts'), 'utf-8');
|
||||
expect(cfg).toContain("CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || ''");
|
||||
expect(cfg).toContain("CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || ''");
|
||||
expect(cfg).toContain(
|
||||
"CONTAINER_CPU_LIMIT = process.env.CONTAINER_CPU_LIMIT || envConfig.CONTAINER_CPU_LIMIT || ''",
|
||||
);
|
||||
expect(cfg).toContain(
|
||||
"CONTAINER_MEMORY_LIMIT = process.env.CONTAINER_MEMORY_LIMIT || envConfig.CONTAINER_MEMORY_LIMIT || ''",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Spawns agent containers with session folder + agent group folder mounts.
|
||||
* The container runs the v2 agent-runner which polls the session DB.
|
||||
*/
|
||||
import { ChildProcess, execSync, spawn } from 'child_process';
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { OneCLI } from '@onecli-sh/sdk';
|
||||
|
||||
@@ -320,12 +321,6 @@ export function buildMounts(
|
||||
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
|
||||
}
|
||||
|
||||
// Global memory directory — always read-only.
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true });
|
||||
}
|
||||
|
||||
// Shared CLAUDE.md — read-only, imported by the composed entry point via
|
||||
// the `.claude-shared.md` symlink inside the group dir.
|
||||
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
|
||||
@@ -510,6 +505,8 @@ async function buildContainerArgs(
|
||||
return args;
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/** Build a per-agent-group Docker image with custom packages. */
|
||||
export async function buildAgentGroupImage(agentGroupId: string): Promise<void> {
|
||||
const agentGroup = getAgentGroup(agentGroupId);
|
||||
@@ -545,9 +542,12 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
||||
const tmpDockerfile = path.join(DATA_DIR, `Dockerfile.${agentGroupId}`);
|
||||
fs.writeFileSync(tmpDockerfile, dockerfile);
|
||||
try {
|
||||
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
|
||||
// Awaited async exec so the single-threaded host stays responsive during
|
||||
// the build (can take minutes) instead of blocking on execSync. exec buffers
|
||||
// stdout/stderr (matching the old stdio: 'pipe') and rejects on a non-zero
|
||||
// exit, so error propagation is unchanged.
|
||||
await execAsync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
|
||||
cwd: DATA_DIR,
|
||||
stdio: 'pipe',
|
||||
timeout: 900_000,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -181,14 +181,6 @@ export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Data
|
||||
})();
|
||||
}
|
||||
|
||||
export function getStuckProcessingIds(outDb: Database.Database): string[] {
|
||||
return (
|
||||
outDb.prepare("SELECT message_id FROM processing_ack WHERE status = 'processing'").all() as Array<{
|
||||
message_id: string;
|
||||
}>
|
||||
).map((r) => r.message_id);
|
||||
}
|
||||
|
||||
export interface ProcessingClaim {
|
||||
message_id: string;
|
||||
status_changed: string;
|
||||
|
||||
@@ -9,15 +9,13 @@
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import { EGRESS_LOCKDOWN, EGRESS_NETWORK, ONECLI_GATEWAY_CONTAINER } from './config.js';
|
||||
import { CONTAINER_RUNTIME_BIN } from './container-runtime.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
/** Locked-down, no-internet network agents are placed on. */
|
||||
export const EGRESS_NETWORK = process.env.NANOCLAW_EGRESS_NETWORK || 'nanoclaw-egress';
|
||||
/** The OneCLI gateway container attached as the only egress hop. */
|
||||
const ONECLI_GATEWAY_CONTAINER = process.env.ONECLI_GATEWAY_CONTAINER || 'onecli';
|
||||
/** Off by default; set NANOCLAW_EGRESS_LOCKDOWN=true to opt in. */
|
||||
const EGRESS_LOCKDOWN = process.env.NANOCLAW_EGRESS_LOCKDOWN === 'true';
|
||||
// Perimeter knobs (locked-down network, gateway container, on/off flag) are read
|
||||
// via config.ts so they honor .env under the shipped service, not just process.env.
|
||||
export { EGRESS_NETWORK };
|
||||
|
||||
/** Raised when lockdown is requested but can't be established. */
|
||||
export class EgressLockdownError extends Error {
|
||||
|
||||
@@ -24,16 +24,7 @@ import { enforceUpgradeTripwire } from './upgrade-state.js';
|
||||
// effects, and the modules call registerResponseHandler/onShutdown at top
|
||||
// level — which would hit a TDZ error if the arrays lived here. Re-exported
|
||||
// here so existing callers see the same surface.
|
||||
import {
|
||||
registerResponseHandler,
|
||||
getResponseHandlers,
|
||||
onShutdown,
|
||||
getShutdownCallbacks,
|
||||
type ResponsePayload,
|
||||
type ResponseHandler,
|
||||
} from './response-registry.js';
|
||||
export { registerResponseHandler, onShutdown };
|
||||
export type { ResponsePayload, ResponseHandler };
|
||||
import { getResponseHandlers, getShutdownCallbacks, type ResponsePayload } from './response-registry.js';
|
||||
|
||||
async function dispatchResponse(payload: ResponsePayload): Promise<void> {
|
||||
for (const handler of getResponseHandlers()) {
|
||||
|
||||
@@ -147,8 +147,8 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
||||
|
||||
const onecliTitle = 'Credentials Request';
|
||||
const onecliOptions = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve', style: 'primary' as const },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject', style: 'danger' as const },
|
||||
];
|
||||
let platformMessageId: string | undefined;
|
||||
try {
|
||||
@@ -254,16 +254,46 @@ async function sweepStaleApprovals(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The hosted gateway's structured request summary — not yet in the SDK's
|
||||
* ApprovalRequest type (observed on api.onecli.sh, 2026-07): the action being
|
||||
* performed plus labeled fields (To / Subject / Body for email sends). */
|
||||
interface ApprovalSummary {
|
||||
action?: string;
|
||||
details?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
const SUMMARY_VALUE_EXCERPT_CHARS = 900;
|
||||
|
||||
function buildQuestion(request: ApprovalRequest, agentName: string): string {
|
||||
const lines = [
|
||||
'Credential access request',
|
||||
`Agent: ${agentName}`,
|
||||
'```',
|
||||
`${request.method} ${request.host}${request.path}`,
|
||||
'```',
|
||||
];
|
||||
if (request.bodyPreview) {
|
||||
lines.push('Body:', '```', request.bodyPreview, '```');
|
||||
const lines = [`*Agent:* ${agentName}`];
|
||||
|
||||
const summary = (request as ApprovalRequest & { summary?: ApprovalSummary }).summary;
|
||||
if (summary?.details?.length) {
|
||||
if (summary.action) lines.push(`*Action:* ${summary.action}`);
|
||||
// A render bug here must never decide the request: handleRequest's catch
|
||||
// returns 'deny', so stay defensive — coerce non-string values instead of
|
||||
// assuming the gateway's shape, and keep the card under Slack's 3000-char
|
||||
// section limit or delivery itself fails.
|
||||
let budget = 2600;
|
||||
for (const { label, value } of summary.details) {
|
||||
const raw = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
|
||||
const cap = Math.min(SUMMARY_VALUE_EXCERPT_CHARS, Math.max(0, budget));
|
||||
if (cap === 0) {
|
||||
lines.push(`_…${summary.details.length} field(s) omitted for length — see the audit payload._`);
|
||||
break;
|
||||
}
|
||||
const v = raw.length > cap ? `${raw.slice(0, cap)}…` : raw;
|
||||
budget -= v.length + String(label).length + 8;
|
||||
// Multi-line values (message bodies) read better fenced; short labeled
|
||||
// fields (To, Subject) inline.
|
||||
if (v.includes('\n')) lines.push(`*${label}:*`, '```', v, '```');
|
||||
else lines.push(`*${label}:* ${v}`);
|
||||
}
|
||||
} else if (request.bodyPreview) {
|
||||
lines.push('```', request.bodyPreview.slice(0, SUMMARY_VALUE_EXCERPT_CHARS * 2), '```');
|
||||
lines.push(`_${request.method} ${request.host}${request.path}_`);
|
||||
} else {
|
||||
lines.push(`_${request.method} ${request.host}${request.path}_`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ export const REJECT_WITH_REASON_VALUE = 'reject_with_reason';
|
||||
* keep their own two-button set in onecli-approvals.ts.
|
||||
*/
|
||||
const APPROVAL_OPTIONS: RawOption[] = [
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve', style: 'primary' },
|
||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject', style: 'danger' },
|
||||
{ label: 'Reject with reason…', selectedLabel: '📝 Rejected (awaiting reason)', value: REJECT_WITH_REASON_VALUE },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tests for the mount allowlist loader/validator.
|
||||
*
|
||||
* Covers the two cleanups:
|
||||
* - The loader honors the per-root `readOnly` key (translating it to
|
||||
* `allowReadWrite`) and tolerates the top-level `nonMainReadOnly` key that
|
||||
* setup writes into every fresh install.
|
||||
* - The allowlist is read per call (mtime-keyed cache), so a parse error is
|
||||
* never cached permanently — a fixed file is picked up without a restart.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The config path is a module-level const in production; point it at a
|
||||
// per-test temp file via a getter so each test is isolated from the cache.
|
||||
const mockState = vi.hoisted(() => ({ allowlistPath: '' }));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('../../config.js');
|
||||
return {
|
||||
...actual,
|
||||
get MOUNT_ALLOWLIST_PATH() {
|
||||
return mockState.allowlistPath;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { loadMountAllowlist, validateMount } from './index.js';
|
||||
|
||||
let tmpDir: string;
|
||||
let configFile: string;
|
||||
let projectsDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mnt-sec-'));
|
||||
configFile = path.join(tmpDir, 'mount-allowlist.json');
|
||||
mockState.allowlistPath = configFile;
|
||||
|
||||
projectsDir = path.join(tmpDir, 'projects');
|
||||
repoDir = path.join(projectsDir, 'repo');
|
||||
fs.mkdirSync(repoDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeAllowlist(obj: unknown): void {
|
||||
fs.writeFileSync(configFile, JSON.stringify(obj, null, 2) + '\n');
|
||||
}
|
||||
|
||||
describe('loadMountAllowlist', () => {
|
||||
it('translates per-root readOnly:false into a read-write grant', () => {
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, readOnly: false }],
|
||||
blockedPatterns: [],
|
||||
});
|
||||
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist).not.toBeNull();
|
||||
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
|
||||
|
||||
// ...and a mount that requests read-write actually gets it.
|
||||
const result = validateMount({ hostPath: repoDir, readonly: false });
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps readOnly:true as a read-only grant', () => {
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, readOnly: true }],
|
||||
blockedPatterns: [],
|
||||
});
|
||||
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(false);
|
||||
|
||||
const result = validateMount({ hostPath: repoDir, readonly: false });
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.effectiveReadonly).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates an unknown top-level nonMainReadOnly key', () => {
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
nonMainReadOnly: true,
|
||||
});
|
||||
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist).not.toBeNull();
|
||||
expect(allowlist!.allowedRoots).toHaveLength(1);
|
||||
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('picks up a fixed file without a restart (parse errors are not cached)', () => {
|
||||
// A broken edit blocks all mounts...
|
||||
fs.writeFileSync(configFile, 'not valid json {');
|
||||
expect(loadMountAllowlist()).toBeNull();
|
||||
|
||||
// ...but fixing the file recovers on the very next call — no restart.
|
||||
writeAllowlist({
|
||||
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
|
||||
blockedPatterns: [],
|
||||
});
|
||||
const allowlist = loadMountAllowlist();
|
||||
expect(allowlist).not.toBeNull();
|
||||
expect(allowlist!.allowedRoots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null when the allowlist file is missing', () => {
|
||||
// No file written.
|
||||
expect(loadMountAllowlist()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,11 @@ export interface AllowedRoot {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Cache the allowlist in memory - only reloads on process restart
|
||||
let cachedAllowlist: MountAllowlist | null = null;
|
||||
let allowlistLoadError: string | null = null;
|
||||
// Cache the last successfully-parsed allowlist, keyed on the file's path +
|
||||
// mtime. A changed or fixed file is picked up on the next call (no restart),
|
||||
// and a parse error is never cached permanently — one bad edit blocks mounts
|
||||
// only until the file is fixed.
|
||||
let cache: { path: string; mtimeMs: number; allowlist: MountAllowlist } | null = null;
|
||||
|
||||
/**
|
||||
* Default blocked patterns - paths that should never be mounted
|
||||
@@ -57,60 +59,108 @@ const DEFAULT_BLOCKED_PATTERNS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the mount allowlist from the external config location.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Result is cached in memory for the lifetime of the process.
|
||||
* Normalize a raw allowed-root entry into an {@link AllowedRoot}.
|
||||
*
|
||||
* The read-only decision is per-root. Historically this validator only read
|
||||
* `allowReadWrite`, but the /manage-mounts skill and setup write `readOnly`
|
||||
* instead — so a `readOnly: false` grant was silently forced read-only.
|
||||
* Translate `readOnly` → `allowReadWrite = !readOnly` (with a warning) unless an
|
||||
* explicit `allowReadWrite` is already present. With neither key, default to
|
||||
* read-only (fail safe).
|
||||
*/
|
||||
export function loadMountAllowlist(): MountAllowlist | null {
|
||||
if (cachedAllowlist !== null) {
|
||||
return cachedAllowlist;
|
||||
function normalizeRoot(root: Record<string, unknown>): AllowedRoot {
|
||||
const rootPath = typeof root.path === 'string' ? root.path : '';
|
||||
const description = typeof root.description === 'string' ? root.description : undefined;
|
||||
|
||||
let allowReadWrite: boolean;
|
||||
if (typeof root.allowReadWrite === 'boolean') {
|
||||
allowReadWrite = root.allowReadWrite;
|
||||
} else if (typeof root.readOnly === 'boolean') {
|
||||
allowReadWrite = !root.readOnly;
|
||||
log.warn('Mount allowlist root uses "readOnly" — translating to allowReadWrite', {
|
||||
root: rootPath,
|
||||
readOnly: root.readOnly,
|
||||
});
|
||||
} else {
|
||||
allowReadWrite = false;
|
||||
}
|
||||
|
||||
if (allowlistLoadError !== null) {
|
||||
// Already tried and failed, don't spam logs
|
||||
return { path: rootPath, allowReadWrite, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the mount allowlist from the external config location.
|
||||
* Returns null if the file doesn't exist or is invalid.
|
||||
* Re-reads on every call, but serves from an in-memory cache while the file's
|
||||
* mtime is unchanged. A parse error is never cached — fix the file and the next
|
||||
* call recovers without a service restart.
|
||||
*/
|
||||
export function loadMountAllowlist(): MountAllowlist | null {
|
||||
// Missing-file behavior: warn and block additional mounts, but do NOT cache
|
||||
// the miss — the file may be created later without a restart.
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(MOUNT_ALLOWLIST_PATH);
|
||||
} catch {
|
||||
log.warn(
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
||||
// Do NOT cache this as an error — file may be created later without restart.
|
||||
// Only parse/structural errors are permanently cached.
|
||||
log.warn(
|
||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
||||
{ path: MOUNT_ALLOWLIST_PATH },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Serve from cache only while the same file is unchanged since the last
|
||||
// successful load. Any edit (including fixing a previously broken file) bumps
|
||||
// the mtime and is picked up on the next call.
|
||||
if (cache !== null && cache.path === MOUNT_ALLOWLIST_PATH && cache.mtimeMs === stat.mtimeMs) {
|
||||
return cache.allowlist;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
||||
const allowlist = JSON.parse(content) as MountAllowlist;
|
||||
const raw = JSON.parse(content) as Record<string, unknown>;
|
||||
|
||||
// Validate structure
|
||||
if (!Array.isArray(allowlist.allowedRoots)) {
|
||||
if (!Array.isArray(raw.allowedRoots)) {
|
||||
throw new Error('allowedRoots must be an array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowlist.blockedPatterns)) {
|
||||
if (!Array.isArray(raw.blockedPatterns)) {
|
||||
throw new Error('blockedPatterns must be an array');
|
||||
}
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
|
||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
||||
// Warn-and-ignore the top-level `nonMainReadOnly` key. Setup writes it into
|
||||
// every fresh install, but this validator has no concept of a "main" agent —
|
||||
// read-only is decided per-root. Do NOT throw: a hard reject would fail
|
||||
// closed and brick all mounts on a standard install.
|
||||
if ('nonMainReadOnly' in raw) {
|
||||
log.warn('Mount allowlist has unsupported top-level "nonMainReadOnly" key — ignoring (read-only is per-root)', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
cachedAllowlist = allowlist;
|
||||
const allowedRoots = (raw.allowedRoots as Array<Record<string, unknown>>).map(normalizeRoot);
|
||||
|
||||
// Merge with default blocked patterns
|
||||
const blockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...(raw.blockedPatterns as string[])])];
|
||||
|
||||
const allowlist: MountAllowlist = { allowedRoots, blockedPatterns };
|
||||
|
||||
cache = { path: MOUNT_ALLOWLIST_PATH, mtimeMs: stat.mtimeMs, allowlist };
|
||||
log.info('Mount allowlist loaded successfully', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
allowedRoots: allowlist.allowedRoots.length,
|
||||
blockedPatterns: allowlist.blockedPatterns.length,
|
||||
});
|
||||
|
||||
return cachedAllowlist;
|
||||
return allowlist;
|
||||
} catch (err) {
|
||||
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
||||
// Do NOT poison the cache — a corrupt edit blocks mounts only until it's
|
||||
// fixed, then the next call re-reads and recovers.
|
||||
cache = null;
|
||||
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
|
||||
path: MOUNT_ALLOWLIST_PATH,
|
||||
error: allowlistLoadError,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string
|
||||
label: `Connect to ${visibleAgentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${visibleAgentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${visibleAgentGroups[0].id}`,
|
||||
style: 'primary',
|
||||
});
|
||||
} else if (visibleAgentGroups.length > 1) {
|
||||
options.push({
|
||||
@@ -110,6 +111,7 @@ function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string
|
||||
label: 'Reject',
|
||||
selectedLabel: '🙅 Rejected',
|
||||
value: REJECT_VALUE,
|
||||
style: 'danger',
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||