mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-15 19:06:18 +08:00
Compare commits
359 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f6cba995d | |||
| e96fd7d25c | |||
| 0aca34d3bb | |||
| 59b3680212 | |||
| c1445876a6 | |||
| 5a114b5427 | |||
| 790a7a6dfd | |||
| f24edc2c3b | |||
| fe235c7ca0 | |||
| a30547fb6a | |||
| 27de55647b | |||
| 6cd15c6d13 | |||
| 40a21fe8f3 | |||
| 91d30713b5 | |||
| 1ad258622b | |||
| e370cae4e7 | |||
| e40d43a43b | |||
| 642c5ecd37 | |||
| 0bb9016e94 | |||
| f797024770 | |||
| bea2dab171 | |||
| 53ffa3d347 | |||
| 8d39368352 | |||
| 59fc8e3cc7 | |||
| 45f656f21c | |||
| a4513f292f | |||
| 38eb5a44df | |||
| 6d397fc116 | |||
| cb2bbcc7aa | |||
| b9998a4bf1 | |||
| 0d41f0ef94 | |||
| 3363a0be0f | |||
| 05cf44035e | |||
| c1eb1079cc | |||
| 1725d86fbd | |||
| e3bd22f96f | |||
| c2e5b14bf8 | |||
| 1ccef326dc | |||
| 39e7604526 | |||
| c0e591f2fb | |||
| df4929d61d | |||
| 14f528c895 | |||
| 366c5f06f1 | |||
| 077ce6fd9d | |||
| a9e5231039 | |||
| f87f82c528 | |||
| 35ca4e14d2 | |||
| 69e3b57d9d | |||
| 0c0f4c2592 | |||
| 92635f1934 | |||
| f7a43ef881 | |||
| 5459925c65 | |||
| b0c76ce4c5 | |||
| 87a2d3af8d | |||
| 68ebc02114 | |||
| d4e73c5523 | |||
| 9a103f4f93 | |||
| a8554c6248 | |||
| 2480ae7cb8 | |||
| 763a3f757b | |||
| 8943c1cbf4 | |||
| 559bb5ca1a | |||
| 04b364e828 | |||
| 627ab23bad | |||
| 8986fef586 | |||
| 7f49450c0c | |||
| cdc519e1f4 | |||
| 1276645c3b | |||
| d3499b7d70 | |||
| c2cf19ec28 | |||
| 44f351349a | |||
| e8a32207d8 | |||
| 1dda751a48 | |||
| 4f1b17c737 | |||
| 967aee2c27 | |||
| 5aac750aa5 | |||
| 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 | |||
| 1985fd6a5e | |||
| 0820d39424 | |||
| c752f9c065 | |||
| 0e73e4a782 | |||
| aecad864e6 | |||
| c87f2e55dc | |||
| 411f5e71df | |||
| cb6e3d117c | |||
| ed7e3f70da | |||
| 557e073c2f | |||
| 91ebc9def2 | |||
| 14c89e9716 | |||
| 549c424a38 | |||
| 186b9befcd | |||
| 863d413d32 | |||
| cf8478ffbb | |||
| 8be5be93ba | |||
| add3fc8f70 | |||
| 0d841bcd05 | |||
| dd1d0e5677 | |||
| 36afa40857 | |||
| 2afbd18233 | |||
| 953496dc37 | |||
| 797491d8b3 | |||
| 2df754459b | |||
| 0896d4089e | |||
| d153d91307 | |||
| ce55af12d5 | |||
| 545800a94e | |||
| bfb309bd0c | |||
| 38d9390eea | |||
| 8d3eca7027 | |||
| 1d6bba4d3f | |||
| 9bb69c0e50 | |||
| 520ec44aec | |||
| 8c6a243ffd | |||
| add6145f1c | |||
| 4e14d08173 | |||
| 8f2f788b6e | |||
| e96d7fd961 | |||
| 2ac7809385 | |||
| 15292ae76c | |||
| 055cf49bd5 | |||
| 8ee7915418 | |||
| e8148bc0a7 | |||
| 625264ba4b | |||
| f34e590bcd | |||
| d208fd7bf5 | |||
| 886c65725b | |||
| 9977af68d7 | |||
| 8e44f07dd4 | |||
| 8c43f13d93 | |||
| 5cf4ff1bd2 | |||
| 6e475e5503 | |||
| 0f8499b141 | |||
| 82e1dc4ae8 | |||
| e70b021cde | |||
| ea90a12846 | |||
| 3b1f4501d6 | |||
| 385fb014fc | |||
| b2160a56aa | |||
| f72658bb50 | |||
| 3180f3f881 | |||
| b0bdc57b37 | |||
| 314b91efc0 | |||
| 53ed3b77c9 | |||
| 070714ec58 | |||
| e4907c2c33 | |||
| 9a142302df | |||
| 3f39f57653 | |||
| 1b86950f10 | |||
| 8b435eb02d | |||
| 7e2004f945 | |||
| 63901d1bde | |||
| e5d96e348f | |||
| 439c24f1b7 | |||
| 2a144bb8d6 | |||
| 197faaaa14 | |||
| 3ffd6dde00 | |||
| e856e924a5 | |||
| ee7f891698 | |||
| 7fde348e2b | |||
| 122135e6dc | |||
| 8563fb0681 | |||
| 0155ab1943 | |||
| d1f94fcd24 | |||
| dd60983f7f | |||
| 096b8bf589 | |||
| 59c4d33adc | |||
| 5f5c28d18d | |||
| b92d1f9343 | |||
| e03c5c194a | |||
| acbb1144b7 | |||
| 028897f38f | |||
| ac0a799cbf | |||
| e3986eb58c | |||
| 6d0d48d585 | |||
| a142c496f7 | |||
| c5b4d11536 | |||
| ed8b4149e7 | |||
| d5ce02d1b8 | |||
| c8af599944 | |||
| 435233a062 | |||
| 785fce3754 | |||
| 01433bae32 | |||
| 6d521a9d8d | |||
| adfae67611 | |||
| 13a37def89 | |||
| 03382e9dd7 | |||
| 9763551656 | |||
| a9c9cb300d | |||
| a619fc1aa2 | |||
| 3d2f3e58ca | |||
| 11afc64ba4 | |||
| 0ee75d393c | |||
| 72b9cc7ed0 | |||
| 5fcf234165 | |||
| 9b1236505f | |||
| 878cd68c1b | |||
| fab1ebf2d6 | |||
| 3f9e89d345 | |||
| 14810a5090 | |||
| 2cfa86e570 | |||
| 36cbf17e10 | |||
| 4459ab2e54 | |||
| 9e6238d28f | |||
| d1bda5d15b | |||
| 7eddc7d8c9 | |||
| 991ef986f8 | |||
| 0f2557e2bc | |||
| 4e6552ed55 | |||
| 978b998ee6 | |||
| 83951d7c01 | |||
| 76ef097521 | |||
| 1c85fd6e50 | |||
| 42275ede1f | |||
| 53e1989529 | |||
| 6f2142d7c7 | |||
| 79a0226962 | |||
| 0b31695e92 | |||
| 421f8707d2 | |||
| 67ccd9e74c | |||
| f69af07c57 | |||
| 93a302b5db | |||
| eef285ba3b | |||
| a806534199 | |||
| 0ac8073e34 | |||
| 539a2b3c63 | |||
| fccaadf24c | |||
| 0516bea638 | |||
| 3329270c67 | |||
| f16ea0c783 | |||
| 1c024bc976 | |||
| 6c26f3ef08 | |||
| ab6ab6936c | |||
| 501afb4beb | |||
| 9040dbb86e | |||
| d8748e3a45 | |||
| 41a720dd59 | |||
| 6ae83f48ac | |||
| dc34ceb83d | |||
| ad3dfad3f5 | |||
| 0bdc6d2bb2 | |||
| 820cd8ece6 | |||
| e44d497cdf | |||
| ac37ecbfd6 | |||
| c6627d32e2 | |||
| 51bf403b22 | |||
| 265953ffec | |||
| 6227bd1a5b | |||
| 28032bc0ec | |||
| 3e3a2945a5 | |||
| f3fc18e56e | |||
| d85efea229 | |||
| c5b22cb308 | |||
| 1592369201 | |||
| 6420c0e254 | |||
| aef8d38b36 | |||
| 6d6f813deb | |||
| f9c86d0af2 | |||
| 9edb33dd3a | |||
| 8ba5261ae8 | |||
| 8c84dec8e9 | |||
| 092487d7ad | |||
| 87850aa7f8 | |||
| 526170fd47 | |||
| 2d9375531b | |||
| e734e5cddd | |||
| 32f067f5bb | |||
| 728c6a641b | |||
| 8385236c30 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -0,0 +1,58 @@
|
||||
# Remove /add-audit
|
||||
|
||||
Reverses every change the skill made. Safe to re-run even if some pieces are
|
||||
already gone. Run from the NanoClaw project root.
|
||||
|
||||
## 1. Delete the copied files
|
||||
|
||||
```bash
|
||||
rm -rf src/audit
|
||||
rm -f src/cli/dispatch.audit.ts src/cli/dispatch.audit.test.ts
|
||||
rm -f src/cli/resources/audit.ts
|
||||
rm -f src/modules/approvals/approvals.audit.ts src/modules/approvals/approvals.audit.test.ts
|
||||
rm -f src/audit-wiring.test.ts
|
||||
```
|
||||
|
||||
## 2. Revert the dispatch composition
|
||||
|
||||
In `src/cli/dispatch.ts`, delete (not comment out) the three edits the skill
|
||||
made:
|
||||
|
||||
1. The import line: `import { withAudit } from './dispatch.audit.js';`
|
||||
2. The composition block (comment + `export const dispatch = withAudit(dispatchInner);`)
|
||||
3. Rename the dispatcher back — change `async function dispatchInner(` to
|
||||
`export async function dispatch(`
|
||||
|
||||
## 3. Unregister the resource
|
||||
|
||||
Delete the `import './audit.js';` line from `src/cli/resources/index.ts`.
|
||||
|
||||
## 4. Unwire the approval observers
|
||||
|
||||
Delete the `import './approvals.audit.js';` line (and its comment) from
|
||||
`src/modules/approvals/index.ts`.
|
||||
|
||||
## 5. Remove the settings
|
||||
|
||||
Delete the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines from `.env`.
|
||||
|
||||
## 6. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Day-files
|
||||
|
||||
`data/audit/*.ndjson` are the operator's records and are deliberately left in
|
||||
place. To purge them too:
|
||||
|
||||
```bash
|
||||
rm -rf data/audit
|
||||
```
|
||||
|
||||
No dependency was added, nothing under `container/` was touched, and no DB
|
||||
schema changed — there is nothing else to undo.
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
name: add-audit
|
||||
description: Add an opt-in local audit log for the ncl command surface and every host-routed approval — each command's dispatch outcome on both transports (host socket and container, scope denials included), plus each approval's request and decision (approved/rejected, across CLI, self-mod, a2a, sender admission, OneCLI, channel registration), written as SIEM-shaped append-only NDJSON day-files under data/audit/. Read back with `ncl audit list`; plug exporters in via registerAuditHook. Off until AUDIT_ENABLED=true.
|
||||
---
|
||||
|
||||
# /add-audit — Local Audit Log (ncl surface + approvals)
|
||||
|
||||
Records one canonical audit event for two surfaces:
|
||||
|
||||
1. **Every `ncl` command** — human over the host socket or agent over the
|
||||
container transport, denials included. Both transports converge on the
|
||||
exported `dispatch`, so one composition covers the whole surface: no second
|
||||
door.
|
||||
2. **Every host-routed approval** — its `pending` request and its
|
||||
`approvals.decide` decision (approved / rejected), whichever stack gated it:
|
||||
CLI commands, self-mod (`install_packages` / `add_mcp_server`), a2a
|
||||
create-agent and message-gate, unknown-sender admission, OneCLI credential
|
||||
holds, and channel registration. Two lifecycle observers cover them all with
|
||||
zero touch points inside the approval flows.
|
||||
|
||||
```
|
||||
ncl (host socket) ──┐
|
||||
├─→ dispatch = withAudit(dispatchInner) ─→┐
|
||||
container cli_request ┘ │ │
|
||||
approved replay (grant) ───────┘ ├─→ one NDJSON line
|
||||
│ data/audit/<UTC-day>.ndjson
|
||||
every hold ─→ requested observer ─→ pending ─────────────────┤
|
||||
every resolution ─→ resolved observer ─→ approvals.decide ───┘
|
||||
```
|
||||
|
||||
What one event carries: `actor` (`host:<os-user>`, the agent group, a
|
||||
`<channel>:<handle>` admin for a decision, or `system` for an expiry/sweep),
|
||||
`origin` (transport, session, channel), dotted `action` from the guard catalog
|
||||
(e.g. `groups.config.add-mcp-server`, `agents.create`, `approvals.decide`),
|
||||
touched/attempted `resources` (a gated chain names its `approval` and, on the
|
||||
`pending` event, the picked approver as a `user`), `outcome` (`success ·
|
||||
failure · denied · pending · approved · rejected`), `correlation_id` (the
|
||||
approval id on gated chains — the `pending` request, the `approvals.decide`
|
||||
decision, and a CLI replay's terminal event all share it, so `--correlation
|
||||
<id>` returns the whole chain), and `details` (governance-safe names only —
|
||||
never raw argument values).
|
||||
|
||||
Two adapters, one leaf: the CLI adapter (`cli/dispatch.audit.ts`) owns command
|
||||
events and the CLI hold's `pending`; the approvals adapter
|
||||
(`modules/approvals/approvals.audit.ts`) owns every non-CLI `pending` and every
|
||||
decision. Both call the same domain-free `src/audit/` emit/store. Later
|
||||
surfaces (message traffic, tool calls, container lifecycle) attach the same way
|
||||
— a new `*.audit.ts` adapter, no schema change.
|
||||
|
||||
## Steps
|
||||
|
||||
### 0. Pre-flight (idempotency)
|
||||
|
||||
The apply is safe to re-run; every step below is guarded. Skip to
|
||||
**Enable** if all of these already hold:
|
||||
|
||||
- `src/audit/` exists
|
||||
- `src/cli/resources/index.ts` contains `import './audit.js';`
|
||||
- `src/cli/dispatch.ts` contains `export const dispatch = withAudit(dispatchInner);`
|
||||
- `src/modules/approvals/index.ts` contains `import './approvals.audit.js';`
|
||||
|
||||
Before editing, verify the reach-in targets still exist: `src/cli/dispatch.ts`
|
||||
must contain `export async function dispatch(` (or the already-applied
|
||||
composition), `src/cli/resources/index.ts` must be the resource barrel (a list
|
||||
of `import './<resource>.js';` lines), and `src/modules/approvals/index.ts`
|
||||
must be the approvals barrel exposing the lifecycle observers
|
||||
(`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` in
|
||||
`./primitive.js`). If any has moved, stop and adapt rather than guessing.
|
||||
|
||||
### 1. Copy the payload
|
||||
|
||||
From the NanoClaw project root:
|
||||
|
||||
```bash
|
||||
cp -R "${CLAUDE_SKILL_DIR}/add/src/." src/
|
||||
```
|
||||
|
||||
What lands (mirrors of the destination paths):
|
||||
|
||||
- `src/audit/` — the domain-free leaf: event schema (`types.ts`), env config
|
||||
(`config.ts`), redactor, NDJSON day-file store, emit seam, post-write hooks,
|
||||
reader, boot/maintenance wiring, vocabulary — plus its tests.
|
||||
- `src/cli/dispatch.audit.ts` — the CLI adapter: `withAudit` middleware and
|
||||
the actor/origin/resource mapping (+ `dispatch.audit.test.ts`).
|
||||
- `src/modules/approvals/approvals.audit.ts` — the approvals adapter: the two
|
||||
lifecycle-observer subscriptions that emit `pending` and `approvals.decide`
|
||||
(+ `approvals.audit.test.ts`).
|
||||
- `src/cli/resources/audit.ts` — the read-only `ncl audit` resource.
|
||||
- `src/audit-wiring.test.ts` — goes red if any of the three core edits below is
|
||||
deleted or drifts.
|
||||
|
||||
### 2. Register the resource
|
||||
|
||||
Append to `src/cli/resources/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './audit.js';
|
||||
```
|
||||
|
||||
### 3. Compose the dispatch middleware
|
||||
|
||||
This is the skill's one functional reach-in, in `src/cli/dispatch.ts`. Three
|
||||
small edits:
|
||||
|
||||
1. Add the import (next to the other `./` imports):
|
||||
|
||||
```typescript
|
||||
import { withAudit } from './dispatch.audit.js';
|
||||
```
|
||||
|
||||
2. Rename the dispatcher declaration — change
|
||||
|
||||
```typescript
|
||||
export async function dispatch(
|
||||
```
|
||||
|
||||
to
|
||||
|
||||
```typescript
|
||||
async function dispatchInner(
|
||||
```
|
||||
|
||||
3. Directly after that function's closing brace (before the
|
||||
`registerApprovalHandler('cli_command', …)` block), add:
|
||||
|
||||
```typescript
|
||||
// Audit middleware (installed by /add-audit): the exported dispatch is the
|
||||
// wrapped function, so both transports and the approved replay below all
|
||||
// pass the one composition.
|
||||
export const dispatch = withAudit(dispatchInner);
|
||||
```
|
||||
|
||||
The composition must live at the definition site (not at the import sites):
|
||||
the approved-replay handler in the same file calls `dispatch(...)` too, and
|
||||
only the wrapped export covers it. `src/audit-wiring.test.ts` asserts exactly
|
||||
this shape via the TypeScript AST.
|
||||
|
||||
Loading `dispatch.audit.ts` also boots the audit log: on an enabled box it
|
||||
asserts `data/audit/` is writable (refusing to start beats a silent audit
|
||||
gap), runs the boot retention prune, and arms an unref'd maintenance timer.
|
||||
(The approvals adapter in the next step boots it too — idempotent — so the
|
||||
approval surface records even on a build that loads it first.)
|
||||
|
||||
### 4. Wire the approval observers
|
||||
|
||||
The approval-lifecycle increment's one reach-in, in the approvals barrel
|
||||
`src/modules/approvals/index.ts`. Append a side-effect import (next to the
|
||||
other module imports; skip if already present):
|
||||
|
||||
```typescript
|
||||
// Approval-lifecycle audit observers (installed by /add-audit): importing the
|
||||
// adapter registers its request/decision observers at boot.
|
||||
import './approvals.audit.js';
|
||||
```
|
||||
|
||||
Importing the adapter is what registers its two observers
|
||||
(`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` from
|
||||
`./primitive.js`) — the request/decision events for every non-CLI hold. The
|
||||
barrel already loads at boot via `src/modules/index.ts`, and
|
||||
`src/audit-wiring.test.ts` asserts the import is present. No `host-sweep.ts`
|
||||
edit.
|
||||
|
||||
### 5. Enable
|
||||
|
||||
Add the two settings to `.env` (idempotent — overwrite or append):
|
||||
|
||||
```bash
|
||||
grep -q '^AUDIT_ENABLED=' .env && sed -i.bak 's/^AUDIT_ENABLED=.*/AUDIT_ENABLED=true/' .env && rm -f .env.bak || echo 'AUDIT_ENABLED=true' >> .env
|
||||
grep -q '^AUDIT_RETENTION_DAYS=' .env || echo 'AUDIT_RETENTION_DAYS=90' >> .env
|
||||
```
|
||||
|
||||
`AUDIT_ENABLED` is the master switch — off (or absent) means `emitAuditEvent`
|
||||
is a no-op and `data/audit/` is never created. `AUDIT_RETENTION_DAYS`: day
|
||||
files strictly older than the horizon are hard-deleted (unlinked) at boot and
|
||||
once per UTC day; `0` = keep forever; unset = 90.
|
||||
|
||||
### 6. Build and test
|
||||
|
||||
Run `build` before the tests — it's what catches a missed copy or a drifted
|
||||
import path across the whole composed tree:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/audit src/cli/dispatch.audit.test.ts src/modules/approvals/approvals.audit.test.ts src/audit-wiring.test.ts
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### 7. Restart the service
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
### 8. Verify (runtime smoke)
|
||||
|
||||
```bash
|
||||
ncl groups list # any command — this one is now an audit event
|
||||
cat data/audit/$(date -u +%F).ndjson | tail -1
|
||||
ncl audit list --limit 5
|
||||
ncl audit list --outcome pending # what is waiting for approval right now
|
||||
```
|
||||
|
||||
The last line of the day-file is the `groups.list` event you just caused. To
|
||||
see the approval surface, drive a gated action (e.g. an agent asking to wire an
|
||||
MCP server) and approve it, then `ncl audit list --correlation <approval-id>`
|
||||
returns the full chain: `pending` → `approvals.decide` → the terminal event.
|
||||
|
||||
## Reading it back
|
||||
|
||||
```
|
||||
ncl audit list [--actor <id>] [--action <name-or-prefix>] [--resource <id-or-type>]
|
||||
[--outcome success|failure|denied|pending|approved|rejected]
|
||||
[--since 7d|24h|30m|ISO] [--until …] [--correlation <approval-id>]
|
||||
[--limit N] [--format ndjson]
|
||||
```
|
||||
|
||||
Newest first, default limit 100. `--action groups.config` matches the whole
|
||||
dotted subtree. `--format ndjson` streams the stored lines verbatim — pipe to
|
||||
a file for SIEM import. On a disabled box the command errors with
|
||||
`audit log is disabled — set AUDIT_ENABLED=true` rather than returning an
|
||||
empty list that would read as "no actions happened".
|
||||
|
||||
The resource is deliberately **not** on the group-scope allowlist: audit
|
||||
spans agent groups, so group-scoped agents are refused before the handler
|
||||
(fails closed). Host callers and `cli_scope: global` agents only.
|
||||
|
||||
## Recording model, failure posture, exporters
|
||||
|
||||
- Events store WHO did WHICH action to WHAT target and the outcome — **never
|
||||
raw argument values**. `details` carries the flag names that were passed plus
|
||||
a small allowlist of governance-relevant enum fields (`role`, `mode`,
|
||||
`session_mode`, `cli_scope`, `access`, `engage_mode`, `sender_scope`,
|
||||
`provider`, `model`) echoed verbatim; every other value is name-only, and a
|
||||
failure keeps the error *code*, never the free-text message. There is no value
|
||||
redactor — a secret can't leak from a value that is never stored. Target
|
||||
identifiers (ids, users, groups) surface structurally in `resources`.
|
||||
- Fail-open + loud: a failed append is `log.error`'d and the audited action
|
||||
proceeds — the emit brackets the dispatcher, so even a command that *throws*
|
||||
still leaves a record. At boot, an enabled box refuses to start if
|
||||
`data/audit/` isn't writable.
|
||||
- In-process exporters register via `registerAuditHook` (from
|
||||
`src/audit/index.js`) — post-write hooks that fire only after the local
|
||||
append succeeds, so an external system can never be ahead of the source of
|
||||
truth. Ship one as its own `/add-*` skill; declare `/add-audit` as its
|
||||
dependency.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Host won't start, banner names the audit directory** — `AUDIT_ENABLED=true`
|
||||
but `data/audit/` isn't writable. Fix permissions or disable audit.
|
||||
- **`audit log is disabled — set AUDIT_ENABLED=true`** — the read-back guard;
|
||||
enable in `.env` and restart.
|
||||
- **An agent gets "CLI access is scoped to this agent group" for `ncl audit`**
|
||||
— by design; grant the group `cli_scope: global` only if it should read
|
||||
cross-group history.
|
||||
- **`pnpm test` on an enabled box adds a few events to today's day-file** —
|
||||
expected: the base dispatch tests drive real dispatches. Harmless noise;
|
||||
the audit test suites themselves write only to temp dirs or capture buffers.
|
||||
|
||||
## Removal
|
||||
|
||||
See [REMOVE.md](REMOVE.md) — reverses every change; existing day-files are
|
||||
left in place (they're the operator's records).
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Wiring tests for the /add-audit skill's two core edits — they go red if
|
||||
* either edit is deleted or drifts:
|
||||
*
|
||||
* 1. src/cli/dispatch.ts — the exported dispatch must be the composed
|
||||
* `withAudit(dispatchInner)` (AST check: only the definition-site
|
||||
* composition covers both transports AND the in-module approved replay).
|
||||
* 2. src/cli/resources/index.ts — the audit resource must register through
|
||||
* the real barrel (behavior check), stay OFF the group-scope allowlist,
|
||||
* and leave guard conformance clean.
|
||||
*
|
||||
* Plus the emit invariant the module's discipline rests on: emitAuditEvent
|
||||
* appears only in src/audit/ and *.audit.ts adapter files.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import ts from 'typescript';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Same production barrels the guard conformance test loads (mirrors
|
||||
// src/guard/conformance.test.ts).
|
||||
import './cli/commands/index.js';
|
||||
import './modules/index.js';
|
||||
import './cli/delivery-action.js';
|
||||
import './cli/dispatch.js';
|
||||
|
||||
import { commandGuard, GROUP_SCOPE_RESOURCES, lookup } from './cli/registry.js';
|
||||
import { getApprovalHandler } from './modules/approvals/primitive.js';
|
||||
import { listGuardedActions } from './guard/index.js';
|
||||
|
||||
describe('audit resource registration (barrel wiring)', () => {
|
||||
it('registers audit-list through the real resource barrel', () => {
|
||||
const cmd = lookup('audit-list');
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd?.action).toBe('audit.list');
|
||||
expect(cmd?.access).toBe('open');
|
||||
});
|
||||
|
||||
it('derives a guard-catalog entry for the audit command', () => {
|
||||
const guard = commandGuard('audit-list');
|
||||
expect(guard.action).toBe('audit.list');
|
||||
});
|
||||
|
||||
it('is NOT on the group-scope allowlist — group-scoped agents fail closed', () => {
|
||||
expect(GROUP_SCOPE_RESOURCES.has('audit')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves guard conformance clean with the audit resource registered', () => {
|
||||
// The invariant guard conformance checks: every holding action pairs with a
|
||||
// registered approve continuation. audit.list is `open` (never holds), so
|
||||
// registering the resource can't introduce a gap — this pins that.
|
||||
const dangling = listGuardedActions()
|
||||
.filter((spec) => spec.grantActionName)
|
||||
.filter((spec) => !getApprovalHandler(spec.grantActionName as string));
|
||||
expect(dangling.map((s) => s.action)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch composition (AST wiring)', () => {
|
||||
const source = fs.readFileSync(path.resolve('src/cli/dispatch.ts'), 'utf8');
|
||||
const sf = ts.createSourceFile('dispatch.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
const hasExportModifier = (node: ts.HasModifiers): boolean =>
|
||||
(ts.getModifiers(node) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
|
||||
let importsWithAudit = false;
|
||||
let innerDeclaredUnexported = false;
|
||||
let exportsWrappedDispatch = false;
|
||||
let exportsUnwrappedDispatchFn = false;
|
||||
|
||||
sf.forEachChild((node) => {
|
||||
if (
|
||||
ts.isImportDeclaration(node) &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
node.moduleSpecifier.text === './dispatch.audit.js'
|
||||
) {
|
||||
const named = node.importClause?.namedBindings;
|
||||
if (named && ts.isNamedImports(named) && named.elements.some((e) => e.name.text === 'withAudit')) {
|
||||
importsWithAudit = true;
|
||||
}
|
||||
}
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === 'dispatchInner') {
|
||||
innerDeclaredUnexported = !hasExportModifier(node);
|
||||
}
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === 'dispatch' && hasExportModifier(node)) {
|
||||
exportsUnwrappedDispatchFn = true;
|
||||
}
|
||||
if (ts.isVariableStatement(node) && hasExportModifier(node)) {
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (
|
||||
ts.isIdentifier(decl.name) &&
|
||||
decl.name.text === 'dispatch' &&
|
||||
decl.initializer &&
|
||||
ts.isCallExpression(decl.initializer) &&
|
||||
ts.isIdentifier(decl.initializer.expression) &&
|
||||
decl.initializer.expression.text === 'withAudit' &&
|
||||
decl.initializer.arguments.length === 1 &&
|
||||
ts.isIdentifier(decl.initializer.arguments[0]) &&
|
||||
decl.initializer.arguments[0].text === 'dispatchInner'
|
||||
) {
|
||||
exportsWrappedDispatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('imports withAudit from the audit adapter', () => {
|
||||
expect(importsWithAudit).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the inner dispatcher unexported (the guarded path is the only path)', () => {
|
||||
expect(innerDeclaredUnexported).toBe(true);
|
||||
});
|
||||
|
||||
it('exports dispatch as withAudit(dispatchInner)', () => {
|
||||
expect(exportsWrappedDispatch).toBe(true);
|
||||
expect(exportsUnwrappedDispatchFn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvals adapter wiring', () => {
|
||||
it('the approvals barrel imports the approval audit adapter', () => {
|
||||
// The one reach-in the approval-lifecycle increment adds: importing the
|
||||
// adapter is what registers its two observers at boot. Goes red if dropped.
|
||||
const source = fs.readFileSync(path.resolve('src/modules/approvals/index.ts'), 'utf8');
|
||||
expect(source).toMatch(/import\s+['"]\.\/approvals\.audit\.js['"];/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emit invariant', () => {
|
||||
it('emitAuditEvent appears only in src/audit/ and *.audit.ts files', () => {
|
||||
const srcRoot = path.resolve('src');
|
||||
const offenders: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.ts')) continue;
|
||||
const rel = path.relative(srcRoot, full).split(path.sep).join('/');
|
||||
if (rel === 'audit-wiring.test.ts') continue; // this file names the symbol
|
||||
if (rel.startsWith('audit/')) continue;
|
||||
if (/\.audit(\.test)?\.ts$/.test(rel)) continue;
|
||||
if (fs.readFileSync(full, 'utf8').includes('emitAuditEvent')) offenders.push(rel);
|
||||
}
|
||||
};
|
||||
walk(srcRoot);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Audit env config (installed by /add-audit) — the two audit vars, read the
|
||||
* same way src/config.ts reads every host setting (readEnvFile from .env,
|
||||
* with process.env taking precedence). Kept audit-owned so installing the
|
||||
* skill never edits core config.ts: the feature's whole footprint in core is
|
||||
* the dispatch composition in dispatch.ts and the resource-barrel import.
|
||||
*/
|
||||
import { readEnvFile } from '../env.js';
|
||||
|
||||
const envConfig = readEnvFile(['AUDIT_ENABLED', 'AUDIT_RETENTION_DAYS']);
|
||||
|
||||
/**
|
||||
* Master switch. Off by default — nothing is persisted (and data/audit/ is
|
||||
* never created) until an operator sets AUDIT_ENABLED=true.
|
||||
*/
|
||||
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
|
||||
|
||||
/**
|
||||
* Day-file retention horizon in days; consulted only when audit is enabled.
|
||||
* Unset or unparseable → 90. An explicit 0 (or negative) = keep forever.
|
||||
*/
|
||||
function parseRetentionDays(raw: string | undefined): number {
|
||||
if (raw === undefined || raw.trim() === '') return 90;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isNaN(n) ? 90 : n;
|
||||
}
|
||||
|
||||
export const AUDIT_RETENTION_DAYS = parseRetentionDays(
|
||||
process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The single emit seam. Adapters import emitAuditEvent from here directly
|
||||
* (it is deliberately not re-exported by the barrel): only src/audit/ and
|
||||
* `*.audit.ts` adapter files may call it — grep holds the invariant.
|
||||
*
|
||||
* The opt-in check lives here, so the whole feature switches at one point.
|
||||
* Fail-open + loud: a failed append (or a throwing input thunk) is
|
||||
* log.error'd and the audited action proceeds.
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { log } from '../log.js';
|
||||
import { AUDIT_ENABLED } from './config.js';
|
||||
import { notifyAuditHooks } from './hooks.js';
|
||||
import { appendAuditLine } from './store.js';
|
||||
import type { AuditEvent, AuditEventInput } from './types.js';
|
||||
|
||||
export function emitAuditEvent(input: AuditEventInput | (() => AuditEventInput)): void {
|
||||
if (!AUDIT_ENABLED) return; // The one opt-in check — the whole feature switches here.
|
||||
try {
|
||||
if (typeof input === 'function') input = input();
|
||||
const event: AuditEvent = {
|
||||
event_id: randomUUID(),
|
||||
time: new Date().toISOString(),
|
||||
schema_version: 1,
|
||||
actor: { ...input.actor, email: null, user_id: null, group_ids: null },
|
||||
origin: input.origin,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
outcome: input.outcome,
|
||||
correlation_id: input.correlationId ?? null,
|
||||
// Adapters build `details` value-free (key names + an enum allowlist), so
|
||||
// there is nothing to redact here — a secret can't leak from a value that
|
||||
// is never stored. Written verbatim.
|
||||
details: input.details ?? {},
|
||||
};
|
||||
const line = JSON.stringify(event);
|
||||
appendAuditLine(line, event.time.slice(0, 10));
|
||||
notifyAuditHooks(event, line);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- fail-open is the posture: an audit failure must never take down the audited action
|
||||
} catch (err) {
|
||||
const action = typeof input === 'function' ? undefined : input.action;
|
||||
log.error('Audit append failed — action proceeding (fail-open)', { action, err });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Post-write hook contract: hooks observe the LOG (fire only after a
|
||||
* successful append, exported ⊆ written), failures are isolated everywhere,
|
||||
* and the lifecycle (init/maintain/shutdown) behaves.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({ enabled: true, appendThrows: false, appended: [] as string[] }));
|
||||
|
||||
vi.mock('../config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config.js')>();
|
||||
return {
|
||||
...actual,
|
||||
DATA_DIR: '/tmp/nanoclaw-test-hooks-unused',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
AUDIT_RETENTION_DAYS: 90,
|
||||
}));
|
||||
|
||||
vi.mock('./store.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./store.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appendAuditLine: (line: string) => {
|
||||
if (state.appendThrows) throw new Error('disk full');
|
||||
state.appended.push(line);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
let hooks: typeof import('./hooks.js');
|
||||
let emit: typeof import('./emit.js');
|
||||
let log: (typeof import('../log.js'))['log'];
|
||||
|
||||
beforeEach(async () => {
|
||||
state.enabled = true;
|
||||
state.appendThrows = false;
|
||||
state.appended.length = 0;
|
||||
vi.resetModules(); // fresh hook registry per test
|
||||
hooks = await import('./hooks.js');
|
||||
emit = await import('./emit.js');
|
||||
log = (await import('../log.js')).log;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const EVENT_INPUT = {
|
||||
actor: { type: 'human' as const, id: 'host:test' },
|
||||
origin: { transport: 'socket' as const },
|
||||
action: 'groups.list',
|
||||
resources: [{ type: 'agent_group' }],
|
||||
outcome: 'success' as const,
|
||||
details: { limit: 5 },
|
||||
};
|
||||
|
||||
describe('post-write notification', () => {
|
||||
it('calls a registered hook with the parsed event and the exact stored line', () => {
|
||||
const seen: Array<{ event: import('./types.js').AuditEvent; line: string }> = [];
|
||||
hooks.registerAuditHook({ name: 'demo', onEvent: (event, line) => seen.push({ event, line }) });
|
||||
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
|
||||
expect(state.appended).toHaveLength(1);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].line).toBe(state.appended[0]);
|
||||
expect(JSON.parse(seen[0].line)).toEqual(seen[0].event);
|
||||
expect(seen[0].event.action).toBe('groups.list');
|
||||
});
|
||||
|
||||
it('does NOT call hooks when the local append fails — exported ⊆ written', () => {
|
||||
const onEvent = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'demo', onEvent });
|
||||
state.appendThrows = true;
|
||||
|
||||
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow(); // action still proceeds
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Audit append failed'), expect.anything());
|
||||
});
|
||||
|
||||
it('does NOT call hooks when audit is disabled', () => {
|
||||
const onEvent = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'demo', onEvent });
|
||||
state.enabled = false;
|
||||
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
|
||||
expect(state.appended).toHaveLength(0);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isolates a throwing hook: the write survives, later hooks still run, the action proceeds', () => {
|
||||
const second = vi.fn();
|
||||
hooks.registerAuditHook({
|
||||
name: 'broken',
|
||||
onEvent: () => {
|
||||
throw new Error('exporter exploded');
|
||||
},
|
||||
});
|
||||
hooks.registerAuditHook({ name: 'healthy', onEvent: second });
|
||||
|
||||
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
|
||||
|
||||
expect(state.appended).toHaveLength(1); // the log has the event regardless
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Audit hook threw'),
|
||||
expect.objectContaining({ hook: 'broken', action: 'groups.list' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('initAuditHooks surfaces a failing init as a fatal error naming the hook', () => {
|
||||
hooks.registerAuditHook({ name: 'ok', onEvent: () => {}, init: vi.fn() });
|
||||
hooks.registerAuditHook({
|
||||
name: 'bad-boot',
|
||||
onEvent: () => {},
|
||||
init: () => {
|
||||
throw new Error('no route to collector');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => hooks.initAuditHooks()).toThrow(/audit hook "bad-boot" failed to initialize.*no route/);
|
||||
});
|
||||
|
||||
it('defers init() until boot for a hook registered before initAuditHooks', () => {
|
||||
const init = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'early', onEvent: () => {}, init });
|
||||
|
||||
expect(init).not.toHaveBeenCalled(); // not yet — boot hasn't run
|
||||
hooks.initAuditHooks();
|
||||
expect(init).toHaveBeenCalledTimes(1); // exactly once, at boot
|
||||
});
|
||||
|
||||
it('runs init() immediately for a hook registered after boot — import-order-insensitive', () => {
|
||||
hooks.initAuditHooks(); // boot completes (no hooks yet)
|
||||
const init = vi.fn();
|
||||
const onEvent = vi.fn();
|
||||
|
||||
hooks.registerAuditHook({ name: 'late', onEvent, init });
|
||||
|
||||
// A module that loaded after the CLI adapter still gets its one-time init...
|
||||
expect(init).toHaveBeenCalledTimes(1);
|
||||
// ...and still receives events (onEvent already read the live array).
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a hook registered after boot whose init throws is fatal, naming the hook', () => {
|
||||
hooks.initAuditHooks();
|
||||
|
||||
expect(() =>
|
||||
hooks.registerAuditHook({
|
||||
name: 'late-bad',
|
||||
onEvent: () => {},
|
||||
init: () => {
|
||||
throw new Error('no route to collector');
|
||||
},
|
||||
}),
|
||||
).toThrow(/audit hook "late-bad" failed to initialize.*no route/);
|
||||
});
|
||||
|
||||
it('maintainAuditHooks calls every maintain and isolates throws', () => {
|
||||
const m1 = vi.fn(() => {
|
||||
throw new Error('flush failed');
|
||||
});
|
||||
const m2 = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain: m1 });
|
||||
hooks.registerAuditHook({ name: 'b', onEvent: () => {}, maintain: m2 });
|
||||
hooks.registerAuditHook({ name: 'c', onEvent: () => {} }); // no maintain — fine
|
||||
|
||||
expect(() => hooks.maintainAuditHooks()).not.toThrow();
|
||||
expect(m1).toHaveBeenCalledTimes(1);
|
||||
expect(m2).toHaveBeenCalledTimes(1);
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('maintenance failed'),
|
||||
expect.objectContaining({ hook: 'a' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shutdownAuditHooks awaits async shutdowns and isolates throws', async () => {
|
||||
const order: string[] = [];
|
||||
hooks.registerAuditHook({
|
||||
name: 'a',
|
||||
onEvent: () => {},
|
||||
shutdown: async () => {
|
||||
await Promise.resolve();
|
||||
order.push('a');
|
||||
},
|
||||
});
|
||||
hooks.registerAuditHook({
|
||||
name: 'b',
|
||||
onEvent: () => {},
|
||||
shutdown: () => {
|
||||
throw new Error('handle already closed');
|
||||
},
|
||||
});
|
||||
hooks.registerAuditHook({
|
||||
name: 'c',
|
||||
onEvent: () => {},
|
||||
shutdown: () => {
|
||||
order.push('c');
|
||||
},
|
||||
});
|
||||
|
||||
await hooks.shutdownAuditHooks();
|
||||
|
||||
expect(order).toEqual(['a', 'c']);
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('shutdown failed'),
|
||||
expect.objectContaining({ hook: 'b' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('maintainAudit skips hook maintenance when audit is disabled', async () => {
|
||||
const init = await import('./init.js');
|
||||
const maintain = vi.fn();
|
||||
hooks.registerAuditHook({ name: 'a', onEvent: () => {}, maintain });
|
||||
|
||||
state.enabled = false;
|
||||
init.maintainAudit();
|
||||
expect(maintain).not.toHaveBeenCalled();
|
||||
|
||||
state.enabled = true;
|
||||
init.maintainAudit();
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Post-write audit hooks — the in-process extension seam.
|
||||
*
|
||||
* A hook observes the audit LOG, not the event stream: `onEvent` fires only
|
||||
* after an event has been durably appended to the local day-file, so anything
|
||||
* a hook exports is guaranteed to exist in the source of truth
|
||||
* (exported ⊆ written). If the local append fails, hooks are not called —
|
||||
* and a hook that misses events (crash, restart) catches up by reading the
|
||||
* day-files, which is the at-least-once story.
|
||||
*
|
||||
* Registration follows the tree's observer idiom (registerApprovalResolvedHandler,
|
||||
* registerResponseHandler, …): an in-tree or skill-installed module calls
|
||||
* `registerAuditHook(...)` at import time — no core edits, and credentials or
|
||||
* transport for an external system live in that module, never here.
|
||||
*/
|
||||
import { log } from '../log.js';
|
||||
import type { AuditEvent } from './types.js';
|
||||
|
||||
export interface AuditHook {
|
||||
/** Short identifier used in logs and lifecycle errors. */
|
||||
name: string;
|
||||
/**
|
||||
* Called after a successful local append. `line` is the exact stored bytes
|
||||
* (one NDJSON line, no trailing newline); `event` is the parsed record.
|
||||
* MUST be fast and non-blocking — this runs on the audited action's call
|
||||
* path. A real exporter buffers here and does its IO from `maintain`/its own
|
||||
* timers. Throwing is tolerated: isolated and logged, never propagated.
|
||||
*/
|
||||
onEvent(event: AuditEvent, line: string): void;
|
||||
/**
|
||||
* One-time setup, called once when audit is enabled — at boot if the hook is
|
||||
* already registered, else immediately on registration (so it fires exactly
|
||||
* once regardless of import order). Throw = host refuses to start.
|
||||
*/
|
||||
init?(): void;
|
||||
/** Periodic maintenance — called from the audit maintenance timer (enabled boxes only). */
|
||||
maintain?(): void;
|
||||
/** Graceful-shutdown hook (flush buffers, close handles). */
|
||||
shutdown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
const hooks: AuditHook[] = [];
|
||||
let hooksInitialized = false;
|
||||
|
||||
export function registerAuditHook(hook: AuditHook): void {
|
||||
hooks.push(hook);
|
||||
// onEvent/maintain/shutdown all read the live array, so they pick a hook up
|
||||
// whenever it registers. init() is the exception — it runs once at boot. If
|
||||
// boot already ran (a hook whose module loaded after the CLI audit adapter),
|
||||
// run its init() now; otherwise it would receive events but never its boot
|
||||
// hook. Same throw-is-fatal posture as boot.
|
||||
if (hooksInitialized) initOneHook(hook);
|
||||
}
|
||||
|
||||
/** Fan out one written event to every hook, isolating failures per hook. */
|
||||
export function notifyAuditHooks(event: AuditEvent, line: string): void {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook.onEvent(event, line);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- isolation is the contract: one bad hook must not affect the log, other hooks, or the audited action
|
||||
} catch (err) {
|
||||
log.error('Audit hook threw — event is safely in the log', { hook: hook.name, action: event.action, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot lifecycle. A hook that can't start is a silent-export-gap risk — fatal. */
|
||||
export function initAuditHooks(): void {
|
||||
for (const hook of hooks) initOneHook(hook);
|
||||
hooksInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one hook's boot init with the fatal-on-throw posture. Shared by the boot
|
||||
* sweep and by a post-boot registration, so every hook gets exactly one init()
|
||||
* regardless of the import order its module loaded in.
|
||||
*/
|
||||
function initOneHook(hook: AuditHook): void {
|
||||
try {
|
||||
hook.init?.();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`audit hook "${hook.name}" failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic lifecycle — maintenance, isolated per hook. */
|
||||
export function maintainAuditHooks(): void {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
hook.maintain?.();
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- one hook's maintenance failure must not stop the others (or the timer)
|
||||
} catch (err) {
|
||||
log.error('Audit hook maintenance failed', { hook: hook.name, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shutdown lifecycle — awaited by the host's graceful shutdown. */
|
||||
export async function shutdownAuditHooks(): Promise<void> {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
await hook.shutdown?.();
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- shutdown must drain every hook even when one throws
|
||||
} catch (err) {
|
||||
log.error('Audit hook shutdown failed', { hook: hook.name, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Opt-in local audit log — installed by /add-audit.
|
||||
*
|
||||
* This directory is a domain-free leaf: the event schema, the emit seam, the
|
||||
* store, the reader, post-write hooks, and shared vocabulary. What gets
|
||||
* audited — and how each domain describes itself — lives in the domain-owned
|
||||
* `*.audit.ts` adapter files next to the code they observe. Business logic
|
||||
* contains zero audit calls.
|
||||
*
|
||||
* emitAuditEvent is deliberately NOT re-exported here: adapters import it
|
||||
* from './emit.js' directly, and only src/audit/ + `*.audit.ts` may call it.
|
||||
*/
|
||||
export * from './types.js';
|
||||
export { AUDIT_DIR } from './store.js';
|
||||
export { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from './config.js';
|
||||
export { initAuditLog, maintainAudit } from './init.js';
|
||||
export { type AuditHook, registerAuditHook } from './hooks.js';
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Boot-time audit wiring, self-contained (installed by /add-audit).
|
||||
*
|
||||
* initAuditLog() runs at module load of the CLI audit adapter
|
||||
* (cli/dispatch.audit.ts) — i.e. during the host's barrel phase, before the
|
||||
* CLI server or any delivery poll accepts work — because dispatch.ts, which
|
||||
* both transports import, composes withAudit at module scope. When enabled:
|
||||
* assert data/audit/ is writable (refusing to start beats running with a
|
||||
* silent audit gap), run the boot prune, start the registered post-write
|
||||
* hooks, and arm the maintenance timer.
|
||||
*
|
||||
* The timer owns the daily cadence in-module (checked hourly; the prune
|
||||
* itself fires at most once per UTC day) so installing the skill touches
|
||||
* dispatch.ts and the resource barrel only — no host-sweep edit. It is
|
||||
* unref'd: it never keeps the process alive.
|
||||
*/
|
||||
import { log } from '../log.js';
|
||||
import { onShutdown } from '../response-registry.js';
|
||||
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from './config.js';
|
||||
import { initAuditHooks, maintainAuditHooks, shutdownAuditHooks } from './hooks.js';
|
||||
import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
|
||||
|
||||
const MAINTENANCE_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initAuditLog(): void {
|
||||
if (!AUDIT_ENABLED || initialized) return;
|
||||
initialized = true;
|
||||
try {
|
||||
assertAuditWritable();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`AUDIT_ENABLED=true but the audit directory is not writable: ${AUDIT_DIR} (${err instanceof Error ? err.message : String(err)})`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
pruneAuditLog();
|
||||
markPrunedToday();
|
||||
initAuditHooks(); // throw → boot fails, same posture as the writability assert
|
||||
onShutdown(() => shutdownAuditHooks());
|
||||
setInterval(maintainAudit, MAINTENANCE_INTERVAL_MS).unref();
|
||||
log.info('Audit log enabled', { dir: AUDIT_DIR, retentionDays: AUDIT_RETENTION_DAYS });
|
||||
}
|
||||
|
||||
/**
|
||||
* One maintenance tick: retention prune (throttled to once per UTC day
|
||||
* internally) plus every hook's periodic maintenance. No-op when audit is
|
||||
* disabled. Exported so a future scheduler can drive it too.
|
||||
*/
|
||||
export function maintainAudit(): void {
|
||||
pruneAuditLogIfDue();
|
||||
if (AUDIT_ENABLED) maintainAuditHooks();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({ dataDir: '', enabled: true }));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
get DATA_DIR() {
|
||||
return state.dataDir;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
AUDIT_RETENTION_DAYS: 90,
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
let reader: typeof import('./reader.js');
|
||||
let store: typeof import('./store.js');
|
||||
|
||||
beforeEach(async () => {
|
||||
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-reader-'));
|
||||
state.enabled = true;
|
||||
vi.resetModules();
|
||||
store = await import('./store.js');
|
||||
reader = await import('./reader.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(state.dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function dayString(daysAgo: number): string {
|
||||
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function isoAt(daysAgo: number, tag: number): string {
|
||||
const base = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
return `${base.toISOString().slice(0, 10)}T10:00:0${tag}.000Z`;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
function seedEvent(daysAgo: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
seq += 1;
|
||||
const event = {
|
||||
event_id: `e-${seq}`,
|
||||
time: isoAt(daysAgo, seq % 10),
|
||||
schema_version: 1,
|
||||
actor: { type: 'human', id: 'host:moshe', email: null, user_id: null, group_ids: null },
|
||||
origin: { transport: 'socket' },
|
||||
action: 'groups.list',
|
||||
resources: [{ type: 'agent_group', id: 'ag-1' }],
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: {},
|
||||
...overrides,
|
||||
};
|
||||
const dir = path.join(state.dataDir, 'audit');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.appendFileSync(path.join(dir, `${dayString(daysAgo)}.ndjson`), JSON.stringify(event) + '\n');
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('listAuditEvents', () => {
|
||||
it('throws the disabled error rather than returning an empty list', () => {
|
||||
state.enabled = false;
|
||||
expect(() => reader.listAuditEvents({})).toThrow('audit log is disabled — set AUDIT_ENABLED=true');
|
||||
});
|
||||
|
||||
it('returns flat rows newest-first across day-files, honoring --limit', () => {
|
||||
seedEvent(2, { event_id: 'old' });
|
||||
seedEvent(1, { event_id: 'mid-a' });
|
||||
seedEvent(1, { event_id: 'mid-b' });
|
||||
seedEvent(0, { event_id: 'new' });
|
||||
|
||||
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
|
||||
expect(rows.map((r) => r.event_id)).toEqual(['new', 'mid-b', 'mid-a', 'old']);
|
||||
expect(rows[0]).toMatchObject({ actor: 'host:moshe', action: 'groups.list', outcome: 'success' });
|
||||
expect(rows[0].resources).toBe('agent_group:ag-1');
|
||||
|
||||
const limited = reader.listAuditEvents({ limit: 2 }) as Array<Record<string, unknown>>;
|
||||
expect(limited.map((r) => r.event_id)).toEqual(['new', 'mid-b']);
|
||||
});
|
||||
|
||||
it('filters by actor, outcome, correlation, and resource (id or type)', () => {
|
||||
seedEvent(0, { event_id: 'a', actor: { type: 'agent', id: 'ag-1' }, outcome: 'denied' });
|
||||
seedEvent(0, { event_id: 'b', correlation_id: 'appr-9', resources: [{ type: 'approval', id: 'appr-9' }] });
|
||||
seedEvent(0, { event_id: 'c', resources: [{ type: 'user', id: 'slack:U1' }] });
|
||||
|
||||
expect((reader.listAuditEvents({ actor: 'ag-1' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ outcome: 'denied' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ correlation: 'appr-9' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ resource: 'slack:U1' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ resource: 'approval' }) as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
it('matches actions exactly or by dotted prefix', () => {
|
||||
seedEvent(0, { event_id: 'cfg', action: 'groups.config.add-mcp-server' });
|
||||
seedEvent(0, { event_id: 'list', action: 'groups.list' });
|
||||
seedEvent(0, { event_id: 'other', action: 'sessions.list' });
|
||||
|
||||
expect((reader.listAuditEvents({ action: 'groups' }) as unknown[]).length).toBe(2);
|
||||
expect((reader.listAuditEvents({ action: 'groups.config' }) as unknown[]).length).toBe(1);
|
||||
expect((reader.listAuditEvents({ action: 'groups.list' }) as unknown[]).length).toBe(1);
|
||||
// Prefix means dotted segments, not substrings.
|
||||
expect((reader.listAuditEvents({ action: 'group' }) as unknown[]).length).toBe(0);
|
||||
});
|
||||
|
||||
it('applies --since/--until with relative and ISO forms', () => {
|
||||
seedEvent(5, { event_id: 'old' });
|
||||
seedEvent(0, { event_id: 'recent' });
|
||||
|
||||
const relative = reader.listAuditEvents({ since: '2d' }) as Array<Record<string, unknown>>;
|
||||
expect(relative.map((r) => r.event_id)).toEqual(['recent']);
|
||||
|
||||
const iso = reader.listAuditEvents({ until: dayString(2) }) as Array<Record<string, unknown>>;
|
||||
expect(iso.map((r) => r.event_id)).toEqual(['old']);
|
||||
|
||||
expect(() => reader.listAuditEvents({ since: 'yesterdayish' })).toThrow('invalid --since');
|
||||
});
|
||||
|
||||
it('--format ndjson returns the stored lines verbatim', () => {
|
||||
const seeded = seedEvent(0, { event_id: 'x1' });
|
||||
const out = reader.listAuditEvents({ format: 'ndjson' });
|
||||
expect(typeof out).toBe('string');
|
||||
expect(JSON.parse(out as string)).toEqual(seeded);
|
||||
expect(() => reader.listAuditEvents({ format: 'csv' })).toThrow('invalid --format');
|
||||
});
|
||||
|
||||
it('--format ndjson exports every event in the window (no silent 100-row cap)', () => {
|
||||
for (let i = 0; i < 150; i++) seedEvent(0, { event_id: `e-${i}` });
|
||||
const ndjson = reader.listAuditEvents({ format: 'ndjson' }) as string;
|
||||
expect(ndjson.split('\n').length).toBe(150);
|
||||
// The table view still caps at the default for readability.
|
||||
expect((reader.listAuditEvents({}) as unknown[]).length).toBe(100);
|
||||
});
|
||||
|
||||
it('honors an explicit --limit 0 as zero rows, not the default', () => {
|
||||
seedEvent(0, { event_id: 'a' });
|
||||
expect(reader.listAuditEvents({ limit: 0 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a negative or non-integer --limit', () => {
|
||||
expect(() => reader.listAuditEvents({ limit: -1 })).toThrow('invalid --limit');
|
||||
expect(() => reader.listAuditEvents({ limit: 'lots' })).toThrow('invalid --limit');
|
||||
});
|
||||
|
||||
it('parses a timezone-less --since/--until datetime as UTC, not local', () => {
|
||||
expect(reader.parseTimeFlag('2026-07-12T00:00:00', '--since')).toBe(Date.parse('2026-07-12T00:00:00Z'));
|
||||
// Date-only already parses UTC; an explicit offset is respected as-is.
|
||||
expect(reader.parseTimeFlag('2026-07-12', '--since')).toBe(Date.parse('2026-07-12'));
|
||||
expect(reader.parseTimeFlag('2026-07-12T00:00:00+02:00', '--until')).toBe(
|
||||
Date.parse('2026-07-12T00:00:00+02:00'),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips malformed stored lines and still returns the rest', () => {
|
||||
seedEvent(0, { event_id: 'good' });
|
||||
fs.appendFileSync(path.join(state.dataDir, 'audit', `${dayString(0)}.ndjson`), 'not-json\n');
|
||||
|
||||
const rows = reader.listAuditEvents({}) as Array<Record<string, unknown>>;
|
||||
expect(rows.map((r) => r.event_id)).toEqual(['good']);
|
||||
});
|
||||
|
||||
it('rejects an unknown --outcome value', () => {
|
||||
expect(() => reader.listAuditEvents({ outcome: 'meh' })).toThrow('invalid --outcome');
|
||||
});
|
||||
|
||||
it('returns empty when nothing has been recorded yet', () => {
|
||||
expect(reader.listAuditEvents({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Read-back for `ncl audit list` — a newest-first stream-scan over the
|
||||
* day-files. No index: fine at v1 volume, and adding one later doesn't change
|
||||
* the store. NDJSON export returns the stored lines verbatim.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../log.js';
|
||||
import { AUDIT_ENABLED } from './config.js';
|
||||
import { AUDIT_DIR, utcDay } from './store.js';
|
||||
import type { AuditEvent, AuditOutcome } from './types.js';
|
||||
|
||||
const OUTCOMES: ReadonlySet<string> = new Set(['success', 'failure', 'denied', 'pending', 'approved', 'rejected']);
|
||||
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export interface AuditQuery {
|
||||
actor?: string;
|
||||
/** Exact action or dotted prefix (`groups` matches `groups.config.update`). */
|
||||
action?: string;
|
||||
/** Matches any resources[] entry by id or by type. */
|
||||
resource?: string;
|
||||
outcome?: AuditOutcome;
|
||||
sinceMs?: number;
|
||||
untilMs?: number;
|
||||
correlation?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** `7d` / `24h` / `30m` relative to now, or an ISO date/datetime (UTC). */
|
||||
export function parseTimeFlag(value: string, flag: string): number {
|
||||
const rel = /^(\d+)([dhm])$/.exec(value);
|
||||
if (rel) {
|
||||
const n = Number(rel[1]);
|
||||
const unitMs = rel[2] === 'd' ? 86_400_000 : rel[2] === 'h' ? 3_600_000 : 60_000;
|
||||
return Date.now() - n * unitMs;
|
||||
}
|
||||
// A timezone-less ISO *datetime* parses as LOCAL per spec, but --since/--until
|
||||
// are documented UTC; append 'Z' so a bare datetime reads as UTC. Date-only
|
||||
// forms already parse as UTC, and an explicit offset (`+02:00`/`Z`) is left as-is.
|
||||
const normalized = /^\d{4}-\d{2}-\d{2}T[\d.:]+$/.test(value) ? `${value}Z` : value;
|
||||
const abs = Date.parse(normalized);
|
||||
if (!Number.isNaN(abs)) return abs;
|
||||
throw new Error(`invalid ${flag} value "${value}" — use e.g. 7d, 24h, 30m, or an ISO date`);
|
||||
}
|
||||
|
||||
/** Newest first across files and within each file, up to q.limit. */
|
||||
export function queryAuditEvents(q: AuditQuery): { events: AuditEvent[]; lines: string[] } {
|
||||
const events: AuditEvent[] = [];
|
||||
const lines: string[] = [];
|
||||
let malformed = 0;
|
||||
|
||||
for (const { day, file } of dayFilesNewestFirst()) {
|
||||
if (events.length >= q.limit) break;
|
||||
// Whole-day skip: a file can't match a window its day lies outside.
|
||||
if (q.sinceMs !== undefined && day < utcDay(new Date(q.sinceMs))) continue;
|
||||
if (q.untilMs !== undefined && day > utcDay(new Date(q.untilMs))) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf8');
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- a torn/pruned day-file must not fail the whole query
|
||||
} catch (err) {
|
||||
log.warn('Audit reader failed to read day-file', { file, err });
|
||||
continue;
|
||||
}
|
||||
const fileLines = content.split('\n').filter((l) => l.trim() !== '');
|
||||
// Lines within a file are chronological — walk backwards for newest-first.
|
||||
for (let i = fileLines.length - 1; i >= 0 && events.length < q.limit; i--) {
|
||||
let event: AuditEvent;
|
||||
try {
|
||||
event = JSON.parse(fileLines[i]) as AuditEvent;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- malformed stored lines are skipped (counted + warned below)
|
||||
} catch {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
if (!matches(event, q)) continue;
|
||||
events.push(event);
|
||||
lines.push(fileLines[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (malformed > 0) {
|
||||
log.warn('Audit reader skipped malformed lines', { malformed });
|
||||
}
|
||||
return { events, lines };
|
||||
}
|
||||
|
||||
function dayFilesNewestFirst(): Array<{ day: string; file: string }> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(AUDIT_DIR);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means no events, not an error
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((e) => DAY_FILE_RE.exec(e))
|
||||
.filter((m): m is RegExpExecArray => m !== null)
|
||||
.map((m) => ({ day: m[1], file: path.join(AUDIT_DIR, m[0]) }))
|
||||
.sort((a, b) => (a.day < b.day ? 1 : -1));
|
||||
}
|
||||
|
||||
function matches(event: AuditEvent, q: AuditQuery): boolean {
|
||||
if (q.actor !== undefined && event.actor?.id !== q.actor) return false;
|
||||
if (q.action !== undefined && event.action !== q.action && !event.action?.startsWith(q.action + '.')) return false;
|
||||
if (q.outcome !== undefined && event.outcome !== q.outcome) return false;
|
||||
if (q.correlation !== undefined && event.correlation_id !== q.correlation) return false;
|
||||
if (q.resource !== undefined) {
|
||||
const hit = (event.resources ?? []).some((r) => r.id === q.resource || r.type === q.resource);
|
||||
if (!hit) return false;
|
||||
}
|
||||
const t = Date.parse(event.time ?? '');
|
||||
if (q.sinceMs !== undefined && !(t >= q.sinceMs)) return false;
|
||||
if (q.untilMs !== undefined && !(t <= q.untilMs)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ncl audit list` handler. Disabled → an explicit error: an empty list would
|
||||
* read as "no actions happened", which is a different truth than "not
|
||||
* recording". `--format ndjson` returns the stored lines verbatim (the human
|
||||
* formatter passes strings through); default returns flat rows for the table.
|
||||
*/
|
||||
export function listAuditEvents(args: Record<string, unknown>): string | Array<Record<string, unknown>> {
|
||||
if (!AUDIT_ENABLED) {
|
||||
throw new Error('audit log is disabled — set AUDIT_ENABLED=true');
|
||||
}
|
||||
|
||||
const format = args.format !== undefined ? String(args.format) : '';
|
||||
if (format && format !== 'ndjson') {
|
||||
throw new Error(`invalid --format "${format}" — only "ndjson" is supported`);
|
||||
}
|
||||
const outcome = args.outcome !== undefined ? String(args.outcome) : undefined;
|
||||
if (outcome !== undefined && !OUTCOMES.has(outcome)) {
|
||||
throw new Error(`invalid --outcome "${outcome}" — one of: ${[...OUTCOMES].join(', ')}`);
|
||||
}
|
||||
|
||||
// An explicit --limit is honored exactly (0 means 0, not "default"). Absent,
|
||||
// the table view caps at DEFAULT_LIMIT for readability, but ndjson — the SIEM
|
||||
// export path — is bounded only by --since/--until, never silently truncated.
|
||||
let limit: number;
|
||||
if (args.limit !== undefined) {
|
||||
const n = Number(args.limit);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new Error(`invalid --limit "${String(args.limit)}" — use a non-negative integer`);
|
||||
}
|
||||
limit = n;
|
||||
} else {
|
||||
limit = format === 'ndjson' ? Infinity : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
const q: AuditQuery = {
|
||||
actor: args.actor !== undefined ? String(args.actor) : undefined,
|
||||
action: args.action !== undefined ? String(args.action) : undefined,
|
||||
resource: args.resource !== undefined ? String(args.resource) : undefined,
|
||||
outcome: outcome as AuditOutcome | undefined,
|
||||
correlation: args.correlation !== undefined ? String(args.correlation) : undefined,
|
||||
sinceMs: args.since !== undefined ? parseTimeFlag(String(args.since), '--since') : undefined,
|
||||
untilMs: args.until !== undefined ? parseTimeFlag(String(args.until), '--until') : undefined,
|
||||
limit,
|
||||
};
|
||||
|
||||
const { events, lines } = queryAuditEvents(q);
|
||||
if (format === 'ndjson') return lines.join('\n');
|
||||
|
||||
return events.map((e) => ({
|
||||
time: e.time,
|
||||
actor: e.actor?.id ?? '',
|
||||
action: e.action,
|
||||
resources: (e.resources ?? []).map((r) => (r.id ? `${r.type}:${r.id}` : r.type)).join(' '),
|
||||
outcome: e.outcome,
|
||||
correlation: e.correlation_id ?? '',
|
||||
event_id: e.event_id,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Core config (DATA_DIR) and the audit config (toggles) are mocked so
|
||||
// store/emit resolve a per-test temp DATA_DIR and audit switches. Getters
|
||||
// keep the values live across vi.resetModules().
|
||||
const state = vi.hoisted(() => ({ dataDir: '', enabled: true, retention: 90 }));
|
||||
|
||||
vi.mock('../config.js', () => ({
|
||||
get DATA_DIR() {
|
||||
return state.dataDir;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
get AUDIT_ENABLED() {
|
||||
return state.enabled;
|
||||
},
|
||||
get AUDIT_RETENTION_DAYS() {
|
||||
return state.retention;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
let store: typeof import('./store.js');
|
||||
let emit: typeof import('./emit.js');
|
||||
let log: (typeof import('../log.js'))['log'];
|
||||
|
||||
beforeEach(async () => {
|
||||
state.dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-store-'));
|
||||
state.enabled = true;
|
||||
state.retention = 90;
|
||||
vi.resetModules();
|
||||
store = await import('./store.js');
|
||||
emit = await import('./emit.js');
|
||||
log = (await import('../log.js')).log;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.chmodSync(path.join(state.dataDir), 0o700);
|
||||
const auditDir = path.join(state.dataDir, 'audit');
|
||||
if (fs.existsSync(auditDir)) fs.chmodSync(auditDir, 0o700);
|
||||
fs.rmSync(state.dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function auditDir(): string {
|
||||
return path.join(state.dataDir, 'audit');
|
||||
}
|
||||
|
||||
function writeDayFile(day: string, lines = 1): void {
|
||||
fs.mkdirSync(auditDir(), { recursive: true });
|
||||
fs.writeFileSync(path.join(auditDir(), `${day}.ndjson`), '{"x":1}\n'.repeat(lines));
|
||||
}
|
||||
|
||||
function dayString(daysAgo: number): string {
|
||||
return store.utcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
const EVENT_INPUT = {
|
||||
actor: { type: 'human' as const, id: 'host:test' },
|
||||
origin: { transport: 'socket' as const },
|
||||
action: 'groups.list',
|
||||
resources: [{ type: 'agent_group' }],
|
||||
outcome: 'success' as const,
|
||||
};
|
||||
|
||||
describe('appendAuditLine', () => {
|
||||
it("appends one line to today's UTC day-file, creating the directory lazily", () => {
|
||||
expect(fs.existsSync(auditDir())).toBe(false);
|
||||
store.appendAuditLine('{"a":1}');
|
||||
store.appendAuditLine('{"b":2}');
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
expect(fs.readFileSync(file, 'utf8')).toBe('{"a":1}\n{"b":2}\n');
|
||||
});
|
||||
|
||||
it('buckets by the given event day, not wall-clock at append', () => {
|
||||
store.appendAuditLine('{"c":3}', '2020-01-02');
|
||||
expect(fs.readFileSync(path.join(auditDir(), '2020-01-02.ndjson'), 'utf8')).toBe('{"c":3}\n');
|
||||
// Today's file is untouched — the line filed under its own day.
|
||||
expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAuditEvent', () => {
|
||||
it('writes a schema_version-1 record with envelope fields and null enrichment', () => {
|
||||
emit.emitAuditEvent({ ...EVENT_INPUT, details: { limit: 100 } });
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
const record = JSON.parse(fs.readFileSync(file, 'utf8').trim());
|
||||
expect(record).toMatchObject({
|
||||
schema_version: 1,
|
||||
actor: { type: 'human', id: 'host:test', email: null, user_id: null, group_ids: null },
|
||||
origin: { transport: 'socket' },
|
||||
action: 'groups.list',
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: { limit: 100 },
|
||||
});
|
||||
expect(record.event_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(new Date(record.time).toISOString()).toBe(record.time);
|
||||
});
|
||||
|
||||
it('stores details verbatim — the emit seam performs no value transformation', () => {
|
||||
// emit is domain-free: adapters build `details` value-free, so there is
|
||||
// nothing to redact here. Whatever is passed is written as-is.
|
||||
emit.emitAuditEvent({ ...EVENT_INPUT, details: { args: ['role'], role: 'admin' } });
|
||||
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
|
||||
const record = JSON.parse(fs.readFileSync(file, 'utf8').trim());
|
||||
expect(record.details).toEqual({ args: ['role'], role: 'admin' });
|
||||
});
|
||||
|
||||
it('is a no-op when audit is disabled — the directory is never created', () => {
|
||||
state.enabled = false;
|
||||
emit.emitAuditEvent(EVENT_INPUT);
|
||||
expect(fs.existsSync(auditDir())).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open and loud when the append fails', () => {
|
||||
fs.mkdirSync(auditDir(), { recursive: true });
|
||||
fs.chmodSync(auditDir(), 0o500);
|
||||
expect(() => emit.emitAuditEvent(EVENT_INPUT)).not.toThrow();
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Audit append failed'),
|
||||
expect.objectContaining({ action: 'groups.list' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertAuditWritable', () => {
|
||||
it('creates the directory and probes it with a zero-byte append', () => {
|
||||
store.assertAuditWritable();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${store.utcDay()}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the directory is not writable', () => {
|
||||
fs.mkdirSync(auditDir(), { recursive: true });
|
||||
fs.chmodSync(auditDir(), 0o500);
|
||||
expect(() => store.assertAuditWritable()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneAuditLog', () => {
|
||||
it('unlinks only day-files strictly older than the horizon', () => {
|
||||
writeDayFile(dayString(100));
|
||||
writeDayFile(dayString(91));
|
||||
writeDayFile(dayString(90));
|
||||
writeDayFile(dayString(1));
|
||||
fs.writeFileSync(path.join(auditDir(), 'not-a-day-file.txt'), 'keep');
|
||||
fs.writeFileSync(path.join(auditDir(), '2026-01-01.ndjson.bak'), 'keep');
|
||||
store.pruneAuditLog(90);
|
||||
const left = fs.readdirSync(auditDir()).sort();
|
||||
expect(left).toEqual(
|
||||
[`${dayString(90)}.ndjson`, `${dayString(1)}.ndjson`, '2026-01-01.ndjson.bak', 'not-a-day-file.txt'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps forever when retention is 0', () => {
|
||||
writeDayFile(dayString(400));
|
||||
store.pruneAuditLog(0);
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(400)}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when the audit directory does not exist', () => {
|
||||
expect(() => store.pruneAuditLog(90)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneAuditLogIfDue', () => {
|
||||
it('prunes at most once per UTC day', () => {
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(false);
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when audit is disabled', () => {
|
||||
state.enabled = false;
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing after markPrunedToday until the day rolls over', () => {
|
||||
store.markPrunedToday();
|
||||
writeDayFile(dayString(100));
|
||||
store.pruneAuditLogIfDue();
|
||||
expect(fs.existsSync(path.join(auditDir(), `${dayString(100)}.ndjson`))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Day-file store — daily NDJSON files under data/audit/, host process is the
|
||||
* single writer. Append-only is structural: nothing here can update a line,
|
||||
* and retention is unlinking whole files (a literal hard delete). Both
|
||||
* transports converge host-side, so single-writer holds by construction.
|
||||
*
|
||||
* This module throws on fs failure; the fail-open posture (log.error, action
|
||||
* proceeds) lives in emit.ts, and the boot-time strictness (refuse to start)
|
||||
* lives in init.ts.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../config.js';
|
||||
import { log } from '../log.js';
|
||||
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from './config.js';
|
||||
|
||||
export const AUDIT_DIR = path.join(DATA_DIR, 'audit');
|
||||
const DAY_FILE_RE = /^(\d{4}-\d{2}-\d{2})\.ndjson$/;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function utcDay(d: Date = new Date()): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function dayFilePath(day: string): string {
|
||||
return path.join(AUDIT_DIR, `${day}.ndjson`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single append point. `day` buckets the line by the EVENT's own UTC day
|
||||
* (emit passes it from event.time), not wall-clock at append — so a line whose
|
||||
* emit crossed a UTC-midnight tick still files under its timestamp's day, and a
|
||||
* bounded reader query can't whole-day-skip it. Throws on fs failure —
|
||||
* emitAuditEvent catches.
|
||||
*/
|
||||
export function appendAuditLine(line: string, day: string = utcDay()): void {
|
||||
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
||||
fs.appendFileSync(dayFilePath(day), line + '\n');
|
||||
}
|
||||
|
||||
/** Boot-time writability assert — a zero-byte append is a true write probe. */
|
||||
export function assertAuditWritable(): void {
|
||||
fs.mkdirSync(AUDIT_DIR, { recursive: true });
|
||||
fs.appendFileSync(dayFilePath(utcDay()), '');
|
||||
}
|
||||
|
||||
/** Unlink day-files strictly older than (today UTC − retentionDays). 0 or negative = keep forever. */
|
||||
export function pruneAuditLog(retentionDays: number = AUDIT_RETENTION_DAYS): void {
|
||||
if (retentionDays <= 0) return;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(AUDIT_DIR);
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- no audit dir yet means nothing to prune, not an error
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const horizon = utcDay(new Date(Date.now() - retentionDays * DAY_MS));
|
||||
let pruned = 0;
|
||||
for (const entry of entries) {
|
||||
const m = DAY_FILE_RE.exec(entry);
|
||||
if (!m || m[1] >= horizon) continue;
|
||||
try {
|
||||
fs.unlinkSync(path.join(AUDIT_DIR, entry));
|
||||
pruned++;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- one stuck file must not stop the prune of the others
|
||||
} catch (err) {
|
||||
log.error('Audit prune failed to unlink day-file', { file: entry, err });
|
||||
}
|
||||
}
|
||||
if (pruned > 0) log.info('Audit retention pruned day-files', { pruned, retentionDays });
|
||||
}
|
||||
|
||||
let lastPruneDay: string | null = null;
|
||||
|
||||
/** Retention prune, throttled to once per UTC day — safe to call every tick. */
|
||||
export function pruneAuditLogIfDue(): void {
|
||||
if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return;
|
||||
const today = utcDay();
|
||||
if (lastPruneDay === today) return;
|
||||
lastPruneDay = today;
|
||||
pruneAuditLog();
|
||||
}
|
||||
|
||||
export function markPrunedToday(): void {
|
||||
lastPruneDay = utcDay();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Canonical audit event — one flat, SIEM-shaped record per action.
|
||||
*
|
||||
* Fields are chosen so they project losslessly onto OCSF and Elastic ECS
|
||||
* (the two schemas SIEMs converge on); the store keeps the neutral shape and
|
||||
* a future forwarder pays the translation once, at the edge. `schema_version`
|
||||
* covers evolution — additive changes only within a version.
|
||||
*/
|
||||
|
||||
export type AuditActorType = 'human' | 'agent' | 'system';
|
||||
|
||||
export interface AuditActor {
|
||||
type: AuditActorType;
|
||||
/** `host:<os-user>` | `<channel>:<handle>` | agent group id | `host` (system). */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuditOrigin {
|
||||
transport: 'socket' | 'container' | 'channel';
|
||||
session_id?: string;
|
||||
messaging_group_id?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface AuditResource {
|
||||
type: string;
|
||||
/** Omitted when only the attempted type is known (e.g. a denied list). */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export type AuditOutcome = 'success' | 'failure' | 'denied' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
/** What emit sites provide. Envelope fields are stamped by emitAuditEvent. */
|
||||
export interface AuditEventInput {
|
||||
actor: AuditActor;
|
||||
origin: AuditOrigin;
|
||||
/** Dotted namespaced verb, e.g. `groups.config.add-mcp-server`. */
|
||||
action: string;
|
||||
resources: AuditResource[];
|
||||
outcome: AuditOutcome;
|
||||
/** The approval id on gated chains; null/omitted otherwise. */
|
||||
correlationId?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
event_id: string;
|
||||
time: string;
|
||||
schema_version: 1;
|
||||
actor: AuditActor & { email: null; user_id: null; group_ids: null };
|
||||
origin: AuditOrigin;
|
||||
action: string;
|
||||
resources: AuditResource[];
|
||||
outcome: AuditOutcome;
|
||||
correlation_id: string | null;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Domain-free event vocabulary — actor and origin constructors shared by the
|
||||
* domain-owned `*.audit.ts` adapters (the CLI adapter today; approval and
|
||||
* channel adapters attach here in later increments). Pure derivation; nothing
|
||||
* here writes the log.
|
||||
*
|
||||
* Leaf rule: this module (like the rest of src/audit/) may depend on node,
|
||||
* config/log, shared types, and the db read layer — never on src/cli/* or
|
||||
* src/modules/*. Domain-specific mapping (CLI resources, approval payloads)
|
||||
* lives in the adapter file of the domain that owns it.
|
||||
*/
|
||||
import os from 'os';
|
||||
|
||||
import { getMessagingGroup } from '../db/messaging-groups.js';
|
||||
import type { AuditOrigin } from './types.js';
|
||||
|
||||
/**
|
||||
* Host callers stamp `host:<install user>` daemon-side: the ncl socket is
|
||||
* 0600 and owned by the install user, so the identity is accurate by
|
||||
* construction without peer credentials.
|
||||
*/
|
||||
export function hostUser(): string {
|
||||
try {
|
||||
return os.userInfo().username;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- os.userInfo throws on exotic hosts; a fallback actor id beats no audit event
|
||||
} catch {
|
||||
return process.env.USER || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function containerOrigin(sessionId: string, messagingGroupId: string | null): AuditOrigin {
|
||||
const origin: AuditOrigin = { transport: 'container', session_id: sessionId };
|
||||
if (messagingGroupId) {
|
||||
origin.messaging_group_id = messagingGroupId;
|
||||
const channel = getMessagingGroup(messagingGroupId)?.channel_type;
|
||||
if (channel) origin.channel = channel;
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* An approval decision answered on a chat platform (the approver clicked a
|
||||
* card). `channelType` is the platform the click came from.
|
||||
*/
|
||||
export function channelOrigin(channelType: string | null): AuditOrigin {
|
||||
return channelType ? { transport: 'channel', channel: channelType } : { transport: 'channel' };
|
||||
}
|
||||
|
||||
/** Channel of a namespaced `<channel>:<handle>` id, or null if unprefixed. */
|
||||
export function channelOf(namespacedUserId: string): string | null {
|
||||
const i = namespacedUserId.indexOf(':');
|
||||
return i > 0 ? namespacedUserId.slice(0, i) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dotted governance name for a hold's action, matching the guard catalog
|
||||
* (agents.create, self_mod.*, a2a.send, senders.admit, channels.register).
|
||||
* An unmapped action falls back to its raw name so a new gated surface still
|
||||
* records — uncatalogued, never dropped.
|
||||
*/
|
||||
const APPROVAL_ACTION_DOTTED: Record<string, string> = {
|
||||
create_agent: 'agents.create',
|
||||
install_packages: 'self_mod.install_packages',
|
||||
add_mcp_server: 'self_mod.add_mcp_server',
|
||||
a2a_message_gate: 'a2a.send',
|
||||
sender_admit: 'senders.admit',
|
||||
channel_registration: 'channels.register',
|
||||
onecli_credential: 'onecli.credential.use',
|
||||
};
|
||||
|
||||
export function approvalActionName(action: string): string {
|
||||
return APPROVAL_ACTION_DOTTED[action] ?? action;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Audit middleware behavior of the exported dispatch — what gets recorded,
|
||||
* for whom, and how gated chains correlate. Drives the real wrapped dispatch
|
||||
* (real registry, real guard); audit is force-enabled and the store's append
|
||||
* is captured. DB reads and approval delivery are mocked.
|
||||
*
|
||||
* Recording model under test: the log stores arg KEY NAMES plus a small
|
||||
* allowlist of safe enum values — never raw argument values — so a
|
||||
* secret-bearing arg leaves only its key behind.
|
||||
*/
|
||||
import os from 'os';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PendingApproval } from '../types.js';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
const pendingRows = vi.hoisted(() => ({ rows: [] as unknown[] }));
|
||||
|
||||
vi.mock('../audit/config.js', () => ({
|
||||
AUDIT_ENABLED: true,
|
||||
AUDIT_RETENTION_DAYS: 90,
|
||||
}));
|
||||
|
||||
// Neutralize the adapter's module-scope boot (writability assert, prune,
|
||||
// maintenance timer) — the middleware is the unit under test here.
|
||||
vi.mock('../audit/init.js', () => ({
|
||||
initAuditLog: vi.fn(),
|
||||
maintainAudit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../audit/store.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../audit/store.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appendAuditLine: (line: string) => {
|
||||
appended.lines.push(line);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockGetContainerConfig = vi.fn();
|
||||
vi.mock('../db/container-configs.js', () => ({
|
||||
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../db/agent-groups.js', () => ({
|
||||
getAgentGroup: vi.fn(() => ({ id: 'g1', name: 'Group One' })),
|
||||
}));
|
||||
|
||||
const mockGetPendingApproval = vi.fn();
|
||||
vi.mock('../db/sessions.js', () => ({
|
||||
getSession: vi.fn(() => ({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' })),
|
||||
getPendingApproval: (...args: unknown[]) => mockGetPendingApproval(...args),
|
||||
getPendingApprovalsByAction: () => pendingRows.rows,
|
||||
}));
|
||||
|
||||
vi.mock('../db/messaging-groups.js', () => ({
|
||||
getMessagingGroup: vi.fn(() => ({ channel_type: 'slack' })),
|
||||
}));
|
||||
|
||||
const mockGetResource = vi.fn();
|
||||
vi.mock('./crud.js', () => ({
|
||||
getResource: (...args: unknown[]) => mockGetResource(...args),
|
||||
}));
|
||||
|
||||
const mockRequestApproval = vi.fn();
|
||||
vi.mock('../modules/approvals/index.js', () => ({
|
||||
registerApprovalHandler: vi.fn(),
|
||||
requestApproval: (...args: unknown[]) => mockRequestApproval(...args),
|
||||
}));
|
||||
|
||||
import { register } from './registry.js';
|
||||
|
||||
register({
|
||||
name: 'groups-test',
|
||||
description: 'echo command on the groups resource',
|
||||
action: 'groups.test',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-get',
|
||||
description: 'echo command for dash-joined id resolution',
|
||||
action: 'groups.get',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({ echo: args }),
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'wirings-list',
|
||||
description: 'not on the group-scope allowlist',
|
||||
action: 'wirings.list',
|
||||
resource: 'wirings',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => [],
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-fail',
|
||||
description: 'handler that throws',
|
||||
action: 'groups.fail',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'groups-gated',
|
||||
description: 'approval-gated command',
|
||||
action: 'groups.gated',
|
||||
resource: 'groups',
|
||||
access: 'approval',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => 'ran',
|
||||
});
|
||||
|
||||
register({
|
||||
name: 'roles-grant',
|
||||
description: 'command carrying an allowlisted --role value',
|
||||
action: 'roles.grant',
|
||||
resource: 'roles',
|
||||
access: 'open',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => ({ granted: true }),
|
||||
});
|
||||
|
||||
import { dispatch } from './dispatch.js';
|
||||
import type { CallerContext } from './frame.js';
|
||||
|
||||
const AGENT_CTX: CallerContext = { caller: 'agent', sessionId: 's1', agentGroupId: 'g1', messagingGroupId: 'mg1' };
|
||||
|
||||
function grantRow(frameId: string, command: string): PendingApproval {
|
||||
return {
|
||||
approval_id: 'appr-123-abc',
|
||||
session_id: 's1',
|
||||
request_id: 'appr-123-abc',
|
||||
action: 'cli_command',
|
||||
payload: JSON.stringify({ frame: { id: frameId, command, args: {} }, callerContext: AGENT_CTX }),
|
||||
created_at: new Date().toISOString(),
|
||||
agent_group_id: 'g1',
|
||||
channel_type: null,
|
||||
platform_id: null,
|
||||
platform_message_id: null,
|
||||
expires_at: null,
|
||||
status: 'pending',
|
||||
title: 'CLI: groups-gated',
|
||||
options_json: '[]',
|
||||
approver_user_id: null,
|
||||
approver_rule: 'admins-of-scope',
|
||||
dedup_key: null,
|
||||
};
|
||||
}
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
appended.lines.length = 0;
|
||||
pendingRows.rows = [];
|
||||
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||
mockGetResource.mockImplementation((plural: string) => (plural === 'groups' ? { scopeField: 'id' } : undefined));
|
||||
mockRequestApproval.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('withAudit(dispatch)', () => {
|
||||
it('records a success event for a host caller with socket origin and host actor', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
schema_version: 1,
|
||||
actor: { type: 'human', id: `host:${os.userInfo().username}`, email: null },
|
||||
origin: { transport: 'socket' },
|
||||
action: 'groups.test',
|
||||
outcome: 'success',
|
||||
correlation_id: null,
|
||||
details: { args: ['foo'] },
|
||||
});
|
||||
// The value 'bar' is never stored — only the key name.
|
||||
expect(event.details.foo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records an agent command value-free, with container origin and channel', async () => {
|
||||
await dispatch({ id: '1', command: 'groups-test', args: {} }, AGENT_CTX);
|
||||
|
||||
const [event] = events();
|
||||
expect(event.actor).toMatchObject({ type: 'agent', id: 'g1' });
|
||||
expect(event.origin).toEqual({
|
||||
transport: 'container',
|
||||
session_id: 's1',
|
||||
messaging_group_id: 'mg1',
|
||||
channel: 'slack',
|
||||
});
|
||||
expect(event.details).toEqual({ args: [] });
|
||||
expect(event.resources).toContainEqual({ type: 'agent_group' });
|
||||
});
|
||||
|
||||
it('records the passed flag names and echoes allowlisted safe values verbatim', async () => {
|
||||
await dispatch(
|
||||
{ id: '1', command: 'roles-grant', args: { role: 'admin', user: 'slack:U1' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
const [event] = events();
|
||||
expect(event.action).toBe('roles.grant');
|
||||
// Both flag names recorded; only the allowlisted `role` keeps its value.
|
||||
expect(event.details.args).toEqual(['role', 'user']);
|
||||
expect(event.details.role).toBe('admin');
|
||||
expect(event.details.user).toBeUndefined();
|
||||
// The user id still surfaces structurally, in resources.
|
||||
expect(event.resources).toContainEqual({ type: 'user', id: 'slack:U1' });
|
||||
});
|
||||
|
||||
it('records a denied event for a scope denial, naming the attempted resource type, with no free-text reason', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, AGENT_CTX);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
action: 'wirings.list',
|
||||
outcome: 'denied',
|
||||
resources: [{ type: 'wirings' }],
|
||||
details: { args: [], error: 'forbidden' },
|
||||
});
|
||||
// The denial message (which can echo caller input) is never stored.
|
||||
expect(event.details.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records a failure event with the error code only when the handler throws', async () => {
|
||||
await dispatch({ id: '1', command: 'groups-fail', args: {} }, { caller: 'host' });
|
||||
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({ action: 'groups.fail', outcome: 'failure', details: { error: 'handler-error' } });
|
||||
expect(event.details.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records a failure event and re-throws when the dispatcher itself throws', async () => {
|
||||
mockRequestApproval.mockRejectedValueOnce(new Error('pending_approvals insert failed'));
|
||||
|
||||
await expect(dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX)).rejects.toThrow(
|
||||
'pending_approvals insert failed',
|
||||
);
|
||||
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({ action: 'groups.gated', outcome: 'failure', details: { error: 'exception' } });
|
||||
});
|
||||
|
||||
it('records a hold as a pending event correlated to the approval row it created', async () => {
|
||||
pendingRows.rows = [grantRow('1', 'groups-gated')];
|
||||
|
||||
const resp = await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
if (!resp.ok) expect(resp.error.code).toBe('approval-pending');
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
action: 'groups.gated',
|
||||
outcome: 'pending',
|
||||
correlation_id: 'appr-123-abc',
|
||||
});
|
||||
expect(event.resources).toContainEqual({ type: 'approval', id: 'appr-123-abc' });
|
||||
expect(event.details.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records an uncorrelated pending event when no approval row was created (no approver)', async () => {
|
||||
pendingRows.rows = [];
|
||||
|
||||
await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX);
|
||||
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({ outcome: 'pending', correlation_id: null });
|
||||
});
|
||||
|
||||
it('records an approved replay as a `success` event carrying the grant approval id', async () => {
|
||||
// The approved/rejected verdict is the approvals.decide event's (approvals.audit.ts);
|
||||
// the replayed command's terminal event is an ordinary success, chained by correlation_id.
|
||||
const grant = grantRow('9', 'groups-gated');
|
||||
mockGetPendingApproval.mockReturnValue(grant);
|
||||
|
||||
const resp = await dispatch({ id: '9', command: 'groups-gated', args: {} }, AGENT_CTX, { grant });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
action: 'groups.gated',
|
||||
outcome: 'success',
|
||||
correlation_id: 'appr-123-abc',
|
||||
});
|
||||
expect(event.resources).toContainEqual({ type: 'approval', id: 'appr-123-abc' });
|
||||
});
|
||||
|
||||
it('records a --help probe under a neutral cli.help action, never the real verb', async () => {
|
||||
const resp = await dispatch({ id: '1', command: 'groups-gated', args: { help: true } }, AGENT_CTX);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const [event] = events();
|
||||
expect(event.action).toBe('cli.help');
|
||||
expect(event.outcome).toBe('success');
|
||||
expect(event.resources).toEqual([]);
|
||||
});
|
||||
|
||||
it('records unknown commands as cli.unknown-command with the raw name in details', async () => {
|
||||
await dispatch({ id: '1', command: 'nope-nothing', args: {} }, { caller: 'host' });
|
||||
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({
|
||||
action: 'cli.unknown-command',
|
||||
outcome: 'failure',
|
||||
resources: [],
|
||||
details: { args: [], command: 'nope-nothing', error: 'unknown-command' },
|
||||
});
|
||||
});
|
||||
|
||||
it('records the resolved command and target id for dash-joined positional ids', async () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
await dispatch({ id: '1', command: `groups-get-${uuid}`, args: {} }, { caller: 'host' });
|
||||
|
||||
const [event] = events();
|
||||
expect(event).toMatchObject({ action: 'groups.get', outcome: 'success' });
|
||||
expect(event.resources).toContainEqual({ type: 'agent_group', id: uuid });
|
||||
// The id surfaces in resources; details keeps only the flag name.
|
||||
expect(event.details.args).toContain('id');
|
||||
expect(event.details.id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes hyphenated arg key names in details', async () => {
|
||||
await dispatch({ id: '1', command: 'groups-test', args: { 'dry-run': 'true' } }, { caller: 'host' });
|
||||
|
||||
const [event] = events();
|
||||
expect(event.details.args).toContain('dry_run');
|
||||
expect(event.details.dry_run).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never stores arg values — a secret-bearing arg leaves only its key', async () => {
|
||||
await dispatch(
|
||||
{ id: '1', command: 'groups-test', args: { env: '{"NOTION_TOKEN":"tok-123","SAFE":"ok"}' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
const [event] = events();
|
||||
expect(event.details.args).toContain('env');
|
||||
expect(event.details.env).toBeUndefined();
|
||||
// The secret never reaches the stored bytes.
|
||||
expect(appended.lines[0]).not.toContain('NOTION_TOKEN');
|
||||
expect(appended.lines[0]).not.toContain('tok-123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* CLI audit adapter (installed by /add-audit) — owns how the dispatcher
|
||||
* describes itself to the audit log: the dispatch middleware plus the
|
||||
* CLI-specific actor/origin/resource mapping. Composed in dispatch.ts as
|
||||
* `export const dispatch = withAudit(dispatchInner)`; business logic there
|
||||
* contains zero audit calls.
|
||||
*
|
||||
* Recording model: the log stores WHO did WHICH action to WHAT target and the
|
||||
* outcome — never raw argument VALUES. `details` carries the arg key names
|
||||
* that were passed plus a small allowlist of governance-relevant enum fields
|
||||
* (role, mode, …) echoed verbatim; everything else is name-only. There is no
|
||||
* value redactor because no free-form value is ever written — a secret can't
|
||||
* leak from a field that was never stored. Target identifiers (ids, users,
|
||||
* groups) are structured and surface separately in `resources`.
|
||||
*
|
||||
* Loading this module also boots the audit log (writability assert, boot
|
||||
* prune, hook lifecycle, maintenance timer): dispatch.ts is imported by both
|
||||
* transports during the host's barrel phase, so initAuditLog() runs before
|
||||
* any command is accepted — and an enabled box with an unwritable
|
||||
* data/audit/ refuses to start.
|
||||
*/
|
||||
import { emitAuditEvent } from '../audit/emit.js';
|
||||
import { initAuditLog } from '../audit/init.js';
|
||||
import {
|
||||
type AuditActor,
|
||||
type AuditEventInput,
|
||||
type AuditOrigin,
|
||||
type AuditOutcome,
|
||||
type AuditResource,
|
||||
} from '../audit/types.js';
|
||||
import { containerOrigin, hostUser } from '../audit/vocab.js';
|
||||
import { getPendingApprovalsByAction } from '../db/sessions.js';
|
||||
import type { PendingApproval } from '../types.js';
|
||||
import { getResource } from './crud.js';
|
||||
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
|
||||
import { commandGuardAction } from './guard.js';
|
||||
import { type CommandDef, lookup } from './registry.js';
|
||||
|
||||
initAuditLog();
|
||||
|
||||
// ── CLI mapping ──
|
||||
|
||||
/**
|
||||
* Host callers stamp `host:<install user>` daemon-side (the ncl socket is
|
||||
* 0600 and owned by the install user); container callers are their agent group.
|
||||
*/
|
||||
export function actorForCaller(ctx: CallerContext): AuditActor {
|
||||
return ctx.caller === 'host' ? { type: 'human', id: `host:${hostUser()}` } : { type: 'agent', id: ctx.agentGroupId };
|
||||
}
|
||||
|
||||
export function originForCaller(ctx: CallerContext): AuditOrigin {
|
||||
if (ctx.caller === 'host') return { transport: 'socket' };
|
||||
return containerOrigin(ctx.sessionId, ctx.messagingGroupId || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame-level args use `--hyphen-keys`; recorded key names use the same
|
||||
* underscore form the parsed handlers see. Mirrors crud's normalizeArgs
|
||||
* (kept local so audit doesn't depend on a module tests commonly mock).
|
||||
*/
|
||||
export function normalizeArgKeys(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
out[k.replace(/-/g, '_')] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arg values safe to record verbatim: governance-relevant enums that are never
|
||||
* secrets and whose value IS the audit-worthy detail (which role was granted,
|
||||
* which scope/mode was set). Every other value is dropped — only the presence
|
||||
* of the flag (its key) is kept — so no free-form or secret-bearing value ever
|
||||
* reaches disk.
|
||||
*/
|
||||
const SAFE_VALUE_FIELDS: ReadonlySet<string> = new Set([
|
||||
'role',
|
||||
'mode',
|
||||
'session_mode',
|
||||
'cli_scope',
|
||||
'access',
|
||||
'engage_mode',
|
||||
'sender_scope',
|
||||
'provider',
|
||||
'model',
|
||||
]);
|
||||
|
||||
/** CLI resource plural → audit resource type, where the singular isn't it. */
|
||||
const RESOURCE_TYPE_OVERRIDES: Record<string, string> = {
|
||||
groups: 'agent_group',
|
||||
'messaging-groups': 'messaging_group',
|
||||
'dropped-messages': 'dropped_message',
|
||||
'user-dms': 'user_dm',
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive touched/attempted resources from a command's args. Generic by design:
|
||||
* `id` → the command's own resource, group/user args → their types, and a bare
|
||||
* `{type}` entry when nothing else is known (a denied `users list` still names
|
||||
* what was attempted). Ids are structured identifiers, not secrets.
|
||||
*/
|
||||
export function resourcesForCli(cmd: CommandDef, args: Record<string, unknown>): AuditResource[] {
|
||||
if (!cmd.resource) return [];
|
||||
const type = RESOURCE_TYPE_OVERRIDES[cmd.resource] ?? getResource(cmd.resource)?.name ?? cmd.resource;
|
||||
|
||||
const out: AuditResource[] = [];
|
||||
const push = (t: string, id: unknown): void => {
|
||||
if (typeof id !== 'string' || !id) return;
|
||||
if (!out.some((r) => r.type === t && r.id === id)) out.push({ type: t, id });
|
||||
};
|
||||
push(type, args.id);
|
||||
push('agent_group', args.agent_group_id ?? args.group);
|
||||
push('user', args.user);
|
||||
if (out.length === 0) out.push({ type });
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Command resolution, mirrored for the record ──
|
||||
// Dispatch resolves the command on a local copy of the frame that never leaves
|
||||
// it, so the middleware mirrors the one documented mechanic below. The mirror
|
||||
// is mechanical, and drift only ever degrades a record's detail (a fallback
|
||||
// action name) — never dispatch behavior, and never an outcome.
|
||||
|
||||
/**
|
||||
* Mirror of dispatch's command resolution: exact lookup, then the longest
|
||||
* registered dash-prefix with the remainder recorded as --id.
|
||||
*/
|
||||
function resolveForRecord(req: RequestFrame): { cmd?: CommandDef; args: Record<string, unknown> } {
|
||||
const direct = lookup(req.command);
|
||||
if (direct) return { cmd: direct, args: req.args };
|
||||
let shortened = req.command;
|
||||
let idx: number;
|
||||
while ((idx = shortened.lastIndexOf('-')) > 0) {
|
||||
shortened = shortened.slice(0, idx);
|
||||
const fallback = lookup(shortened);
|
||||
if (fallback) {
|
||||
const tail = req.command.slice(shortened.length + 1);
|
||||
return { cmd: fallback, args: { ...req.args, id: req.args.id ?? tail } };
|
||||
}
|
||||
}
|
||||
return { args: req.args };
|
||||
}
|
||||
|
||||
/**
|
||||
* The approval row a hold just created for this frame — it gives the pending
|
||||
* event the same correlation_id the approved replay will carry as its guard
|
||||
* grant. requestApproval keeps the minted id internal, so the row is
|
||||
* recovered by the frame id it stored in its payload; no row (e.g. no
|
||||
* configured approver) → the hold is still recorded, uncorrelated.
|
||||
*/
|
||||
function holdApprovalIdFor(frameId: string): string | null {
|
||||
const rows = getPendingApprovalsByAction('cli_command');
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const payload = JSON.parse(rows[i].payload) as { frame?: { id?: string } };
|
||||
if (payload.frame?.id === frameId) return rows[i].approval_id;
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all -- a row with an unparseable payload is simply not this frame's hold
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── The dispatch middleware ──
|
||||
|
||||
type DispatchInner = (
|
||||
req: RequestFrame,
|
||||
ctx: CallerContext,
|
||||
opts?: { grant?: PendingApproval },
|
||||
) => Promise<ResponseFrame>;
|
||||
|
||||
/**
|
||||
* Build the audit record for one dispatch. `res` is the response frame, or
|
||||
* null when `inner` threw (`err` set) — a crash still leaves a record.
|
||||
*
|
||||
* Outcome: ok → success (an approved replay included — the approvals.decide
|
||||
* event owns the approved/rejected verdict), forbidden → denied (captures
|
||||
* pre-handler scope denials), approval-pending → pending (the record of a
|
||||
* hold), a thrown/other error → failure. A `--help`
|
||||
* probe is introspection, not the verb, so it records under a neutral
|
||||
* `cli.help` action with no target — never as the real command succeeding.
|
||||
* Correlation is the approval id: a replay carries the row as its grant, and a
|
||||
* fresh hold recovers the row it just created.
|
||||
*/
|
||||
function buildEvent(
|
||||
req: RequestFrame,
|
||||
ctx: CallerContext,
|
||||
opts: { grant?: PendingApproval },
|
||||
res: ResponseFrame | null,
|
||||
err: unknown,
|
||||
): AuditEventInput {
|
||||
const resolved = resolveForRecord(req);
|
||||
const cmd = resolved.cmd;
|
||||
const normArgs = normalizeArgKeys(resolved.args);
|
||||
|
||||
const isHelp = req.args.help === true && !!res && res.ok;
|
||||
const pending = !!res && !res.ok && res.error.code === 'approval-pending';
|
||||
|
||||
// An approved replay records as ordinary `success`; the approvals.decide
|
||||
// event (approvals.audit.ts) owns the approved/rejected verdict, chained to
|
||||
// this terminal event by the same correlation_id (the grant's approval id).
|
||||
const outcome: AuditOutcome = !res
|
||||
? 'failure' // inner threw
|
||||
: res.ok
|
||||
? 'success'
|
||||
: res.error.code === 'forbidden'
|
||||
? 'denied'
|
||||
: pending
|
||||
? 'pending'
|
||||
: 'failure';
|
||||
|
||||
// details: arg key names + allowlisted safe values only. No free-form value
|
||||
// is ever stored, so nothing needs redacting and nothing can leak.
|
||||
const details: Record<string, unknown> = { args: Object.keys(normArgs).sort() };
|
||||
for (const f of SAFE_VALUE_FIELDS) {
|
||||
if (normArgs[f] !== undefined) details[f] = normArgs[f];
|
||||
}
|
||||
if (err) {
|
||||
details.error = 'exception'; // the throw's message is never stored
|
||||
} else if (res && !res.ok && !pending) {
|
||||
details.error = res.error.code; // the error CODE (a safe enum), never the free-text message
|
||||
}
|
||||
if (!cmd) details.command = req.command;
|
||||
|
||||
const correlationId = opts.grant?.approval_id ?? (pending ? holdApprovalIdFor(req.id) : null);
|
||||
|
||||
const action = isHelp ? 'cli.help' : cmd ? commandGuardAction(cmd) : 'cli.unknown-command';
|
||||
const resources: AuditResource[] = isHelp ? [] : cmd ? resourcesForCli(cmd, normArgs) : [];
|
||||
if (correlationId) resources.push({ type: 'approval', id: correlationId });
|
||||
|
||||
return {
|
||||
actor: actorForCaller(ctx),
|
||||
origin: originForCaller(ctx),
|
||||
action,
|
||||
resources,
|
||||
outcome,
|
||||
correlationId,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch middleware — the exported `dispatch` is the wrapped function, so
|
||||
* the socket server, the container delivery-action, and the in-module
|
||||
* approved replay are all covered by the one composition.
|
||||
*
|
||||
* The emit brackets `inner` so a thrown dispatcher still leaves a record: on
|
||||
* throw we emit a `failure` event and re-raise unchanged, so an approval-gated
|
||||
* command whose hold crashes on the DB write is not a silent governance gap.
|
||||
*/
|
||||
export function withAudit(inner: DispatchInner): DispatchInner {
|
||||
return async (req, ctx, opts = {}) => {
|
||||
let res: ResponseFrame;
|
||||
try {
|
||||
res = await inner(req, ctx, opts);
|
||||
} catch (err) {
|
||||
emitAuditEvent(() => buildEvent(req, ctx, opts, null, err));
|
||||
throw err;
|
||||
}
|
||||
emitAuditEvent(() => buildEvent(req, ctx, opts, res, null));
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { listAuditEvents } from '../../audit/reader.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/**
|
||||
* Read-only audit resource (installed by /add-audit). The "table" is the
|
||||
* NDJSON day-file store — no generic CRUD verbs are declared, so it is never
|
||||
* queried as SQL; `list` is a custom operation backed by the audit reader.
|
||||
*
|
||||
* The filters are declared as the op's `args` (not `columns`), so crud
|
||||
* validates them: a typo'd flag (`--actro`) is rejected instead of silently
|
||||
* ignored, and `ncl audit list --help` lists every filter.
|
||||
*
|
||||
* Deliberately NOT on the group-scope allowlist (GROUP_SCOPE_RESOURCES):
|
||||
* audit spans agent groups, so group-scoped agents are refused before the
|
||||
* handler — the resource is host + `cli_scope: global` only, by omission
|
||||
* (fails closed).
|
||||
*/
|
||||
registerResource({
|
||||
name: 'audit_event',
|
||||
plural: 'audit',
|
||||
table: '(data/audit/*.ndjson)',
|
||||
description: 'Local audit log — one event per ncl command. Newest first. Requires AUDIT_ENABLED=true.',
|
||||
idColumn: 'event_id',
|
||||
columns: [],
|
||||
operations: {},
|
||||
customOperations: {
|
||||
list: {
|
||||
access: 'open',
|
||||
description: 'Query audit events, newest first. --format ndjson streams the stored lines for SIEM export.',
|
||||
args: [
|
||||
{
|
||||
name: 'actor',
|
||||
type: 'string',
|
||||
description: 'Filter: exact actor id (host:<user>, <channel>:<handle>, agent group id)',
|
||||
},
|
||||
{ name: 'action', type: 'string', description: 'Filter: exact action or dotted prefix (e.g. groups.config)' },
|
||||
{ name: 'resource', type: 'string', description: 'Filter: matches any event resource by id or type' },
|
||||
{
|
||||
name: 'outcome',
|
||||
type: 'string',
|
||||
description: 'Filter: event outcome',
|
||||
enum: ['success', 'failure', 'denied', 'pending', 'approved'],
|
||||
},
|
||||
{ name: 'since', type: 'string', description: 'Window start: 7d / 24h / 30m relative, or ISO date/datetime (UTC)' },
|
||||
{ name: 'until', type: 'string', description: 'Window end: same formats as --since' },
|
||||
{ name: 'correlation', type: 'string', description: 'Filter: approval id tying a gated chain together' },
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Max events (default 100 for the table; ndjson exports every event in the window)',
|
||||
},
|
||||
{ name: 'format', type: 'string', description: 'Output format', enum: ['ndjson'] },
|
||||
],
|
||||
examples: [
|
||||
'ncl audit list --outcome denied --since 7d',
|
||||
'ncl audit list --correlation appr-1751970000000-x1y2z3',
|
||||
'ncl audit list --action groups.config --format ndjson',
|
||||
],
|
||||
handler: async (args) => listAuditEvents(args),
|
||||
formatHuman: (data) => {
|
||||
// NDJSON export prints the stored lines verbatim; for the table view
|
||||
// this throws so dispatch falls back to the row objects, which every
|
||||
// client already renders.
|
||||
if (typeof data === 'string') return data;
|
||||
throw new Error('render rows client-side');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Approvals audit adapter — what the two lifecycle observers record. Audit is
|
||||
* force-enabled and the store's append is captured; the observer registration
|
||||
* is captured through a mock of the approvals primitive, then invoked directly.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
|
||||
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
|
||||
const handlers = vi.hoisted(
|
||||
() =>
|
||||
({ requested: undefined, resolved: undefined }) as {
|
||||
requested?: (e: unknown) => unknown;
|
||||
resolved?: (e: unknown) => unknown;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock('../../audit/config.js', () => ({ AUDIT_ENABLED: true, AUDIT_RETENTION_DAYS: 90 }));
|
||||
vi.mock('../../audit/init.js', () => ({ initAuditLog: vi.fn(), maintainAudit: vi.fn() }));
|
||||
vi.mock('../../audit/store.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../audit/store.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appendAuditLine: (line: string) => {
|
||||
appended.lines.push(line);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('../../log.js', () => ({
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
// containerOrigin (via vocab) looks up the messaging group's channel.
|
||||
vi.mock('../../db/messaging-groups.js', () => ({ getMessagingGroup: () => ({ channel_type: 'slack' }) }));
|
||||
// Capture the observer handlers the adapter registers at module load.
|
||||
vi.mock('./primitive.js', () => ({
|
||||
registerApprovalRequestedHandler: (h: (e: unknown) => unknown) => {
|
||||
handlers.requested = h;
|
||||
},
|
||||
registerApprovalResolvedHandler: (h: (e: unknown) => unknown) => {
|
||||
handlers.resolved = h;
|
||||
},
|
||||
}));
|
||||
|
||||
// Loads the adapter (registers into `handlers`) with the mocks in place.
|
||||
import './approvals.audit.js';
|
||||
|
||||
function events(): Array<Record<string, any>> {
|
||||
return appended.lines.map((l) => JSON.parse(l));
|
||||
}
|
||||
function last(): Record<string, any> {
|
||||
return events()[appended.lines.length - 1];
|
||||
}
|
||||
|
||||
const AGENT_SESSION: Session = {
|
||||
id: 's1',
|
||||
agent_group_id: 'g1',
|
||||
messaging_group_id: 'mg1',
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'running',
|
||||
last_active: null,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function hold(action: string, over: Partial<PendingApproval> = {}): PendingApproval {
|
||||
return {
|
||||
approval_id: 'appr-1',
|
||||
session_id: 's1',
|
||||
request_id: 'appr-1',
|
||||
action,
|
||||
payload: '{}',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
agent_group_id: 'g1',
|
||||
channel_type: null,
|
||||
platform_id: null,
|
||||
platform_message_id: null,
|
||||
expires_at: null,
|
||||
status: 'pending',
|
||||
title: '',
|
||||
options_json: '[]',
|
||||
approver_user_id: null,
|
||||
approver_rule: 'admins-of-scope',
|
||||
dedup_key: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appended.lines = [];
|
||||
});
|
||||
|
||||
describe('approvals audit — request (pending)', () => {
|
||||
it('records a non-CLI hold as pending: dotted action, picked approver, container origin', async () => {
|
||||
await handlers.requested!({ approval: hold('add_mcp_server'), session: AGENT_SESSION, deliveredTo: 'slack:UADMIN' });
|
||||
const e = last();
|
||||
expect(e.action).toBe('self_mod.add_mcp_server');
|
||||
expect(e.outcome).toBe('pending');
|
||||
expect(e.actor).toEqual({ type: 'agent', id: 'g1', email: null, user_id: null, group_ids: null });
|
||||
expect(e.origin).toEqual({ transport: 'container', session_id: 's1', messaging_group_id: 'mg1', channel: 'slack' });
|
||||
expect(e.correlation_id).toBe('appr-1');
|
||||
expect(e.resources).toContainEqual({ type: 'agent_group', id: 'g1' });
|
||||
expect(e.resources).toContainEqual({ type: 'approval', id: 'appr-1' });
|
||||
expect(e.resources).toContainEqual({ type: 'user', id: 'slack:UADMIN' });
|
||||
});
|
||||
|
||||
it('skips cli_command — the dispatch adapter owns those holds', async () => {
|
||||
await handlers.requested!({ approval: hold('cli_command'), session: AGENT_SESSION, deliveredTo: 'slack:UADMIN' });
|
||||
expect(appended.lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a sessionless sender hold records as the system actor on the host', async () => {
|
||||
await handlers.requested!({
|
||||
approval: hold('sender_admit', { agent_group_id: null, session_id: null }),
|
||||
session: null,
|
||||
deliveredTo: 'slack:UADMIN',
|
||||
});
|
||||
const e = last();
|
||||
expect(e.action).toBe('senders.admit');
|
||||
expect(e.actor.type).toBe('system');
|
||||
expect(e.origin).toEqual({ transport: 'socket' });
|
||||
// no agent_group resource when host-raised
|
||||
expect(e.resources.some((r: any) => r.type === 'agent_group')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvals audit — decision (approvals.decide)', () => {
|
||||
it('an approve records approved; actor is the deciding admin on their channel', async () => {
|
||||
await handlers.resolved!({ approval: hold('add_mcp_server'), session: AGENT_SESSION, outcome: 'approve', userId: 'slack:UADMIN' });
|
||||
const e = last();
|
||||
expect(e.action).toBe('approvals.decide');
|
||||
expect(e.outcome).toBe('approved');
|
||||
expect(e.actor).toEqual({ type: 'human', id: 'slack:UADMIN', email: null, user_id: null, group_ids: null });
|
||||
expect(e.origin).toEqual({ transport: 'channel', channel: 'slack' });
|
||||
expect(e.correlation_id).toBe('appr-1');
|
||||
expect(e.details.gated_action).toBe('self_mod.add_mcp_server');
|
||||
expect(e.details.requested_by).toBe('g1');
|
||||
});
|
||||
|
||||
it('a reject records rejected, naming the gated action', async () => {
|
||||
await handlers.resolved!({ approval: hold('a2a_message_gate'), session: AGENT_SESSION, outcome: 'reject', userId: 'discord:UMOD' });
|
||||
const e = last();
|
||||
expect(e.action).toBe('approvals.decide');
|
||||
expect(e.outcome).toBe('rejected');
|
||||
expect(e.origin.channel).toBe('discord');
|
||||
expect(e.details.gated_action).toBe('a2a.send');
|
||||
});
|
||||
|
||||
it('expiry decides as the system actor: rejected + reason expired', async () => {
|
||||
await handlers.resolved!({ approval: hold('onecli_credential'), session: null, outcome: 'expire', userId: '' });
|
||||
const e = last();
|
||||
expect(e.actor).toEqual({ type: 'system', id: 'host', email: null, user_id: null, group_ids: null });
|
||||
expect(e.origin).toEqual({ transport: 'socket' });
|
||||
expect(e.outcome).toBe('rejected');
|
||||
expect(e.details.reason).toBe('expired');
|
||||
expect(e.details.gated_action).toBe('onecli.credential.use');
|
||||
});
|
||||
|
||||
it('startup sweep decides as the system actor with reason swept', async () => {
|
||||
await handlers.resolved!({ approval: hold('onecli_credential'), session: null, outcome: 'sweep', userId: '' });
|
||||
const e = last();
|
||||
expect(e.actor.type).toBe('system');
|
||||
expect(e.outcome).toBe('rejected');
|
||||
expect(e.details.reason).toBe('swept');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvals audit — correlation chain', () => {
|
||||
it('the request and the decision share the approval id', async () => {
|
||||
const approval = hold('create_agent', { approval_id: 'appr-xyz', request_id: 'appr-xyz' });
|
||||
await handlers.requested!({ approval, session: AGENT_SESSION, deliveredTo: 'slack:UADMIN' });
|
||||
await handlers.resolved!({ approval, session: AGENT_SESSION, outcome: 'approve', userId: 'slack:UADMIN' });
|
||||
const [pending, decide] = events();
|
||||
expect(pending.action).toBe('agents.create');
|
||||
expect(pending.outcome).toBe('pending');
|
||||
expect(pending.correlation_id).toBe('appr-xyz');
|
||||
expect(decide.action).toBe('approvals.decide');
|
||||
expect(decide.correlation_id).toBe('appr-xyz');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Approvals audit adapter (installed by /add-audit) — records the approval
|
||||
* lifecycle for every host-routed hold, whichever stack created it: CLI
|
||||
* commands, self-mod (install_packages / add_mcp_server), a2a create-agent and
|
||||
* message-gate, unknown-sender admission, OneCLI credential holds, and channel
|
||||
* registration. It subscribes to the two approval lifecycle observers, so the
|
||||
* approval flows themselves contain zero audit calls.
|
||||
*
|
||||
* - request → a `pending` event naming the picked approver (a `user`
|
||||
* resource), correlated by the approval id. `cli_command` is skipped: the
|
||||
* CLI adapter (dispatch.audit.ts) already records those holds with the
|
||||
* command's own dotted action + arg names — the mapping it alone owns.
|
||||
* - resolve → an `approvals.decide` event (approved / rejected; expiry and
|
||||
* sweep decide as the system actor), same correlation id. The gated action
|
||||
* it decided rides `details.gated_action`.
|
||||
*
|
||||
* The approved/rejected verdict lives here; a CLI command's replayed execution
|
||||
* records its own terminal success/failure through the dispatch adapter, on the
|
||||
* same correlation id. Non-CLI holds have no separate execution event in this
|
||||
* increment — the request and decision are the durable governance record, which
|
||||
* is what survives the pending_approvals row being deleted on resolution.
|
||||
*
|
||||
* Recording model matches the rest of the log: WHO decided WHAT, never raw
|
||||
* payload values — the gated action name and the approval id, not its args.
|
||||
*/
|
||||
import { emitAuditEvent } from '../../audit/emit.js';
|
||||
import { initAuditLog } from '../../audit/init.js';
|
||||
import type { AuditActor, AuditOrigin, AuditOutcome, AuditResource } from '../../audit/types.js';
|
||||
import { approvalActionName, channelOf, channelOrigin, containerOrigin } from '../../audit/vocab.js';
|
||||
import type { PendingApproval, Session } from '../../types.js';
|
||||
import {
|
||||
type ApprovalRequestedEvent,
|
||||
type ApprovalResolvedEvent,
|
||||
registerApprovalRequestedHandler,
|
||||
registerApprovalResolvedHandler,
|
||||
} from './primitive.js';
|
||||
|
||||
// Loading this adapter also boots the audit log when enabled (idempotent — the
|
||||
// dispatch adapter may have run it already), so the approval surface records
|
||||
// even on a build that composed it before the CLI middleware.
|
||||
initAuditLog();
|
||||
|
||||
/** The agent group whose action raised the hold, or null for host-raised holds. */
|
||||
function requesterOf(approval: PendingApproval, session: Session | null): string | null {
|
||||
return approval.agent_group_id ?? session?.agent_group_id ?? null;
|
||||
}
|
||||
|
||||
/** The requester as an actor: the agent group, else the host (system-raised). */
|
||||
function requesterActor(agentGroupId: string | null): AuditActor {
|
||||
return agentGroupId ? { type: 'agent', id: agentGroupId } : { type: 'system', id: 'host' };
|
||||
}
|
||||
|
||||
/** Origin of the requesting side: the container session if any, else the host socket. */
|
||||
function requesterOrigin(session: Session | null): AuditOrigin {
|
||||
return session ? containerOrigin(session.id, session.messaging_group_id) : { transport: 'socket' };
|
||||
}
|
||||
|
||||
// request → pending. cli_command holds belong to the dispatch adapter (correct
|
||||
// dotted action + args); every other gated surface is recorded here.
|
||||
registerApprovalRequestedHandler((event: ApprovalRequestedEvent) => {
|
||||
if (event.approval.action === 'cli_command') return;
|
||||
emitAuditEvent(() => {
|
||||
const agentGroupId = requesterOf(event.approval, event.session);
|
||||
const resources: AuditResource[] = [];
|
||||
if (agentGroupId) resources.push({ type: 'agent_group', id: agentGroupId });
|
||||
resources.push({ type: 'approval', id: event.approval.approval_id });
|
||||
if (event.deliveredTo) resources.push({ type: 'user', id: event.deliveredTo });
|
||||
return {
|
||||
actor: requesterActor(agentGroupId),
|
||||
origin: requesterOrigin(event.session),
|
||||
action: approvalActionName(event.approval.action),
|
||||
resources,
|
||||
outcome: 'pending' as AuditOutcome,
|
||||
correlationId: event.approval.approval_id,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const RESOLVED_OUTCOME: Record<ApprovalResolvedEvent['outcome'], AuditOutcome> = {
|
||||
approve: 'approved',
|
||||
reject: 'rejected',
|
||||
expire: 'rejected',
|
||||
sweep: 'rejected',
|
||||
};
|
||||
|
||||
// resolve → approvals.decide. A human clicker is the actor (answered on a chat
|
||||
// platform); expiry and startup sweep decide as the system actor.
|
||||
registerApprovalResolvedHandler((event: ApprovalResolvedEvent) => {
|
||||
emitAuditEvent(() => {
|
||||
const agentGroupId = requesterOf(event.approval, event.session);
|
||||
const bySystem = !event.userId || event.outcome === 'expire' || event.outcome === 'sweep';
|
||||
const resources: AuditResource[] = [];
|
||||
if (agentGroupId) resources.push({ type: 'agent_group', id: agentGroupId });
|
||||
resources.push({ type: 'approval', id: event.approval.approval_id });
|
||||
const details: Record<string, unknown> = { gated_action: approvalActionName(event.approval.action) };
|
||||
if (agentGroupId) details.requested_by = agentGroupId;
|
||||
if (event.outcome === 'expire') details.reason = 'expired';
|
||||
if (event.outcome === 'sweep') details.reason = 'swept';
|
||||
return {
|
||||
actor: bySystem ? { type: 'system', id: 'host' } : { type: 'human', id: event.userId },
|
||||
origin: bySystem ? { transport: 'socket' } : channelOrigin(channelOf(event.userId)),
|
||||
action: 'approvals.decide',
|
||||
resources,
|
||||
outcome: RESOLVED_OUTCOME[event.outcome],
|
||||
correlationId: event.approval.approval_id,
|
||||
details,
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -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,86 @@
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// Local calendar day "YYYY-MM-DD" — chart labels are read by a human, so
|
||||
// bucket by the server's local day, not the UTC date prefix.
|
||||
function localDay(date) {
|
||||
return date.toLocaleDateString('sv-SE');
|
||||
}
|
||||
|
||||
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 = localDay(new Date(ts));
|
||||
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 (local time, newest last)
|
||||
*/
|
||||
export function collectActivity(sessionsRoot, days, now) {
|
||||
const dates = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
dates.push(localDay(new Date(now.getTime() - i * 86_400_000)));
|
||||
}
|
||||
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,723 @@
|
||||
// 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}/;
|
||||
|
||||
// Local wall time "YYYY-MM-DD HH:mm" — the raw ISO string is UTC; slicing it
|
||||
// would display UTC wall time with no marker, masquerading as local.
|
||||
function absTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
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' }, absTime(f.iso)),
|
||||
]));
|
||||
}
|
||||
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)} (${absTime(v)})`);
|
||||
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();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
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 |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
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,51 @@
|
||||
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(); };
|
||||
// Seed with instants moments ago: they land in the LOCAL-today bucket
|
||||
// (series.at(-1)) regardless of the machine's timezone.
|
||||
const now = Date.now();
|
||||
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [
|
||||
new Date(now - 120_000).toISOString(),
|
||||
new Date(now - 60_000).toISOString(),
|
||||
]);
|
||||
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [new Date(now - 90_000).toISOString()]);
|
||||
});
|
||||
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,85 @@
|
||||
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);
|
||||
});
|
||||
|
||||
// Buckets are LOCAL calendar days — derive expectations with the same
|
||||
// mapping so the assertions hold in any machine timezone.
|
||||
const day = (t) => new Date(t).toLocaleDateString('sv-SE');
|
||||
|
||||
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, day(new Date(NOW.getTime() - 13 * 86_400_000)));
|
||||
assert.equal(series[13].date, day(NOW));
|
||||
});
|
||||
|
||||
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]));
|
||||
const expIn = {};
|
||||
const expOut = {};
|
||||
for (const t of ['2026-06-14T09:01:23Z', '2026-06-14T10:00:00Z', '2026-06-13T08:00:00Z']) {
|
||||
expIn[day(t)] = (expIn[day(t)] ?? 0) + 1;
|
||||
}
|
||||
for (const t of ['2026-06-14T09:05:00Z', '2026-06-14T10:05:00Z']) {
|
||||
expOut[day(t)] = (expOut[day(t)] ?? 0) + 1;
|
||||
}
|
||||
for (const [d, n] of Object.entries(expIn)) assert.equal(byDate[d].in, n);
|
||||
for (const [d, n] of Object.entries(expOut)) assert.equal(byDate[d].out, n);
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,83 +1,78 @@
|
||||
# Remove Codex provider
|
||||
# Remove the Codex agent provider
|
||||
|
||||
Idempotent — safe to run even if some steps were never applied. Reverses both the host (`src/providers/`) and container (`container/agent-runner/src/providers/`) trees, plus the Dockerfile CLI install.
|
||||
Reverses every change `/add-codex` makes and returns every group to the default provider. Safe to run when partially installed — skip any step whose target is already absent.
|
||||
|
||||
## 1. Delete the barrel import lines (both trees)
|
||||
## 1. Switch codex groups back to the default
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each barrel:
|
||||
List groups still on codex and switch each one (each group's `memory/` tree stays on disk and readable; run `/migrate-memory` per group if its memory should carry back to Claude — see [docs/provider-migration.md](../../docs/provider-migration.md)):
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
# for each group whose config shows provider=codex:
|
||||
ncl groups config update --id <group-id> --provider claude
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
## 2. Delete the barrel imports
|
||||
|
||||
Delete (do not comment out) the `import './codex.js';` line from each of:
|
||||
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
This unregisters the provider from both `listProviderContainerConfigNames()` (host) and `listProviderNames()` (container).
|
||||
|
||||
## 2. Delete the copied files (both trees)
|
||||
## 3. Delete every copied file
|
||||
|
||||
```bash
|
||||
rm -f src/providers/codex.ts \
|
||||
src/providers/codex-agents-md.ts \
|
||||
src/providers/codex-registration.test.ts \
|
||||
src/providers/codex-host-contribution.test.ts \
|
||||
src/providers/codex-agents-md.test.ts \
|
||||
container/agent-runner/src/providers/codex.ts \
|
||||
container/agent-runner/src/providers/codex-app-server.ts \
|
||||
container/agent-runner/src/providers/codex.factory.test.ts \
|
||||
container/agent-runner/src/providers/exchange-archive.ts \
|
||||
container/agent-runner/src/providers/exchange-archive.test.ts \
|
||||
container/agent-runner/src/providers/codex-registration.test.ts \
|
||||
container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
container/agent-runner/src/providers/codex.factory.test.ts \
|
||||
container/agent-runner/src/providers/codex.turns.test.ts \
|
||||
container/agent-runner/src/providers/codex-app-server.test.ts \
|
||||
container/agent-runner/src/providers/codex-cli-tools.test.ts \
|
||||
setup/providers/codex.ts \
|
||||
setup/providers/codex.test.ts \
|
||||
setup/providers/codex-registration.test.ts
|
||||
```
|
||||
|
||||
## 3. Revert the Dockerfile CLI install
|
||||
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
|
||||
|
||||
In `container/Dockerfile`, remove both Codex edits (skip whichever is already gone):
|
||||
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
|
||||
|
||||
**(a)** Delete the version ARG from the "Pin CLI versions" block:
|
||||
## 4. Remove the CLI manifest entry
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Delete the standalone Codex install layer:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Leave the other per-CLI install layers (claude-code, agent-browser, vercel) untouched.
|
||||
|
||||
## 4. Dependency
|
||||
|
||||
Codex is a CLI binary installed via the Dockerfile — there is no agent-runner package dependency to uninstall. Step 3 removes the only install surface; no `bun remove` / `pnpm uninstall` is needed.
|
||||
|
||||
## 5. Unset Codex env vars
|
||||
|
||||
Remove any Codex-specific lines you added to `.env` (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `CODEX_MODEL`) if no other integration uses them, then re-sync to the container:
|
||||
Delete the `@openai/codex` entry from `container/cli-tools.json`:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8")).filter((t) => t.name !== "@openai/codex");
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
'
|
||||
```
|
||||
|
||||
Switch any group still on Codex back to the default provider — set `"provider": "claude"` in `groups/<folder>/container.json` and clear `agent_provider` on the group/session in the DB.
|
||||
## 5. Vault secret (optional)
|
||||
|
||||
## 6. Rebuild and restart
|
||||
The ChatGPT/OpenAI secret in the OneCLI vault grants nothing once the provider is gone. To remove it: `onecli secrets list`, then `onecli secrets delete --id <id>` for the `chatgpt.com` / `api.openai.com` entry.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
## 6. Rebuild and verify
|
||||
|
||||
```bash
|
||||
pnpm run build && ./container/build.sh
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart $(systemd_unit)
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
pnpm test
|
||||
cd container/agent-runner && bun test
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, the registration guards no longer apply (their files are gone). Confirm the provider is fully unwired:
|
||||
|
||||
```bash
|
||||
grep -R "codex.js" src/providers/index.ts container/agent-runner/src/providers/index.ts # no output
|
||||
grep "@openai/codex" container/Dockerfile # no output
|
||||
```
|
||||
|
||||
In a wired agent, requesting `agent_provider = 'codex'` should fall back to the default provider since `codex` is no longer in the registry.
|
||||
All suites green and `ncl groups list` showing no codex groups means the removal is complete. Restart the service (`launchctl kickstart -k gui/$(id -u)/<label>` on macOS, `systemctl --user restart <unit>` on Linux).
|
||||
|
||||
@@ -1,186 +1,144 @@
|
||||
---
|
||||
name: add-codex
|
||||
description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
description: Use Codex (OpenAI's codex app-server) as a full agent provider — planning, tool orchestration, MCP tools, server-side history, session resume — alongside or instead of Claude. ChatGPT subscription or OpenAI API key, vault-only via OneCLI. Per-group via `ncl groups config update --provider codex`. Distinct from using OpenAI as an MCP tool (where Claude remains the planner).
|
||||
---
|
||||
|
||||
# Codex agent provider
|
||||
|
||||
NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`).
|
||||
> Shortcut: `pnpm exec tsx setup/index.ts --step provider-auth codex` performs this whole install (manifest-driven from the providers branch: files, barrels, CLI manifest entry, image rebuild) plus auth in one command. The steps below are the same operations, for agent-driven or manual application.
|
||||
|
||||
Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image.
|
||||
NanoClaw selects each group's agent backend from `container_configs.provider` (default `claude`). This skill installs the Codex provider: copy the payload from the `providers` branch, append one import to each of the three provider barrels, add the pinned Codex CLI to the container manifest (`container/cli-tools.json`), rebuild, then run the vault auth walk-through.
|
||||
|
||||
The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in.
|
||||
The provider runs `codex app-server` as a child process speaking JSON-RPC over stdio: native streaming, MCP tools, server-side conversation history (the continuation is a thread id, no on-disk transcript). Credentials are **vault-only**: OneCLI serves a sentinel `auth.json` stub into the container and swaps the real ChatGPT token or API key on the wire — no key in `.env`, nothing readable in the container.
|
||||
|
||||
## Install
|
||||
|
||||
### Pre-flight
|
||||
|
||||
If all of the following are already present, skip to **Configuration**:
|
||||
Check whether the payload is already wired (a prior apply, or a trunk that still carries it). All of these present means installed — skip to **Authenticate**:
|
||||
|
||||
- `src/providers/codex.ts`
|
||||
- `src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts`
|
||||
- `container/agent-runner/src/providers/codex-app-server.ts`
|
||||
- `container/agent-runner/src/providers/codex.factory.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-registration.test.ts`
|
||||
- `container/agent-runner/src/providers/codex-dockerfile.test.ts`
|
||||
- `import './codex.js';` line in `src/providers/index.ts`
|
||||
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
|
||||
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
|
||||
- `src/providers/codex.ts` and `src/providers/codex-agents-md.ts`
|
||||
- `container/agent-runner/src/providers/codex.ts` and `codex-app-server.ts`
|
||||
- `setup/providers/codex.ts`
|
||||
- `import './codex.js';` in `src/providers/index.ts`, `container/agent-runner/src/providers/index.ts`, and `setup/providers/index.ts`
|
||||
- an `@openai/codex` entry in `container/cli-tools.json`
|
||||
|
||||
Missing pieces — continue below. All steps are idempotent; re-running is safe.
|
||||
|
||||
### 1. Fetch the providers branch
|
||||
### Fetch and copy
|
||||
|
||||
```bash
|
||||
git fetch origin providers
|
||||
```
|
||||
|
||||
### 2. Copy the Codex source files and tests
|
||||
Copy each file with `git show origin/providers:<path> > <path>` (additive — never merge the branch):
|
||||
|
||||
Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed):
|
||||
Host (`src/providers/`):
|
||||
- `codex.ts` — provider contribution: per-group `.codex-shared` state dir, AGENTS.md compose, skill links
|
||||
- `codex-agents-md.ts` — AGENTS.md composition (32KB Codex cap: degrades by dropping the largest instruction sections, never blocks a spawn)
|
||||
- `codex-registration.test.ts` — barrel-driven host registration guard
|
||||
- `codex-host-contribution.test.ts` — drives the real contribution against a real test DB (the "consumes core" leg)
|
||||
- `codex-agents-md.test.ts` — cap-degradation behavior
|
||||
|
||||
Container (`container/agent-runner/src/providers/`):
|
||||
- `codex.ts` — the provider (turn loop, steering, memory scaffold + `onExchangeComplete` archiving)
|
||||
- `codex-app-server.ts` — JSON-RPC child-process wrapper
|
||||
- `exchange-archive.ts` — per-exchange markdown writer the `onExchangeComplete` hook uses (provider-owned, not runner code)
|
||||
- `exchange-archive.test.ts` — writer behavior
|
||||
- `codex-registration.test.ts` — barrel-driven container registration guard
|
||||
- `codex.factory.test.ts`, `codex.turns.test.ts`, `codex-app-server.test.ts` — provider behavior
|
||||
- `codex-cli-tools.test.ts` — structural guard for the Codex entry in `container/cli-tools.json`
|
||||
|
||||
Setup (`setup/providers/`):
|
||||
- `codex.ts` — picker entry self-registration + the vault auth walk-through + install check
|
||||
- `codex.test.ts` — install-check coverage
|
||||
- `codex-registration.test.ts` — barrel-driven setup registration guard
|
||||
|
||||
Shared base (skip if present):
|
||||
- `container/AGENTS.md` — the runtime-contract base the composed AGENTS.md embeds
|
||||
|
||||
### Wire the barrels
|
||||
|
||||
Append `import './codex.js';` to each of:
|
||||
- `src/providers/index.ts`
|
||||
- `container/agent-runner/src/providers/index.ts`
|
||||
- `setup/providers/index.ts`
|
||||
|
||||
### CLI manifest
|
||||
|
||||
The agent's global Node CLIs install from `container/cli-tools.json` (a json-merge seam), not hand-edited Dockerfile layers. Add Codex by appending one entry — `@openai/codex` has no native postinstall, so no `onlyBuilt`:
|
||||
|
||||
```bash
|
||||
git show origin/providers:src/providers/codex.ts > src/providers/codex.ts
|
||||
git show origin/providers:src/providers/codex-registration.test.ts > src/providers/codex-registration.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts
|
||||
git show origin/providers:container/agent-runner/src/providers/codex-registration.test.ts > container/agent-runner/src/providers/codex-registration.test.ts
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const file = "container/cli-tools.json";
|
||||
const tools = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!tools.some((t) => t.name === "@openai/codex")) {
|
||||
tools.push({ name: "@openai/codex", version: "0.138.0" });
|
||||
const fmt = (t) => " { " + Object.entries(t).map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v)).join(", ") + " }";
|
||||
fs.writeFileSync(file, "[\n" + tools.map(fmt).join(",\n") + "\n]\n");
|
||||
}
|
||||
'
|
||||
```
|
||||
|
||||
The two `codex-registration.test.ts` files are the **registration guards**. Each imports only the real barrel — the host one calls `listProviderContainerConfigNames()` from `src/providers/index.ts`, the container one calls `listProviderNames()` from `container/agent-runner/src/providers/index.ts` — and asserts `codex` is present. They go red the instant a barrel import line is deleted or drifts. (`codex.factory.test.ts` imports `./codex.js` directly and self-registers, so it stays green even if the barrel line is gone — keep it as a unit test of provider behavior, but it is **not** the registration guard.)
|
||||
The version (`0.138.0`) is the canonical pin — keep it in sync with `setup/add-codex.sh`. The Dockerfile already installs every manifest entry via pinned `pnpm install -g`; no Dockerfile edit is needed.
|
||||
|
||||
If `git show origin/providers:.../codex-registration.test.ts` errors with `path ... does not exist`, the registration tests have not landed on `origin/providers` yet. Run `git fetch origin providers` again; once the branch carries them, the copies above succeed. The rest of the install proceeds regardless — the Dockerfile and factory tests still run.
|
||||
|
||||
Copy the Dockerfile structural test that ships with this skill into the container provider tree:
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cp .claude/skills/add-codex/codex-dockerfile.test.ts container/agent-runner/src/providers/codex-dockerfile.test.ts
|
||||
pnpm run build
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
`codex-dockerfile.test.ts` reads the real `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` line and the `pnpm install -g "@openai/codex@${CODEX_VERSION}"` line are both present. The Codex CLI is a binary, not an importable package, so the registration tests cannot see it — this structural test is what guards the Dockerfile edits in step 4.
|
||||
### Restart the host
|
||||
|
||||
### 3. Append the self-registration imports
|
||||
|
||||
Each barrel gets one line — alphabetical placement keeps diffs small.
|
||||
|
||||
`src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
`container/agent-runner/src/providers/index.ts`:
|
||||
|
||||
```typescript
|
||||
import './codex.js';
|
||||
```
|
||||
|
||||
### 4. Add the Codex CLI to the container Dockerfile
|
||||
|
||||
Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`:
|
||||
|
||||
```dockerfile
|
||||
ARG CODEX_VERSION=0.124.0
|
||||
```
|
||||
|
||||
**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call:
|
||||
|
||||
```dockerfile
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@openai/codex@${CODEX_VERSION}"
|
||||
```
|
||||
|
||||
Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`.
|
||||
|
||||
### 5. Build and validate
|
||||
The image rebuild does not reload the **host**. Codex's host contribution
|
||||
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
|
||||
passthrough, and the running host only picks it up on restart. Skip this and the
|
||||
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml` —
|
||||
with no mount, Docker auto-creates the dir root-owned and the non-root container
|
||||
user can't write to it.
|
||||
|
||||
```bash
|
||||
pnpm run build # host
|
||||
pnpm exec vitest run src/providers/codex-registration.test.ts # host registration guard
|
||||
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
|
||||
cd container/agent-runner && bun test src/providers/codex-registration.test.ts && cd - # container registration guard
|
||||
cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts && cd - # Dockerfile structural guard
|
||||
./container/build.sh # agent image
|
||||
# macOS (launchd)
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
# Linux (systemd)
|
||||
systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
All must be clean before proceeding.
|
||||
|
||||
- The **host** `codex-registration.test.ts` imports the real host barrel (`src/providers/index.ts`) and asserts `listProviderContainerConfigNames()` contains `codex`. It goes red if the `import './codex.js';` line is deleted or drifts, or if the barrel fails to evaluate.
|
||||
- The **container** `codex-registration.test.ts` imports the real container barrel (`container/agent-runner/src/providers/index.ts`) and asserts `listProviderNames()` contains `codex`. Same failure surface for the container-side import line.
|
||||
- The **Dockerfile** `codex-dockerfile.test.ts` reads `container/Dockerfile` and asserts the `ARG CODEX_VERSION=` and `@openai/codex@${CODEX_VERSION}` install lines are present — red if either edit is dropped.
|
||||
|
||||
The `@openai/codex` CLI binary is guarded by the Dockerfile structural test plus the container build (`./container/build.sh` fails if the install line is bad), **not** by the registration test — Codex is a CLI binary, not an importable package, so nothing imports it for the registration guard to trip on. To confirm the binary is actually present after the image rebuild, probe it inside a running container with `docker exec <container> codex --version`.
|
||||
|
||||
The host-side provider also consumes core APIs (per-session `~/.codex` mount, env passthrough); that typed core-API consumption is guarded by `pnpm run build`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup.
|
||||
|
||||
### Option A — ChatGPT subscription (recommended for individuals)
|
||||
|
||||
On the host (not inside the container), run Codex's OAuth login:
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
codex login
|
||||
pnpm vitest run src/providers/codex-registration.test.ts src/providers/codex-host-contribution.test.ts src/providers/codex-agents-md.test.ts setup/providers/
|
||||
cd container/agent-runner && bun test src/providers/
|
||||
```
|
||||
|
||||
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
|
||||
The registration tests import only the real barrels — they go red if a barrel line is missing, a barrel fails to evaluate, or the payload is broken.
|
||||
|
||||
No `.env` variables required for this mode.
|
||||
## Authenticate
|
||||
|
||||
### Option B — API key (recommended for CI or API billing)
|
||||
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=sk-...
|
||||
CODEX_MODEL=gpt-5.4-mini
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step provider-auth codex
|
||||
```
|
||||
|
||||
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
|
||||
The same walk-through fresh installs get from the setup picker: ChatGPT subscription (browser login or device pairing) or an OpenAI API key, landed in the OneCLI vault. Idempotent — it short-circuits when a matching secret already exists. It finishes with the install check.
|
||||
|
||||
### Option C — BYO OpenAI-compatible endpoint (experimental)
|
||||
## Use it
|
||||
|
||||
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
|
||||
Per group:
|
||||
|
||||
```env
|
||||
OPENAI_API_KEY=...
|
||||
OPENAI_BASE_URL=https://api.groq.com/openai/v1
|
||||
CODEX_MODEL=llama-3.3-70b-versatile
|
||||
```bash
|
||||
ncl groups config update --id <group-id> --provider codex
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
|
||||
Switching is an operator action — run it from the host. Memory does NOT carry over automatically — each provider keeps its own store; run `/migrate-memory` to carry it across. See [docs/provider-migration.md](../../docs/provider-migration.md) for the carry-over table and rollback.
|
||||
|
||||
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
|
||||
There is no install-wide default provider. Setup's provider picker sets codex on the first agent it creates; creation itself is provider-agnostic (no `--provider` flag — provider is a DB property). Any group switches afterward via `ncl groups config update --provider` as above.
|
||||
|
||||
### Per group / per session
|
||||
## Troubleshooting
|
||||
|
||||
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json` → `'claude'`.
|
||||
|
||||
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
|
||||
|
||||
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
|
||||
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
|
||||
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
|
||||
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
|
||||
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
|
||||
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
|
||||
|
||||
## Next Steps
|
||||
|
||||
The registration and Dockerfile guards in **Build and validate** confirm the wiring. For a live end-to-end check, set `agent_provider = 'codex'` on a test group and send a message after the image rebuild. A successful round-trip looks like:
|
||||
|
||||
- `init` event with a stable thread ID as continuation
|
||||
- One or more `activity` / `progress` events during the turn
|
||||
- `result` event with the model's reply
|
||||
|
||||
If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. To confirm the CLI binary itself landed in the image, `docker exec <container> codex --version`.
|
||||
|
||||
To back this provider out, follow [REMOVE.md](REMOVE.md).
|
||||
- **Container dies at boot, channel silent:** `grep 'Container exited non-zero' logs/nanoclaw.error.log` — the `stderrTail` carries the reason (e.g. `Unknown provider: codex. Registered: claude` means the barrels aren't wired in the running build).
|
||||
- **In-channel `Error: spawn codex ENOENT` on every message:** the image predates the manifest entry — re-run `./container/build.sh`.
|
||||
- **Auth errors mid-conversation:** the vault secret is missing or stale — re-run `pnpm exec tsx setup/index.ts --step provider-auth codex` (subscription re-login updates the vault copy).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Structural guard for the Codex CLI install in container/cli-tools.json.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
|
||||
// json-merge seam), not an importable package, so the barrel-driven
|
||||
// registration tests cannot see it. This test reads the real cli-tools.json
|
||||
// and asserts the @openai/codex entry is present and pinned to an exact
|
||||
// version. It goes red if the manifest entry is dropped or unpins.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/cli-tools.json
|
||||
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
|
||||
const manifestPresent = existsSync(MANIFEST);
|
||||
|
||||
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
|
||||
// read has to be guarded for the bare-branch (no manifest) case.
|
||||
const tools: Array<{ name: string; version: string }> = manifestPresent
|
||||
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
|
||||
: [];
|
||||
const codex = tools.find((t) => t.name === '@openai/codex');
|
||||
|
||||
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
|
||||
// so skip there. In an installed tree (trunk + this payload) it must carry the
|
||||
// pinned @openai/codex entry.
|
||||
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
|
||||
it('includes the @openai/codex entry', () => {
|
||||
expect(codex).toBeDefined();
|
||||
});
|
||||
|
||||
it('pins it to an exact semver (no latest, no ranges)', () => {
|
||||
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
// Structural guard for the Codex CLI install in container/Dockerfile.
|
||||
//
|
||||
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
|
||||
// importable package, so the barrel-driven registration tests cannot see it.
|
||||
// This test reads the real Dockerfile and asserts the version ARG and the
|
||||
// `pnpm install -g` line for @openai/codex are both present. It goes red if
|
||||
// either Dockerfile edit is dropped or drifts.
|
||||
//
|
||||
// Runs under bun (same suite as the container registration test):
|
||||
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// container/agent-runner/src/providers/ -> container/Dockerfile
|
||||
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
|
||||
|
||||
describe('container/Dockerfile codex CLI install', () => {
|
||||
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
|
||||
|
||||
it('declares the CODEX_VERSION ARG', () => {
|
||||
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
|
||||
});
|
||||
|
||||
it('installs the @openai/codex CLI pinned to that ARG', () => {
|
||||
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
|
||||
});
|
||||
});
|
||||
@@ -427,12 +427,20 @@ function collectContextWindows() {
|
||||
return results;
|
||||
}
|
||||
|
||||
// "YYYY-MM-DDTHH" in the host's local time — the chart's hour labels are read
|
||||
// by a human, so bucket by local hour, not UTC. sv-SE renders "YYYY-MM-DD HH".
|
||||
function localHourKey(d: Date): string {
|
||||
return d
|
||||
.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false })
|
||||
.replace(' ', 'T');
|
||||
}
|
||||
|
||||
function collectActivity() {
|
||||
const now = Date.now();
|
||||
const buckets: Record<string, { inbound: number; outbound: number }> = {};
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const key = new Date(now - i * 3600000).toISOString().slice(0, 13);
|
||||
const key = localHourKey(new Date(now - i * 3600000));
|
||||
buckets[key] = { inbound: 0, outbound: 0 };
|
||||
}
|
||||
|
||||
@@ -453,7 +461,7 @@ function collectActivity() {
|
||||
const table = direction === 'outbound' ? 'messages_out' : 'messages_in';
|
||||
const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[];
|
||||
for (const row of rows) {
|
||||
const key = row.timestamp.slice(0, 13);
|
||||
const key = localHourKey(new Date(row.timestamp));
|
||||
if (buckets[key]) buckets[key][direction]++;
|
||||
}
|
||||
db.close();
|
||||
|
||||
@@ -167,7 +167,14 @@ pnpm exec tsx scripts/init-first-agent.ts \
|
||||
|
||||
### Groups
|
||||
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group.
|
||||
Add the bot email to a DeltaChat group. When any member sends a message, the router creates a `messaging_groups` row with `is_group = 1`. Run `/manage-channels` to wire it to an agent group, or wire it directly with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
|
||||
|
||||
```bash
|
||||
# Engage mode/pattern default to the DeltaChat adapter's declared channel
|
||||
# defaults — for DeltaChat groups that's a name pattern (the platform has no
|
||||
# mention metadata), so the agent responds when addressed by name.
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -184,7 +191,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your DeltaC
|
||||
- **user-id-format**: `deltachat:{email}` — the contact's email address
|
||||
- **how-to-find-id**: Send a message from DeltaChat to the bot email, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant over DeltaChat DMs; small groups where participants use DeltaChat
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
- **default-isolation**: One agent per bot identity. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import './discord.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/discord@4.27.0
|
||||
pnpm install @chat-adapter/discord@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -105,6 +105,14 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
|
||||
|
||||
Alternatively create the rows with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket). Engage mode/pattern and `unknown_sender_policy` default to the Emacs adapter's declared channel defaults:
|
||||
|
||||
```bash
|
||||
ncl messaging-groups create --channel-type emacs --platform-id "default" --name "Emacs"
|
||||
ncl wirings create --messaging-group-id <mg-id-from-above> --agent-group-id <ag-id> \
|
||||
--session-mode agent-shared
|
||||
```
|
||||
|
||||
## Configure Emacs
|
||||
|
||||
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
|
||||
|
||||
@@ -18,7 +18,7 @@ There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https:
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '<group-id>';"
|
||||
```
|
||||
|
||||
|
||||
@@ -168,11 +168,11 @@ HOST_PATH="$HOME/.calendar-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ import './gchat.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/gchat@4.27.0
|
||||
pnpm install @chat-adapter/gchat@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -50,7 +50,7 @@ import './github.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/github@4.27.0
|
||||
pnpm install @chat-adapter/github@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
@@ -107,16 +107,17 @@ Ask the user: **Is this a private or public repo?**
|
||||
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
|
||||
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
|
||||
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
```bash
|
||||
# Create messaging group (one per repo)
|
||||
ncl messaging-groups create --channel-type github --platform-id "github:owner/repo" \
|
||||
--name "owner/repo" --is-group 1 --unknown-sender-policy <policy>
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
# Wire to agent group (engage mode/pattern default to the GitHub adapter's
|
||||
# declared channel defaults; grab the mg id from the create output above)
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
|
||||
--session-mode per-thread
|
||||
```
|
||||
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
@@ -125,14 +126,12 @@ Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
|
||||
When using `strict`, add each GitHub user who should be able to trigger the agent:
|
||||
|
||||
```sql
|
||||
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
|
||||
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
|
||||
```bash
|
||||
# Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
ncl users create --id "github:<user-id>" --kind github --display-name "<username>"
|
||||
|
||||
-- Grant membership to the agent group
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
|
||||
VALUES ('github:<user-id>', '<agent-group-id>');
|
||||
# Grant membership to the agent group
|
||||
ncl members add --user "github:<user-id>" --group "<agent-group-id>"
|
||||
```
|
||||
|
||||
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
|
||||
|
||||
@@ -26,7 +26,7 @@ GROUP_ID='<group-id>'
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
|
||||
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
|
||||
@@ -189,11 +189,11 @@ HOST_PATH="$HOME/.gmail-mcp"
|
||||
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
|
||||
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
|
||||
updated_at = datetime('now') \
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \
|
||||
WHERE agent_group_id = '$GROUP_ID';"
|
||||
```
|
||||
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
|
||||
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints.
|
||||
|
||||
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ import './linear.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.27.0
|
||||
pnpm install @chat-adapter/linear@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
@@ -115,19 +115,20 @@ Ask the user: **Is this a private or public Linear workspace?**
|
||||
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
|
||||
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
|
||||
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or create the rows directly with `ncl`. **The host service must be running** — `ncl` connects to it over a Unix socket:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
|
||||
```bash
|
||||
# Create messaging group (one per team)
|
||||
ncl messaging-groups create --channel-type linear --platform-id "linear:ENG" \
|
||||
--name "Engineering" --is-group 1 --unknown-sender-policy <policy>
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
# Wire to agent group (engage mode/pattern default to the Linear adapter's
|
||||
# declared channel defaults; grab the mg id from the create output above)
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <your-agent-group-id> \
|
||||
--session-mode per-thread
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above. The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -220,30 +220,21 @@ Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agen
|
||||
|
||||
### Groups
|
||||
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. For isolated per-group sessions:
|
||||
Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket):
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR IGNORE INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||
VALUES
|
||||
('mga-'||hex(randomblob(8)), 'mg-GROUPID', 'ag-AGENTID', 'isolated', 0, '$NOW');
|
||||
"
|
||||
# Engage mode/pattern default to the Signal adapter's declared channel defaults
|
||||
ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID
|
||||
```
|
||||
|
||||
### Grant user access
|
||||
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups`:
|
||||
New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups` (host service running):
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "
|
||||
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||
VALUES ('signal:UUID', 'ag-AGENTID', 'system', '$NOW');
|
||||
"
|
||||
ncl users create --id "signal:UUID" --kind signal --display-name "<name>"
|
||||
ncl roles grant --user "signal:UUID" --role owner
|
||||
ncl members add --user "signal:UUID" --group ag-AGENTID
|
||||
```
|
||||
|
||||
Find the UUID from `messaging_groups.platform_id` or the `users` table.
|
||||
@@ -264,7 +255,7 @@ Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal
|
||||
- Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID
|
||||
- **how-to-find-id**: Send a message to the bot, then query `messaging_groups` as shown above
|
||||
- **typical-use**: Personal assistant via Signal DMs or small group chats
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically use `isolated` session mode
|
||||
- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/slack.ts` exists
|
||||
- `src/channels/slack-registration.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './slack.js';`
|
||||
- `container/skills/slack-formatting/SKILL.md` exists
|
||||
- `@chat-adapter/slack` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
@@ -33,8 +34,15 @@ git fetch origin channels
|
||||
```bash
|
||||
git show origin/channels:src/channels/slack.ts > src/channels/slack.ts
|
||||
git show origin/channels:src/channels/slack-registration.test.ts > src/channels/slack-registration.test.ts
|
||||
mkdir -p container/skills/slack-formatting
|
||||
git show origin/channels:container/skills/slack-formatting/SKILL.md > container/skills/slack-formatting/SKILL.md
|
||||
```
|
||||
|
||||
The `slack-formatting` container skill is part of the channel payload: it
|
||||
reaches agents via `~/.claude/skills` (synced at spawn) and teaches Slack's
|
||||
mrkdwn syntax. Trunk does not ship it — without this copy step agents send
|
||||
Slack messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
@@ -46,7 +54,7 @@ import './slack.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/slack@4.27.0
|
||||
pnpm install @chat-adapter/slack@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -46,7 +46,7 @@ import './teams.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/teams@4.27.0
|
||||
pnpm install @chat-adapter/teams@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -60,7 +60,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
|
||||
### 5. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/telegram@4.27.0
|
||||
pnpm install @chat-adapter/telegram@4.29.0
|
||||
```
|
||||
|
||||
### 6. Build and validate
|
||||
|
||||
@@ -121,9 +121,11 @@ The bot is now connected as your WeChat account.
|
||||
|
||||
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
|
||||
|
||||
**Prerequisite: the host service must be running.** The wire script creates the wiring through `ncl`, which talks to the running host over a Unix socket — there is no offline mode.
|
||||
|
||||
### 1. Trigger the first inbound message
|
||||
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id` and the `unknown_sender_policy` the WeChat adapter declares.
|
||||
|
||||
### 2. Run the wire script
|
||||
|
||||
@@ -131,9 +133,9 @@ Have a different WeChat account send a message to the bot account. This auto-cre
|
||||
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
```
|
||||
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and runs `ncl wirings create` — engage mode/pattern and priority come from the WeChat adapter's declared channel defaults, so a wiring created here matches one created by `/manage-channels` or the approval-card flow.
|
||||
|
||||
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
With `request_approval` as the sender policy, the next DM from a stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
|
||||
Non-interactive:
|
||||
|
||||
@@ -147,10 +149,16 @@ pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
|
||||
Flags:
|
||||
|
||||
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
|
||||
- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; auto-picked when only one exists)
|
||||
- `--sender-policy public|strict|request_approval` — override the messaging group's `unknown_sender_policy` (default: leave whatever the WeChat adapter declared when the row was auto-created)
|
||||
- `--session-mode shared|per-thread` — default `shared`
|
||||
|
||||
Equivalent raw `ncl` invocation (host must be running):
|
||||
|
||||
```bash
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> --session-mode shared
|
||||
```
|
||||
|
||||
### 3. Test
|
||||
|
||||
Have the sender message the bot again — the agent should respond.
|
||||
|
||||
@@ -5,43 +5,49 @@
|
||||
* After /add-wechat installs the adapter and the user scans the QR login,
|
||||
* the first inbound message from another WeChat account auto-creates a
|
||||
* `messaging_groups` row. This script finds that row, asks the operator
|
||||
* which agent group to wire it to, and inserts the `messaging_group_agents`
|
||||
* join row with sensible defaults — the "post-login wiring" step /add-wechat
|
||||
* otherwise requires manual SQL for.
|
||||
* which agent group to wire it to, and creates the wiring via
|
||||
* `ncl wirings create` — engage mode/pattern and priority come from the
|
||||
* WeChat adapter's declared channel defaults, not from SQL baked into this
|
||||
* script, so it can't drift against schema migrations.
|
||||
*
|
||||
* Usage:
|
||||
* PREREQUISITE: the NanoClaw host service must be RUNNING — `ncl` talks to
|
||||
* it over a Unix socket and has no offline mode.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
*
|
||||
* Flags:
|
||||
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
|
||||
* --sender-policy <p> public | strict (default: public)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; auto-picked when only one exists)
|
||||
* --sender-policy <p> public | strict | request_approval — overrides the
|
||||
* channel-declared unknown_sender_policy on the
|
||||
* messaging group (default: leave as the adapter declared)
|
||||
* --session-mode <m> shared | per-thread (default: shared)
|
||||
* --non-interactive Fail instead of prompting
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
|
||||
// <root>/.claude/skills/add-wechat/scripts/wire-dm.ts → <root>
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
|
||||
type SenderPolicy = 'public' | 'strict' | 'request_approval';
|
||||
|
||||
interface Args {
|
||||
platformId?: string;
|
||||
agentGroupId?: string;
|
||||
senderPolicy: SenderPolicy;
|
||||
senderPolicy?: SenderPolicy;
|
||||
sessionMode: 'shared' | 'per-thread';
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = {
|
||||
// Default matches the router's auto-create (`request_approval`) so the
|
||||
// admin gets an approval card on the next unknown-sender DM rather than
|
||||
// a silent allow. Pass `--sender-policy public` to open the channel to
|
||||
// anyone, or `strict` to require explicit membership.
|
||||
senderPolicy: 'request_approval',
|
||||
// No --sender-policy default: the router already stamped the policy the
|
||||
// WeChat adapter declares when it auto-created the messaging group.
|
||||
// Only an explicit flag overrides it.
|
||||
sessionMode: 'shared',
|
||||
interactive: true,
|
||||
};
|
||||
@@ -68,72 +74,90 @@ function parseArgs(argv: string[]): Args {
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Run one ncl command against the running host and return its parsed data. */
|
||||
function ncl(...cliArgs: string[]): unknown {
|
||||
const res = spawnSync('pnpm', ['exec', 'tsx', 'src/cli/client.ts', ...cliArgs, '--json'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
let frame: { ok: boolean; data?: unknown; error?: { message: string } } | undefined;
|
||||
try {
|
||||
frame = JSON.parse(res.stdout);
|
||||
} catch {
|
||||
// No frame — transport-level failure (host not running), reported on stderr.
|
||||
}
|
||||
if (frame && !frame.ok) throw new Error(`ncl ${cliArgs.join(' ')} failed: ${frame.error?.message}`);
|
||||
if (!frame || res.status !== 0) {
|
||||
const detail = (res.stderr || res.stdout || '').trim();
|
||||
throw new Error(
|
||||
`ncl ${cliArgs.join(' ')} failed:\n${detail}\n\n` +
|
||||
'Is the NanoClaw host service running? ncl connects to it over a Unix socket.',
|
||||
);
|
||||
}
|
||||
return frame.data;
|
||||
}
|
||||
|
||||
async function prompt(q: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
interface MgRow { id: string; platform_id: string; name: string | null; is_group: number; created_at: string }
|
||||
interface AgRow { id: string; name: string; created_at: string }
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
const mgs = ncl('messaging-groups', 'list', '--channel-type', 'wechat') as MgRow[];
|
||||
const wirings = ncl('wirings', 'list', '--limit', '10000') as Array<{ messaging_group_id: string }>;
|
||||
const wiredMgIds = new Set(wirings.map((w) => w.messaging_group_id));
|
||||
|
||||
// 1. Pick the messaging group
|
||||
let platformId = args.platformId;
|
||||
if (!platformId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
|
||||
FROM messaging_groups mg
|
||||
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
|
||||
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
|
||||
ORDER BY mg.created_at DESC
|
||||
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
|
||||
let mg: MgRow | undefined;
|
||||
if (args.platformId) {
|
||||
mg = mgs.find((r) => r.platform_id === args.platformId);
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${args.platformId}`);
|
||||
} else {
|
||||
const unwired = mgs
|
||||
.filter((r) => !wiredMgIds.has(r.id))
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (unwired.length === 0) {
|
||||
console.error('No unwired WeChat messaging groups found.');
|
||||
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (rows.length === 1 || !args.interactive) {
|
||||
platformId = rows[0].platform_id;
|
||||
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
|
||||
if (unwired.length === 1 || !args.interactive) {
|
||||
mg = unwired[0];
|
||||
console.log(`Using most recent unwired group: ${mg.platform_id} (${mg.is_group ? 'group' : 'DM'})`);
|
||||
} else {
|
||||
console.log('Unwired WeChat messaging groups:');
|
||||
rows.forEach((r, i) => {
|
||||
unwired.forEach((r, i) => {
|
||||
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
|
||||
platformId = rows[idx].platform_id;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= unwired.length) throw new Error('invalid choice');
|
||||
mg = unwired[idx];
|
||||
}
|
||||
}
|
||||
|
||||
const mg = db.prepare(
|
||||
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
|
||||
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
|
||||
|
||||
// 2. Pick the agent group
|
||||
let agentGroupId = args.agentGroupId;
|
||||
if (!agentGroupId) {
|
||||
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
|
||||
.all() as Array<{ id: string; name: string; is_admin: number }>;
|
||||
const agents = (ncl('groups', 'list') as AgRow[])
|
||||
.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
|
||||
|
||||
const adminAgents = agents.filter((a) => a.is_admin === 1);
|
||||
if (adminAgents.length === 1 && !args.interactive) {
|
||||
agentGroupId = adminAgents[0].id;
|
||||
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
|
||||
if (agents.length === 1) {
|
||||
agentGroupId = agents[0].id;
|
||||
console.log(`Auto-selected sole agent group: ${agents[0].name} (${agentGroupId})`);
|
||||
} else if (args.interactive) {
|
||||
console.log('Agent groups:');
|
||||
agents.forEach((a, i) => {
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
@@ -144,26 +168,29 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
|
||||
{ id: string; name: string } | undefined;
|
||||
const ag = (ncl('groups', 'list') as AgRow[]).find((a) => a.id === agentGroupId);
|
||||
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
|
||||
|
||||
// 3. Update sender policy + wire
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
|
||||
.run(args.senderPolicy, mg.id);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now'))
|
||||
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
|
||||
});
|
||||
tx();
|
||||
// 3. Wire, then apply the optional policy override. Engage mode/pattern and
|
||||
// priority are filled by the wirings resolveDefaults hook from the WeChat
|
||||
// adapter's declared channel defaults. Policy update runs second so a
|
||||
// failed create (e.g. already wired) leaves the mg row untouched.
|
||||
const wiring = ncl(
|
||||
'wirings', 'create',
|
||||
'--messaging-group-id', mg.id,
|
||||
'--agent-group-id', ag.id,
|
||||
'--session-mode', args.sessionMode,
|
||||
) as { engage_mode: string; engage_pattern: string | null };
|
||||
if (args.senderPolicy) {
|
||||
ncl('messaging-groups', 'update', mg.id, '--unknown-sender-policy', args.senderPolicy);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
|
||||
db.close();
|
||||
console.log(
|
||||
`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} ` +
|
||||
`engage=${wiring.engage_mode}${wiring.engage_pattern ? `(${wiring.engage_pattern})` : ''} ` +
|
||||
`policy=${args.senderPolicy ?? '(channel default)'} mode=${args.sessionMode}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -46,7 +46,7 @@ import './whatsapp-cloud.js';
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/whatsapp@4.27.0
|
||||
pnpm install @chat-adapter/whatsapp@4.29.0
|
||||
```
|
||||
|
||||
### 5. Build and validate
|
||||
|
||||
@@ -20,6 +20,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/whatsapp.test.ts` exists
|
||||
- `src/channels/index.ts` contains `import './whatsapp.js';`
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `container/skills/whatsapp-formatting/instructions.md` exists
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
@@ -40,8 +41,17 @@ git show origin/channels:src/channels/whatsapp-registration.test.ts > src/cha
|
||||
git show origin/channels:src/channels/whatsapp.test.ts > src/channels/whatsapp.test.ts
|
||||
git show origin/channels:setup/whatsapp-auth.ts > setup/whatsapp-auth.ts
|
||||
git show origin/channels:setup/groups.ts > setup/groups.ts
|
||||
mkdir -p container/skills/whatsapp-formatting
|
||||
git show origin/channels:container/skills/whatsapp-formatting/SKILL.md > container/skills/whatsapp-formatting/SKILL.md
|
||||
git show origin/channels:container/skills/whatsapp-formatting/instructions.md > container/skills/whatsapp-formatting/instructions.md
|
||||
```
|
||||
|
||||
The `whatsapp-formatting` container skill is part of the channel payload: its
|
||||
`instructions.md` becomes the `skill-whatsapp-formatting.md` fragment in every
|
||||
group's composed CLAUDE.md (see `src/claude-md-compose.ts`), teaching agents
|
||||
WhatsApp's formatting syntax. Trunk does not ship it — without this copy step
|
||||
agents format WhatsApp messages with generic markdown that renders literally.
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if already present):
|
||||
@@ -82,7 +92,7 @@ WhatsApp uses linked-device authentication — no API key, just a one-time pairi
|
||||
|
||||
### Check current state
|
||||
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Shared vs dedicated number".
|
||||
Check if WhatsApp is already authenticated. If `store/auth/creds.json` exists, skip to "Dedicated vs personal number".
|
||||
|
||||
```bash
|
||||
test -f store/auth/creds.json && echo "WhatsApp auth exists" || echo "No WhatsApp auth"
|
||||
@@ -192,16 +202,60 @@ for i in $(seq 1 60); do grep -q 'STATUS: authenticated' /tmp/wa-auth.log 2>/dev
|
||||
test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed"
|
||||
```
|
||||
|
||||
### Shared vs dedicated number
|
||||
## Dedicated vs personal number
|
||||
|
||||
The adapter behaves fundamentally differently depending on whether the linked number is the assistant's own or the operator's personal one. The switch is `ASSISTANT_HAS_OWN_NUMBER` in `.env`, read by the adapter itself at startup. **Inference rule: absent (or anything other than `true`) means shared/personal** — the safe default, since misreading a personal number as dedicated makes the bot claim messages addressed to the human.
|
||||
|
||||
- **Shared/personal number** (`ASSISTANT_HAS_OWN_NUMBER` unset or not `true`) — DMs to this number and group @-tags of it address the *human*, not the bot. The adapter never emits a mention signal (`mentions: 'never'` in its declared channel defaults), so: no stranger DM ever auto-creates a messaging group or raises an admin approval card; group wirings default to a name pattern (`\b<AgentName>\b`) instead of platform mentions; auto-created chats default to `unknown_sender_policy: 'strict'`; outbound messages are prefixed with the assistant's name.
|
||||
- **Dedicated number** (`ASSISTANT_HAS_OWN_NUMBER=true`) — everything sent to the number is for the bot. DMs and group mentions carry a real mention signal (`mentions: 'platform'`), unknown senders escalate via `request_approval` approval cards, and card-approved groups wire with `engage_mode: 'mention'`. No name prefix on outbound.
|
||||
|
||||
AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number?
|
||||
- **Shared number** — your personal WhatsApp (bot prefixes messages with its name)
|
||||
- **Dedicated number** — a separate phone/SIM for the assistant
|
||||
|
||||
If dedicated, add to `.env`:
|
||||
Write the answer to `.env` **explicitly in both cases** (don't rely on the inference rule for new installs):
|
||||
|
||||
```bash
|
||||
# Dedicated:
|
||||
ASSISTANT_HAS_OWN_NUMBER=true
|
||||
# Shared/personal:
|
||||
ASSISTANT_HAS_OWN_NUMBER=false
|
||||
```
|
||||
|
||||
### Update path: existing install, flag unset
|
||||
|
||||
If WhatsApp auth already exists (`store/auth/creds.json` present) but `.env` has no `ASSISTANT_HAS_OWN_NUMBER` line, the install predates the explicit switch. Ask the operator which mode applies and write it explicitly.
|
||||
|
||||
Suggest a default by comparing the authed number against the wired DM chat:
|
||||
|
||||
```bash
|
||||
# The number this install is authenticated as
|
||||
node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0])"
|
||||
# The wired WhatsApp DM chats
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=0"
|
||||
```
|
||||
|
||||
If the wired DM's phone **equals** the authed number, the operator is talking to the bot in their own self-chat — that's a personal number: suggest **Shared**. If they differ, the operator messages the bot from a different number: suggest **Dedicated**. Confirm with the operator either way, then write the flag and restart the service.
|
||||
|
||||
### Migration audit: spam-era group wirings
|
||||
|
||||
Before the shared-number fix, group chats approved via the channel-registration card were wired `engage_mode='pattern'` with pattern `.` — respond-to-everything — because the card flow couldn't tell groups from DMs on non-threaded platforms. On a personal number this shows up as the bot answering every message in family/work groups after someone once tapped Connect on a spam-triggered card.
|
||||
|
||||
List the suspect wirings (host service running — `ncl` is socket-only):
|
||||
|
||||
```bash
|
||||
ncl wirings list --engage-mode pattern --engage-pattern "." --json
|
||||
```
|
||||
|
||||
Cross-reference against WhatsApp group chats (`ncl messaging-groups list --channel-type whatsapp --is-group 1`). For each wiring with pattern `.` on a WhatsApp group that is *not* the operator's deliberate always-on chat (e.g. their self-chat), offer:
|
||||
|
||||
- **Flip to name-based engagement**: `ncl wirings update <wiring-id> --engage-mode pattern --engage-pattern '\b<AgentName>\b'` (or `--engage-mode mention` on a dedicated number)
|
||||
- **Delete the wiring**: `ncl wirings delete <wiring-id>`
|
||||
|
||||
Stale approval cards from that era can also linger. Clear pending channel approvals for chats the operator doesn't want wired:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM pending_channel_approvals WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='whatsapp')"
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
@@ -279,3 +333,12 @@ systemctl --user start $(systemd_unit)
|
||||
### "conflict" disconnection
|
||||
|
||||
Two instances connected with same credentials. Ensure only one NanoClaw process is running.
|
||||
|
||||
### Trunk updated but shared-number behavior unchanged (stale adapter copy)
|
||||
|
||||
The shared-number behavior (no stranger approval cards, name-pattern group defaults) lives in the **adapter copy** at `src/channels/whatsapp.ts`, installed from the `channels` branch — not in trunk. If you updated trunk via `/update-nanoclaw` but skipped the skill-update step, the old adapter copy neither reads `ASSISTANT_HAS_OWN_NUMBER` itself nor declares channel defaults, so trunk falls back to the legacy behavior: approval cards still fire on a personal number, and new wirings get the channel-blind defaults. Symptoms of the skew:
|
||||
|
||||
- `.env` says `ASSISTANT_HAS_OWN_NUMBER=false` (or unset) but strangers' DMs still raise approval cards
|
||||
- `ncl wirings create` on a WhatsApp group defaults to `mention` instead of a name pattern
|
||||
|
||||
Fix: re-run `/add-whatsapp` (or `/update-skills`) to pull the current adapter from the `channels` branch, then restart the service. The reverse skew (new adapter, old trunk) can't happen — the adapter's `defaults` field is optional and old trunk ignores it.
|
||||
|
||||
@@ -71,6 +71,8 @@ Parse the `PAIR_TELEGRAM_ISSUED` status block for `CODE` and follow the `REMINDE
|
||||
|
||||
## 4. Run the init script
|
||||
|
||||
First, pick the agent provider. Read `src/providers/index.ts` and collect the installed providers from its `import './<name>.js';` lines — `claude` is always available as the built-in default. If a non-default provider is installed (e.g. codex), ask the user which one this agent should run on; if only claude is available, skip the question and omit the flag.
|
||||
|
||||
```bash
|
||||
npx tsx scripts/init-first-agent.ts \
|
||||
--channel "${CHANNEL}" \
|
||||
@@ -80,7 +82,7 @@ npx tsx scripts/init-first-agent.ts \
|
||||
--agent-name "${AGENT_NAME}"
|
||||
```
|
||||
|
||||
Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
Add `--provider <name>` when the user picked a non-default provider (there is no install-wide default — the choice is explicit per group). Add `--welcome "System instruction: ..."` to override the default welcome prompt.
|
||||
|
||||
The script:
|
||||
1. Upserts the `users` row and grants `owner` role if no owner exists.
|
||||
@@ -111,7 +113,7 @@ If they say it didn't arrive, then diagnose using the DB directly (no waiting lo
|
||||
|
||||
**"Missing required args"** — the script wants `--channel`, `--user-id`, `--platform-id`, `--display-name` at minimum. Re-check the command you assembled.
|
||||
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — the router silently drops messages from unknown senders under `strict` policy but still creates the `messaging_groups` row. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
**No `messaging_groups` row appears after the user DMs (step 3a)** — auto-created rows are stamped with the channel adapter's declared `unknown_sender_policy` (two-level model: adapter declaration → per-row override; `strict` only when the adapter has no declaration). Under `strict` the router silently drops messages from unknown senders but still creates the `messaging_groups` row; under `request_approval` an approval card goes to an admin instead. If the row is missing entirely, the adapter isn't receiving the inbound message. Check `logs/nanoclaw.log` for adapter errors (auth, gateway disconnect, rate limit).
|
||||
|
||||
**Owner already exists** — `hasAnyOwner()` returned true, so the grant is skipped silently. That's fine; the script still creates the agent and wiring. Reassigning ownership needs a separate flow (not this skill).
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: learn
|
||||
description: "Distill a reusable skill from anything — a directory, a URL, pasted notes, or what you just did together — or refine an existing skill with new learnings. Use when the user says '/learn', 'learn this', 'turn this into a skill', 'capture this workflow', 'make a skill from <source>', or 'improve/update the <name> skill'. Produces or updates a .claude/skills/<name>/SKILL.md authored to NanoClaw's skill guidelines. (This CREATES or REFINES a skill from a source; it does not install existing skills from a registry.)"
|
||||
---
|
||||
|
||||
# Learn — Distill a Skill from Anything
|
||||
|
||||
Turn a source — a directory, a URL, pasted notes, or the work just done in this conversation — into a clean, reusable NanoClaw skill. The output is a new `.claude/skills/<name>/SKILL.md` (plus optional `scripts/`, `references/`, `templates/`) authored to the project's skill guidelines.
|
||||
|
||||
This skill is **instruction-only**: it uses the tools you already have (`Read`, `Grep`, `Glob`, `WebFetch`, `Write`) — there is no separate distillation engine and no reach-ins into core code.
|
||||
|
||||
## When to use
|
||||
|
||||
Invoke when the user wants to *capture* a workflow as a reusable skill:
|
||||
|
||||
- `/learn <path>` — read a project/dir and build a skill for working with it
|
||||
- `/learn <url>` — read docs / an API page and build a usage skill
|
||||
- `/learn what we just did` — distill the current conversation's workflow
|
||||
- `/learn` + pasted notes — turn notes into a structured skill
|
||||
|
||||
If the user instead wants to *find and install* an existing community skill, that is a different task — this skill **creates** new skills, it does not import them.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the source — and whether this is a new skill or a refine
|
||||
- A **path** → read the code/files.
|
||||
- A **URL** → fetch and read the page.
|
||||
- **"what we just did" / "this"** → use the current conversation as the source.
|
||||
- **Pasted text** → use it directly.
|
||||
|
||||
Then check `.claude/skills/` for an existing skill that already covers this topic (the user may name it, e.g. *"update the wow-on-steam-deck skill"*, or the subject may obviously match one). **If one exists, this is a REFINE, not a fresh create** — go to step 4's "Refining" branch.
|
||||
|
||||
If it is ambiguous what the skill should *do*, ask one clarifying question before proceeding.
|
||||
|
||||
### 2. Gather the material
|
||||
- **Path:** `Glob` the structure, `Read` the key files, `Grep` for the important entry points. Read enough to understand the *repeatable procedure*, not every line.
|
||||
- **URL:** `WebFetch` the page; pull out the concrete commands/steps, not the prose.
|
||||
- **Conversation:** re-read what was actually done — the commands, the gotchas, the decisions — and keep the parts that generalize.
|
||||
|
||||
### 3. Distill — find the reusable procedure
|
||||
Strip the one-off specifics; keep the *repeatable* shape. A good skill answers: *"Next time someone needs to do X, what are the exact steps, files, commands, and gotchas?"* Capture:
|
||||
|
||||
- the trigger / when-to-use,
|
||||
- the step-by-step procedure (commands, file paths, decision points),
|
||||
- the non-obvious **gotchas** that were hit — usually the most valuable part,
|
||||
- any scripts or templates worth shipping alongside.
|
||||
|
||||
### 4. Author the SKILL.md
|
||||
|
||||
**Refining an existing skill?** First `Read` the current `.claude/skills/<name>/SKILL.md`, then *update it in place* — do not blindly overwrite:
|
||||
- Keep what is still correct; weave the new learnings into the right sections.
|
||||
- **Dedupe** — don't append a near-duplicate step or a second gotcha that says the same thing.
|
||||
- Correct anything the new source proves stale (a changed path, command, or flag).
|
||||
- Preserve the existing `name`/folder and overall structure; the diff should read as a focused improvement, not a rewrite.
|
||||
|
||||
**New skill?** Write `.claude/skills/<kebab-name>/SKILL.md`.
|
||||
|
||||
**Frontmatter (required):**
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: <kebab-case, matches the folder>
|
||||
description: "<what it does + when to use it + likely trigger phrases>"
|
||||
---
|
||||
```
|
||||
|
||||
`description` is what the agent reads to decide relevance — make it concrete and include the phrases a user would actually say.
|
||||
|
||||
**Body:** open with one paragraph on what the skill does, then a `## When to use` section and a `## Workflow` of numbered steps (the actual procedure). Use tables for command/file references, and add a short examples or troubleshooting section when the gotchas warrant it.
|
||||
|
||||
**House authoring rules (from `docs/skill-guidelines.md`):**
|
||||
|
||||
- **Additive, minimal reach-ins** — prefer adding files; make the *smallest possible* edit to existing code, and only via single-line calls into skill-owned functions.
|
||||
- **Instruction-only when possible** — if Claude can do it by following prose plus existing tools, ship no code. These are the easiest skills to maintain and to merge.
|
||||
- If apply leaves anything behind, ship a **`REMOVE.md`** that fully reverses every change (no soft-disabled/commented-out removals).
|
||||
- If the skill adds an integration point in core code, add a **test that goes red if the wiring is deleted or drifts**.
|
||||
- Anti-patterns to avoid: separate `VERIFY.md` files, incomplete cleanup, raw SQL against core DBs, branch merges (use additive fetch), hand-maintained duplicate copies.
|
||||
|
||||
### 5. Place and verify
|
||||
- Write into `.claude/skills/<name>/`; confirm the folder name matches the `name` frontmatter and the YAML parses.
|
||||
- If feasible, dry-run the procedure the skill describes to confirm it is correct.
|
||||
- Tell the user the skill exists and how to invoke it (`/<name>`).
|
||||
|
||||
## Example
|
||||
|
||||
`/learn what we just did` after a multi-step setup:
|
||||
|
||||
1. Re-read the conversation's commands and gotchas.
|
||||
2. Distill the repeatable procedure.
|
||||
3. Write `.claude/skills/<topic>-setup/SKILL.md` with the steps, file paths, and the gotchas hit along the way.
|
||||
4. Report: *"Created `/<topic>-setup` — invoke it next time to repeat this."*
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep skills **focused** — one capability per skill (mirrors the project's "one change per PR" rule).
|
||||
- The most valuable content is the **gotchas**, not the happy path.
|
||||
- This skill is prose and safe to re-run — use it again to refine an existing skill.
|
||||
@@ -22,7 +22,7 @@ pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||
```sql
|
||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
|
||||
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
|
||||
SELECT messaging_group_id, agent_group_id, engage_mode, engage_pattern, session_mode, threads, priority FROM messaging_group_agents;
|
||||
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
|
||||
```
|
||||
|
||||
@@ -36,6 +36,47 @@ If the instance has no owner yet (`SELECT COUNT(*) FROM user_roles WHERE role='o
|
||||
|
||||
**Delegate to `/init-first-agent`.** It handles: channel choice, operator identity lookup, DM platform id resolution (with cold-DM or pair-code fallback), agent group creation, wiring, and the welcome DM. Return here afterward for any additional channels.
|
||||
|
||||
## Channel Defaults: The Two-Level Model
|
||||
|
||||
Wiring defaults (engage mode/pattern, threading, `unknown_sender_policy`) resolve through **exactly two levels**:
|
||||
|
||||
1. **Adapter declaration** — each channel adapter declares `ChannelDefaults` (separate DM and group contexts, plus a `mentions` capability) in its source file. The adapter copy is skill-installed and user-owned: to change a default install-wide, edit `src/channels/<channel>.ts` and restart. Declarations are never persisted to the DB.
|
||||
2. **Per-wiring/per-mg values chosen at creation** — every creation surface (`ncl wirings create` / `ncl messaging-groups create`, the `register` wizard step, the approval-card flow, `/init-first-agent`) fills omitted fields from the declaration and stores the result on the row. Pass explicit flags to override one wiring.
|
||||
|
||||
There is no third level: existing rows are never re-resolved, so editing a declaration only affects wirings created afterward. The one exception is the **`threads` column**, which stays live — `NULL` means "inherit the declaration at message time".
|
||||
|
||||
Channels with no declaration (stale adapter copies) fall back to the legacy behavior; run `/update-skills` to pull current adapters.
|
||||
|
||||
### Wiring via ncl
|
||||
|
||||
`ncl` requires the **host service to be running** (it connects over a Unix socket):
|
||||
|
||||
```bash
|
||||
ncl messaging-groups create --channel-type <type> --platform-id "<id>" --name "<name>" [--is-group 1]
|
||||
ncl wirings create --messaging-group-id <mg-id> --agent-group-id <ag-id> [--session-mode <mode>]
|
||||
```
|
||||
|
||||
Omitted `engage_mode`/`engage_pattern`/`unknown_sender_policy` come from the adapter declaration for the right context (DM vs group). Run `ncl wirings help` / `ncl messaging-groups help` for the full flag list.
|
||||
|
||||
### Threading override (`--threads`)
|
||||
|
||||
`ncl wirings create/update ... --threads true|false` controls whether platform thread ids are honored for this wiring. `true` (in groups) means per-thread sessions and in-thread replies/typing/cards; `false` collapses to a flat session with top-level replies. Omitted = `NULL` = inherit the channel declaration. A wiring can *disable* threads on a threaded platform (Slack, Discord, GitHub), never enable them on a non-threaded one.
|
||||
|
||||
Two consequences to warn the user about:
|
||||
|
||||
- **Session identity**: sessions are never deleted. Flipping `threads` on a live wiring orphans existing per-thread sessions (or splinters a shared one) — history stays in the old sessions; new messages start fresh ones.
|
||||
- **`mention-sticky` needs threads**: sticky engagement is keyed on per-thread session existence, so with resolved threads off it would engage once and never disengage. Creation and update coerce `mention-sticky` → `mention` (with a warning) when the effective thread policy is off.
|
||||
|
||||
### Mention capability
|
||||
|
||||
Each declaration states which mention signal the adapter emits: `platform` (real platform mentions), `dm-only` (only DMs are flagged), or `never`. On a `mentions: 'never'` channel (Linear OAuth apps, WhatsApp personal-number mode, Emacs), `mention`/`mention-sticky` wirings are **inert — they can never engage** — and `ncl` rejects them at create/update with an error citing the declaration. For groups on those channels, use a name pattern instead:
|
||||
|
||||
```bash
|
||||
ncl wirings update <id> --engage-mode pattern --engage-pattern '(?i)^@?<Name>\b'
|
||||
```
|
||||
|
||||
**Renaming an agent group does not update stored patterns.** Declared group patterns containing `{name}` are substituted with the agent group's name *at creation* and stored literally — after `ncl groups update <id> --name <NewName>`, audit that group's wirings for patterns still matching the old name and update them.
|
||||
|
||||
## Wire New Channel
|
||||
|
||||
For each unwired channel:
|
||||
@@ -67,13 +108,17 @@ pnpm exec tsx setup/index.ts --step register -- \
|
||||
|
||||
The `register` step creates the agent group (reusing it if the folder already exists), the messaging group, and the wiring row. `createMessagingGroupAgent` auto-creates the companion `agent_destinations` row so the agent can address the channel by name.
|
||||
|
||||
Omitted engage/policy fields default from the channel adapter's declaration (see "Channel Defaults" above). Optional overrides: `--trigger "<regex>"` (explicit engage pattern), `--engage-mode <pattern|mention|mention-sticky>`, `--is-group <true|false>`, `--unknown-sender-policy <strict|request_approval|public>`. Don't pick a mention mode on a channel whose declaration says `mentions: 'never'` — it can never engage there.
|
||||
|
||||
When creating a NEW agent group on a non-default provider, append `--provider <name>` (e.g. `--provider codex`) — there is no install-wide default; existing groups switch via `ncl groups config update --provider` instead.
|
||||
|
||||
For separate agents, also ask for a folder name and optionally a different assistant name.
|
||||
|
||||
## Add Channel Group
|
||||
|
||||
When adding another group/chat on an already-configured platform (e.g. a second Telegram group):
|
||||
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row with `unknown_sender_policy = 'strict'` and upserted the paired user — `register` only needs to add the wiring:
|
||||
1. **Telegram:** ask the isolation question first to determine intent (`wire-to:<folder>` for an existing agent, `new-agent:<folder>` for a fresh one). Run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent <intent>`, show the `CODE` from the `PAIR_TELEGRAM_CODE` status block, and tell the user to post `@<botname> CODE` in the target group (or DM the bot for a private chat). Wait for the final `PAIR_TELEGRAM` block. The inbound interceptor has already created the `messaging_groups` row stamped with the Telegram adapter's declared policy (`request_approval` on current adapter copies; `strict` only on stale pre-declaration copies) and upserted the paired user — `register` only needs to add the wiring:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- \
|
||||
@@ -92,6 +137,16 @@ When adding another group/chat on an already-configured platform (e.g. a second
|
||||
3. Delete the old `messaging_group_agents` entry, create a new one
|
||||
4. Note: existing sessions stay with the old agent group; new messages route to the new one. The `agent_destinations` row created for the old wiring is NOT automatically removed — if you want the old agent to stop seeing the channel as a named target, delete it from `agent_destinations` manually.
|
||||
|
||||
## One-Time Check: Legacy Mis-Wired WhatsApp Groups
|
||||
|
||||
Installs that approved WhatsApp group registration cards before the channel-defaults model wired those groups as `engage_mode='pattern'`, `engage_pattern='.'` — respond-to-everything (the card flow couldn't tell groups from DMs on non-threaded platforms). Check once:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db "SELECT mga.id, mg.platform_id, mg.name FROM messaging_group_agents mga JOIN messaging_groups mg ON mg.id = mga.messaging_group_id WHERE mg.channel_type='whatsapp' AND mg.is_group=1 AND mga.engage_mode='pattern' AND mga.engage_pattern='.'"
|
||||
```
|
||||
|
||||
For any hit the operator didn't deliberately configure as always-on, offer the repair options in `/add-whatsapp`'s "Migration audit" section (flip to mention/name-pattern engagement, or delete the wiring).
|
||||
|
||||
## Show Configuration
|
||||
|
||||
Display a readable summary showing:
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -194,8 +194,10 @@ Notes:
|
||||
runtime, so pass the `=>` value discovery emitted (or the raw OpenClaw id).
|
||||
- Reuse a `--folder` to put a group on an existing agent (shared base/separate
|
||||
conversations); use a new `--folder` for a fully separate agent.
|
||||
- Group chats default to mention-only; pass `--trigger` to set a regex, or
|
||||
`--no-trigger-required` for respond-to-everything.
|
||||
- Engage defaults come from the channel adapter's declaration (most group
|
||||
chats default to mention-based engagement; channels without a mention
|
||||
signal default to a name pattern). Pass `--trigger` to set an explicit
|
||||
regex, or `--no-trigger-required` for respond-to-everything.
|
||||
- Register groups from channels v2 doesn't support yet too — the messaging
|
||||
group and wiring persist and activate when that channel is installed.
|
||||
|
||||
@@ -238,10 +240,13 @@ model, which is **not** a JSON file. Each messaging group has an
|
||||
- `dmPolicy: "disabled"` → don't wire that chat (or leave it registered but
|
||||
unwired).
|
||||
|
||||
The messaging groups `register` / `init-first-agent` create already default to
|
||||
`unknown_sender_policy = 'strict'`, so unknown senders are gated until you add
|
||||
them. Show the user the OpenClaw allowlist and confirm who to grant before
|
||||
running the commands.
|
||||
The messaging groups `register` / `init-first-agent` create default their
|
||||
`unknown_sender_policy` to whatever the channel adapter declares for that
|
||||
context (DM vs group) — `strict` when the channel has no declaration — so
|
||||
unknown senders are gated until you add them (or an admin approves the
|
||||
adapter-declared approval card). Pass `--unknown-sender-policy` to `register`
|
||||
to override. Show the user the OpenClaw allowlist and confirm who to grant
|
||||
before running the commands.
|
||||
|
||||
## Phase 3: Identity and Memory
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: migrate-memory
|
||||
description: Carry an agent group's memory across a provider switch, in either direction (e.g. Claude ↔ Codex, or any provider to/from another). Run after the operator switches a group's provider with `ncl groups config update --provider`. The coding agent reads the source provider's memory store, distills it into the target provider's store, and restarts the group. Triggers on "migrate memory", "carry memory over", "the agent forgot everything after the switch".
|
||||
---
|
||||
|
||||
# Migrate memory across a provider switch
|
||||
|
||||
NanoClaw does not migrate memory at runtime — each provider keeps its own store, and carrying content across is the operator's move, executed by you (the coding agent). This skill is the whole mechanism: read the source store, **infer** what is durable, write it into the target store, restart.
|
||||
|
||||
You translate between **store shapes**, not provider names. There are two:
|
||||
|
||||
- **Flat file** — `CLAUDE.local.md` at the group workspace root (the Claude provider; may reference satellite files in the workspace).
|
||||
- **Scaffold tree** — `memory/` (any provider with `usesMemoryScaffold`, e.g. Codex). `memory/index.md` is the index; durable notes live under `memory/memories/`; `memory/memories/imported-agent-memory.md` is the conventional landing file for imported memory.
|
||||
|
||||
A switch only needs migration when it **crosses shapes**. Two providers that both use the scaffold share the same `memory/` tree, so switching between them carries nothing — the memory is already there. The work is always one of: flat → scaffold, or scaffold → flat.
|
||||
|
||||
Principles: **copy, never move** (the source store stays intact — it IS the rollback), **idempotent** (re-running must not duplicate), **distill, don't dump** (you are the inference step: keep identity/seed instructions, user preferences, durable facts; drop conversational residue).
|
||||
|
||||
## Step 1: Identify the group, both providers, and the direction
|
||||
|
||||
- `ncl groups list`, then `ncl groups config get --id <group-id>` — note the current (target) `provider`. Ask the operator which group, and which provider it switched *from*, if either is ambiguous.
|
||||
- Map each provider to its store shape (flat `CLAUDE.local.md` vs `memory/` scaffold), then inspect `groups/<folder>/`:
|
||||
- **Same shape on both sides** (e.g. scaffold → scaffold) → the store is shared; nothing to migrate. Tell the operator and stop.
|
||||
- **Flat → scaffold** (source has `CLAUDE.local.md` content, target uses the scaffold) → Step 2.
|
||||
- **Scaffold → flat** (source has a `memory/` tree, target is Claude) → Step 3.
|
||||
- Source missing or empty → nothing to migrate; tell the operator and stop.
|
||||
|
||||
## Step 2: flat → scaffold (`CLAUDE.local.md` → `memory/`)
|
||||
|
||||
1. Read `groups/<folder>/CLAUDE.local.md` and any workspace files it references.
|
||||
2. If `memory/memories/imported-agent-memory.md` already exists, a previous import happened — show the operator what's there and ask before overwriting; integrate only what's new.
|
||||
3. Distill the content into `groups/<folder>/memory/memories/imported-agent-memory.md` (create the directories if missing — the container scaffolds the rest of the tree at boot and never clobbers your files). Lead with anything that defines who the agent is or how it must behave; references to satellite files keep their workspace-root paths.
|
||||
4. If `memory/index.md` exists, add the following: `- [Imported agent memory](memories/imported-agent-memory.md) — seed instructions and memory carried over from a previous provider. Read it first and treat it as binding; it may define who you are and how to behave. Integrate its facts into your memory as you work; never modify files that belong to another provider's memory system.`
|
||||
5. Leave the source store exactly as it is.
|
||||
|
||||
## Step 3: scaffold → flat (`memory/` → `CLAUDE.local.md`)
|
||||
|
||||
1. Read `memory/index.md`, then the files it points to under `memory/memories/` (and `memory/data/` where durable).
|
||||
2. Integrate the durable facts into `groups/<folder>/CLAUDE.local.md` under a clearly marked section (e.g. `## Imported from memory/ (<date>)`), deduplicating against what's already there. If the section already exists, update it instead of appending a second one.
|
||||
3. Leave the source store exactly as it is.
|
||||
|
||||
## Step 4: Restart and verify
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
Tell the operator to send the group a quick test message that depends on a migrated fact (a preference, a project name). If the agent doesn't know it, re-check that the target file landed in the right group folder.
|
||||
|
||||
Note: switching the provider is an operator action — `ncl groups config update --id <group-id> --provider <name>` from the host. See [docs/provider-migration.md](../../../docs/provider-migration.md) for what carries over automatically.
|
||||
@@ -28,6 +28,15 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
|
||||
|
||||
---
|
||||
|
||||
# Phase 0: Refresh this skill first
|
||||
|
||||
The migration process itself evolves, so run its newest version before doing anything else:
|
||||
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
|
||||
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/migrate-nanoclaw/`
|
||||
- Re-read `.claude/skills/migrate-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
|
||||
|
||||
This is the only working-tree change expected before the preflight check below; changes limited to `.claude/skills/migrate-nanoclaw/` are this self-refresh — ignore them in the 1.0 clean-tree check and proceed.
|
||||
|
||||
# Phase 1: Extract
|
||||
|
||||
## 1.0 Preflight
|
||||
@@ -464,6 +473,11 @@ Point the branch at the upgraded state with `git reset --hard <upgrade-commit>`
|
||||
|
||||
Run `pnpm install && pnpm run build` in the main tree to confirm.
|
||||
|
||||
Stamp the upgrade marker (required — without it the startup tripwire stops the host on next start). Only do this after the build above succeeds:
|
||||
```bash
|
||||
pnpm exec tsx scripts/upgrade-state.ts set "" migrate-nanoclaw
|
||||
```
|
||||
|
||||
Restart the service. Service labels are per-install — derive them from `setup/lib/install-slug.sh`:
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
|
||||
@@ -60,11 +60,20 @@ Help a user with a customized NanoClaw install safely incorporate upstream chang
|
||||
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
|
||||
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
|
||||
|
||||
# Step 0a: Refresh this skill first
|
||||
The update process itself evolves, so run its newest version before doing anything else:
|
||||
- Ensure the `upstream` remote exists (default `https://github.com/nanocoai/nanoclaw.git`) and fetch: `git fetch upstream --prune`. Detect the upstream branch (`main` or `master`).
|
||||
- Refresh this skill from upstream: `git checkout upstream/<branch> -- .claude/skills/update-nanoclaw/`
|
||||
- Re-read `.claude/skills/update-nanoclaw/SKILL.md`. If it changed, **follow the updated version from the top** instead of this one.
|
||||
|
||||
This is the only working-tree change expected before the preflight check; the full update commits it along with everything else.
|
||||
|
||||
# Step 0: Preflight (stop early if unsafe)
|
||||
Run:
|
||||
- `git status --porcelain`
|
||||
If output is non-empty:
|
||||
- Tell the user to commit or stash first, then stop.
|
||||
- Exception: changes limited to `.claude/skills/update-nanoclaw/` are the Step 0a self-refresh — ignore those and proceed.
|
||||
|
||||
Confirm remotes:
|
||||
- `git remote -v`
|
||||
@@ -112,6 +121,7 @@ Bucket the upstream changed files:
|
||||
- **Host source** (`src/`): may conflict if user modified the same files
|
||||
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
|
||||
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
|
||||
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
|
||||
- **Other**: docs, tests, setup scripts, misc
|
||||
|
||||
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
|
||||
@@ -206,6 +216,11 @@ If build fails:
|
||||
- Do not refactor unrelated code.
|
||||
- If unclear, ask the user before making changes.
|
||||
|
||||
# Step 5.5: OneCLI upgrade (if pins moved)
|
||||
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
|
||||
|
||||
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
|
||||
|
||||
# Step 6: Breaking changes check
|
||||
After validation succeeds, check if the update introduced any breaking changes.
|
||||
|
||||
@@ -231,30 +246,78 @@ If one or more `[BREAKING]` lines are found:
|
||||
- For each skill the user selects, invoke it using the Skill tool.
|
||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
||||
|
||||
# Step 7: Check for skill and channel/provider updates
|
||||
# Step 7: Skill updates (part of updating NanoClaw)
|
||||
|
||||
## 7a: Skill branches
|
||||
Check if skills are distributed as branches in this repo:
|
||||
- `git branch -r --list 'upstream/skill/*'`
|
||||
Updating your installed skills is **part of** updating NanoClaw, not an optional
|
||||
extra. Channel and provider code ships on long-lived branches (`channels`,
|
||||
`providers`) that the host merge above doesn't touch — so stopping here leaves
|
||||
that code on whatever version you installed, which is how an important upstream
|
||||
fix gets silently left behind. The default is to continue into `/update-skills`,
|
||||
which re-applies your installed channels/providers to pull their latest code.
|
||||
|
||||
If any `upstream/skill/*` branches exist:
|
||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
||||
Detect whether anything is installed: read `src/channels/index.ts` and
|
||||
`src/providers/index.ts`, collecting `import './<name>.js';` lines (excluding
|
||||
`cli`).
|
||||
|
||||
## 7b: Channel and provider updates
|
||||
Detect installed channels by reading `src/channels/index.ts` and collecting all `import './<name>.js';` lines (excluding `cli`). For providers, check `src/providers/index.ts` the same way.
|
||||
- If nothing is installed: skip silently and proceed to Step 7.9.
|
||||
- If one or more are installed: continue into skill updates.
|
||||
|
||||
If any channels/providers are installed AND `upstream/channels` or `upstream/providers` branches exist:
|
||||
- List the installed channels/providers.
|
||||
- Use AskUserQuestion to ask: "Would you like to update your installed channels/providers? Re-running `/add-<name>` is safe — it only updates code files, credentials and wiring are untouched."
|
||||
- One option per installed channel/provider (e.g., "Update Slack (/add-slack)")
|
||||
- "Skip — I'll update them later"
|
||||
- Set `multiSelect: true`
|
||||
- For each selected option, invoke the corresponding `/add-<channel>` or `/add-<provider>` skill.
|
||||
**Hand-off — default in, minimal opt-out.** Use AskUserQuestion (single-select).
|
||||
Name the installed skills in the question so the choice is concrete:
|
||||
- Question: "Skill updates are part of this NanoClaw update — your installed
|
||||
channels/providers (<list the detected ones>) ride separate branches the host
|
||||
update didn't touch. Continue into `/update-skills` to bring them up to date?"
|
||||
- Option 1 (Recommended): "Continue into skill updates" — description: "Runs
|
||||
`/update-skills`, which re-applies your installed channels/providers to pull
|
||||
their latest upstream code. You pick which ones there."
|
||||
- Option 2: "Skip — I'll run `/update-skills` myself later" — description: "Your
|
||||
installed skill code stays as-is and may be behind upstream."
|
||||
|
||||
If no channels/providers are installed, skip silently.
|
||||
Keep it to these two options — the per-skill selection lives inside
|
||||
`/update-skills`, not here.
|
||||
|
||||
- On "Continue": invoke `/update-skills` using the Skill tool. (If the re-apply
|
||||
touches container code, `/update-skills` rebuilds the agent image itself — see
|
||||
its Step 4 — so nothing container-related is owed back here.)
|
||||
- On "Skip": note that `/update-skills` can be run anytime, then proceed.
|
||||
|
||||
## Known behavior changes when channel adapters update
|
||||
|
||||
Channel adapters now declare per-channel wiring defaults (engage mode, threading,
|
||||
sender policy). Updating trunk alone changes nothing for existing rows, but once
|
||||
`/update-skills` pulls current adapter copies, two deliberate behavior changes
|
||||
land. If the user's install has Slack, Discord, or WhatsApp, tell them:
|
||||
|
||||
1. **Slack/Discord DM replies move top-level.** Both adapters now declare
|
||||
`threads: false` for DMs, so DM replies stop chasing per-message sub-threads
|
||||
and land in the main DM view, matching the DM session (which was already
|
||||
flat). Group/channel threading is unchanged. To keep the old in-thread DM
|
||||
behavior for a specific wiring, override it per wiring:
|
||||
`ncl wirings update <wiring-id> --threads true`.
|
||||
2. **Shared-identity channels stop raising stranger approval cards.** On
|
||||
channels where the linked account is the operator's personal identity, the
|
||||
mechanics differ by channel: WhatsApp personal-number mode suppresses the
|
||||
mention signal entirely (no auto-created messaging groups, no cards);
|
||||
iMessage and WeChat still emit DM mention signals — stranger DMs still
|
||||
auto-create `messaging_groups` rows — but their declared `strict` policy
|
||||
makes those rows drop unknown senders silently instead of raising
|
||||
channel-registration cards to the admin.
|
||||
|
||||
**WhatsApp installs on a shared/personal number should re-run `/add-whatsapp`**
|
||||
after the skill update: it now asks the dedicated-vs-personal question
|
||||
explicitly (writing `ASSISTANT_HAS_OWN_NUMBER` to `.env`), audits for legacy
|
||||
mis-wired group rows from spam-era approval cards, and shows how to clear
|
||||
stale pending approvals.
|
||||
|
||||
Proceed to Step 7.9.
|
||||
|
||||
# Step 7.9: Stamp the upgrade marker (required)
|
||||
After validation has **succeeded**, record that this install reached the new version through the supported path. Without this, the startup tripwire stops the host on its next start.
|
||||
|
||||
- `pnpm exec tsx scripts/upgrade-state.ts set "" update-nanoclaw`
|
||||
- The empty version argument stamps the current `package.json` version.
|
||||
|
||||
If validation did NOT succeed, do not stamp — leave the tripwire to catch the broken state.
|
||||
|
||||
Proceed to Step 8.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ For each selected skill (process one at a time):
|
||||
After all selected skills are re-applied:
|
||||
- `pnpm run build`
|
||||
- `pnpm test` (do not fail the flow if tests are not configured)
|
||||
- If the re-apply changed any files under `container/` (`git diff --name-only -- container/` is non-empty), rebuild the agent image so new sessions pick up the new code: `./container/build.sh`. Skill code that lives in the container (e.g. a provider's runtime) keeps running the old image until this is done — the rebuild is what makes the fix live, not the file copy. If nothing under `container/` changed (e.g. only a channel adapter was re-applied), skip it.
|
||||
|
||||
Each channel/provider skill copies in its own registration test; those run as part of `pnpm test` and assert the barrel still registers the adapter against the freshly fetched code.
|
||||
|
||||
|
||||
@@ -18,12 +18,20 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Bump patch version
|
||||
run: |
|
||||
# Skip the auto-bump when the pushed commits already changed the
|
||||
# version themselves (e.g. a release PR that set a minor/major).
|
||||
# Otherwise the bot would patch a deliberate 2.1.0 up to 2.1.1.
|
||||
if git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -qx 'package.json'; then
|
||||
echo "package.json already changed in this push; skipping auto-bump."
|
||||
exit 0
|
||||
fi
|
||||
pnpm version patch --no-git-tag-version
|
||||
git add package.json
|
||||
git diff --cached --quiet && exit 0
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write # createLabel — auto-provisions the core-team label
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -30,6 +31,24 @@ jobs:
|
||||
|
||||
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
|
||||
|
||||
// Lowercase GitHub logins; keep in sync with the core team roster.
|
||||
const CORE_TEAM = ['gavrielc', 'koshkoshinsk', 'glifocat', 'gabi-simons', 'omri-maya', 'amit-shafnir', 'moshe-nanoco'];
|
||||
const author = context.payload.pull_request.user.login.toLowerCase();
|
||||
if (CORE_TEAM.includes(author)) {
|
||||
labels.push('core-team');
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'core-team',
|
||||
color: '1D76DB',
|
||||
description: 'PR opened by a core team member',
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 422) throw e; // 422: label already exists
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -39,3 +39,10 @@ groups/*
|
||||
.nanoclaw/
|
||||
|
||||
agents-sdk-docs
|
||||
.agents
|
||||
AGENTS.md
|
||||
|
||||
# Internal working docs, never committed
|
||||
docs/maintainer-guide.md
|
||||
docs/drafts/
|
||||
forks.md
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **New skill: `/add-audit` — opt-in local audit log for the `ncl` surface.** Installs an append-only, SIEM-shaped audit log: every command through the dispatcher (both transports — host socket and container — including scope denials, approval holds, and approved replays) becomes one canonical NDJSON event under `data/audit/<UTC-day>.ndjson`. Gated chains share a `correlation_id` (the approval id: the hold's `pending` event and the replay's terminal event carry the same one); `details` record only the flag names passed plus an allowlist of safe enum values (`role`, `mode`, …), so raw argument values — where secrets hide — are never written. `AUDIT_RETENTION_DAYS` (default 90; `0` = keep forever) hard-deletes day-files past the horizon at boot and once per UTC day. Read back with `ncl audit list [--actor --action --resource --outcome --since --until --correlation --limit] [--format ndjson]` — host + global-scope callers only (the resource stays off the group-scope allowlist, so group-scoped agents fail closed). In-process exporters plug in via `registerAuditHook` — post-write hooks that fire only after the local append succeeds. Off by default: nothing is persisted (and `data/audit/` is never created) until `AUDIT_ENABLED=true`, an enabled box refuses to boot if the directory isn't writable, and a disabled box answers `ncl audit list` with a clear error instead of an empty list. The skill's whole core footprint is the dispatch composition (`export const dispatch = withAudit(dispatchInner)`) plus the resource-barrel import, both guarded by a shipped wiring test. Approval-lifecycle events (request/decision as their own events), OneCLI credential holds, and channel/sender events are later increments on the same schema.
|
||||
- **The guard seam.** Every privileged action crossing the container or channel boundary now passes ONE decision function — `guard()` in the new `src/guard/` leaf — before it executes: `allow`, `hold` (through the flow's existing approval mechanics), or `deny`. The keyed registries (ncl commands, delivery actions) wrap their handlers at registration, so the guarded path is the only path by construction: every ncl command derives its guard from its own definition inside `register()`, and a delivery action registers with a guard spec or an explicit `unguarded(<reason>)` declaration — omission doesn't compile, and `grep "unguarded("` is the complete justified inventory. The broadcast hooks (response handlers, message interceptors) stay plain registrations; the one privileged click path — channel registration — consults the guard inline in its handler, like the a2a route and the unknown-sender gate. Consults carry the branded `GuardedAction` value returned by `defineGuardedAction`, so a typo'd or dropped wiring is a compile error and a forged value is denied at runtime — never a fail-open. The decision is today's checks verbatim (cli_scope enforcement, create_agent's scope branch, a2a destination ACL + `agent_message_policies` hold, self-mod unconditional hold, unknown_sender_policy, channel-registration click auth, host-as-trusted-caller in code). Policy-as-data (tighten-only rule sources composing with the decision) is deliberately deferred — a generalized rules table can arrive later, with its first operator-visible consumer. Approved replays now carry the verified approval row as a **grant** — the `approved: true` boolean is deleted — and the guard re-runs the structural checks on every replay. Two outcome changes are inherent to that replay semantics and ship deliberately: **(a)** approving a held a2a message after its destination was revoked no longer delivers it (the checks re-run live); **(b)** a forged, already-consumed, or wrong-action/wrong-payload grant refuses the replay instead of executing (previously any in-process caller passing `approved: true` executed). Zero rules ship by default — the seeded posture is today's behavior. (The D1/D2/D4 click-auth fixes from the guarded-actions defect inventory are NOT in this change — they belong to the approval-contract PR.)
|
||||
- **Delivery-registry hardening.** Re-registering a guard-wrapped delivery action *without* a guard spec now throws at registration — previously that silently replaced the guarded handler while the catalog still reported the action as guarded. (The cross-registry pairing — every holding action has a registered approve continuation — is enforced by the conformance test; at runtime a missing continuation already resolves loudly, telling the requester no handler is installed.)
|
||||
- [BREAKING] **`whatsapp-formatting` and `slack-formatting` container skills moved from trunk to the `channels` branch.** They now install with their channel — `/add-whatsapp` / `/add-slack` and the setup installers copy them in — so installs without those channels stop carrying channel-specific formatting instructions in every agent's context. **Migration — only if this install has the channel wired** (check `src/channels/whatsapp.ts` / `src/channels/slack.ts`): updating removes both skills from the working tree — WhatsApp agents lose the formatting fragment from their composed CLAUDE.md on next spawn, Slack agents lose the mrkdwn skill from `~/.claude/skills`. Re-run the matching skill, `/add-whatsapp` or `/add-slack` (idempotent), to restore. Installs without the channel need nothing — do NOT run the add-skill just in case; it installs the full channel adapter.
|
||||
- **Pre-task script failures back their series off instead of spinning.** A `--script` that errors lands the occurrence as a failed run (`script-skip:error` ack → `failed` status); recurrence reads the series' trailing failed streak and re-arms at `max(cron next, now + 2·2^(n−1) min, cap 60)`; after 8 consecutive failures the series is auto-paused with a host-written note in its run log (`ncl tasks resume` revives it). A deliberate `wakeAgent:false` gate is a normal run and never backs off. Also fixed: an explicitly-addressed `<message to>` in a task fire's final text now delivers as a deliberate send (previously suppressed as a turn-reply echo → zero delivery when the agent skipped the MCP tool); identical echoes of an MCP send are dropped in the runner, where the duplication originates.
|
||||
- [BREAKING] **Scheduled tasks moved from MCP tools to `ncl tasks`.** The six scheduling MCP tools are no longer exposed to agent containers; agents and operators manage tasks with `ncl tasks list/get/create/update/cancel/pause/resume/delete`. New tasks run from a per-agent-group system session rather than waking the chat session that created them, and task writes are not approval-gated inside the owning group. **Migration:** [docs/ncl-tasks-migration.md](docs/ncl-tasks-migration.md).
|
||||
- **One approval contract across every hold.** All approval holds now share one hold-record shape on `pending_approvals` (approver rule, dedup key — migration 020, with backfills) and ONE click-authorization rule (`mayResolve` in `src/modules/approvals/approver-rule.ts`), replacing three divergent click-auth copies. Unknown-sender admission folds onto the approvals primitive (sessionless hold, action `sender_admit`) — the `pending_sender_approvals` table is dropped by migration, its `UNIQUE(messaging_group_id, sender_identity)` preserved as a partial UNIQUE on the hold's dedup key; an in-flight sender card does not survive the upgrade (a new message from the same sender re-triggers one). Channel registration and OneCLI keep their flows and adopt the shared rule. Observers now see the full hold lifecycle with zero touch points inside the flows: `registerApprovalRequestedHandler` (new, the creation-side sibling of `registerApprovalResolvedHandler`) fires once whenever a hold record comes into existence, whichever stack created it — `requestApproval`, the OneCLI credential bridge, channel registration (as a synthesized hold view) — and every resolution announces through the approval-resolved observer (outcome `approve` | `reject` | `expire` | `sweep`, session nullable). This is a behavior-preserving refactor: unifying the three flows changes no click-authorization decision versus today.
|
||||
- **Optional per-container resource caps.** `CONTAINER_CPU_LIMIT` and `CONTAINER_MEMORY_LIMIT` pass through to `docker run` as `--cpus` / `--memory` (`container-runner.ts`). Both empty by default — no flag added, spawn args byte-identical to today — so existing installs are unaffected. Set them to cap an agent container's CPU/memory so one agent can't monopolize the host (e.g. `CONTAINER_CPU_LIMIT=2`, `CONTAINER_MEMORY_LIMIT=8g`). Swap is intentionally not managed here: `--memory` is a hard cap on a swapless host.
|
||||
- [BREAKING] **Chat SDK pinned to `4.29.0` (was `4.26.0` via `^4.24.0`).** `chat` and the `@chat-adapter/*` channel adapters are version-locked — the adapter's `ChatInstance` must match the bridge's, so a mismatched pair fails to typecheck at `createChatSdkBridge(...)`. `chat` is therefore pinned exactly, and the channel-adapter install pins move with it — the `/add-<channel>` SKILL.md steps and `setup/*.sh` scripts on `main`, plus the adapter code on the `channels` branch. Core installs with no channel (only `cli`) are unaffected. **Migration:** if any channel is installed (Slack, Discord, Telegram, Teams, …), re-run its `/add-<channel>` skill to pull the matching `4.29.0` adapter.
|
||||
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
|
||||
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
|
||||
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
|
||||
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
|
||||
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
|
||||
- **Memory migrates via `/migrate-memory`, never at runtime.** Each provider keeps its own store; fresh groups on a surfaces-owning provider see no stale `CLAUDE.*` files. See [docs/provider-migration.md](docs/provider-migration.md).
|
||||
- **Per-exchange archiving is provider-owned** — the `onExchangeComplete` hook; the markdown writer ships with the codex payload.
|
||||
- **Container boot failures now say why** — the last stderr lines are logged at `warn` on a non-zero exit instead of a silent crash loop.
|
||||
- **Slash commands now interrupt an in-flight turn.** A runner-handled command (`/clear`, `/compact`, `/cost`, …) arriving mid-turn aborts the active stream and runs immediately instead of waiting out the turn.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
- [BREAKING] **Startup now requires an upgrade marker.** The host refuses to boot unless `data/upgrade-state.json` records that this install reached the current version through a sanctioned path (`/setup`, `/update-nanoclaw`, `/migrate-nanoclaw`). After this update completes — and before restarting the service — stamp the marker by running `pnpm exec tsx scripts/upgrade-state.ts set`. If the host has already tripped on restart with "update did not go through the supported path", that same command clears it. See [docs/upgrade-recovery.md](docs/upgrade-recovery.md).
|
||||
|
||||
## [2.0.64] - 2026-05-18
|
||||
|
||||
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
|
||||
|
||||
@@ -15,11 +15,11 @@ If you are a fresh install (you ran `git clone`, not `git pull`) and there are n
|
||||
|
||||
# NanoClaw
|
||||
|
||||
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
Personal AI assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
|
||||
## Quick Context
|
||||
|
||||
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls Claude, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
|
||||
The host is a single Node process that orchestrates per-session agent containers. Platform messages land via channel adapters, route through an entity model (users → messaging groups → agent groups → sessions), get written into the session's inbound DB, and wake a container. The agent-runner inside the container polls the DB, calls the agent, and writes back to the outbound DB. The host polls the outbound DB and delivers through the same adapter.
|
||||
|
||||
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
|
||||
|
||||
@@ -32,8 +32,8 @@ agent_group_members (user_id, agent_group_id) — unprivileged access gate
|
||||
user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
|
||||
|
||||
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
|
||||
messaging_groups (one chat/channel on one platform; unknown_sender_policy)
|
||||
↕ many-to-many via messaging_group_agents (session_mode, engage_mode/engage_pattern, sender_scope, priority)
|
||||
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
|
||||
|
||||
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
|
||||
```
|
||||
@@ -44,8 +44,8 @@ Privilege is user-level (owner/admin), not agent-group-level. See [docs/isolatio
|
||||
|
||||
Each session has **two** SQLite files under `data/v2-sessions/<session_id>/`:
|
||||
|
||||
- `inbound.db` — host writes, container reads. `messages_in`, routing, destinations, pending_questions, processing_ack.
|
||||
- `outbound.db` — container writes, host reads. `messages_out`, session_state.
|
||||
- `inbound.db` — host writes, container reads. `messages_in`, delivered, destinations, session_routing.
|
||||
- `outbound.db` — container writes, host reads. `messages_out`, processing_ack, session_state, container_state.
|
||||
|
||||
Exactly one writer per file — no cross-mount lock contention. Heartbeat is a file touch at `/workspace/.heartbeat`, not a DB update. Host uses even `seq` numbers, container uses odd.
|
||||
|
||||
@@ -62,27 +62,32 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/index.ts` | Entry point: init DB, migrations, channel adapters, delivery polls, sweep, shutdown |
|
||||
| `src/router.ts` | Inbound routing: messaging group → agent group → session → `inbound.db` → wake |
|
||||
| `src/delivery.ts` | Polls `outbound.db`, delivers via adapter, handles system actions (schedule, approvals, etc.) |
|
||||
| `src/delivery-guard.ts` | `DeliveryGuardSpec` + `runGuarded` — the precheck → guard → deny/hold/allow consult path for privileged delivery actions; the registry itself stays in `delivery.ts` |
|
||||
| `src/host-sweep.ts` | 60s sweep: `processing_ack` sync, stale detection, due-message wake, recurrence |
|
||||
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
|
||||
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
|
||||
| `src/container-runtime.ts` | Docker CLI wrapper (runtime binary, host-gateway args, mount args), orphan cleanup |
|
||||
| `src/guard/` | Privileged-action decision seam: `guard(action, input)` → allow \| hold \| deny. Domain-free leaf; domain decisions are `defineGuardedAction` values exported from module-edge `guard.ts` adapters (cli, agent-to-agent, self-mod, permissions) — consults hold the value, so a missing/typo'd wiring is a compile error, never a fail-open. The keyed registries (ncl commands, delivery actions) demand a guard at registration (derived spec or explicit `unguarded(<reason>)`); broadcast hooks stay plain registrations, and the privileged click path — the channel-registration card handler — consults the guard inline, like the a2a route and the unknown-sender gate (the free-text name reply is deliberately not re-authorized; the click is the auth, as on main). Approved replays carry the approval row as a grant and re-run the structural checks. Policy-as-data (a runtime rules table) is deliberately deferred. Conformance test: `src/guard/conformance.test.ts` |
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry, hold-lifecycle observers (`registerApprovalRequestedHandler` / `registerApprovalResolvedHandler` — every stack announces creation + resolution through these) |
|
||||
| `src/modules/approvals/approver-rule.ts` | `mayResolve` — the one click-authorization rule (approver rule `exclusive` \| `admins-of-scope`) for every hold |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/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/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 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 |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/channels/channel-defaults.ts` | Wiring-creation helpers over adapter-declared channel defaults (`resolveWiringDefaults`, `resolveThreadPolicy`, engage validation) |
|
||||
| `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`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` install with their channel) |
|
||||
| `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. |
|
||||
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
@@ -104,6 +109,7 @@ ncl help
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| tasks | list, get, create, update, cancel, pause, resume, delete, run, append-log | Scheduled tasks for an agent group |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
@@ -114,11 +120,13 @@ Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.
|
||||
|
||||
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
|
||||
|
||||
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
|
||||
- **`channels` branch** — Discord, Slack, Telegram, WhatsApp, Teams, Linear, GitHub, iMessage, Webex, Resend, Matrix, Google Chat, WhatsApp Cloud, Signal, WeChat, DeltaChat, Emacs (+ helpers, tests, channel-specific setup steps). Installed via `/add-<channel>` skills.
|
||||
- **`providers` branch** — OpenCode (and any future non-default agent providers). Installed via `/add-opencode`.
|
||||
|
||||
Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy module(s) into the standard paths → append a self-registration import to the relevant barrel → `pnpm install <pkg>@<pinned-version>` → build.
|
||||
|
||||
**Channel defaults.** Each adapter declares its wiring-time defaults (`ChannelDefaults`: per DM/group context — engage mode/pattern, thread policy, unknown-sender policy — plus mention signaling). Exactly two levels: the adapter declaration, and the per-wiring override chosen at creation — no per-instance DB config table. Undeclared (stale) adapters resolve through a behavior-faithful fallback, so a trunk update alone changes nothing. See [docs/api-details.md](docs/api-details.md#channel-defaults) and `src/channels/channel-defaults.ts`.
|
||||
|
||||
## Self-Modification
|
||||
|
||||
One tier of agent self-modification today:
|
||||
@@ -136,7 +144,7 @@ Per-agent-group container runtime config (provider, model, packages, MCP servers
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members`, `tasks` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
@@ -151,7 +159,7 @@ Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer
|
||||
|
||||
## Secrets / Credentials / OneCLI
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/modules/approvals/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
### Secret modes
|
||||
|
||||
@@ -169,7 +177,7 @@ No container restart needed — the gateway looks up secrets per request.
|
||||
|
||||
Approval-gating credentialed actions is a **two-sided** flow:
|
||||
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@2.2.5`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
|
||||
|
||||
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
|
||||
@@ -181,7 +189,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`, `vercel-cli`, `welcome`; channel-specific skills like `slack-formatting` and `whatsapp-formatting` are copied in by their `/add-<channel>` skill).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
@@ -192,6 +200,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
| `/debug` | Container issues, logs, troubleshooting |
|
||||
| `/update-nanoclaw` | Bring upstream updates into a customized install |
|
||||
| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials |
|
||||
| `/migrate-memory` | Carry a group's agent memory across a provider switch (operator-run, both directions) |
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -214,7 +223,7 @@ Run commands directly — don't tell the user to run them.
|
||||
|
||||
```bash
|
||||
# Host (Node + pnpm)
|
||||
pnpm run dev # Host with hot reload
|
||||
pnpm run dev # Host via tsx (no watch)
|
||||
pnpm run build # Compile host TypeScript (src/)
|
||||
./container/build.sh # Rebuild agent container image (nanoclaw-agent:latest)
|
||||
pnpm test # Host tests (vitest)
|
||||
@@ -249,6 +258,13 @@ Check these first when something goes wrong:
|
||||
|
||||
Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect.
|
||||
|
||||
## Timestamps
|
||||
|
||||
Two rules, no exceptions:
|
||||
|
||||
- **Storage**: every timestamp written from JS is `new Date().toISOString()` (ISO-8601 UTC with `Z`). Never `datetime('now')` — its naive `YYYY-MM-DD HH:MM:SS` shape is misparsed as local time by `new Date()` and breaks string comparisons against ISO values. In pure-SQL contexts (skill snippets) use `strftime('%Y-%m-%dT%H:%M:%fZ','now')`. SQL-side *comparisons* wrap both sides in `datetime()`.
|
||||
- **Display**: anything shown to an agent or a user renders in the install timezone — `formatLocalTime` (prose) or `formatLocalStamp` (log lines) from `src/timezone.ts` / `container/agent-runner/src/timezone.ts`. `--json` output, DB values, and operator logs stay ISO.
|
||||
|
||||
## Supply Chain Security (pnpm)
|
||||
|
||||
This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them.
|
||||
@@ -274,6 +290,11 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
|
||||
| [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) | v1→v2 architecture diff — vocabulary for where v1 things moved |
|
||||
| [docs/migration-dev.md](docs/migration-dev.md) | Migration development guide — testing, debugging, dev loop |
|
||||
| [docs/provider-migration.md](docs/provider-migration.md) | Switching a live agent group between providers (e.g. Claude → Codex) — what carries over, rollback |
|
||||
| [docs/customizing.md](docs/customizing.md) | Short intro to customizing via skills |
|
||||
| [docs/skills-model.md](docs/skills-model.md) | The skills model in full: recipes, tests, upgrades, migrations |
|
||||
| [docs/skill-guidelines.md](docs/skill-guidelines.md) | Authoritative checklist for writing a skill |
|
||||
| [docs/templates.md](docs/templates.md) | Agent templates: what they are, stamping via `ncl groups create --template` + the setup wizard, the OneCLI/MCP-credential model, supported providers, and how to contribute one |
|
||||
|
||||
## Container Build Cache
|
||||
|
||||
@@ -290,7 +311,7 @@ The agent container runs on **Bun**; the host runs on **Node** (pnpm). They comm
|
||||
- **Writing a new named-param SQL insert/update in the container** → use `$name` in both SQL and JS keys: `.run({ $id: msg.id })`. `bun:sqlite` does not auto-strip the prefix the way `better-sqlite3` does on the host. Positional `?` params work normally.
|
||||
- **Adding a test in `container/agent-runner/src/`** → import from `bun:test`, not `vitest`. Vitest runs on Node and can't load `bun:sqlite`. `vitest.config.ts` excludes this tree.
|
||||
- **Adding a Node CLI the agent invokes at runtime** (like `agent-browser`, `claude-code`, `vercel`) → put it in the Dockerfile's pnpm global-install block, pinned to an exact version via a new `ARG`. Don't use `bun install -g` — that bypasses the pnpm supply-chain policy.
|
||||
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~301) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
|
||||
- **Changing the Dockerfile entrypoint or the dynamic-spawn command** (`src/container-runner.ts` line ~503) → keep `exec bun ...` so signals forward cleanly. The image has no `/app/dist`; don't reintroduce a tsc build step.
|
||||
- **Changing session-DB pragmas** (`container/agent-runner/src/db/connection.ts`) → `journal_mode=DELETE` is load-bearing for cross-mount visibility. Read the comment block at the top of the file first.
|
||||
|
||||
## CJK font support
|
||||
|
||||
+32
-16
@@ -19,6 +19,13 @@
|
||||
|
||||
**Not accepted:** Features, capabilities, compatibility, enhancements. These should be skills.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Breaking changes are allowed; **silent** ones are not. NanoClaw does not migrate user installs at runtime — the user's coding agent is the migrator, so every breaking change must ship a migration path that agent can execute without a human reverse-engineering the diff:
|
||||
|
||||
1. **Every `[BREAKING]` CHANGELOG entry must reference its migration path** — either a skill to run (`Run /<skill-name> to <action>`) or a `docs/` page covering **detect / why / fix / verify / rollback** (see [docs/onecli-upgrades.md](docs/onecli-upgrades.md) for the shape). `/update-nanoclaw` surfaces these entries after every update and walks the user through them.
|
||||
2. **If the change moves an external component's sanctioned version** (gateway, pinned CLI binary, …), update its pin in [`versions.json`](versions.json). The changelog stays human-narrative; `versions.json` is the machine-checkable signal — `/update-nanoclaw` diffs it across the update and routes the user to the linked doc for any pin that moved.
|
||||
|
||||
## Skills
|
||||
|
||||
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) — markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
|
||||
@@ -29,26 +36,27 @@ Every user should have clean and minimal code that does exactly what they need.
|
||||
|
||||
### Skill types
|
||||
|
||||
#### 1. Feature skills (branch-based)
|
||||
#### 1. Channel and provider skills (registry branches)
|
||||
|
||||
Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setup instructions; the actual code lives on a `skill/*` branch.
|
||||
Add a messaging channel or an agent provider. The SKILL.md contains the install steps; the actual code lives on a long-lived registry branch (`channels` or `providers`) that we keep in sync with `main`.
|
||||
|
||||
**Location:** `.claude/skills/` on `main` (instructions only), code on `skill/*` branch
|
||||
**Location:** `.claude/skills/` on `main` (instructions only), code on the `channels` or `providers` branch
|
||||
|
||||
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`
|
||||
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-opencode`
|
||||
|
||||
**How they work:**
|
||||
1. User runs `/add-telegram`
|
||||
2. Claude follows the SKILL.md: fetches and merges the `skill/telegram` branch
|
||||
3. Claude walks through interactive setup (env vars, bot creation, etc.)
|
||||
2. Claude follows the SKILL.md: `git fetch origin channels`, then copies each file in with `git show origin/channels:<path> > <path>`. Install is an additive fetch, never a `git merge`.
|
||||
3. The adapter's registration test is fetched the same way and run as verification
|
||||
4. Claude walks through interactive setup (tokens, bot creation, etc.)
|
||||
|
||||
**Contributing a feature skill:**
|
||||
**Contributing a channel or provider skill:**
|
||||
1. Fork `nanocoai/nanoclaw` and branch from `main`
|
||||
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
|
||||
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
|
||||
4. Open a PR. We'll create the `skill/<name>` branch from your work
|
||||
2. Build the adapter following [docs/skill-guidelines.md](docs/skill-guidelines.md): a self-registering module, one appended barrel import, and a registration test that imports the real barrel
|
||||
3. Add a SKILL.md in `.claude/skills/<name>/` with the fetch-and-copy steps, and a REMOVE.md that reverses every change
|
||||
4. Open a PR. We'll land the code on the registry branch from your work
|
||||
|
||||
See `/add-telegram` for a good example. See [docs/skills-as-branches.md](docs/skills-as-branches.md) for the full system design.
|
||||
See `/add-slack` for a good example. See [docs/skills-model.md](docs/skills-model.md) for why install is a fetch, never a merge.
|
||||
|
||||
#### 2. Utility skills (with code files)
|
||||
|
||||
@@ -58,7 +66,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
|
||||
|
||||
**Examples:** a self-contained CLI or helper shipped in a `scripts/` subfolder of the skill.
|
||||
|
||||
**Key difference from feature skills:** No branch merge needed. The code is self-contained in the skill directory and gets copied into place during installation.
|
||||
**Key difference from channel/provider skills:** the code is self-contained in the skill directory and gets copied into place during installation; nothing is fetched from a registry branch.
|
||||
|
||||
**Guidelines:**
|
||||
- Put code in separate files, not inline in the SKILL.md
|
||||
@@ -67,7 +75,7 @@ Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells
|
||||
|
||||
#### 3. Operational skills (instruction-only)
|
||||
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
|
||||
Workflows and guides with no code changes. The SKILL.md is the entire skill — the coding agent follows the instructions to perform a task.
|
||||
|
||||
**Location:** `.claude/skills/` on `main`
|
||||
|
||||
@@ -80,19 +88,23 @@ Workflows and guides with no code changes. The SKILL.md is the entire skill —
|
||||
|
||||
#### 4. Container skills (agent runtime)
|
||||
|
||||
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
Skills that run inside the agent container, not on the host. These teach the NanoClaw agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
|
||||
|
||||
**Location:** `container/skills/<name>/`
|
||||
|
||||
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
|
||||
**Examples:** `agent-browser` (web browsing), `frontend-engineer`, `onecli-gateway` (OneCLI proxy usage), `self-customize`, `vercel-cli`, `welcome`; channel-specific: `slack-formatting` (Slack mrkdwn syntax) and `whatsapp-formatting` (channels branch; installed by `/add-slack` / `/add-whatsapp`)
|
||||
|
||||
**Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
|
||||
**Key difference:** You never invoke these from a coding-agent session on the host, the way you run `/setup` or `/update-nanoclaw` in Claude Code/Codex/OpenCode. They're mounted into the sandbox and loaded by the NanoClaw agent itself, shaping how it behaves when you chat with it.
|
||||
|
||||
**Guidelines:**
|
||||
- Follow the same SKILL.md + frontmatter format
|
||||
- Use `allowed-tools` frontmatter to scope tool permissions
|
||||
- Keep them focused — the agent's context window is shared across all container skills
|
||||
|
||||
### Writing a good skill
|
||||
|
||||
The authoring bar is [docs/skill-guidelines.md](docs/skill-guidelines.md): mostly adds, minimal reach-ins into existing code, a test for every functional integration point, and a REMOVE.md whenever apply leaves anything behind. [docs/skills-model.md](docs/skills-model.md) explains the model behind it.
|
||||
|
||||
### SKILL.md format
|
||||
|
||||
All skills use the [Claude Code skills standard](https://code.claude.com/docs/en/skills):
|
||||
@@ -113,6 +125,10 @@ Instructions here...
|
||||
- Put code in separate files, not inline in the markdown
|
||||
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
|
||||
|
||||
## Templates
|
||||
|
||||
Agent templates (reusable bundles of instructions + MCP servers + skills) ship in the separate [`nanocoai/nanoclaw-templates`](https://github.com/nanocoai/nanoclaw-templates) repo, not this one. Contribute them there via PR (its README has the anatomy and checklist). For how templates load and the OneCLI credential model, see [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Testing
|
||||
|
||||
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">docs</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
@@ -21,7 +22,7 @@
|
||||
|
||||
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
|
||||
|
||||
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
|
||||
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Agents run in their own Linux containers with filesystem isolation, not merely behind permission checks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -48,7 +49,7 @@ bash migrate-v2.sh
|
||||
|
||||
Run the script directly, not from inside a Claude session — the deterministic side needs interactive prompts and real shell I/O for Node/pnpm bootstrap, Docker, OneCLI, and the container build.
|
||||
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including Baileys keystore + LID mappings for WhatsApp), builds the agent container.
|
||||
**What it does:** merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders + session data + scheduled tasks, installs the channel adapters you select, copies channel auth state (including the Baileys keystore for WhatsApp — LID mapping is now resolved per-message by the Baileys v7 adapter, not migrated), builds the agent container.
|
||||
|
||||
**What it doesn't:** flip the system service. Pick *"switch to v2"* at the prompt, or do it manually after testing — your v1 install is left untouched.
|
||||
|
||||
@@ -77,10 +78,11 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
||||
- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
|
||||
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
|
||||
- **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
|
||||
- **Scheduled tasks** — recurring jobs executed by the agent, which can message you the results
|
||||
- **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 containers (macOS/Linux/WSL2)
|
||||
- **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 `ncl groups create --template <ref>`. Templates load from the local `templates/` folder; populate it by hand or by copying from the [public library](https://github.com/nanocoai/nanoclaw-templates). See [docs/templates.md](docs/templates.md).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -122,10 +124,7 @@ This keeps trunk as pure registry and infra, and every fork stays lean — users
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
Skills we'd like to see:
|
||||
|
||||
**Communication Channels**
|
||||
- `/add-signal` — Add Signal as a channel
|
||||
No channel or provider skills are currently requested — propose one via an issue.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -140,7 +139,7 @@ Skills we'd like to see:
|
||||
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
|
||||
```
|
||||
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs the agent, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
|
||||
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
|
||||
|
||||
@@ -163,7 +162,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.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
@@ -196,11 +195,19 @@ Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?"
|
||||
|
||||
If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.
|
||||
|
||||
**How do I uninstall NanoClaw?**
|
||||
|
||||
```bash
|
||||
bash nanoclaw.sh --uninstall
|
||||
```
|
||||
|
||||
Every install is tagged with a per-checkout id, so the uninstaller removes only what belongs to that copy: the background service, containers and image, app data and logs, your agents' files, and this copy's OneCLI vault agents. Shared things — the OneCLI app and your credentials, other NanoClaw copies on the machine — are left alone. It shows exactly what it found and asks for confirmation per group; nothing is deleted until you say yes. Use `--dry-run` to preview without changing anything, or `--yes` to skip the prompts. Your `.env` is backed up before removal. To finish, delete the checkout folder itself.
|
||||
|
||||
**What changes will be accepted into the codebase?**
|
||||
|
||||
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.
|
||||
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch.
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills: channel and provider code on the `channels`/`providers` registry branches, everything else as a self-contained skill. See [docs/customizing.md](docs/customizing.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="https://docs.nanoclaw.dev">ドキュメント</a> •
|
||||
<a href="README.md">English</a> •
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ko.md">한국어</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user