Compare commits

...

299 Commits

Author SHA1 Message Date
Moshe Krupper 26c17c0bba feat: /add-audit skill — opt-in local audit log as an install skill
The audit feature as a utility skill instead of trunk code, in the B-prime
shape: src/audit/ is a domain-free leaf (schema, emit seam, NDJSON day-file
store, reader, post-write hooks, shared vocabulary); how each domain
describes itself lives in domain-owned *.audit.ts adapters shipped next to
the code they observe (dispatch middleware, requestApproval decorator,
approval-resolved observer, OneCLI wrappers, permissions card decisions,
ungated create-agent). Apply composes each adapter at its module edge in
one line, on the seam contracts the previous commit landed.

Payload: 26 files under add/ mirroring target paths — the leaf + adapters,
their unit tests, the composed-system integration tests (dispatch,
requestApproval, response-handler chains), an AST wiring test for the two
boot-path edits (add-dashboard pattern), and the ncl audit resource.
Zero new dependencies. REMOVE.md reverses every change.

Verified: unapplied tree builds + passes 595 tests; a worktree with
SKILL.md's steps applied mechanically builds + passes 664, and
grep emitAuditEvent outside src/audit/ + *.audit.ts is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:08:05 +03:00
Moshe Krupper d413b9a839 refactor: richer results + context threading at the dispatch/approval seams
Standalone seam contracts, no consumer shipped in trunk:
- CommandDef.action: canonical dotted action name, stamped by
  registerResource (verb-boundary-aware), defaulted from name otherwise
- dispatch: DispatchTrace out-param (resolved cmd + effective args) and
  approvalId on DispatchOptions for replay correlation
- requestApproval returns ApprovalHold (minted id + picked approver);
  ApprovalHandlerContext carries approvalId
- resolveOneCLIApproval takes the resolving admin's userId (logged)
- permissions response handlers return SenderApprovalResult /
  ChannelApprovalResult decision descriptors; registry coercions keep the
  claimed-boolean contract
- performCreateAgent returns CreateAgentResult

These are the integration points /add-audit composes on (next commit) —
middleware/observers wrap here without reaching into function bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:57:02 +03:00
glifocat b6cb53e21c Merge pull request #2953 from nanocoai/docs-staleness-fixes
docs: correct stale mount topology row + removed env var
2026-07-04 23:06:27 +02:00
gavrielc 5a7e75f854 Apply suggestion from @gavrielc 2026-07-04 23:36:05 +03:00
glifocat d770e56596 docs: correct stale mount topology + removed env var vs code 2026-07-04 19:39:13 +00:00
github-actions[bot] 08a1ac9753 chore: bump version to 2.1.38 2026-07-04 16:58:45 +00:00
gavrielc 273489badf Merge pull request #2931 from nanocoai/cleanup/async-image-build
Build agent images asynchronously instead of blocking the host
2026-07-04 19:58:34 +03:00
gavrielc 504651633f Merge branch 'main' into cleanup/async-image-build 2026-07-04 19:58:25 +03:00
github-actions[bot] 694ab74aa1 chore: bump version to 2.1.37 2026-07-04 16:55:14 +00:00
gavrielc aae81321e9 Merge pull request #2948 from nanocoai/cleanup/arch-scheduling-provider-docs
Fix stale architecture, scheduling, provider-config, and overlay docs
2026-07-04 19:55:02 +03:00
gavrielc 6c46b1e43d Merge branch 'main' into cleanup/arch-scheduling-provider-docs 2026-07-04 19:54:49 +03:00
gavrielc 71453707ba Apply suggestion from @gavrielc 2026-07-04 19:54:15 +03:00
gavrielc 0aa9e668f6 Apply suggestion from @gavrielc 2026-07-04 19:53:24 +03:00
gavrielc 023128def5 Merge pull request #2946 from nanocoai/cleanup/remove-env-secrets-mirror
Remove the dead data/env/env secrets mirror
2026-07-04 19:52:00 +03:00
gavrielc c4a1679666 Merge branch 'main' into cleanup/remove-env-secrets-mirror 2026-07-04 19:51:30 +03:00
gavrielc 2e40e17155 Merge pull request #2945 from nanocoai/cleanup/security-docs-v2
Rewrite the security docs to match the v2 perimeter
2026-07-04 19:51:10 +03:00
gavrielc f896caefa0 Merge branch 'main' into cleanup/security-docs-v2 2026-07-04 19:50:59 +03:00
gavrielc 55c003c5f4 Apply suggestion from @gavrielc 2026-07-04 19:50:43 +03:00
gavrielc 20f2bae5cd Apply suggestion from @gavrielc 2026-07-04 19:50:10 +03:00
gavrielc d6b82e6473 Apply suggestion from @gavrielc 2026-07-04 19:47:01 +03:00
gavrielc fea5ac5f2c Apply suggestion from @gavrielc 2026-07-04 19:45:37 +03:00
gavrielc 2b86bbef19 Apply suggestion from @gavrielc 2026-07-04 19:45:04 +03:00
gavrielc 9dc0a7e62f Apply suggestion from @gavrielc 2026-07-04 19:44:33 +03:00
gavrielc 2035033397 Delete docs/docker-sandboxes.md 2026-07-04 19:44:00 +03:00
gavrielc 1c294ff9a5 Delete docs/APPLE-CONTAINER-NETWORKING.md 2026-07-04 19:43:27 +03:00
github-actions[bot] 43198310e1 chore: bump version to 2.1.36 2026-07-04 16:41:48 +00:00
gavrielc b7d6eebf4d Merge pull request #2943 from nanocoai/cleanup/mount-allowlist-readonly-and-cache
Mount allowlist: honor the readOnly key and stop caching parse errors
2026-07-04 19:41:35 +03:00
gavrielc 803f3413ec Merge branch 'main' into cleanup/mount-allowlist-readonly-and-cache 2026-07-04 19:41:26 +03:00
github-actions[bot] a8b7da7bcf docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:40:39 +00:00
github-actions[bot] 0b6ad5550d chore: bump version to 2.1.35 2026-07-04 16:40:37 +00:00
gavrielc c1965cfcaf Merge pull request #2942 from nanocoai/cleanup/a2a-batch-stamp-crossprocess
Fix the agent-to-agent in_reply_to stamp (cross-process no-op)
2026-07-04 19:40:23 +03:00
gavrielc 3cefbfccf4 Merge branch 'main' into cleanup/a2a-batch-stamp-crossprocess 2026-07-04 19:38:09 +03:00
github-actions[bot] 6f22c73aac docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:38:00 +00:00
gavrielc 31dd37b3a8 Merge pull request #2940 from nanocoai/cleanup/remove-onedb-shims
Delete one-DB-era @deprecated shims and dead exports
2026-07-04 19:37:47 +03:00
gavrielc 0dfde3aa5b Merge branch 'main' into cleanup/remove-onedb-shims 2026-07-04 19:37:36 +03:00
github-actions[bot] 33d2366252 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:35:58 +00:00
github-actions[bot] 9bf1514f26 chore: bump version to 2.1.34 2026-07-04 16:35:56 +00:00
gavrielc be3502c23b Merge pull request #2937 from nanocoai/cleanup/resolve-session-reprovision
Re-provision a missing session folder so the documented reset works
2026-07-04 19:35:44 +03:00
gavrielc e3f38dbed9 Merge branch 'main' into cleanup/resolve-session-reprovision 2026-07-04 19:35:26 +03:00
github-actions[bot] d8a6b99f0e chore: bump version to 2.1.33 2026-07-04 16:35:06 +00:00
gavrielc 8ca7564fbc Merge pull request #2936 from nanocoai/cleanup/cli-protocol-vocab
Clean up dead ncl CLI protocol vocabulary
2026-07-04 19:34:51 +03:00
gavrielc afa07f0566 Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:42 +03:00
github-actions[bot] 8059ee4eec docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:34:08 +00:00
gavrielc 41c486cacc Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:02 +03:00
gavrielc 190a7d4f43 Merge pull request #2935 from nanocoai/cleanup/remove-dead-v1-config
Delete dead v1 config knobs and the broken pnpm auth script
2026-07-04 19:33:53 +03:00
gavrielc e3d2d43b0e Merge branch 'main' into cleanup/remove-dead-v1-config 2026-07-04 19:33:11 +03:00
github-actions[bot] afde23bbe6 chore: bump version to 2.1.32 2026-07-04 16:32:51 +00:00
gavrielc 776bc14ca0 Merge pull request #2934 from nanocoai/cleanup/reachable-perimeter-env
Make the security-perimeter env vars reachable under the shipped service
2026-07-04 19:32:37 +03:00
gavrielc a9461afef1 Merge branch 'main' into cleanup/reachable-perimeter-env 2026-07-04 19:32:28 +03:00
github-actions[bot] ca81558ec8 chore: bump version to 2.1.31 2026-07-04 16:30:56 +00:00
github-actions[bot] 2f88e4fa15 docs: update token count to 208k tokens · 104% of context window 2026-07-04 16:30:54 +00:00
gavrielc 9b0d1dd044 Merge pull request #2933 from nanocoai/feat/approval-button-styles
feat(approvals): colored buttons on approval cards (Slack primary/danger)
2026-07-04 19:30:42 +03:00
gavrielc 79e490dfcf Merge branch 'main' into feat/approval-button-styles 2026-07-04 19:29:58 +03:00
gavrielc 01b07d6652 Merge pull request #2795 from leetwito/feat/add-clidash-skill
feat: add /add-clidash — read-only CLI-derived dashboard skill
2026-07-04 16:53:44 +03:00
gavrielc be20e9f723 Merge branch 'main' into feat/add-clidash-skill 2026-07-04 16:53:25 +03:00
gavrielc e3d156f800 test(channels): cover ask_question option style normalization
Reviewers on #2933 asked for tests on the button-style plumbing. Two layers:

- src/channels/ask-question.test.ts (new): normalizeOption/normalizeOptions
  style whitelist — primary/danger/default pass through; unknown strings,
  case variants, and non-string values drop to undefined; string-shorthand
  options carry no style; style coexists with the label/selectedLabel/value
  defaulting. This is the load-bearing gate: an invalid style reaching
  Slack Block Kit fails the whole card with invalid_blocks, which in the
  approval flow is an effective auto-deny.

- src/channels/chat-sdk-bridge.test.ts: ask_question delivery passes each
  normalized option style into Button() and omits it when unset; an invalid
  style in the raw payload is stripped before the card is built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:04 +03:00
github-actions[bot] b4da018d8c chore: bump version to 2.1.30 2026-07-04 13:33:48 +00:00
gavrielc 4b11079007 Merge pull request #2932 from nanocoai/cleanup/dispatch-longest-prefix
Fix ncl positional IDs for generated (dashed) identifiers
2026-07-04 16:33:36 +03:00
gavrielc 31cc35ea32 Merge branch 'main' into cleanup/dispatch-longest-prefix 2026-07-04 16:33:27 +03:00
github-actions[bot] bf0a3d2612 chore: bump version to 2.1.29 2026-07-04 13:31:46 +00:00
gavrielc 6dc25a9b8a Merge pull request #2930 from nanocoai/cleanup/command-gate-start-and-failopen
command-gate: restore the /start filter and remove the fail-open admin check
2026-07-04 16:31:32 +03:00
gavrielc cafba7fb18 Merge branch 'main' into cleanup/command-gate-start-and-failopen 2026-07-04 16:31:05 +03:00
github-actions[bot] 10d400ec64 chore: bump version to 2.1.28 2026-07-04 13:29:49 +00:00
gavrielc 1a9643cfa3 Merge pull request #2929 from nanocoai/feat/onecli-approval-card-summary
feat(approvals): render OneCLI approval requests from the gateway's structured summary
2026-07-04 16:29:38 +03:00
gavrielc 2ba2555032 Merge branch 'main' into feat/onecli-approval-card-summary 2026-07-04 16:28:01 +03:00
github-actions[bot] 687d7d13ac chore: bump version to 2.1.27 2026-07-04 13:26:56 +00:00
gavrielc 2938bbf94a Merge pull request #2928 from nanocoai/cleanup/remove-dead-global-mount
Remove the dead /workspace/global mount and untrack v1 group seed files
2026-07-04 16:26:42 +03:00
gavrielc d1a2b04d32 Merge branch 'main' into cleanup/remove-dead-global-mount 2026-07-04 16:26:10 +03:00
github-actions[bot] 455014e7b9 chore: bump version to 2.1.26 2026-07-04 13:25:56 +00:00
gavrielc 5032c431ae Merge pull request #2927 from nanocoai/cleanup/unregister-mock-provider
Unregister the mock provider from the production container barrel
2026-07-04 16:25:40 +03:00
gavrielc ac8273c698 Merge branch 'main' into cleanup/unregister-mock-provider 2026-07-04 16:24:37 +03:00
gavrielc 0835089a51 Fix stale architecture, scheduling, provider-config, and overlay docs
Correct docs (and one code comment) that describe systems that no longer
exist in v2: mark docs/SPEC.md as the historical v1 spec; replace the
impossible "write messages_in (to self)" / stale list_tasks scheduling
model with the real messages_out system-action path in
agent-runner-details.md and architecture.md; drop the false
MAX_CONCURRENT_CONTAINERS cap claim; remove the per-group agent-runner-src
overlay from the live DB map (source is a shared read-only mount) and fix
the insertTask attribution to src/modules/scheduling/db.ts; correct the
container-skills count to 8; repoint the deleted-doc comment in
src/claude-md-compose.ts; and replace the AGENT_PROVIDER / hand-edit
container.json provider-config instructions in add-opencode and add-mnemon
with the real `ncl groups config update --provider` flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:18:08 +03:00
gavrielc c82f062d57 Remove the dead data/env/env secrets mirror from setup
Nothing has read data/env/env since commit 1a07869 removed the container
mount, yet setup still wrote the full .env (live tokens included) there.
Drop every writer and correct the stale comments/docs that described it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:11:09 +03:00
gavrielc 3906104960 docs: rewrite security docs to match the v2 perimeter
Rewrite docs/SECURITY.md against the real v2 codepaths: the actual
buildMounts mount table, the allowReadWrite allowlist schema the mount
validator enforces, and the true defaults (egress open, no CPU/mem
limits, additional mounts blocked until an allowlist exists). Replace the
deleted v1 perimeter (main/non-main groups, ephemeral containers, IPC
authorization, /dev/null .env shadow, data/sessions path) with a v2 Trust
Model built on user_roles and long-lived per-session containers. Drop the
dangling /add-golden-registry reference. Mark docs/docker-sandboxes.md and
docs/APPLE-CONTAINER-NETWORKING.md as v1-historical and update the
docs/README.md portal rows. Remove the Apple Container native-runtime
claims from README.md (no runtime seam exists; container-runtime.ts
hardcodes docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:10:46 +03:00
gavrielc ed9a3e330d Mount allowlist: honor the readOnly key and stop caching parse errors
Translate the per-root `readOnly` key (and tolerate the top-level
`nonMainReadOnly` key) that /manage-mounts and setup actually write, so
read-write grants are no longer silently forced read-only. Read+validate
the allowlist per call (mtime-keyed cache) instead of caching it — and its
parse errors — for the whole process lifetime. Add tests and fix the
skill docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:08:46 +03:00
gavrielc 102ce80fda Fix a2a in_reply_to stamp: publish through outbound.db, not module state
The active batch's inReplyTo lived in module-level state in current-batch.ts,
but the nanoclaw MCP server runs as a separate stdio subprocess from the poll
loop, so getCurrentInReplyTo() always read null there. The a2a reply stamp was
therefore dead — only the host peer-affinity fallback kept replies routing.

Publish the stamp through session_state in outbound.db (both processes already
open it): the poll loop writes it at batch start and clears it in the existing
finally; the MCP tools read it with an updated_at staleness guard so a stamp
left behind by a killed container isn't reused. Delete current-batch.ts and
reseed core.test.ts via the DB so it reflects the real process boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:49 +03:00
gavrielc 0626dcec92 Delete one-DB-era @deprecated shims and dead exports
These are leftovers from the April-2026 inbound/outbound session-DB
split, marked @deprecated "kept temporarily for test compatibility":
sessionDbPath, openSessionDb, writeSystemResponse (session-manager),
getStuckProcessingIds (session-db, superseded by getProcessingClaims),
getSessionDb (container connection + barrel re-export), and the dead
registerResponseHandler/onShutdown re-exports from index.ts. The only
real callers are two dev scripts, repointed to inboundDbPath /
getInboundDb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:07:02 +03:00
gavrielc b42329551d Re-provision a missing session folder so the documented reset works
The /debug skill tells operators to rm -rf a session folder to reset a
stuck session, but the sessions row survives. The next message then takes
the existing-session path and opens inbound.db in a directory that no
longer exists — better-sqlite3 throws and the message is dropped forever.

writeSessionMessage now calls the idempotent initSessionFolder when the
inbound.db path is missing, so the documented reset actually re-provisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:55 +03:00
gavrielc 855f204d8a Clean up dead ncl CLI protocol vocabulary
Remove scaffold-era vocabulary in the ncl protocol that no code produces:
the duplicate `Access` union in crud.ts (now imported from registry.ts),
the unused `'hidden'` access level, and the never-emitted `'permission-denied'`
and `'not-found'` error codes. Also refresh three stale docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:43 +03:00
gavrielc d4ca8a4ea6 chore(config): remove dead v1 config knobs and broken pnpm auth script
These config exports in src/config.ts have zero readers and advertise
controls that v2 removed:

- CONTAINER_TIMEOUT / CONTAINER_MAX_OUTPUT_SIZE / IDLE_TIMEOUT /
  MAX_CONCURRENT_CONTAINERS / MAX_MESSAGES_PER_PROMPT
- the trigger block (escapeRegex, buildTriggerPattern, DEFAULT_TRIGGER,
  getTriggerPattern, TRIGGER_PATTERN)

Also remove the `auth` script from package.json — it pointed at the
deleted src/whatsapp-auth.ts, so `pnpm run auth` only errored — and fix
the stale MAX_MESSAGES_PER_PROMPT reference in the container's
messages-in.ts comment (the value comes from container.json's
maxMessagesPerPrompt).

Grep confirms no importers in src/, container/, scripts/, setup/, or
tests. Precedent: 0283391.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:34 +03:00
gavrielc 7615d7d846 Make security-perimeter env vars reachable under the shipped service
Egress-lockdown and CPU/memory limits were read from process.env only, but
the shipped launchd/systemd service sets just PATH+HOME and the host never
loads .env into process.env — so these knobs could not be turned on the
supported way. Route them through the same readEnvFile path as the other
config keys, keeping process.env precedence so dev-mode/nohup installs are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:27 +03:00
gavrielc f094f56bf6 Fix ncl positional IDs for generated (dashed) identifiers
The dispatcher trimmed exactly one trailing dash-segment to recover the
target id, so any generated id containing dashes (UUIDs, sess-*, appr-*)
never matched a command and failed unknown-command; only --id worked.
Resolve by longest registered prefix instead: split the dash-joined
command, take the longest prefix that lookup() resolves as the command,
and treat the re-joined remainder as args.id. Server-side only, no wire
or client change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:02 +03:00
gavrielc fcee39ea14 command-gate: restore the /start filter and remove the fail-open admin check
Add '/start' back to the host FILTERED set (a Telegram fix from 866b791
was silently undone when host gating was added) so it is dropped instead
of reaching the agent as a normal message. Replace the inline isAdmin
query and its hasTable('user_roles') fail-open guard with a call to
hasAdminPrivilege; user_roles always exists (core migration 001-initial),
so the guard only ever masked a missing check. Add a focused command-gate
test covering both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:58 +03:00
gavrielc e3b2ffce36 Build agent images asynchronously instead of blocking the host
Replace the execSync docker build in buildAgentGroupImage with an awaited
promisified exec so the single-threaded host stays responsive during the
image build (up to 15 minutes) that a package-install approval or
`ncl groups restart --rebuild` triggers. Timeout, buffered stdio, and
non-zero-exit error propagation are preserved; both callers already await.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:56 +03:00
gavrielc 2bce84781b feat(approvals): colored buttons on approval cards (Slack primary/danger)
Approval cards render every button with the same neutral style, so Approve
and Reject read identically at a glance. The chat SDK's Button already
accepts style ('primary' | 'danger' | 'default') and @chat-adapter/slack
maps primary→green and danger→red Block Kit styles (Telegram ignores it),
but the ask_question pipeline dropped the field.

- ask-question.ts: add OptionStyle and an optional style field to
  OptionInput/NormalizedOption; normalizeOption whitelists the value.
  Bare-string options stay unstyled.
- chat-sdk-bridge.ts: forward opt.style into Button() on ask_question
  cards. Persisted options_json tolerates the extra key
  (getAskQuestionRender only reads label/selectedLabel/value).
- Producers: module approvals (Approve/Reject), sender approvals
  (Allow/Deny), channel approvals (Connect/Reject), and OneCLI credential
  cards (Approve/Reject) annotate primary/danger. Multi-choice picker
  options stay unstyled — a list of equals.

Purely additive: options without style render exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:24 +03:00
gavrielc f4be00e6ed Remove dead /workspace/global mount and untrack v1 group seed files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:14 +03:00
gavrielc c9aa69dea9 Unregister the mock provider from the production container barrel
The container self-registration barrel imported mock.js, so every container
registered a 'mock' provider returning canned text. A typo'd --provider mock
would silently 'work' instead of failing loudly. Drop the import (mock.ts stays
for direct test use) and tidy the now-stale host comments and docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:04:09 +03:00
gavrielc 3731e26769 feat(approvals): render OneCLI approval requests from the gateway's structured summary
The hosted OneCLI gateway (api.onecli.sh) sends a structured summary field
on ApprovalRequest — { action, details: [{label, value}] } — that the SDK's
TypeScript type doesn't declare yet. When present, render it as the approval
card body (*Action:* plus labeled fields, fencing multi-line values) instead
of the raw METHOD host/path + bodyPreview, so approvers see what the agent
is doing (e.g. To / Subject / Body of an email send) rather than an HTTP
trace.

Rendering is defensive: non-string values are coerced via JSON.stringify (a
render throw would turn into a deny via handleRequest's catch), each value is
capped at 900 chars, and a 2600-char running budget keeps the card under
Slack's 3000-char section block limit — overflow is reported with an
explicit "…N field(s) omitted for length" line instead of a failed
delivery. Without a summary, the old bodyPreview fallback remains, now
capped and with the method/host/path as a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:08 +03:00
leetwito d472d9d32b Merge remote-tracking branch 'upstream/main' into feat/add-clidash-skill 2026-07-04 15:38:41 +03:00
leetwito 6c2d26836f fix(add-clidash): mkdir -p tools in install step; require Node >=22.5
Address review feedback on PR #2795:
- SKILL.md Step 1 now creates tools/ before cp -R (cp does not create
  intermediate parents; tools/ is not a standard NanoClaw dir, so the copy
  failed on a fresh checkout).
- package.json engines bumped to >=22.5 — server.js statically imports
  activity.js -> node:sqlite (DatabaseSync), which lands in Node 22.5, so
  the server crashed at module load on Node 20/21 LTS.
- Doc references (SKILL.md, README.md) updated to Node >=22.5 for consistency.

Verified 87/87 tests pass on Node 22.14.
2026-07-04 15:38:38 +03:00
github-actions[bot] b28c917997 docs: update token count to 207k tokens · 104% of context window 2026-07-04 08:08:53 +00:00
github-actions[bot] a00a5610bd chore: bump version to 2.1.25 2026-07-04 08:08:50 +00:00
gavrielc 05dc1b0a3c Merge pull request #2611 from Hinotoi-agent/fix/approval-cli-caller-context
[security] fix(cli): preserve caller context after approval
2026-07-04 11:08:39 +03:00
glifocat a7b34bc872 Merge branch 'main' into fix/approval-cli-caller-context 2026-07-04 09:11:27 +02:00
github-actions[bot] aecad864e6 chore: bump version to 2.1.24 2026-07-02 11:57:00 +00:00
gavrielc c87f2e55dc Merge pull request #2890 from amit-shafnir/worktree-nanoclaw-templates
feat(templates): local template loader, ncl --template, and docs
2026-07-02 14:56:45 +03:00
Amit Shafnir 411f5e71df feat(templates): local template loader, ncl --template, provider-agnostic persona and skills seams
Agent templates: folder-only templates under templates/ (context/instructions.md +
optional context extras, .mcp.json, skills/). Stamping via ncl groups create
--template writes the provider-neutral instructions.prepend.md (inlined at the top
of CLAUDE.md/AGENTS.md every spawn), copies context extras preserving their
template-relative layout, writes MCP servers to container config, and installs the
per-group skills overlay. Includes docs (docs/templates.md, templates/README.md).

Setup-wizard wiring ships separately on top of this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:36:33 +03:00
gavrielc cb6e3d117c Merge pull request #2885 from PartridgeNet/fix/slack-setup-socket-mode
fix(setup): offer Slack Socket Mode in the guided setup flow
2026-06-30 23:05:45 +03:00
gavrielc ed7e3f70da Merge branch 'main' into fix/slack-setup-socket-mode 2026-06-30 23:04:38 +03:00
github-actions[bot] 557e073c2f chore: bump version to 2.1.23 2026-06-30 19:28:09 +00:00
gavrielc 91ebc9def2 chore(container): bump claude-code, agent SDK to latest
- @anthropic-ai/claude-code (cli-tools.json): 2.1.170 → 2.1.197
- @anthropic-ai/claude-agent-sdk: ^0.3.170 → ^0.3.197
- @anthropic-ai/sdk: ^0.100.0 → ^0.108.0

Typecheck and all 112 agent-runner tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:27:46 +03:00
github-actions[bot] 14c89e9716 docs: update token count to 204k tokens · 102% of context window 2026-06-30 15:49:15 +00:00
github-actions[bot] 549c424a38 chore: bump version to 2.1.22 2026-06-30 15:49:12 +00:00
gavrielc 186b9befcd Merge pull request #2880 from johnmathews/fix/2828-inbox-symlink-containment-upstream
fix(security): contain inbox symlink escapes in attachment writes (#2828)
2026-06-30 18:48:59 +03:00
John Mathews 863d413d32 Merge branch 'main' into fix/2828-inbox-symlink-containment-upstream 2026-06-29 18:27:07 +02:00
Rob Stevenson cf8478ffbb fix(setup): offer Slack Socket Mode in the guided setup flow
PR #2837 added Slack Socket Mode end-to-end ("adapter + guided setup") but
was merged into the `channels` branch, not `main`. As a result the
`setup:auto` Slack flow on main is webhook-only: it always collects a
signing secret and pushes the user toward a public Request URL, with no
Socket Mode option — even though setup/verify.ts already recognizes
SLACK_APP_TOKEN and the channels-branch adapter supports it.

Forward-port the setup-side of #2837 onto current main, re-authored on top
of main's current flow (back-nav, inline agent wiring, operator-role prompt,
welcome DM all preserved):

- setup/channels/slack.ts: add a mode picker (askSlackMode), mode-specific
  app-creation steps, collectAppToken() for the xapp- app-level token,
  conditional credential collection + env, and a mode-aware post-install
  checklist (Socket Mode skips the public-URL guidance).
- setup/add-slack.sh: require/persist either SLACK_APP_TOKEN (Socket Mode)
  or SLACK_SIGNING_SECRET (webhook) instead of mandating the signing secret.

Scope is setup-side only: the adapter's socket support already lives on
`channels` (#2837/#2839) and reaches users via /add-slack; the add-slack
SKILL.md doc is handled by #2700.
2026-06-29 14:45:31 +01:00
github-actions[bot] 8be5be93ba docs: update token count to 203k tokens · 101% of context window 2026-06-29 05:58:05 +00:00
omri-maya add3fc8f70 Merge pull request #2882 from nanocoai/fix/ncl-messaging-group-instance
fix(ncl): default messaging-groups create instance to channel_type
2026-06-29 08:57:53 +03:00
Omri Maya 0d841bcd05 fix(ncl): default messaging-groups create instance to channel_type
`ncl messaging-groups create` failed with a NOT NULL violation on the
`instance` column (migration 016). The generic CRUD insert builds its
column list from the resource definition, and `instance` wasn't declared
there — so the INSERT omitted the column entirely. The router path never
hit this because it goes through `createMessagingGroup`, which has its own
`instance ?? channel_type` fallback.

There is no operator-facing reason to require `--instance`: the default
instance IS the channel type (migration 016, `createMessagingGroup`, and
the default-instance resolver all encode this). So rather than force a
flag, default it.

- crud: add `defaultFrom` to ColumnDef — default a column to another
  already-resolved column's value on create. Generic, reusable.
- messaging-groups: declare `instance` with `defaultFrom: 'channel_type'`
  (placed after channel_type so it resolves first), still overridable via
  `--instance` for multi-instance setups.
- test: drive the real dispatch('messaging-groups-create') path; asserts
  omitted -> channel_type and explicit --instance preserved. Goes red if
  the column/defaultFrom wiring is deleted (insert fails NOT NULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:38:22 +03:00
John Mathews dd1d0e5677 fix(security): contain channel-inbound attachments via shared inbox guard (#2828)
extractAttachmentFiles (the channel-inbound attachment path) hardened only
the per-message inbox subdir, not the `inbox` root itself. A compromised
container can write inside its own session dir, so it could replace `inbox`
with a symlink: mkdirSync({recursive}) then followed it, and the realpath
containment check passed because it compared against realpathSync(inboxRoot)
— which had already followed the symlink. A brand-new attachment file (the
`wx` flag only blocks an existing dst) therefore landed outside the session
sandbox. This is the same symlink-follow class fixed for the A2A path in
#2828 (CWE-59), but reachable from ordinary inbound messages.

Extract the guard both inbound paths need into src/inbox-safety.ts
(ensureContainedInboxDir + isPathInside): lstat-reject a pre-placed symlink
or non-dir at the inbox root AND the per-message subdir before mkdir, then a
realpath containment check. forwardAttachedFiles and extractAttachmentFiles
now share it, removing the duplicated guard logic. Callers still write with
an exclusive flag (COPYFILE_EXCL / wx) and skip-with-warn on failure.

Adds src/session-manager.attachments.test.ts (red before this change, green
after) covering the symlinked inbox-root vector on the channel path.
2026-06-29 00:27:58 +02:00
John Mathews 36afa40857 fix(agent-to-agent): containment-check target inbox in forwardAttachedFiles (#2828)
forwardAttachedFiles hardened only the source side of A2A attachment
forwarding; the target side called fs.mkdirSync({recursive}) and
fs.copyFileSync without any symlink or containment checks. A compromised
target agent that can write inside its own session dir could pre-place
`inbox` (or `inbox/<future-msgId>`) as a symlink pointing anywhere
host-writable — mkdirSync silently follows it and copyFileSync lands
attacker-influenced bytes outside the sandbox (CWE-59, GHSA #2828). This
mirrors the existing defensive pattern in src/session-manager.ts
saveAttachments(): lstat-reject a pre-existing symlink/non-dir at the
inbox root and the per-message subdir before mkdir, realpath + isPathInside
containment check, and an exclusive (COPYFILE_EXCL) copy that refuses to
follow or overwrite a pre-placed symlinked destination. Failures log.warn
with structured context and skip rather than throw, so one bad attachment
never kills a batch. Tests cover a symlinked inbox dir, a symlinked
inbox/<msgId> subdir, a pre-existing symlinked destination file, and a
normal end-to-end forward regression.
2026-06-29 00:27:58 +02:00
gavrielc 2afbd18233 Merge pull request #2859 from cben0ist/fix/migrate-v2-is-main
fix(migrate-v2): don't SELECT is_main from v1 registered_groups
2026-06-26 13:38:42 +03:00
gavrielc 953496dc37 Merge branch 'main' into fix/migrate-v2-is-main 2026-06-26 13:38:27 +03:00
Christophe Benoist 797491d8b3 fix(migrate-v2): don't SELECT is_main from v1 registered_groups
The v2 DB seed queried `is_main` from the v1 `registered_groups` table, but
that column was a later v1 addition — older v1 installs (e.g. 1.1.0) don't have
it, so the migration's `1b-db` step crashes with `no such column: is_main` and
v2.db is never created, cascading into the sessions and tasks steps failing.

`is_main` was selected into the V1Group interface but never read anywhere, so
this just drops it from the SELECT and the interface. The accompanying comment
already states the intent ("Query only the columns we know exist in all v1
installs") — the code now matches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:43:19 -04:00
github-actions[bot] 2df754459b chore: bump version to 2.1.21 2026-06-25 18:59:54 +00:00
gavrielc 0896d4089e Merge pull request #2832 from nanocoai/feat/reject-with-reason
feat(approvals): reject with reason
2026-06-25 21:59:42 +03:00
gavrielc d153d91307 Merge branch 'main' into feat/reject-with-reason 2026-06-25 21:45:12 +03:00
gavrielc ce55af12d5 Merge pull request #2843 from robbyczgw-cla/feat/learn-skill
feat: add /learn skill — distill or refine a reusable skill from anything
2026-06-25 21:41:23 +03:00
gavrielc 545800a94e Merge branch 'main' into feat/learn-skill 2026-06-25 21:40:55 +03:00
github-actions[bot] bfb309bd0c chore: bump version to 2.1.20 2026-06-25 18:34:34 +00:00
gavrielc 38d9390eea Merge pull request #2856 from nanocoai/container-limits
feat(container): per-container CPU/memory limits (opt-in)
2026-06-25 21:34:16 +03:00
gavrielc 8d3eca7027 Merge branch 'main' into container-limits 2026-06-25 21:34:00 +03:00
Omri Maya 1d6bba4d3f feat(container): per-container CPU/memory limits (opt-in)
Pass CONTAINER_CPU_LIMIT / CONTAINER_MEMORY_LIMIT through to `docker run`
as --cpus / --memory in buildContainerArgs. Both default to empty, so spawn
args are byte-identical to today unless an operator opts in — no risk of
OOM-ing existing workloads. Caps an agent container's CPU/memory so one agent
can't monopolize the host. Swap is a deployment concern (--memory is a hard
cap on a swapless host); not managed here.

Structural tests assert each flag is pushed and guarded by its env knob,
matching the existing buildContainerArgs structural-test convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:39:16 +03:00
amit-shafnir 9bb69c0e50 Merge pull request #2830 from amit-shafnir/fix/peer-dead-plist-reaper
fix(setup): reap dead peer service registrations whose binary is gone
2026-06-25 11:27:54 +03:00
robbyczgw-cla 520ec44aec feat: add /learn skill — distill or refine a reusable skill from anything
Instruction-only skill that distills a reusable skill from any source
(directory, URL, pasted notes, or the current conversation) or refines an
existing skill in place. Uses existing agent tools (Read/Grep/Glob/WebFetch/
Write) and injects the project's skill-authoring guidelines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:41:49 +02:00
gavrielc 8c6a243ffd Merge branch 'main' into feat/reject-with-reason 2026-06-23 15:46:02 +03:00
gavrielc add6145f1c Merge pull request #2826 from nanocoai/fix/skill-updates-nudge-and-container-rebuild
fix(update-skills): nudge into skill updates, rebuild container on re-apply
2026-06-23 15:41:05 +03:00
gavrielc 4e14d08173 Merge pull request #2834 from nanocoai/chore/bump-chat-sdk-4.29.0
chore(deps): move chat SDK + channel-adapter pins to 4.29.0
2026-06-23 15:26:50 +03:00
Gabi Simons 8f2f788b6e chore(deps): bump channel adapter install pins to 4.29.0 (skills + setup)
The prior commit moves `chat` to 4.29.0, but main's own install pins were left
behind — and were inconsistent: the 8 /add-<channel> SKILL.md steps pinned
@chat-adapter/*@4.27.0 while the 12 setup/*.sh scripts pinned @4.26.0. Unify all
to @4.29.0 so `/add-<channel>` (and setup:auto) on a main install fetch an
adapter whose ChatInstance matches the bridge.

20 files, version-string only. Shell scripts pass `bash -n`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:47:31 +03:00
Gabi Simons e96d7fd961 chore(deps): pin chat SDK to 4.29.0
`chat` and the `@chat-adapter/*` channel adapters are version-locked — the
adapter's ChatInstance must match the bridge's, so the pair must move together.
Pin `chat` exactly to 4.29.0 (was 4.26.0 via `^4.24.0`); a caret range floats to
4.31.0 and reintroduces the skew.

Host build + full test suite green at 4.29.0 (chat is consumed only as type
imports by the Chat SDK bridge). The channels-branch adapters bump to 4.29.0 in
lockstep; CHANGELOG notes the reinstall migration for existing channel installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:03:48 +03:00
Moshe Krupper 2ac7809385 feat(agent-to-agent): clarify the a2a gate approval prompt
Replace the terse "Approve delivery?" with a one-line legend that names all
three buttons and notes that "Reject with reason…" prompts the approver to
type a reason relayed back to the sender. The longer line also widens the
card bubble, easing the three-button-row truncation on platforms that size
buttons to the message width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:16:03 +03:00
Amit Shafnir 15292ae76c fix(setup): reap dead peer service registrations whose binary is gone
The setup preflight unloads *crash-looping* peers but ignores a more common
leftover: a launchd plist (or systemd unit) whose program no longer exists,
left behind when a NanoClaw checkout is deleted without running the
uninstaller. The health probe can't see these because an unloaded/inactive job
doesn't report via `launchctl print` / `systemctl show`, so they accumulate —
the OS keeps retrying a missing binary forever.

Detect a registration as dead when its `dist/index.js` target is absent on
disk, then unload (best-effort) and delete the orphaned config file. Own-label
and still-valid registrations are never touched.

Adds peer-cleanup.test.ts (the file previously had no tests) covering both
platforms: dead target removed, live target kept, own registration spared,
unrecognized config ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:58:51 +03:00
Koshkoshinsk 055cf49bd5 fix(update-skills): nudge into skill updates, rebuild container on re-apply
/update-nanoclaw Step 7 framed skill updates as an optional, "safe to skip"
extra, so an important channel/provider fix — shipped on the channels/providers
branches the host merge never touches — could be silently missed. Reframe it as
part of the update: default into /update-skills, name the installed skills, and
leave one minimal opt-out.

Move the container image rebuild into /update-skills Step 4: when a re-apply
changes files under container/ (e.g. a provider's runtime), rebuild so new
sessions actually run the new code. Living in update-skills covers both the
standalone and via-update-nanoclaw paths; the update-nanoclaw Step 7.5 that
briefly owned this is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHaa6bp25E62AuUJyW1V5J
2026-06-21 17:08:51 +03:00
Lee Twito 8ee7915418 Merge branch 'main' into feat/add-clidash-skill 2026-06-21 16:19:48 +03:00
Moshe Krupper e8148bc0a7 feat(approvals): reject-with-reason — relay an optional decline reason to the agent
Add a third "Reject with reason…" button to module approval cards. Plain
Reject stays the instant fast path; the new option holds the row
(status='awaiting_reason'), DM-prompts the approver, and captures their
next DM (≤280 chars, truncated) as a one-line reason relayed to the
requesting agent as a single combined message. A ghosted hold is
finalized as a plain reject by the host sweep after ~5 min — restart-safe
via the durable DB row.

- Generalize the router message-interceptor to a list
  (registerMessageInterceptor) so approvals can capture replies alongside
  the permissions agent-naming flow.
- Share reject finalization across the instant, captured, and swept paths
  via finalizeReject.
- Scope: all module approvals (create_agent, install_packages,
  add_mcp_server); OneCLI credential cards are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:55:01 +03:00
amit-shafnir 625264ba4b Merge pull request #2811 from amit-shafnir/setup-agent-provider-flag
fix(setup): allow env-selected agent provider
2026-06-18 22:08:37 +03:00
github-actions[bot] f34e590bcd docs: update token count to 199k tokens · 100% of context window 2026-06-18 15:19:05 +00:00
github-actions[bot] d208fd7bf5 chore: bump version to 2.1.19 2026-06-18 15:18:56 +00:00
Moshe Krupper 886c65725b Merge pull request #2793 from nanocoai/feat/a2a-approval-policies
feat(agent-to-agent): per-message approval policies on connected agents
2026-06-18 18:18:43 +03:00
Moshe Krupper 9977af68d7 chore(migrations): number the new migration files (017, 018)
Rename the two new migration files to the numbered convention used by the core
migrations (001–016), with matching migrationNNN exports, instead of the
module- prefix. Versions (17, 18) and stable migration `name`s are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8e44f07dd4 refactor(approvals): carry approver on a pending_approvals column, not the payload
Per review: move the assigned approver from the approval payload to a dedicated
`approver_user_id` column on pending_approvals.

- New migration adds the column; createPendingApproval + requestApproval write it.
- isAuthorizedApprovalClick reads approval.approver_user_id directly (drops the
  payload-parsing helper); when set, only that exact user may resolve.
- The gate no longer stuffs `approver` into the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 8c43f13d93 refactor(approvals): assigned approver is strict — only the named user may resolve
Per review: drop the owner/global-admin override on assigned approvals. When an
approval names an approver, only that exact user can resolve it. (Non-assigned
approvals are unchanged — still group/owner authorized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 5cf4ff1bd2 refactor: destructure approverUserId / policy.approver instead of repeated access
Per review: pull `approverUserId` into the `opts` destructure in requestApproval,
and `approver` out of `policy` in the gate, instead of accessing the property
twice. (policies.ts already binds args.* to locals.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 6e475e5503 chore(agent-to-agent): drop self-explanatory comments
Remove redundant doc/inline comments where the code speaks for itself; keep only
the non-obvious notes (return-vs-throw consume, ghost-gate cleanup, caller-does-
auth, reject-handled-elsewhere, stored-vs-click payload). Also drops a couple of
now-stale "target admin" descriptions. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 0f8499b141 refactor(agent-to-agent): drop set-time admin check on policy approver
With payload-based click-auth (clicker === approver), the approver no longer
needs to be a group admin — the operator (operator-only command) designates
whoever should approve, and only that user (or an owner) can resolve the card.
Removes the now-redundant hasAdminPrivilege validation and its import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 82e1dc4ae8 feat(approvals): authorize by approver named in payload; policy approver may be source or target
Per review (no new pending_approvals column): the gate carries `approver` inside
the existing approval `payload`, and isAuthorizedApprovalClick authorizes the
named approver (or an owner/global admin) when an approval names one — reading
the real value at click time, no group re-derivation.

- `ncl policies set --approver` validates the user is an admin/owner of the
  source OR target.
- Drops `approverAgentGroupId` and the agent_group_id stamp; `requestApproval`
  keeps `approverUserId` only for delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper e70b021cde refactor(agent-to-agent): destructure payload in applyA2aMessageGate
Per review: destructure the approval payload once instead of repeating
`payload.x`, and narrow `platform_id` up front so it's used directly (drops the
separate `targetAgentGroupId` local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper ea90a12846 feat(agent-to-agent): make policy approver mandatory
Per review, the policy approver is now required, not optional. Every policy
names one specific admin/owner of the target who approves.

- `approver` column is NOT NULL; `AgentMessagePolicy.approver` is non-nullable.
- `ncl policies set --approver <user-id>` is required and validated to be an
  admin/owner of the target.
- The gate always delivers the card to `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3b1f4501d6 feat(agent-to-agent): optional single approver per policy
Per review, add an optional `approver` to a policy: a specific admin/owner of
the target who receives the approval card (instead of all target admins). NULL
keeps the default (all target admins/owners).

- `approver` column on agent_message_policies; carried on AgentMessagePolicy.
- `ncl policies set --approver <user-id>` validates the user is an admin/owner
  of the target at set-time, so the existing click-auth gate is unchanged.
- `requestApproval` gains `approverUserId` (single) to deliver the card to that
  one user; the gate passes `policy.approver`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 385fb014fc refactor(agent-to-agent): split content parsing out of buildGateQuestion
Address PR review: extract `parseMessageContent` (text + attachment names from
the message content JSON) so `buildGateQuestion` reads as pure formatting, and
name the body-length cap (`GATE_CARD_BODY_MAX`) instead of a bare 1500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b2160a56aa refactor(agent-to-agent): drop named-approver list from v1
Address PR review: remove the `approvers` option entirely for v1 — the
approver is always the target group's admins/owners. Drops the `approvers`
DB column, the `--approvers` flag + its set-time validation, the now-unused
`approverUserIds` param on requestApproval, and the related tests. The
target-scoped approver pick (`approverAgentGroupId`) stays. Named approvers
can be re-added later via a migration when needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper f72658bb50 refactor(agent-to-agent): extract sourceAgentGroupId in routeAgentMessage
Address PR review: hoist session.agent_group_id into a named local
`sourceAgentGroupId`, mirroring `targetAgentGroupId`, and use it throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper 3180f3f881 chore(agent-to-agent): trim comments to match repo convention
Shorten the verbose doc/inline comments added with the approval-policy gate
down to terse one-liners, matching the surrounding style (e.g. agent-destinations,
write-destinations). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:13 +03:00
Moshe Krupper b0bdc57b37 refactor(agent-to-agent): align policy files with resource conventions
- policies.ts: drop the 10-line top banner. Sibling resource files carry no
  descriptive header (only destinations.ts, and only for a non-obvious
  side-effect); the prose already lives in the resource `description`.
- agent-message-policies.ts: remove `listMessagePolicies` — no production
  caller (the `ncl policies list` op uses the generic table-based CRUD); only
  its own test referenced it.
- message-gate.test.ts: assert the upsert-no-duplicate invariant via a direct
  row count instead of the removed helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Moshe Krupper 314b91efc0 feat(agent-to-agent): per-message approval policies on connected agents
Add an optional, directed, per-message require-approval gate on top of an
existing agent-to-agent connection. No policy = today's free flow (fully
backward compatible). When a policy exists for A→B, each message A sends to B
is held, an approval card showing the message goes to B's admins, and the
message is delivered on approve / declined on reject. Rejecting one message
never blocks the connection.

- New `agent_message_policies` table (directed from→to; row exists = require
  approval; `approvers` JSON, NULL = target admins). Deleted alongside its
  connection so a stale rule can't reactivate on re-wire.
- Gate inside `routeAgentMessage` after the self/`hasDestination` checks:
  holds the message via `requestApproval` and returns to consume it (like a
  system action); the held message rides in the approval payload and is
  re-routed by `applyA2aMessageGate` on approve. Self/internal messages are
  never gated.
- `requestApproval` gains `approverAgentGroupId` / `approverUserIds` and stamps
  `agent_group_id` on the pending row so the target's admins pass the
  click-auth gate.
- `ncl policies list/set/remove`, operator-only (not in the container cli_scope
  allowlist); `set` validates named approvers are admins/owners of the target.

Reuses the existing requestApproval / pending_approvals / approval-handler
spine (same shape as create_agent). Host-only; no container changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:09:12 +03:00
Amit Shafnir 53ed3b77c9 fix(setup): allow env-selected agent provider 2026-06-18 17:49:13 +03:00
Daniel M 070714ec58 Merge pull request #2810 from nanocoai/refactor/agents-claude-symlinks
refactor: mirror .claude skills + CLAUDE.md into .agents via symlinks
2026-06-18 17:11:26 +03:00
exe.dev user e4907c2c33 refactor: mirror .claude into .agents via symlinks
.agents/skills -> ../.claude/skills and AGENTS.md -> CLAUDE.md so the
agents-convention paths resolve to the canonical ones. Drops the
host/skills indirection in favor of .claude as the single source of truth.
2026-06-18 14:03:38 +00:00
gavrielc 9a142302df Merge branch 'main' into feat/add-clidash-skill 2026-06-18 09:18:10 +03:00
github-actions[bot] 3f39f57653 chore: bump version to 2.1.18 2026-06-18 06:15:49 +00:00
gavrielc 1b86950f10 Merge pull request #2803 from sturdy4days/refactor/remove-dead-resolvegroupipcpath
refactor: remove dead resolveGroupIpcPath
2026-06-18 09:15:36 +03:00
gavrielc 8b435eb02d Merge branch 'main' into refactor/remove-dead-resolvegroupipcpath 2026-06-18 09:15:23 +03:00
gavrielc 7e2004f945 Merge pull request #2806 from arkjun/docs/add-korean-readme
docs: add Korean README
2026-06-18 09:14:52 +03:00
gavrielc 63901d1bde Merge branch 'main' into docs/add-korean-readme 2026-06-18 09:14:35 +03:00
gavrielc e5d96e348f Merge pull request #2805 from amit-shafnir/fix/setup-token-pty-parsing
fix(setup): parse Claude OAuth token from wrapped PTY capture
2026-06-18 09:12:56 +03:00
Juntai Park 439c24f1b7 docs: link Korean README in language switchers 2026-06-18 11:21:51 +09:00
Juntai Park 2a144bb8d6 docs: add Korean README 2026-06-18 11:21:50 +09:00
Amit Shafnir 197faaaa14 fix(setup): parse Claude OAuth token from wrapped PTY capture
`claude setup-token` runs under script(1) so the browser OAuth flow keeps a
TTY while we capture the printed token. On terminals that wrap long lines
(e.g. sbx), the token lands split across lines with padding spaces, and the
old parser — which stripped only ANSI codes and newlines — matched just the
first fragment and failed the trailing `AA` check. Login succeeded; only our
parse of the human-oriented output failed (`No sk-ant-oat…AA token found`).

Add setup/lib/captured-token.ts: normalize the capture (strip ANSI/control
bytes and all whitespace, un-wrapping the token) then extract. The TS caller
(claude-assist.ts) and the bash registration script now share it, so the
normalization rules can't drift. Placeholder lines like
`export CLAUDE_CODE_OAUTH_TOKEN=<token>` are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:20:21 +03:00
sturdy4days 3ffd6dde00 refactor: remove dead resolveGroupIpcPath
resolveGroupIpcPath has no production callers (only its own test); IPC was
removed in the v2 architecture (host<->container communicate solely via the two
session DBs). Drop the function, the now-unused DATA_DIR import, and its tests.
2026-06-17 15:19:29 -04:00
leetwito e856e924a5 feat: add /add-clidash — read-only CLI-derived dashboard skill
clidash is 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 plus docker, with message-activity charts, a
log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.

Packaged as a utility skill per CONTRIBUTING.md: code under the skill dir,
copied into tools/clidash on install. No edits to NanoClaw src, no new deps.
2026-06-17 15:40:39 +03:00
github-actions[bot] ee7f891698 docs: update token count to 196k tokens · 98% of context window 2026-06-16 11:15:10 +00:00
github-actions[bot] 7fde348e2b chore: bump version to 2.1.17 2026-06-16 11:15:04 +00:00
Gabi Simons 122135e6dc Merge pull request #2759 from assapin/fix/budget-error-surfaced-to-user
fix(agent-runner): deliver budget/billing error turns instead of dropping them
2026-06-16 14:14:48 +03:00
Gabi Simons 8563fb0681 Merge remote-tracking branch 'origin/main' into fix/budget-error-surfaced-to-user
# Conflicts:
#	CHANGELOG.md
2026-06-16 11:35:45 +03:00
omri-maya 0155ab1943 Merge pull request #2775 from nanocoai/docs/onecli-gateway-upgrade-notice
docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
2026-06-16 09:55:25 +03:00
Koshkoshinsk d1f94fcd24 docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
The breaking notice said the onecli setup step enforces the pinned versions, which is only true for fresh installs — on an existing install, updating does not upgrade the running gateway. Clarify that the gateway is separate: /update-nanoclaw upgrades it when the pin moves, otherwise upgrade manually per docs/onecli-upgrades.md.
2026-06-15 20:25:42 +03:00
gavrielc dd60983f7f Merge pull request #2774 from nanocoai/feat/update-nanoclaw-onecli-pin
feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
2026-06-15 20:09:01 +03:00
Koshkoshinsk 096b8bf589 feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
When an update moves the onecli-gateway/onecli-cli pin in versions.json, the running gateway must be upgraded to match — otherwise the new code's @onecli-sh/sdk calls fail (404 on /v1/agents) and agents can't spawn. update-nanoclaw never detected this, so the upgrade was silently skipped. Add a conditional step that follows docs/onecli-upgrades.md before restart when the pin moves.
2026-06-15 19:37:23 +03:00
Gabi Simons 59c4d33adc Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 17:42:01 +03:00
omri-maya 5f5c28d18d Merge pull request #2773 from nanocoai/docs/codex-fix-docs
docs(add-codex): drop redundant TTY warning in auth note
2026-06-15 16:04:28 +03:00
Koshkoshinsk b92d1f9343 docs(add-codex): drop redundant TTY warning in auth note
The 'don't run via `!` prefix or Bash tool' sentence was redundant with
the leading 'Run this in a separate, real terminal — it is interactive.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:32:04 +03:00
Gabi Simons e03c5c194a Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 12:17:20 +03:00
Daniel M acbb1144b7 Merge pull request #2769 from nanocoai/docs/codex-interactive-host-restart
docs(add-codex): flag interactive auth step + add host-restart step
2026-06-15 02:24:06 +03:00
Koshkoshinsk 028897f38f docs(add-codex): flag interactive auth step + add host-restart step
- Authenticate: run in a separate real terminal, not Claude Code's `!`
  prefix or an agent Bash tool — the provider-auth picker + browser/device
  login need an interactive TTY, so those prompts stall otherwise (CDX-002).
- add a "Restart the host" step after the image rebuild so the host
  reloads Codex's /home/node/.codex mount + env; skipping it left the dir
  root-owned and the container hit EACCES writing config.toml (CDX-003).

Refs CDX-002, CDX-003.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:58:30 +03:00
gavrielc ac0a799cbf refactor(add-codex): install Codex CLI via cli-tools.json, not the Dockerfile
adfae67 moved the agent's global Node CLIs into container/cli-tools.json so a
skill adds one with a json-merge instead of editing the Dockerfile. The Codex
provider install was left behind — add-codex.sh still awk'd an ARG + RUN into
the Dockerfile and its test guarded that shape.

Migrate add-codex to the seam:
- add-codex.sh appends { name: "@openai/codex", version } to cli-tools.json
  (idempotent json-merge); install/idempotency gates read the manifest.
- SKILL.md / REMOVE.md document the manifest append/removal, not Dockerfile edits.
- codex-dockerfile.test.ts -> codex-cli-tools.test.ts, asserting the manifest
  entry (skips when the manifest is absent, e.g. the bare providers branch).

Pairs with the providers-branch commit that drops the codex Dockerfile lines,
renames the payload test, and points the setup install-check at the manifest.

Verified end-to-end: full add-codex install into a clean worktree leaves the
Dockerfile codex-free, the manifest correctly appended and idempotent; vitest
cli-tools.test.ts (6) and bun codex-cli-tools.test.ts (2) green; host tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:40:44 +03:00
github-actions[bot] e3986eb58c chore: bump version to 2.1.16 2026-06-14 18:29:28 +00:00
github-actions[bot] 6d0d48d585 docs: update token count to 195k tokens · 98% of context window 2026-06-14 18:29:25 +00:00
gavrielc a142c496f7 Merge pull request #2756 from nanocoai/provider-selection
feat(providers): operator-driven provider selection, switching, and memory migration
2026-06-14 21:29:12 +03:00
gavrielc c5b4d11536 Apply suggestion from @gavrielc 2026-06-14 21:16:19 +03:00
Daniel M ed8b4149e7 Merge pull request #2764 from glifocat/docs/fix-claude-md-relocated-paths
docs(CLAUDE.md): fix two relocated Key Files paths
2026-06-14 18:13:31 +03:00
glifocat d5ce02d1b8 docs(CLAUDE.md): fix two relocated Key Files paths
The Key Files table and the Secrets/OneCLI section referenced
src/onecli-approvals.ts and src/user-dm.ts, but both files were moved
under src/modules/ (src/modules/approvals/onecli-approvals.ts and
src/modules/permissions/user-dm.ts). onecli-approvals.ts is already
cited at its correct new path elsewhere in the same doc, so this was a
partial-rename miss. Docs only — no code changes.

Closes #2763

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:01:40 +02:00
omri-maya c8af599944 Merge branch 'main' into provider-selection 2026-06-14 15:17:13 +03:00
github-actions[bot] 435233a062 chore: bump version to 2.1.15 2026-06-14 11:04:33 +00:00
gavrielc 785fce3754 Merge pull request #2758 from nanocoai/feat/cli-tools-manifest
feat(container): data-drive global CLI installs from cli-tools.json
2026-06-14 14:04:16 +03:00
assafpin 01433bae32 fix(agent-runner): deliver budget/billing error turns instead of dropping them
A turn that ends in a non-retryable provider error (e.g. an Anthropic
403 billing_error) comes back from the streaming SDK as a result with
is_error=true and no <message> envelope. dispatchResultText treated it
as scratchpad and dropped it, then the poll-loop pushed a re-wrap nudge
-> new turn -> same error, re-hammering the gateway until idle-kill. The
user saw silence.

- providers/claude.ts: surface is_error on the result event, and fall
  back to errors[] for the message text (error subtypes carry no result).
- poll-loop.ts: when a result has no <message> blocks and is_error, deliver
  the notice verbatim to the originating channel and skip the nudge.

Verified live (real agent image + SDK, 403 mock): the notice is delivered
to the channel and the retry loop is gone.

Refs #2751
2026-06-14 12:56:02 +03:00
Omri Maya 6d521a9d8d refactor(memory): scope imported-memory doctrine to /migrate-memory
The "read imported-agent-memory.md, treat it as binding" doctrine sat in the
memory definition that every group loads, but it only matters when an import
actually happened. Move it into the /migrate-memory skill — the step that
writes the imported file and its index pointer (which the agent inlines into
its prompt each turn) — and drop the always-on block from definition.md.

Addresses review feedback on #2756.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:12:41 +03:00
gavrielc adfae67611 feat(container): data-drive global CLI installs from cli-tools.json
The agent's global Node CLIs (claude-code, agent-browser, vercel) were each
a hardcoded ARG + RUN layer in the Dockerfile, so adding or bumping one meant
editing the Dockerfile — a code reach-in every tool-installing skill had to make.

Move the tool list into container/cli-tools.json. A skill now adds a CLI by
appending a {name, version} entry (a json-merge) — the safest change shape:
deterministic, idempotent, removable. install-cli-tools.sh parses the manifest
with node (no new jq dep), writes the per-tool only-built-dependencies opt-ins,
and runs one pinned `pnpm install -g`, so the pnpm supply-chain path is unchanged.

Behavior is byte-for-byte: same opt-ins, same pinned installs. agent-browser is
now pinned (0.27.1, what `latest` last resolved to) instead of floating.

container/cli-tools.test.ts guards the seam: red if a baseline tool is dropped,
a version unpins, or the Dockerfile wiring / pnpm path is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:07:14 +03:00
Omri Maya 13a37def89 feat(providers): operator-driven provider selection, switching, and memory migration
Make the agent provider a first-class, operator-chosen property instead of a
Claude-only assumption. Trunk gains the seams; the actual non-default payloads
(Codex first) install from the `providers` branch.

Setup
- A provider registry feeds a hard-wired setup picker (Claude | Codex). Picking
  a non-default provider installs its payload (setup/add-codex.sh, channel-style),
  runs a vault-only auth walkthrough (--step provider-auth), and records the pick
  on the first agent before its first spawn.
- Picking Claude changes nothing — default installs are byte-for-byte unaffected.

Provider as a DB property
- Provider lives on container_configs.provider (materialized to container.json,
  read by resolveProviderName). Creation stays provider-agnostic; the picked
  provider is applied via the picked-provider seam. The deprecated
  agent_groups.agent_provider path is not used.

Switching + memory
- Switch a live group with `ncl groups config update --provider` + restart.
- Memory never migrates at runtime — each provider keeps its own store. The
  /migrate-memory skill carries a group's memory across a switch in either
  direction (flat CLAUDE.local.md <-> memory/ scaffold). group-init seeds an
  imported-agent-memory note for non-default providers; the runner's memory
  definition reads it first turn. See docs/provider-migration.md.

No install-wide default, no runtime provider guard — switching is operator-by-
convention, consistent with the no-install-gating posture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:49:39 +03:00
github-actions[bot] 03382e9dd7 chore: bump version to 2.1.14 2026-06-13 13:05:30 +00:00
github-actions[bot] 9763551656 docs: update token count to 194k tokens · 97% of context window 2026-06-13 13:05:27 +00:00
gavrielc a9c9cb300d Merge pull request #2754 from nanocoai/oss/exchange-hook
feat(runner): onExchangeComplete provider hook + slash-command interruption
2026-06-13 16:05:14 +03:00
gavrielc a619fc1aa2 Apply suggestion from @gavrielc 2026-06-13 16:03:02 +03:00
Omri Maya 3d2f3e58ca feat(runner): onExchangeComplete provider hook + slash-command interruption
Inverts conversation archiving into an optional onExchangeComplete provider
hook: the runner never archives on a provider's behalf, and the markdown
writer ships with the provider that needs it. Dormant for the default
provider.

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:56:43 +03:00
gavrielc 11afc64ba4 Merge pull request #2747 from nanocoai/oss/onecli-sdk-v2
feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
2026-06-13 15:49:40 +03:00
github-actions[bot] 0ee75d393c chore: bump version to 2.1.13 2026-06-13 12:27:29 +00:00
github-actions[bot] 72b9cc7ed0 docs: update token count to 192k tokens · 96% of context window 2026-06-13 12:27:24 +00:00
gavrielc 5fcf234165 Merge pull request #2746 from nanocoai/oss/agent-surfaces
feat(providers): agent-surfaces capability seam
2026-06-13 15:27:12 +03:00
github-actions[bot] 9b1236505f chore: bump version to 2.1.12 2026-06-13 12:25:58 +00:00
github-actions[bot] 878cd68c1b docs: update token count to 191k tokens · 96% of context window 2026-06-13 12:25:52 +00:00
gavrielc fab1ebf2d6 Merge pull request #2745 from nanocoai/oss/memory-scaffold
feat(memory): opt-in persistent memory scaffold for providers
2026-06-13 15:25:39 +03:00
Omri Maya 3f9e89d345 feat(onecli): SDK 2.2.1 — credential-stub mounts + machine-checkable pins
Injects credentials as request-time stubs so no credential is ever written
into a container or to disk. Gateway and CLI versions move to versions.json
(machine-checkable pins); breaking upgrades are documented in
docs/onecli-upgrades.md as an agent-executable runbook (detect / why / fix /
verify / rollback), and the update flow follows linked docs and diffs the
pins.

BREAKING: requires a gateway upgrade; the doc carries the steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:11 +03:00
Omri Maya 14810a5090 feat(providers): agent-surfaces capability seam
Host-side registry where a provider can declare, by capability rather than
by name, that it owns its agent surfaces (project doc, skills). Default
providers keep the standard surfaces; a surfaces-owning provider suppresses
them. Dormant until a provider registers — no change for existing installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:10 +03:00
Omri Maya 2cfa86e570 feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:09 +03:00
github-actions[bot] 36cbf17e10 chore: bump version to 2.1.11 2026-06-11 17:16:51 +00:00
gavrielc 4459ab2e54 Merge pull request #2739 from nanocoai/feat/raw-webhook-registry
feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
2026-06-11 20:16:33 +03:00
gavrielc 9e6238d28f Merge main (channel instances): keep both webhook suites as separate files
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:30 +03:00
github-actions[bot] d1bda5d15b chore: bump version to 2.1.10 2026-06-11 16:42:59 +00:00
gavrielc 7eddc7d8c9 Merge pull request #2738 from nanocoai/fix/write-outbound-direct-rw
fix(session-manager): writeOutboundDirect opens outbound.db read-only — command-gate denials never deliver
2026-06-11 19:42:39 +03:00
github-actions[bot] 991ef986f8 docs: update token count to 190k tokens · 95% of context window 2026-06-11 16:42:36 +00:00
github-actions[bot] 0f2557e2bc chore: bump version to 2.1.9 2026-06-11 16:42:32 +00:00
gavrielc 4e6552ed55 Merge pull request #2737 from nanocoai/feat/approval-resolved-hook
feat(approvals): approval-resolved callback registry — modules observe resolution additively
2026-06-11 19:42:12 +03:00
github-actions[bot] 978b998ee6 chore: bump version to 2.1.8 2026-06-11 16:41:56 +00:00
gavrielc 83951d7c01 Merge pull request #2736 from nanocoai/fix/host-sweep-wake-grace
fix(host-sweep): grace period for freshly-woken containers with stale processing claims
2026-06-11 19:41:38 +03:00
github-actions[bot] 76ef097521 chore: bump version to 2.1.7 2026-06-11 16:41:14 +00:00
gavrielc 1c85fd6e50 Merge pull request #2735 from nanocoai/fix/approval-card-actor-byline
fix(chat-sdk-bridge): record the acting user on resolved approval cards
2026-06-11 19:40:59 +03:00
github-actions[bot] 42275ede1f chore: bump version to 2.1.6 2026-06-11 16:40:40 +00:00
gavrielc 53e1989529 Merge pull request #2734 from nanocoai/feat/delivery-action-getter
feat(delivery): getDeliveryAction read side for the action registry
2026-06-11 19:40:20 +03:00
github-actions[bot] 6f2142d7c7 docs: update token count to 189k tokens · 95% of context window 2026-06-11 16:39:51 +00:00
github-actions[bot] 79a0226962 chore: bump version to 2.1.5 2026-06-11 16:39:41 +00:00
gavrielc 0b31695e92 Merge pull request #2733 from nanocoai/feat/channel-instances
feat(channels): native channel-instance dimension — multi-bot substrate
2026-06-11 19:39:19 +03:00
gavrielc 421f8707d2 Merge pull request #2741 from nanocoai/setup-handoff-kickoff-prompt
fix(setup): auto-submit handoff context as Claude's first prompt
2026-06-11 17:36:04 +03:00
gavrielc 67ccd9e74c fix(setup): auto-submit handoff context as Claude's first prompt
Interactive setup handoffs (mid-flow `?` escape and on-failure) spawned
claude with all context in --append-system-prompt and no user message,
so Claude sat at an empty REPL until the user re-explained themselves.

Move the context into a positional prompt that auto-submits as the
first user message: Claude starts orienting immediately, the context
stays visible in the transcript, and it survives --resume.

Also:
- Share one session across all handoffs in a setup run: pin a
  generated UUID via --session-id on the first spawn, --resume it on
  later ones (stdio is inherited, so Claude's own id is never visible).
- Switch --permission-mode from acceptEdits to auto.
- Dedupe the two spawn blocks into spawnInteractiveClaude().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:48 +03:00
gavrielc f69af07c57 feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
Add a RawWebhookHandler registry alongside the Chat SDK adapter routes
so modules can mount plain Node handlers at /webhook/{path} on the
shared server instead of editing webhook-server.ts or standing up a
second HTTP server on another port. Raw routes dispatch ahead of
adapter routes, handler throws surface as a 500, and stopWebhookServer
clears the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:58:51 +03:00
gavrielc 93a302b5db feat(approvals): add approval-resolved callback registry
Modules can already register to handle an approval (registerApprovalHandler),
but nothing lets a module observe that an approval was resolved — e.g. to
clear an "awaiting approval" status indicator it set when the card went out.
Today that observation is only possible by core importing module code.

Add registerApprovalResolvedHandler/notifyApprovalResolved to the approvals
primitive and fire it at the three resolution exits in the response handler
(reject, approve-with-no-handler, approve-after-handler). Callback errors are
logged and isolated so one bad callback never blocks resolution or other
callbacks. The hook only fires for authorized clicks (it sits behind the
isAuthorizedApprovalClick gate) and carries the namespaced user id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:54:21 +03:00
gavrielc eef285ba3b fix(session-manager): open outbound.db read-write in writeOutboundDirect
writeOutboundDirect opened the session's outbound DB through
openOutboundDb, which sets readonly: true. The INSERT it then runs threw
SQLITE_READONLY on every call, so the command-gate denial path
(router.ts) never delivered its 'Permission denied' response — the
sender just got silence, and the throw aborted routing for that inbound
event.

Switch to the openOutboundDbRw wrapper, which opens the same path with
write access (DELETE journal + busy_timeout). The host-side write to the
container-owned outbound.db is safe: both sides use DELETE journal mode,
and the even host seq stays out of the container's odd-seq space.

Adds a guard test that drives writeOutboundDirect against a real session
folder and asserts the denial rows land in messages_out with even seqs;
it goes red if the open call reverts to the readonly form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:54:17 +03:00
gavrielc a806534199 fix(host-sweep): grace period for freshly-woken containers with stale processing claims
The sweep tick that wakes a container for due messages also ran the
running-container SLA check in the same iteration. A fresh container that
inherits stale processing_ack rows from a previous crash hasn't had a chance
to run its startup cleanup (clearStaleProcessingAcks) yet, so the per-claim
stuck rule saw an hours-old claim, concluded the just-spawned container was
stuck, and SIGKILL'd it — an immediate spawn-kill loop.

Carry a justWoke flag from the wake step into the SLA gate and skip the
check for that one tick. The next tick (60s later) enforces the SLA
normally, so a genuinely stuck container is still killed.

Guarded by src/host-sweep-grace.test.ts, which drives two real sweep ticks
against on-disk session DBs: the wake tick must not kill, a later tick with
the claim still stale must kill claim-stuck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:53:15 +03:00
gavrielc 0ac8073e34 fix(chat-sdk-bridge): record the acting user on resolved approval cards
When a button on an approval/question card is clicked, the bridge edits
the card down to the title and the selected answer — but not who clicked
it. In shared channels every member sees the same resolved card, so the
audit trail of which user approved or rejected is lost the moment the
buttons disappear.

Append an actor byline (" — <userName>", falling back to fullName) to
the edited card markdown. The shared chat.onAction handler covers every
Chat SDK webhook platform; cards edited for actors with no resolvable
name stay byline-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:52:45 +03:00
gavrielc 539a2b3c63 feat(delivery): getDeliveryAction read side for the action registry
registerDeliveryAction had no read side, so module registrations could
not be verified through the registry itself. Add a getter beside it and
a guard test covering lookup, miss, and overwrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:52:00 +03:00
gavrielc fccaadf24c fix(channels,db): exact instance dispatch, FK-check scoping, migration-safe skill snippets
Review-round fixes on the instance dimension:
- delivery/typing resolve adapters by exact registry key, never the
  channelType fallback — a named instance with an offline adapter gets
  offline handling, not a cross-identity send through a sibling bot;
  the fallback scan (channelType-only callers) now warns when it
  resolves through a differently-keyed instance
- migration runner only fails on FK violations a migration introduced:
  pre-existing latent orphans (FK-OFF CLI surgery) are logged and
  carried, not turned into a boot crash-loop
- typing re-trigger updates the full address (channelType, platformId,
  threadId, instance) together — no torn entries on agent-shared
  sessions spanning instances
- bridge rejects empty/whitespace instance names (URL-route and
  state-namespace safety)
- add-github / add-linear SKILL.md wiring inserts include the NOT NULL
  instance column
- drop the 10s same-platform boot stagger: operational policy, not
  substrate — reintroducible skill-side for gateway-mode installs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:43:18 +03:00
Hinotobi 0516bea638 Merge branch 'main' into fix/approval-cli-caller-context 2026-06-11 11:29:11 +08:00
github-actions[bot] 3329270c67 docs: update token count to 185k tokens · 93% of context window 2026-06-10 20:02:28 +00:00
Daniel M f16ea0c783 Merge pull request #2719 from amit-shafnir/feat/uninstall-script
feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
2026-06-10 23:02:11 +03:00
gavrielc 1c024bc976 docs: document the channel-instance dimension
- CLAUDE.md entity model: instance on messaging_groups.
- db-central.md: updated messaging_groups DDL (instance NOT NULL, triple
  UNIQUE, denied_at), instance semantics (default = channel_type via
  migration 016 backfill; inbound exact-on-instance, outbound
  default-first), and the user_dms per-platform (not per-instance)
  cold-DM note.
- architecture.md: same DDL update in the schema appendix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:07:22 +03:00
gavrielc 6c26f3ef08 feat(host): thread the channel instance through router, delivery, and typing
Inbound: src/index.ts onInbound stamps `instance: adapter.instance ??
adapter.channelType` — the single host-side stamping seam; adapters stay
instance-blind and onInboundEvent (CLI) passes events through unchanged.
The router resolves the thread-policy adapter and the messaging group by
the receiving instance (exact-only — an unknown named instance auto-creates
its own group, persisting the instance, instead of hijacking a sibling's
row).

Outbound: ChannelDeliveryAdapter.deliver/setTyping grow a trailing
`instance` param (host-internal interface only — messages_out, destinations
and session_routing schemas are untouched; containers never see instance).
deliverMessage resolves the messaging group ORIGIN-SESSION-FIRST, so a
named instance's session replies through its own adapter even when a
sibling default row shares the same (channel_type, platform_id); dispatch
goes through getChannelAdapter(instance ?? channelType).

Typing: TypingTarget stores the instance and all three tick sites
(immediate, 4s interval, re-trigger) forward it, so the indicator fires
through the bot that owns the chat.

Also updates a raw-SQL fixture in groups.test.ts for the NOT NULL instance
column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:05:50 +03:00
gavrielc ab6ab6936c feat(channels): per-instance Chat SDK state namespaces and webhook routes
ChatSdkBridgeConfig gains `instance`. The bridge keeps channelType =
adapter.name (semantic platform identity is untouched) and threads the
instance into three places:

- Registry identity: bridge.name / bridge.instance follow config.instance.
- Chat SDK state: SqliteStateAdapter takes an optional namespace and
  prefixes every key at a single choke point (k()). All bridges share the
  chat_sdk_* tables and two same-platform instances see identical
  thread/message ids — without the namespace, the SDK's
  dedupe:${adapter.name}:${message.id} key makes the second bot silently
  drop every message the first processed, locks serialize across bots, and
  subscriptions leak engagement. The namespace applies ONLY when instance
  is set AND differs from adapter.name: the default instance stays on the
  legacy UNPREFIXED keyspace byte-identically, so live installs' existing
  subscriptions/kv/locks/lists rows are never orphaned. enqueue does not
  prefix (appendToList does) — layout is ns:queue:<tid>; acquireLock
  returns the raw threadId and release/extend re-apply k() at their SQL
  sites.
- Webhook route: registerWebhookAdapter(chat, adapterName, routingPath =
  adapterName) splits the URL segment from the chat.webhooks handler key,
  so each same-platform instance gets its own URL (and signing secret).
  Signature adopted verbatim from PR #2617 (credit @davekim917's #1804
  prototype); the handler body needed zero change — dispatch already read
  entry.adapterName, not the route key.

Instance names are validated URL-safe (no '/', '?', ':' or whitespace) at
bridge construction: the route regex is [^/?]+ and ':' is the namespace
delimiter. The Chat instance's inner adapters map stays keyed adapter.name
(the SDK resolves adapters via channelId.split(':')[0] and serializes by
adapter.name) — instance identity lives entirely outside the Chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:57:33 +03:00
gavrielc 501afb4beb feat(channels): key the adapter registry by instance with channelType fallback
ChannelAdapter and InboundEvent gain an optional `instance` field — the
host-side routing identity for N adapters of one platform. channelType
stays the semantic platform key (user ids, formatting, container config).

Registry changes:
- activeAdapters keys by `adapter.instance ?? adapter.channelType`, so the
  default instance keeps today's channelType key byte-identically. A
  duplicate instance key warns loudly and overwrites (today's boot
  semantics, made visible).
- getChannelAdapter(key) resolves the exact instance key first, then falls
  back to the first-registered adapter of that channel type — channelType-
  only callers (cold DMs, user-id prefix resolution, approval delivery)
  still resolve deterministically when every instance of a platform is
  named.
- initChannelAdapters staggers same-channelType setups by 10s so two
  gateway bots of one platform don't identify simultaneously from one IP.
  Inert when no two registrations share a channelType.

No adapter sets `instance` today, so every existing install boots
identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:53:01 +03:00
gavrielc 9040dbb86e feat(db): add messaging_groups.instance with FK-safe recreate migration
Adds the channel-instance dimension to the schema: an `instance` column
(NOT NULL, default instance = channel_type) on messaging_groups, relaxing
UNIQUE(channel_type, platform_id) to the triple so N adapter instances of
one platform can each own a row per chat.

SQLite can't relax a table-level UNIQUE in place, and DROP TABLE fails FK
integrity on live DBs with child rows (the failure that forced migration
011 to abandon its rebuild) — so the migration runner grows an opt-in
`disableForeignKeys` flag: foreign_keys=OFF around the transaction (the
pragma is a no-op inside one), PRAGMA foreign_key_check inside it so a
violating recreate rolls back atomically.

Query semantics (deliberately asymmetric, both documented):
- getMessagingGroupWithAgentCount (router fast path): exact-on-instance,
  no fallback — an unknown named instance returns null so the router
  auto-creates a per-instance group instead of hijacking a sibling's row.
  Default param (= channelType) keeps existing callers identical.
- getMessagingGroupByPlatform (outbound/cold-DM/setup): unset instance
  resolves default-instance-first with a deterministic ORDER BY; set
  instance is exact-only.

Existing rows are backfilled instance = channel_type, so single-instance
installs see zero behavior change and need no operator action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:49:02 +03:00
Amit Shafnir d8748e3a45 fix: address uninstaller review findings
- .env backup and removal are now one atomic action: a failed backup
  throws into executePlan's catch and the deletion never runs (the bash
  original's set -e gave the same guarantee; the port had lost it)
- containers are re-listed by install label at removal time instead of
  removed from scan-time ids — the live host can spawn containers during
  the confirm phase
- uninstall telemetry no longer creates data/install-id (persistId:false
  on emit), so --dry-run truly changes nothing and the already-clean
  exit can fire
- runtime-tail failure notes are printed before the Done line instead
  of being discarded
- uninstall.sh translates the old short flags (-n/-y) instead of
  silently dropping them (-n used to fall through to a real interactive
  uninstall)
- nanoclaw.sh gates the TS uninstaller on node (tsx's interpreter), not
  pnpm, which the direct-exec path never uses
- detectExistingInstall also checks the system-level systemd unit
- a delete-onecli-agent spawn failure now notes the manual command
  instead of claiming the agent was already gone
- setupLog.userInput is skipped when logs/ is absent so the uninstall
  doesn't recreate it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
Amit Shafnir 41a720dd59 feat: port uninstaller to TS, wire nanoclaw.sh --uninstall, detect existing installs in setup
Replaces the standalone bash uninstall.sh with a TypeScript flow inside the
setup driver (setup/uninstall/): scan (slug-scoped inventory), plan (pure
ordered removal actions), remove (per-action executor that absorbs failures
into notes), and flow (clack UI). uninstall.sh is now a 3-line pointer that
execs nanoclaw.sh --uninstall.

- nanoclaw.sh --uninstall short-circuits before diagnostics/bootstrap; with
  no node_modules it prints manual cleanup commands and exits 1
- setup:auto routes --uninstall before initProgressionLog so an uninstall
  never resets logs/setup.log
- fresh setup runs detect an existing install (service registration or
  data/v2.db) and offer keep-and-continue (default) or uninstall-and-exit;
  suppressed on fail()-retry and sg re-exec resumes
- self-deletion safety: static imports only, dist/ + node_modules/ removed
  dead last, nothing but console.log after the runtime tail
- --yes never deletes orphan ag-* vault agents; their manual delete
  commands (by vault uuid) are printed instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
Amit Shafnir 6ae83f48ac feat: add uninstall.sh — per-copy uninstaller with confirmation, dry-run, and OneCLI agent cleanup
Removes only what belongs to this checkout (slug-scoped): background
service, containers + image, data/, logs/, groups/, ncl symlink, and
this copy's OneCLI vault agents. Shared tools (OneCLI app, credentials,
other copies) are left alone. Interactive per-group confirmation with
--dry-run and --yes modes; .env is backed up before removal.

Documented in README FAQ and the CLAUDE.md key-files table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:50:12 +03:00
gavrielc dc34ceb83d Merge pull request #2721 from nanocoai/docs/skills-model
docs: customizing intro, skills model, and skill guidelines
2026-06-10 11:41:40 +03:00
gavrielc ad3dfad3f5 docs: align CONTRIBUTING and README with the registry-branch install model
CONTRIBUTING still described feature skills as installed by merging a
skill/* branch, a design the shipped skills no longer use: /add-slack,
/add-telegram and the rest install by additive fetch from the channels
and providers registry branches (git fetch + git show per file), with
registration tests and a REMOVE.md. Rewrite the skill-type section to
match, point the authoring bar at docs/skill-guidelines.md, fix the
README FAQ line that sent every contribution to the registry branches,
and delete docs/skills-as-branches.md (the superseded merge-based
design, including a marketplace flow that was never the shipped path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:27:51 +03:00
gavrielc 0bdc6d2bb2 docs: customizing intro, skills model, and skill guidelines
Three public docs establishing the skills-based customization contract:

- docs/customizing.md: the short doorway. The problem (merge fights on
  update), the idea (every change is a skill), how to work (edit first,
  skillify after), the one rule (/update-nanoclaw, never raw git pull),
  and the two-sided deal.
- docs/skills-model.md: the full model. Recipes, skill anatomy, the
  two kinds of skills, registry branches (additive fetch, never merge),
  a test for every integration point, upgrading, migrations and the
  startup tripwire, the maintainer commitments, and the registry
  review rule.
- docs/skill-guidelines.md: the authoritative checklist for writing a
  skill. Two principles (minimal integration surface; a test per
  functional integration point), anatomy, change shapes, testing
  doctrine with archetypes, anti-patterns, worked examples.

Also: CLAUDE.md docs index rows for the three docs, and .gitignore
entries for local-only working artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:12:17 +03:00
github-actions[bot] 820cd8ece6 docs: update token count to 185k tokens · 92% of context window 2026-06-09 19:31:53 +00:00
github-actions[bot] e44d497cdf chore: bump version to 2.1.4 2026-06-09 19:31:49 +00:00
gavrielc ac37ecbfd6 Merge pull request #2720 from nanocoai/security/authorize-create-agent
security: authorize create_agent host-side (approval for confined groups)
2026-06-09 22:31:36 +03:00
gavrielc c6627d32e2 security: authorize create_agent host-side (approval for confined groups)
create_agent writes central-DB state (agent_groups, container_configs,
agent_destinations) and scaffolds host filesystem state, but the only
gate lived inside the untrusted container and is bypassed by writing the
outbound system row directly (the "host re-checks permission" comment was
false). Authorize host-side by CLI scope: trusted owner agent groups
(global scope) create sub-agents directly; confined groups require admin
approval via requestApproval. Adds regression tests for the branch.

Alternative to #2383 (which denies confined groups outright); co-authored
from that work.

Co-Authored-By: hinotoi-agent <paperlantern.agent@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:29:57 +03:00
github-actions[bot] 51bf403b22 chore: bump version to 2.1.3 2026-06-09 19:29:33 +00:00
github-actions[bot] 265953ffec docs: update token count to 184k tokens · 92% of context window 2026-06-09 19:29:29 +00:00
gavrielc 6227bd1a5b Merge pull request #2478 from Hinotoi-agent/security/approval-response-admin-authz
[security] fix(approvals): require admin for approval responses
2026-06-09 22:29:07 +03:00
gavrielc 28032bc0ec Merge pull request #2468 from Hinotoi-agent/security/a2a-attachment-symlink-guard
[security] fix(agent-route): reject unsafe forwarded attachments
2026-06-09 22:29:03 +03:00
github-actions[bot] 3e3a2945a5 chore: bump version to 2.1.2 2026-06-09 18:04:39 +00:00
gavrielc f3fc18e56e chore: bump claude-code to 2.1.170 and agent SDK to 0.3.170
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:04:13 +03:00
github-actions[bot] d85efea229 chore: bump version to 2.1.1 2026-06-08 12:10:39 +00:00
github-actions[bot] c5b22cb308 docs: update token count to 183k tokens · 92% of context window 2026-06-08 12:10:36 +00:00
gavrielc 1592369201 Merge pull request #2713 from nanocoai/feat/egress-lockdown
feat(security): egress lockdown (opt-in, off by default)
2026-06-08 15:10:22 +03:00
Omri Maya 6420c0e254 feat(security): egress lockdown (opt-in) — agent egress only via OneCLI
Place agent containers on a Docker `--internal` network (no internet route)
with the OneCLI gateway attached, aliased host.docker.internal. The injected
proxy URL resolves only to the gateway, so a non-proxy-aware client or raw
socket has nowhere to go — closing the HTTPS_PROXY-bypass hole. The agent is
non-root with no NET_ADMIN, so it cannot undo this. Self-healing: the gateway
is re-attached at every spawn and on each host-sweep tick.

Fail-fast: when lockdown is enabled but the network/gateway can't be
established, refuse to spawn and surface a clear EgressLockdownError rather
than silently falling back to open egress. The host-sweep re-heal is the lone
exception — a heal failure there is logged, not fatal, since running agents
stay on the internal net (no leak) until the gateway returns.

Off by default — opt in with NANOCLAW_EGRESS_LOCKDOWN=true (so OSS users get
the prior behavior unchanged on pull). Also NANOCLAW_EGRESS_NETWORK and
ONECLI_GATEWAY_CONTAINER.

The lockdown logic lives in its own src/egress-lockdown.ts; container-runtime.ts
keeps only the generic runtime surface. Documented in docs/SECURITY.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:23:17 +03:00
gavrielc aef8d38b36 Merge pull request #2710 from markbala/docs/ollama-prefix-cache
docs(ollama): allow prompt caching by filtering the cache-busting hash
2026-06-07 23:21:45 +03:00
gavrielc 6d6f813deb Merge branch 'main' into docs/ollama-prefix-cache 2026-06-07 22:01:26 +03:00
markbala f9c86d0af2 docs(ollama): allow prompt caching by filtering the cache-busting hash
The Claude Agent SDK adds a per-request cch=<hash> to the front of every
prompt; it changes each turn, and Ollama's prompt cache only reuses a
prompt whose start is unchanged, so it re-reads the whole prompt every
time (slow). A tiny proxy filters the hash out (pins cch to a constant) so
caching kicks in. In our setup (31B on Apple Silicon) follow-up replies
went ~80s -> ~4s; numbers vary by model/hardware. Ollama ignores the hash,
so output is unchanged.

Scope: only the Claude-Code-CLI -> Ollama path; Codex/OpenCode emit no cch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:20:11 +08:00
github-actions[bot] 9edb33dd3a docs: update token count to 182k tokens · 91% of context window 2026-06-07 14:06:19 +00:00
gavrielc 8ba5261ae8 Merge pull request #2707 from nanocoai/feat/upgrade-tripwire
feat(upgrade): startup tripwire + upgrade marker
2026-06-07 17:06:03 +03:00
gavrielc 8c84dec8e9 Merge remote-tracking branch 'origin/main' into feat/upgrade-tripwire
# Conflicts:
#	.claude/skills/migrate-nanoclaw/SKILL.md
2026-06-07 17:05:24 +03:00
gavrielc 092487d7ad chore: release 2.1.0; guard auto-bump against deliberate version changes
Set package.json to 2.1.0 to match the CHANGELOG entry for the upgrade
tripwire (a [BREAKING] change warrants a minor bump). The startup
tripwire reads package.json as the source of truth, so this is the
version the gate will enforce.

bump-version.yml previously ran `pnpm version patch` on every push to
main, which would patch a deliberate 2.1.0 up to 2.1.1. It now skips the
auto-bump when the pushed commits already changed package.json
themselves. fetch-depth: 0 so the before/after diff has both tips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:03:02 +03:00
gavrielc 87850aa7f8 docs(changelog): release the upgrade-tripwire entry as 2.1.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:59:30 +03:00
gavrielc 526170fd47 feat(upgrade): add human-addressed guidance to tripwire banner
The startup tripwire message was written for a coding agent and gave a
human no direction — only the bare `set` override (which skips the
migrations the gate guards). Add one human-addressed stanza pointing to
/update-nanoclaw as the correct fix. The tested CODING AGENT block is
left byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:57:13 +03:00
gavrielc 2d9375531b Merge pull request #2698 from nanocoai/feat/skill-exemplars
Skills conformance: exemplars + fleet retrofit (upgrade-maintainable skills)
2026-06-06 20:16:24 +03:00
gavrielc cee19ad300 feat(skills): rewrite use-native-credential-proxy, add-ollama-tool, migrate-from-openclaw for v2
Three skills that were broken on v2 (branch-merge installs of stale branches,
SKILL.md-only with no shipped code, dead v1 schema targets) rewritten to the
additive standard:
- use-native-credential-proxy: a skill-owned .env credential proxy + one-line
  seam reach-in (behavior + wiring tests, REMOVE.md). Explicit OneCLI opt-out;
  the credential-home inversion is flagged in docs/skill-smells.md.
- add-ollama-tool: an atomic-chat-shaped MCP-tool skill — bun stdio server,
  container registration + wiring tests, idempotent REMOVE.md.
- migrate-from-openclaw: retargeted at v2 (data/v2.db, ncl, OneCLI SecretRef,
  v2 recurrence) with a transform unit test and a REMOVE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:09:12 +03:00
gavrielc 14dba9b73f feat(skills): MCP-tool / capability / operational conformance cleanup
Anatomy + correctness pass driven by the audit: REMOVE.md added to every
file-creating skill (gmail/gcal/rtk/mnemon/vercel/macos-statusbar/karpathy);
dead/fabricated reach-ins removed (mnemon's nonexistent OpenCode path and
migration-doc reach-in; migrate-from-v1's nonexistent scanForV1Patterns);
structural Dockerfile dep-tests where a CLI binary was unguarded; debug and
customize rewritten off stale v1 architecture onto v2 (data/v2.db, ncl,
two-DB sessions); update-skills + migrate-nanoclaw's branch-merge reapply
converted to additive re-runs; diff-against-past framing and non-step callouts
stripped throughout. Direct-DB / credential / telemetry smells flagged, not
actioned (see docs/skill-smells.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:59 +03:00
gavrielc 83c245722e feat(skills): provider conformance for opencode and codex
The provider-multipoint archetype: each skill now fetches and runs a
barrel-driven registration test in BOTH trees (host listProviderContainerConfigNames,
container listProviderNames) — pushed to origin/providers — instead of relying on
the shipped *.factory.test.ts, which imports the provider module directly,
self-registers, and stays green when a barrel line is deleted. Adds a structural
Dockerfile dep-test for codex's @openai/codex CLI binary, and a cross-runtime
REMOVE.md that reverses both barrels, the copied files, the dependency, and the
Dockerfile edits. Drops the grep-based "Verify" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:42 +03:00
gavrielc 2908ffccf7 feat(skills): channel-family conformance retrofit
All 15 channel skills brought to the slack/deltachat standard: SKILL.md fetches
and runs the behavior registration test (pushed to origin/channels) in a
"Build and validate" step; REMOVE.md rewritten to delete every copied file and
remove the channel's actual env vars + package (each was individually wrong —
e.g. discord falsely claimed "no package to uninstall"); the VERIFY.md
anti-pattern deleted across the fleet; REMOVE.md created for emacs and whatsapp.
linear drops its stale bridge-patch step and relies on the bridge default +
the wiring's engage mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:31 +03:00
gavrielc 28e814d70c chore(skills): drop 4 skills broken on v2 + fix dangling refs
claw, x-integration, add-parallel, and convert-to-apple-container target
removed v1 architecture (v1 DB schema, file-IPC) or install via a forbidden
branch-merge of a stale branch — they can't be made conformant and are retired.
Cleans up the references to them in README.md, docs/SPEC.md, CONTRIBUTING.md,
and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:08:09 +03:00
gavrielc ff22a2bcfb skill(add-dashboard): document that build guards the @nanoco dependency
The behavior test mocks @nanoco/nanoclaw-dashboard (its startDashboard binds a
real port), so the test alone passes with the dependency missing. The build
step is what catches a missing dep (TS2307 on the `await import(...)`), so the
validate step must run `pnpm run build` before the tests. Make that explicit so
it survives edits — a dependency install is an integration point that needs a
red-on-missing check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:45 +03:00
gavrielc 4578b58a0b skill(add-deltachat,add-slack): behavior registration test, implicit dep check
The registration tests now import the real barrel and assert the registry
contains the channel (not a structural source parse). Update the validate-step
prose accordingly: the test also goes red if the adapter package isn't
installed (the unmocked barrel import throws), so it implicitly verifies the
dependency-install step — and a structural check would falsely pass when the
barrel can't evaluate or the dep is missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:05:36 +03:00
gavrielc b23c25fa19 skill(add-slack): make conformant — integration test, drop VERIFY.md
Retrofits the Slack skill to the two core principles and establishes the
template for the Chat SDK channel family (discord, telegram, teams, gchat,
webex, linear, github, …), which all share this single-barrel-import shape:

- Fetch and run a new src/channels/slack-registration.test.ts (lives on the
  channels branch next to the adapter, copied in via git show). Structural
  barrel parse asserting the `import './slack.js';` line — the one reach-in
  that fires the adapter's top-level registerChannelAdapter. Hermetic (does
  not import @chat-adapter/slack); red-on-delete. SKILL.md gains a
  build+validate step that also notes the build leg guards the adapter's
  createChatSdkBridge core-API consumption.
- REMOVE.md now deletes the import line and rm's the adapter and its test
  (was a soft comment-out), and re-syncs .env to the container.
- Drop VERIFY.md — tests are the verification; its manual check is covered by
  Next Steps / webhook setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:56:09 +03:00
gavrielc c6afef1fe6 skill(add-deltachat): make conformant — integration test, drop VERIFY.md
Retrofits the existing deltachat channel skill to the two core principles
(minimal integration surface + a test for every functional integration point):

- Fetch and run a new src/channels/deltachat-registration.test.ts (lives on
  the channels branch next to the adapter, copied in via git show like the
  adapter itself). It guards the skill's one reach-in — the
  `import './deltachat.js';` line in the channel barrel that fires the
  adapter's top-level registerChannelAdapter. Structural barrel parse rather
  than importing it, so the native @deltachat/stdio-rpc-server isn't pulled
  into the host test process; the build leg covers that the import resolves.
  Red-on-delete of the barrel line. SKILL.md gains a build+validate step.
- REMOVE.md now deletes the import line and rm's the adapter and its test
  (was a soft comment-out), per the anatomy rule that remove reverses every
  change including copied test files.
- Drop VERIFY.md — tests are the verification; its manual log/connectivity/
  e2e checks were operational and already covered in Troubleshooting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:21:23 +03:00
gavrielc 3ddfbfabd2 feat(add-atomic-chat-tool): make conformant — integration tests + standalone remove
- Add red-on-delete integration tests for both reach-ins:
  - index.ts mcpServers registration (AST, container/Bun tree)
  - buildContainerArgs env-forward call (AST, host/Node tree)
- Extract env forwarding into src/atomic-chat-env.ts so the container-runner
  reach-in is a single call (minimal integration point)
- Drop the redundant providers/claude.ts allowlist edit — the allow-pattern is
  derived from registered MCP server names
- Move removal into a standalone REMOVE.md; SKILL.md reads as a self-contained,
  present-tense artifact
- Document the shared stderr logger reach-in as a known hotspot (registry candidate)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:25:06 +03:00
gavrielc 6592e8461c feat(add-dashboard): conformant skill (minimal integration point + tests)
First exemplar of the skills upgradeability model, built example-first.
Giving the skill real tests surfaced — and the build caught — two silent
drifts that would break any adopter today.

What conformance means here:
- Minimal integration point: all startup logic lives in startDashboard() in
  the skill's own file; the edit to src/index.ts is a single colocated block
  (dynamic import + await call) in main() — no top-of-file import.
- Behavior test (dashboard-pusher.test.ts): real in-memory DB + real pusher
  + fake dashboard HTTP endpoint; asserts the /api/ingest snapshot on both
  the enabled and disabled paths.
- Wiring test (dashboard-wiring.test.ts): TS-AST assertion that index.ts
  dynamically imports the pusher and awaits startDashboard() colocated in
  main(), after DB init and before the boot-complete log — catches deletion
  AND misplacement, which a grep can't.
- Build catches drift: fixed imports of five DB modules that moved into
  src/modules/, and a stale g.container_config (now getContainerConfig()).

apply copies all three files and runs the tests; remove deletes them and the
single index.ts block. apply/remove stay markdown prose; the tests are the
verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:25:06 +03:00
gavrielc e734e5cddd feat(upgrade): startup tripwire + upgrade marker
Refuse to start unless this install reached the current version through a
sanctioned path (setup / update / migrate). A raw `git pull` that skips
migrations now fails loudly with a self-healing message instead of
silently breaking.

- src/upgrade-state.ts: marker at data/upgrade-state.json, getCodeVersion,
  isUpgradeCurrent, enforceUpgradeTripwire (fails closed on missing /
  corrupt / mismatched marker)
- src/index.ts: gate wired in at startup step 0.5, before DB init
- scripts/upgrade-state.ts: get/set CLI (also the override / recovery cmd)
- setup/service.ts, /update-nanoclaw, /migrate-nanoclaw: stamp on success;
  update/migrate also self-update their own skill first
- CHANGELOG [BREAKING] entry bridges existing installs via the skills'
  breaking-change check
- docs/upgrade-recovery.md: clearing the tripwire

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:02:12 +03:00
github-actions[bot] d14472142d chore: bump version to 2.0.76 2026-06-05 08:04:24 +00:00
gavrielc 0c1897ad12 fix: blank the secret_url path instead of /*
A bare * in the pre-filled secret_url path doesn't survive (the gateway
URL-encodes everything, so an unencoded * collapses to just /, which only
exact-matches the path /). Leave the path blank instead so the created
secret matches all of huggingface.co, not a single endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:04:06 +03:00
github-actions[bot] d16b24d5b4 docs: update token count to 181k tokens · 91% of context window 2026-06-05 07:56:50 +00:00
github-actions[bot] d0de64b999 chore: bump version to 2.0.75 2026-06-05 07:56:45 +00:00
gavrielc f3fde69536 fix: trim the upload-trace not-signed-in message
Drop "(host pattern pre-filled)" and "— no restart needed" from the HF
setup instructions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:56:30 +03:00
github-actions[bot] 20140c84be chore: bump version to 2.0.74 2026-06-05 07:55:13 +00:00
gavrielc 33c36842fa Merge pull request #2691 from nanocoai/upload-trace-gateway-setup-url
feat: show OneCLI's own setup URL when HF token is missing
2026-06-05 10:54:58 +03:00
hinotoi-agent 32f067f5bb fix(cli): preserve caller context after approval 2026-05-25 19:51:54 +08:00
hinotoi-agent 728c6a641b fix(approvals): require admin for approval responses 2026-05-15 10:34:46 +08:00
hinotoi-agent 8385236c30 fix(agent-route): reject unsafe forwarded attachments 2026-05-14 21:04:04 +08:00
424 changed files with 24909 additions and 7052 deletions
+1
View File
@@ -0,0 +1 @@
../.claude/skills
@@ -0,0 +1,49 @@
# Remove Atomic Chat
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied files (both trees)
```bash
rm -f container/agent-runner/src/atomic-chat-mcp-stdio.ts \
container/agent-runner/src/atomic-chat-registration.test.ts \
src/atomic-chat-env.ts \
src/atomic-chat-wiring.test.ts
```
## 2. Unregister the MCP server
In `container/agent-runner/src/index.ts`, remove the `atomic_chat: { … }` entry from the `mcpServers` object (leave `nanoclaw` and any other entries).
## 3. Revert the host-side edits in `src/container-runner.ts`
- Remove the `import { atomicChatEnvArgs } from './atomic-chat-env.js';` import.
- Remove the `args.push(...atomicChatEnvArgs());` line that follows the `TZ` env line.
- Restore the `container.stderr` logger to its single-line `log.debug(line, …)` form (remove the `[ATOMIC]` info-level branch).
## 4. Remove env vars
Remove the Atomic Chat block from `.env.example`, and the `ATOMIC_CHAT_*` lines from `.env` if you set them.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```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)
```
## Verification
After removal, confirm the tool is gone — in a wired agent, asking it to "list atomic chat models" should report no such tool, and the logs should show no `[ATOMIC]` lines after the last restart:
```bash
grep "\[ATOMIC\]" logs/nanoclaw.log | tail -5
```
+37 -30
View File
@@ -13,7 +13,7 @@ Tools exposed:
Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library.
The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent.
The skill ships the MCP server source (and its test) in this folder and copies them into the agent-runner tree at install time, then registers the server in `index.ts` and forwards host env vars in `container-runner.ts`. Registering the server is enough to expose its tools — the agent's allow-pattern (`mcp__atomic_chat__*`) is derived from the registered server name.
## Phase 1: Pre-flight
@@ -39,10 +39,19 @@ If the request fails:
## Phase 2: Apply Code Changes
### Copy the MCP server source
### Copy the skill's source and tests into both trees
This skill reaches into both the container (Bun) tree and the host (Node) tree, so its
files go into both, alongside the integration points they cover.
```bash
cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
S=.claude/skills/add-atomic-chat-tool
# Container (Bun) tree — the MCP server and the registration wiring test
cp $S/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts
cp $S/atomic-chat-registration.test.ts container/agent-runner/src/atomic-chat-registration.test.ts
# Host (Node) tree — the env-forwarding helper and the wiring test
cp $S/atomic-chat-env.ts src/atomic-chat-env.ts
cp $S/atomic-chat-wiring.test.ts src/atomic-chat-wiring.test.ts
```
### Register the MCP server in the agent-runner
@@ -79,40 +88,31 @@ Add an `atomic_chat` entry alongside `nanoclaw`:
};
```
### Add the tool glob to the allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line:
```ts
'mcp__nanoclaw__*',
'mcp__atomic_chat__*',
];
```
`atomic-chat-registration.test.ts` asserts this entry is present and points at the server module — the tool only appears to the agent if it is registered here.
### Forward host env vars into the container
Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line:
The env-forwarding logic lives in the copied `src/atomic-chat-env.ts` (`atomicChatEnvArgs()`), so the reach-in into `buildContainerArgs` is a single call.
Import it in `src/container-runner.ts` (alongside the other local imports):
```ts
import { atomicChatEnvArgs } from './atomic-chat-env.js';
```
Then, in `buildContainerArgs`, find the `TZ` env line and add the call right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
args.push(...atomicChatEnvArgs());
```
Add ATOMIC_CHAT forwarding right after it:
```ts
args.push('-e', `TZ=${TIMEZONE}`);
// Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337).
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
```
`atomic-chat-wiring.test.ts` asserts this `args.push(...atomicChatEnvArgs())` call exists inside `buildContainerArgs`.
### Surface `[ATOMIC]` log lines at info level
> **Shared block.** This rewrites the `container.stderr` logger, which other local-model tools (e.g. `add-ollama-tool` for `[OLLAMA]`) also edit to surface their own prefix. Touch only the `[ATOMIC]` branch and leave the rest of the block intact, so the edits coexist and removal restores it cleanly.
In the same file, find the stderr logger:
```ts
@@ -157,10 +157,18 @@ Append to `.env.example`:
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
# Host tree: buildContainerArgs wiring
pnpm exec vitest run src/atomic-chat-wiring.test.ts
# Container tree: index.ts registration
(cd container/agent-runner && bun test src/atomic-chat-registration.test.ts)
./container/build.sh
```
All three must be clean before proceeding.
All must be clean before proceeding. The wiring and registration tests confirm the two
integration points — the `buildContainerArgs` call and the `index.ts` registration — are
actually in place; a failure means one drifted. (The MCP server's own request/response
behavior against Atomic Chat is the author's build-time concern, not part of these tests —
verify it manually in Phase 4.)
## Phase 3: Configure
@@ -218,9 +226,8 @@ Look for:
The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means:
1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers`
3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST`
4. The container wasn't rebuilt — run `./container/build.sh`
2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` (the allow-pattern is derived from this, so registration is the only thing to check)
3. The container wasn't rebuilt — run `./container/build.sh`
### "Failed to connect to Atomic Chat"
@@ -0,0 +1,18 @@
/**
* Host-side env forwarding for the Atomic Chat MCP tool. Returns the Docker `-e`
* arguments that pass any `ATOMIC_CHAT_*` host overrides into the container.
*
* Lives in its own file so the reach-in in `container-runner.ts` is a single call
* (`args.push(...atomicChatEnvArgs())`) and this logic is behavior-testable in
* isolation, without invoking the OneCLI-entangled `buildContainerArgs`.
*/
export function atomicChatEnvArgs(): string[] {
const args: string[] = [];
if (process.env.ATOMIC_CHAT_HOST) {
args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`);
}
if (process.env.ATOMIC_CHAT_API_KEY) {
args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`);
}
return args;
}
@@ -0,0 +1,65 @@
/**
* Wiring test for the MCP-server registration integration point (container/Bun tree).
*
* The handlers are behavior-tested in atomic-chat-mcp-stdio.test.ts, but that does not
* prove the server is registered — delete the index.ts entry and the tool simply never
* appears, yet the handler test stays green. index.ts is the container boot entry and is
* not cheaply invocable, so we assert the registration structurally: the `mcpServers`
* object literal has an `atomic_chat` property whose command runs `atomic-chat-mcp-stdio.ts`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.join(import.meta.dir, 'index.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
/** Find the object literal assigned to `const mcpServers = { ... }`. */
function mcpServersLiteral(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'mcpServers' &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
found = node.initializer;
}
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
function property(obj: ts.ObjectLiteralExpression, name: string): ts.PropertyAssignment | undefined {
return obj.properties.find(
(p): p is ts.PropertyAssignment =>
ts.isPropertyAssignment(p) &&
((ts.isIdentifier(p.name) && p.name.text === name) ||
(ts.isStringLiteral(p.name) && p.name.text === name)),
);
}
describe('index.ts registers the atomic_chat MCP server', () => {
const obj = mcpServersLiteral(sourceFile());
it('finds the mcpServers object literal', () => {
expect(obj).toBeDefined();
});
it('has an atomic_chat entry', () => {
expect(obj && property(obj, 'atomic_chat')).toBeDefined();
});
it('points atomic_chat at atomic-chat-mcp-stdio.ts', () => {
const entry = obj && property(obj, 'atomic_chat');
const text = entry ? entry.getText() : '';
expect(text).toContain('atomic-chat-mcp-stdio.ts');
});
});
@@ -0,0 +1,69 @@
/**
* Wiring test for the host-side env-forwarding integration point (host/vitest tree).
*
* The env helper is behavior-tested in atomic-chat-env.test.ts, but that does not prove
* buildContainerArgs actually uses it — a direct unit test stays green even if the reach-in
* is deleted. buildContainerArgs is entangled with OneCLI and not cheaply invocable, so we
* assert the integration structurally: inside buildContainerArgs there is an
* `args.push(...atomicChatEnvArgs())` call. Delete the reach-in and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import ts from 'typescript';
function sourceFile(): ts.SourceFile {
const p = path.resolve(process.cwd(), 'src/container-runner.ts');
return ts.createSourceFile(p, fs.readFileSync(p, 'utf8'), ts.ScriptTarget.Latest, true);
}
function findFunction(sf: ts.SourceFile, name: string): ts.FunctionDeclaration | undefined {
let found: ts.FunctionDeclaration | undefined;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/** Is this node `args.push(...atomicChatEnvArgs())`? */
function isSpreadPushOfEnvArgs(node: ts.Node): boolean {
if (!ts.isCallExpression(node)) return false;
const callee = node.expression;
if (
!ts.isPropertyAccessExpression(callee) ||
callee.name.text !== 'push' ||
!ts.isIdentifier(callee.expression) ||
callee.expression.text !== 'args'
) {
return false;
}
return node.arguments.some(
(arg) =>
ts.isSpreadElement(arg) &&
ts.isCallExpression(arg.expression) &&
ts.isIdentifier(arg.expression.expression) &&
arg.expression.expression.text === 'atomicChatEnvArgs',
);
}
describe('container-runner.ts wires in atomicChatEnvArgs', () => {
const sf = sourceFile();
const fn = findFunction(sf, 'buildContainerArgs');
it('finds buildContainerArgs', () => {
expect(fn).toBeDefined();
});
it('calls args.push(...atomicChatEnvArgs()) inside buildContainerArgs', () => {
let wired = false;
const visit = (node: ts.Node) => {
if (isSpreadPushOfEnvArgs(node)) wired = true;
if (!wired) ts.forEachChild(node, visit);
};
if (fn?.body) visit(fn.body);
expect(wired).toBe(true);
});
});
+68
View File
@@ -0,0 +1,68 @@
# Remove /add-audit
Reverses every change apply made. The audit day-files under `data/audit/` are
the operator's records — this removes the recorder, not the recordings; delete
`data/audit/` yourself if you also want the history gone.
## 1. Delete every copied file
```bash
rm -rf src/audit
rm -f src/audit-wiring.test.ts \
src/cli/dispatch.audit.ts src/cli/dispatch.audit.test.ts \
src/cli/resources/audit.ts \
src/modules/approvals/approvals.audit.ts src/modules/approvals/approvals.audit.test.ts \
src/modules/approvals/approvals-observer.audit.ts \
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
src/modules/permissions/permissions.audit.ts src/modules/permissions/permissions.audit.test.ts \
src/modules/agent-to-agent/create-agent.audit.ts src/modules/agent-to-agent/create-agent.audit.test.ts
```
## 2. Revert the seam compositions (DELETE lines, do not comment out)
- `src/cli/resources/index.ts`: delete the `import './audit.js';` line.
- `src/cli/dispatch.ts`: delete the `import { withAudit } from './dispatch.audit.js';`
line and the `export const dispatch = withAudit(dispatchInner);` block (with
its comment); rename `async function dispatchInner(` back to
`export async function dispatch(`.
- `src/modules/approvals/primitive.ts`: delete the
`import { auditRequestApproval } from './approvals.audit.js';` line (and its
comment) and the `export const requestApproval = auditRequestApproval(...)`
block; rename `async function requestApprovalInner(` back to
`export async function requestApproval(`.
- `src/modules/approvals/response-handler.ts`: delete the
`import { runApprovedHandler } from './approvals.audit.js';` line; replace the
`await runApprovedHandler(...)` call (and its comment) with:
`await handler({ session, payload, userId, approvalId: approval.approval_id, notify });`
- `src/modules/approvals/onecli-approvals.ts`: delete the adapter import line,
the `recordOneCliHold` const (change `recordOneCliHold({` back to
`createPendingApproval({`), and the three wrapper consts (with comments);
rename the three `...Inner` functions back (`resolveOneCLIApprovalInner`
`export function resolveOneCLIApproval`, `expireApprovalInner`
`expireApproval`, `sweepStaleApprovalsInner``sweepStaleApprovals`).
- `src/modules/permissions/index.ts`: delete the adapter import line; restore
the three plain registrations:
```ts
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
```
- `src/index.ts`: delete the audit block in `main()` (the comment, the two
`await import(...)` lines, and `initAuditLog();`).
- `src/host-sweep.ts`: delete the audit-maintenance `try { ... }` block in
`sweep()` (with its comment).
## 3. Revert config and env
- `src/config.ts`: remove `'AUDIT_ENABLED',` and `'AUDIT_RETENTION_DAYS',`
from the `readEnvFile([...])` array, and delete the
`AUDIT_ENABLED` / `AUDIT_RETENTION_DAYS` export block (with its comments).
- `.env`: remove the `AUDIT_ENABLED` and `AUDIT_RETENTION_DAYS` lines.
## 4. Verify
```bash
pnpm run build && pnpm test
```
Then restart the service.
+303
View File
@@ -0,0 +1,303 @@
---
name: add-audit
description: Add an opt-in local audit log — every ncl command (both transports, including denials), every host-routed approval (pending/decision/terminal, correlated by approval id), permissions card decisions, OneCLI credential holds, and ungated agent creation, written as SIEM-shaped append-only NDJSON day-files under data/audit/. Read back with `ncl audit list`; export exporters via registerAuditHook. Off until AUDIT_ENABLED=true.
---
# /add-audit — opt-in local audit log
Records one canonical, SIEM-shaped event per action — actor, origin, dotted
action, touched resources, outcome, approval correlation — to append-only
NDJSON day-files under `data/audit/`. Feature is **opt-in**: with
`AUDIT_ENABLED` unset nothing is persisted, the emit seam no-ops at one
boolean check, and `data/audit/` is never created.
Architecture: `src/audit/` is a **domain-free leaf** (schema, emit seam,
store, reader, post-write hooks, shared vocabulary). How each domain
describes itself lives in **domain-owned `*.audit.ts` adapter files** next to
the code they observe; each composes at its module's edge in one line.
Business logic contains zero audit calls: `grep emitAuditEvent src/` matches
only `src/audit/` and `*.audit.ts`.
The adapters compose on seams trunk already ships (`DispatchTrace`,
`ApprovalHold`, `SenderApprovalResult`/`ChannelApprovalResult`,
`CreateAgentResult`, the `userId` arg on `resolveOneCLIApproval`). If an edit
below is already present, skip it — apply is safe to re-run.
## Steps
### 1. Copy the files
```bash
cp -R .claude/skills/add-audit/add/src/. src/
```
Adds `src/audit/` (the leaf + its tests), the domain adapters
(`src/cli/dispatch.audit.ts`, `src/modules/approvals/approvals.audit.ts`,
`src/modules/approvals/approvals-observer.audit.ts`,
`src/modules/permissions/permissions.audit.ts`,
`src/modules/agent-to-agent/create-agent.audit.ts`), their tests, the
`ncl audit` resource (`src/cli/resources/audit.ts`), and
`src/audit-wiring.test.ts`. No dependency is added — the module is stdlib-only.
### 2. Register the `ncl audit` resource
Append to `src/cli/resources/index.ts`:
```ts
import './audit.js';
```
### 3. Add the two config vars to `src/config.ts`
Add both names to the `readEnvFile([...])` array:
```ts
'AUDIT_ENABLED',
'AUDIT_RETENTION_DAYS',
```
Then append after the `ONECLI_GATEWAY_CONTAINER` export block:
```ts
// Local audit log — opt-in (installed by /add-audit). Off by default: the
// audit emitter no-ops and data/audit/ is never created.
export const AUDIT_ENABLED = (process.env.AUDIT_ENABLED || envConfig.AUDIT_ENABLED) === 'true';
// Audit day-files older than this many days are unlinked (a hard delete).
// 0 = keep forever. Read only when AUDIT_ENABLED=true.
const auditRetentionRaw = parseInt(process.env.AUDIT_RETENTION_DAYS || envConfig.AUDIT_RETENTION_DAYS || '90', 10);
export const AUDIT_RETENTION_DAYS = Number.isNaN(auditRetentionRaw) ? 90 : auditRetentionRaw;
```
### 4. Compose the dispatch middleware — `src/cli/dispatch.ts`
Add to the import block:
```ts
import { withAudit } from './dispatch.audit.js';
```
Rename the dispatcher (signature line only):
```ts
export async function dispatch( async function dispatchInner(
```
Insert immediately after `dispatchInner`'s closing brace (before the
`registerApprovalHandler('cli_command', ...)` call):
```ts
/**
* Public dispatcher — the audit middleware wraps the inner dispatcher, so the
* socket server, the container delivery-action, and the approved replay are
* all covered without changing a call site.
*/
export const dispatch = withAudit(dispatchInner);
```
### 5. Decorate requestApproval — `src/modules/approvals/primitive.ts`
Add to the import block:
```ts
// Sibling adapter import; it imports this module back type-only (no cycle).
import { auditRequestApproval } from './approvals.audit.js';
```
Rename the request function (signature line only):
```ts
export async function requestApproval( async function requestApprovalInner(
```
Insert immediately after `requestApprovalInner`'s closing brace:
```ts
/**
* Public export — the audit decorator wraps the inner request so every gated
* hold emits its pending event from one place. Pass-through: callers see the
* hold exactly as the inner returns it.
*/
export const requestApproval = auditRequestApproval(requestApprovalInner);
```
### 6. Wrap the approved-handler run — `src/modules/approvals/response-handler.ts`
Add to the import block:
```ts
import { runApprovedHandler } from './approvals.audit.js';
```
Replace the handler invocation inside `handleRegisteredApproval`:
```ts
await handler({ session, payload, userId, approvalId: approval.approval_id, notify });
```
with:
```ts
// runApprovedHandler wraps the invocation to emit the gated chain's
// terminal audit event; rethrows, so the catch below behaves as before.
await runApprovedHandler(
handler,
{ session, payload, userId, approvalId: approval.approval_id, notify },
approval,
session,
);
```
### 7. Wrap the OneCLI paths — `src/modules/approvals/onecli-approvals.ts`
Add to the import block:
```ts
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
```
Four compositions:
a. After the `shortApprovalId()` function, add:
```ts
/** Row insert for a hold — the audit wrapper emits the pending event from it. */
const recordOneCliHold = auditOneCliHold(createPendingApproval);
```
and inside `handleRequest`, change the row insert `createPendingApproval({`
to `recordOneCliHold({`.
b. Rename `export function resolveOneCLIApproval(` to
`function resolveOneCLIApprovalInner(` (signature line only) and insert after
its closing brace:
```ts
/**
* The audit wrapper records the decision with the clicking admin as actor
* (OneCLI rows never reach notifyApprovalResolved, so the shared observer
* can't cover them).
*/
export const resolveOneCLIApproval = auditOneCliDecision(resolveOneCLIApprovalInner);
```
c. Rename `async function expireApproval(` to
`async function expireApprovalInner(` and insert after its closing brace:
```ts
/** Timer-driven expiry — the audit wrapper records a system-actor rejection. */
const expireApproval = auditOneCliExpiry(expireApprovalInner);
```
d. Rename `async function sweepStaleApprovals(` to
`async function sweepStaleApprovalsInner(` and insert after its closing brace:
```ts
/** Startup sweep — the audit wrapper records a system-actor rejection per row. */
const sweepStaleApprovals = auditOneCliSweep(sweepStaleApprovalsInner, () =>
getPendingApprovalsByAction(ONECLI_ACTION),
);
```
### 8. Wrap the permissions decisions — `src/modules/permissions/index.ts`
Add to the import block:
```ts
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
```
Replace the three registration coercions:
```ts
registerResponseHandler(async (payload) => (await handleSenderApprovalResponse(payload)).claimed);
registerResponseHandler(auditSenderDecision(handleSenderApprovalResponse));
registerResponseHandler(async (payload) => (await handleChannelApprovalResponse(payload)).claimed);
registerResponseHandler(auditChannelDecision(handleChannelApprovalResponse));
registerMessageInterceptor(async (event) => (await channelNameInterceptor(event)).claimed);
registerMessageInterceptor(auditChannelNameInterceptor(channelNameInterceptor));
```
### 9. Boot wiring — `src/index.ts`
Insert inside `main()`, after the `migrateGroupsToClaudeLocal();` step and
before the container-runtime step:
```ts
// Audit log (optional — installed by /add-audit; AUDIT_ENABLED gates writes).
// The observer import self-registers approvals.decide; initAuditLog asserts
// data/audit/ is writable when enabled (throw → exit 1) and starts hooks.
await import('./modules/approvals/approvals-observer.audit.js');
const { initAuditLog } = await import('./audit/index.js');
initAuditLog();
```
### 10. Sweep wiring — `src/host-sweep.ts`
Insert inside `sweep()`, immediately before the `setTimeout(sweep, SWEEP_INTERVAL_MS);` reschedule:
```ts
// Audit maintenance (installed by /add-audit) — retention prune (throttled
// to once per UTC day inside the module) + post-write hooks' maintain().
try {
const { maintainAudit } = await import('./audit/index.js');
maintainAudit();
} catch (err) {
log.error('Audit maintenance failed', { err });
}
```
### 11. Enable it
Append to `.env` (this is the point of installing the skill — but the switch
stays yours):
```bash
AUDIT_ENABLED=true
# Optional; default 90. 0 = keep forever.
#AUDIT_RETENTION_DAYS=90
```
### 12. Verify
```bash
pnpm run build
pnpm exec vitest run src/audit src/audit-wiring.test.ts src/cli/dispatch.audit.test.ts \
src/modules/approvals/primitive.audit.test.ts src/modules/approvals/response-handler.audit.test.ts \
src/modules/approvals/approvals.audit.test.ts src/modules/permissions/permissions.audit.test.ts \
src/modules/agent-to-agent/create-agent.audit.test.ts
pnpm test # full suite — the composed system must stay green
```
Then restart the service (macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
Linux: `systemctl --user restart nanoclaw`).
## Usage
```bash
ncl audit list # newest first, default limit 100
ncl audit list --actor host:moshe --since 7d
ncl audit list --outcome denied --since 90d --format ndjson # SIEM export
ncl audit list --correlation appr-... # everything on one gated chain
```
Host socket + `cli_scope: global` agents only; group-scoped agents never see
the resource (audit spans groups — exclusion fails closed). On a disabled box
the command errors with "audit log is disabled" rather than returning an
empty list that would read as history.
Exporters: call `registerAuditHook(...)` (from `src/audit/index.js`) in a
module imported at boot — `onEvent` fires only after a successful local
append, so an exporter can never know an event the source of truth doesn't.
## Semantics worth knowing
- **Fail-open + loud**: a failed append is `log.error`'d and the action
proceeds; at boot (enabled only) the host refuses to start if `data/audit/`
isn't writable.
- **Append-only**: nothing updates a line; retention prune unlinks whole
day-files past `AUDIT_RETENTION_DAYS` (a literal hard delete).
- **No message bodies**: message-bearing events record shape only
(`body_chars`, attachment names); a recursive key-pattern redactor masks
secret-looking values at the emit seam.
@@ -0,0 +1,104 @@
/**
* Wiring test for the /add-audit skill's two boot-path code edits.
*
* The skill inserts one colocated block into src/index.ts (dynamic import of
* the self-registering approvals observer + `initAuditLog()`), and one into
* src/host-sweep.ts (`maintainAudit()` inside the sweep, fail-isolated). A
* behavioral test can't see whether those edits are present and correctly
* placed — booting the real host is too heavy — so this asserts them
* structurally, via the TypeScript AST:
* - the observer module is dynamically imported by its correct path and
* initAuditLog() runs after it (registration before enablement),
* - both are DIRECT statements of main()'s body, after DB init and before
* the boot-complete log,
* - maintainAudit() is called inside sweep() before the reschedule.
*
* Delete or misplace an edit and this goes red. The seam wrappers themselves
* are covered behaviorally (dispatch.audit.test.ts and friends).
*
* Ships with the skill; apply copies it to src/.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
function bodyOf(file: string, fnName: string): { stmts: ts.NodeArray<ts.Statement>; sf: ts.SourceFile } {
const source = fs.readFileSync(path.resolve(process.cwd(), file), 'utf8');
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
let body: ts.NodeArray<ts.Statement> | undefined;
sf.forEachChild((n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === fnName && n.body) {
body = n.body.statements;
}
});
if (!body) throw new Error(`${fnName}() not found in ${file}`);
return { stmts: body, sf };
}
/** `await import('<path>')` as a bare expression statement. */
function isAwaitedImportStatement(s: ts.Statement, importPath: string): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isAwaitExpression(s.expression) &&
ts.isCallExpression(s.expression.expression) &&
s.expression.expression.expression.kind === ts.SyntaxKind.ImportKeyword &&
ts.isStringLiteral(s.expression.expression.arguments[0]!) &&
(s.expression.expression.arguments[0] as ts.StringLiteral).text === importPath
);
}
/** `const { ... } = await import('<path>')` as a statement. */
function isDynamicImportBinding(s: ts.Statement, importPath: string): boolean {
if (!ts.isVariableStatement(s)) return false;
const init = s.declarationList.declarations[0]?.initializer;
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
const call = init.expression;
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
const arg = call.arguments[0];
return !!arg && ts.isStringLiteral(arg) && arg.text === importPath;
}
function isBareCall(s: ts.Statement, callee: string): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
ts.isIdentifier(s.expression.expression) &&
s.expression.expression.text === callee
);
}
describe('/add-audit wiring in src/index.ts', () => {
it('imports the self-registering observer, then initAuditLog(), colocated in main() after DB init and before the boot-complete log', () => {
const { stmts, sf } = bodyOf('src/index.ts', 'main');
const observerIdx = stmts.findIndex((s) =>
isAwaitedImportStatement(s, './modules/approvals/approvals-observer.audit.js'),
);
const importIdx = stmts.findIndex((s) => isDynamicImportBinding(s, './audit/index.js'));
const callIdx = stmts.findIndex((s) => isBareCall(s, 'initAuditLog'));
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
expect(observerIdx, 'the observer must be dynamically imported in main()').toBeGreaterThanOrEqual(0);
expect(importIdx, "dynamic import('./audit/index.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
expect(callIdx, 'initAuditLog() must be a statement of main()').toBeGreaterThanOrEqual(0);
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
expect(observerIdx, 'the observer import must come after DB init').toBeGreaterThan(migrateIdx);
expect(importIdx, 'the audit import must come after the observer import').toBeGreaterThan(observerIdx);
expect(callIdx, 'initAuditLog() must come after its import (colocated)').toBeGreaterThan(importIdx);
expect(callIdx, 'initAuditLog() must run before the boot-complete log').toBeLessThan(runningIdx);
});
});
describe('/add-audit wiring in src/host-sweep.ts', () => {
it('calls maintainAudit() inside sweep(), fail-isolated, before the reschedule', () => {
const { stmts, sf } = bodyOf('src/host-sweep.ts', 'sweep');
const auditIdx = stmts.findIndex((s) => ts.isTryStatement(s) && s.getText(sf).includes('maintainAudit()'));
const rescheduleIdx = stmts.findIndex((s) => s.getText(sf).includes('setTimeout(sweep'));
expect(auditIdx, 'a try-wrapped maintainAudit() must be a statement of sweep()').toBeGreaterThanOrEqual(0);
expect(rescheduleIdx, 'setTimeout(sweep) anchor not found').toBeGreaterThanOrEqual(0);
expect(auditIdx, 'maintainAudit() must run before the sweep reschedules itself').toBeLessThan(rescheduleIdx);
});
});
@@ -0,0 +1,49 @@
/**
* emitAuditEvent — the single opt-in check and the single append point.
*
* Only src/audit/ and the domain-owned `*.audit.ts` adapter files may call
* this (adapters compose it at their module's edge; business logic never
* does): `grep emitAuditEvent src/` outside those files must stay empty.
*/
import { randomUUID } from 'crypto';
import { AUDIT_ENABLED } from '../config.js';
import { log } from '../log.js';
import { notifyAuditHooks } from './hooks.js';
import { redactDetails } from './redact.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 {
// Lazy inputs keep a disabled box at literally zero audit work (and shield
// the action from assembly errors — origin lookups can touch the DB).
if (typeof input === 'function') input = input();
const event: AuditEvent = {
event_id: randomUUID(),
time: new Date().toISOString(),
schema_version: 1,
// Directory-enrichment fields stamp null until the adapter lands.
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,
details: redactDetails(input.details ?? {}),
};
const line = JSON.stringify(event);
appendAuditLine(line);
// Post-write hooks: fired only after the append succeeded, so an exporter
// can never know an event the source of truth doesn't. Failures are
// isolated inside notifyAuditHooks.
notifyAuditHooks(event, line);
// eslint-disable-next-line no-catch-all/no-catch-all -- fail-open is the contract: auditing must never take down the audited action
} catch (err) {
// Fail-open + loud: the audited action must proceed even when the log
// can't be written (a full disk must not brick recovery commands).
const action = typeof input === 'function' ? undefined : input.action;
log.error('Audit append failed — action proceeding (fail-open)', { action, err });
}
}
@@ -0,0 +1,200 @@
/**
* 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,
get AUDIT_ENABLED() {
return state.enabled;
},
AUDIT_RETENTION_DAYS: 90,
DATA_DIR: '/tmp/nanoclaw-test-hooks-unused',
};
});
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('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,92 @@
/**
* 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;
/** Boot hook, called only when audit is enabled. Throw = host refuses to start. */
init?(): void;
/** Periodic maintenance — called from the 60s host-sweep (enabled boxes only). */
maintain?(): void;
/** Graceful-shutdown hook (flush buffers, close handles). */
shutdown?(): void | Promise<void>;
}
const hooks: AuditHook[] = [];
export function registerAuditHook(hook: AuditHook): void {
hooks.push(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) {
try {
hook.init?.();
} catch (err) {
throw new Error(
`audit hook "${hook.name}" failed to initialize: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
}
/** Sweep lifecycle — periodic 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 sweep)
} 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 { redactDetails } from './redact.js';
export { AUDIT_DIR } from './store.js';
export { initAuditLog, maintainAudit } from './init.js';
export { type AuditHook, registerAuditHook } from './hooks.js';
@@ -0,0 +1,40 @@
/**
* Boot-time audit wiring — composed in src/index.ts alongside the import of
* the domain observer (`approvals-observer.audit.ts`, which self-registers).
*
* When enabled: assert data/audit/ is writable (refusing to start beats
* running with a silent audit gap), run the boot prune, and start the
* registered post-write hooks' lifecycle (init here, maintain via the host
* sweep, shutdown via the host's graceful-shutdown registry).
*/
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS } from '../config.js';
import { log } from '../log.js';
import { onShutdown } from '../response-registry.js';
import { initAuditHooks, maintainAuditHooks, shutdownAuditHooks } from './hooks.js';
import { assertAuditWritable, AUDIT_DIR, markPrunedToday, pruneAuditLog, pruneAuditLogIfDue } from './store.js';
export function initAuditLog(): void {
if (!AUDIT_ENABLED) return;
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 → main() exit 1, same posture as the writability assert
onShutdown(() => shutdownAuditHooks());
log.info('Audit log enabled', { dir: AUDIT_DIR, retentionDays: AUDIT_RETENTION_DAYS });
}
/**
* Host-sweep tick: retention prune (throttled to once per UTC day internally)
* plus every hook's periodic maintenance. No-op when audit is disabled.
*/
export function maintainAudit(): void {
pruneAuditLogIfDue();
if (AUDIT_ENABLED) maintainAuditHooks();
}
@@ -0,0 +1,150 @@
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;
},
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('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,160 @@
/**
* 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 { AUDIT_ENABLED } from '../config.js';
import { log } from '../log.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;
}
const abs = Date.parse(value);
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(', ')}`);
}
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: args.limit !== undefined ? Math.max(1, Number(args.limit) || DEFAULT_LIMIT) : DEFAULT_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,69 @@
import { describe, expect, it } from 'vitest';
import { redactDetails } from './redact.js';
describe('redactDetails', () => {
it('masks sensitive keys at any depth, case-insensitively', () => {
const out = redactDetails({
name: 'notion',
env: { NOTION_TOKEN: 'secret-value', SAFE_VALUE: 'ok' },
nested: { Authorization: 'Bearer abc', list: [{ 'api-key': 'k' }, { plain: 'p' }] },
password: 'hunter2',
});
expect(out).toEqual({
name: 'notion',
env: { NOTION_TOKEN: '[REDACTED]', SAFE_VALUE: 'ok' },
nested: { Authorization: '[REDACTED]', list: [{ 'api-key': '[REDACTED]' }, { plain: 'p' }] },
password: '[REDACTED]',
});
});
it('matches the documented key pattern (token|secret|key|password|credential|auth|bearer)', () => {
const out = redactDetails({
access_token: 'x',
clientSecret: 'x',
ssh_key: 'x',
credentials: 'x',
oauth_flow: 'x',
bearerValue: 'x',
username: 'moshe',
});
expect(Object.entries(out).filter(([, v]) => v === '[REDACTED]')).toHaveLength(6);
expect(out.username).toBe('moshe');
});
it('never recurses into a masked key — the whole value is replaced', () => {
const out = redactDetails({ auth: { inner: 'visible?' } });
expect(out.auth).toBe('[REDACTED]');
});
it('truncates strings over 2 KB post-redaction', () => {
const long = 'a'.repeat(5000);
const out = redactDetails({ blob: long, short: 'b' });
expect(out.blob).toBe('a'.repeat(2048) + '…[truncated]');
expect(out.short).toBe('b');
});
it('passes non-string scalars through untouched', () => {
const out = redactDetails({ n: 42, b: true, z: null, u: undefined });
expect(out).toEqual({ n: 42, b: true, z: null, u: undefined });
});
it('caps depth (cycle guard) without throwing', () => {
const cyclic: Record<string, unknown> = { a: 1 };
cyclic.self = cyclic;
const out = redactDetails(cyclic);
let cursor: unknown = out;
for (let i = 0; i < 20 && typeof cursor === 'object' && cursor !== null; i++) {
cursor = (cursor as Record<string, unknown>).self;
}
expect(cursor).toBe('[MAX_DEPTH]');
});
it('does not mutate the input', () => {
const input = { token: 'x', nested: { list: ['a'.repeat(3000)] } };
const snapshot = JSON.parse(JSON.stringify(input));
redactDetails(input);
expect(input).toEqual(snapshot);
});
});
@@ -0,0 +1,39 @@
/**
* Recursive details redaction — runs at the single emit seam so every current
* and future surface is guarded by default. Two rules:
* 1. Any key matching the sensitive pattern is masked to '[REDACTED]'
* (the value is never inspected or recursed into).
* 2. Strings are truncated to ~2 KB post-redaction.
*
* Message bodies are excluded upstream by the per-surface mappers (shape only:
* body_chars, attachment names) — this mask is defense-in-depth, not the
* mechanism that keeps chat content out of the log.
*/
const SENSITIVE_KEY = /(token|secret|key|password|credential|auth|bearer)/i;
const MAX_VALUE_CHARS = 2048;
const MAX_DEPTH = 8;
export function redactDetails(details: Record<string, unknown>): Record<string, unknown> {
return redactObject(details, 0);
}
function redactObject(obj: Record<string, unknown>, depth: number): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = SENSITIVE_KEY.test(k) ? '[REDACTED]' : redactValue(v, depth + 1);
}
return out;
}
function redactValue(value: unknown, depth: number): unknown {
if (typeof value === 'string') {
return value.length > MAX_VALUE_CHARS ? `${value.slice(0, MAX_VALUE_CHARS)}…[truncated]` : value;
}
if (value === null || typeof value !== 'object') return value;
// Depth cap doubles as a cheap cycle guard — details payloads are
// JSON-serializable in practice, but the emit path must never throw.
if (depth > MAX_DEPTH) return '[MAX_DEPTH]';
if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1));
return redactObject(value as Record<string, unknown>, depth);
}
@@ -0,0 +1,183 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Config + log are mocked so store/emit resolve a per-test temp DATA_DIR and
// audit toggles. 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;
},
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');
});
});
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('redacts details at the emit seam', () => {
emit.emitAuditEvent({ ...EVENT_INPUT, details: { env: { API_TOKEN: 'x' } } });
const file = path.join(auditDir(), `${store.utcDay()}.ndjson`);
expect(fs.readFileSync(file, 'utf8')).toContain('"API_TOKEN":"[REDACTED]"');
});
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,96 @@
/**
* Audit day-file store — the tree's first structured file writer.
*
* Daily NDJSON files under data/audit/, named <UTC-day>.ndjson. Append-only is
* structural: nothing in the system can update a line. Retention is unlinking
* whole day-files past the horizon — a literal hard delete, no VACUUM. The
* host process is the single writer by construction (both ncl transports and
* every approval converge host-side).
*/
import fs from 'fs';
import path from 'path';
import { AUDIT_ENABLED, AUDIT_RETENTION_DAYS, DATA_DIR } from '../config.js';
import { log } from '../log.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;
/** UTC day string (YYYY-MM-DD) — day-file names and prune boundaries. */
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. Throws on fs failure — emitAuditEvent catches
* (fail-open + loud lives there, not here).
*/
export function appendAuditLine(line: string): void {
fs.mkdirSync(AUDIT_DIR, { recursive: true });
fs.appendFileSync(dayFilePath(utcDay()), line + '\n');
}
/**
* Boot-time writability assert — called only when AUDIT_ENABLED. A zero-byte
* append is a true write probe (fs.access can pass on read-only mounts).
* Throws so main() refuses to start rather than run with a silent audit gap.
*/
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. Lexicographic compare is correct for ISO days.
*/
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
} catch {
return; // Directory absent — nothing recorded yet.
}
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 stubborn file must not stop the rest of the prune
} 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 });
}
}
// Host-sweep throttle: the sweep ticks every 60s but retention only needs to
// move once per UTC day.
let lastPruneDay: string | null = null;
/** Sweep hook — no-op unless audit is enabled with a finite retention. */
export function pruneAuditLogIfDue(): void {
if (!AUDIT_ENABLED || AUDIT_RETENTION_DAYS <= 0) return;
const today = utcDay();
if (lastPruneDay === today) return;
lastPruneDay = today;
pruneAuditLog();
}
/** Called after the boot-time prune so the first sweep tick doesn't re-prune. */
export function markPrunedToday(): void {
lastPruneDay = utcDay();
}
@@ -0,0 +1,64 @@
/**
* Canonical audit event vocabulary — schema_version 1.
*
* Fields are chosen to project losslessly onto OCSF and Elastic ECS; the
* field mapping lives with the design docs and stays documentation until a
* SIEM forwarder exists.
*/
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>;
}
/**
* Stored record — one NDJSON line. The directory-enrichment fields (email,
* user_id, group_ids) ship in the schema but stamp null until the directory
* adapter lands; they are filled at write time, never backfilled.
*/
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>;
}
/** Actor for timer/sweep-driven outcomes (OneCLI expiry, startup sweeps, ghost rejects). */
export const SYSTEM_ACTOR: AuditActor = { type: 'system', id: 'host' };
@@ -0,0 +1,55 @@
/**
* Domain-free event vocabulary — actor and origin constructors shared by the
* domain-owned `*.audit.ts` adapters. 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,
* OneCLI rows) lives in the adapter file of the domain that owns it.
*/
import os from 'os';
import { getMessagingGroup } from '../db/messaging-groups.js';
import type { Session } from '../types.js';
import { type AuditActor, type AuditOrigin, SYSTEM_ACTOR } 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';
}
}
/** Empty resolver id (sweep/timer paths) → the system actor. */
export function humanOrSystemActor(namespacedUserId: string): AuditActor {
return namespacedUserId ? { type: 'human', id: namespacedUserId } : SYSTEM_ACTOR;
}
export function originForSession(session: Session): AuditOrigin {
return containerOrigin(session.id, session.messaging_group_id);
}
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;
}
/** Approval decisions arrive as card clicks on a chat platform. */
export function channelOriginForUser(namespacedUserId: string): AuditOrigin {
const idx = namespacedUserId.indexOf(':');
const channel = idx > 0 ? namespacedUserId.slice(0, idx) : undefined;
return channel ? { transport: 'channel', channel } : { transport: 'channel' };
}
@@ -0,0 +1,222 @@
/**
* Audit middleware behavior of the exported dispatch — what gets recorded,
* for whom, and when nothing must be recorded. Mirrors dispatch.test.ts
* mocking; audit is force-enabled and the store's append is captured.
*/
import os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: 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' })),
}));
vi.mock('../db/sessions.js', () => ({
getSession: vi.fn(() => ({ id: 's1', agent_group_id: 'g1', messaging_group_id: 'mg1' })),
}));
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),
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
}));
import { register } from './registry.js';
register({
name: 'groups-test',
description: 'echo command on the groups resource',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'groups-get',
description: 'echo command for dash-joined id resolution',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'wirings-list',
description: 'not on the group-scope allowlist',
resource: 'wirings',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => [],
});
register({
name: 'groups-fail',
description: 'handler that throws',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => {
throw new Error('boom');
},
});
register({
name: 'groups-gated',
description: 'approval-gated command',
resource: 'groups',
access: 'approval',
parseArgs: (raw) => raw,
handler: async () => 'ran',
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
const AGENT_CTX: CallerContext = { caller: 'agent', sessionId: 's1', agentGroupId: 'g1', messagingGroupId: 'mg1' };
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
vi.clearAllMocks();
appended.lines.length = 0;
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetResource.mockImplementation((plural: string) => (plural === 'groups' ? { scopeField: 'id' } : 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: { foo: 'bar' },
});
});
it('records effective args after group auto-fill, 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).toMatchObject({ id: 'g1', agent_group_id: 'g1', group: 'g1' });
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'g1' });
});
it('records a denied event for a scope denial, naming the attempted resource type', 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: { error: 'forbidden' },
});
expect(event.details.reason).toContain('scoped');
});
it('records a failure event 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).toContain('boom');
});
it('records nothing for an approval-pending response — the pending event belongs to requestApproval', async () => {
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');
expect(appended.lines).toHaveLength(0);
});
it('records an approved replay with the approval id as correlation_id', async () => {
await dispatch({ id: '1', command: 'groups-gated', args: {} }, AGENT_CTX, {
approved: true,
approvalId: 'appr-123-abc',
});
const [event] = events();
expect(event).toMatchObject({ action: 'groups.gated', outcome: 'success', correlation_id: 'appr-123-abc' });
});
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: { command: 'nope-nothing', error: 'unknown-command' },
});
});
it('records the resolved command and 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 });
expect(event.details.id).toBe(uuid);
});
it('normalizes hyphenated arg keys in details', async () => {
await dispatch({ id: '1', command: 'groups-test', args: { 'dry-run': 'true' } }, { caller: 'host' });
const [event] = events();
expect(event.details).toMatchObject({ dry_run: 'true' });
});
});
@@ -0,0 +1,123 @@
/**
* 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.
*/
import { emitAuditEvent } from '../audit/emit.js';
import { hostUser } from '../audit/vocab.js';
import { containerOrigin } from '../audit/vocab.js';
import { type AuditActor, type AuditOrigin, type AuditOutcome, type AuditResource } from '../audit/types.js';
import { getResource } from './crud.js';
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
import type { CommandDef } from './registry.js';
// Type-only import from the module this adapter wraps — erased at runtime,
// so dispatch.ts importing this file back is not a cycle.
import type { DispatchOptions, DispatchTrace } from './dispatch.js';
// ── 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 details 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;
}
/** 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 effective 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).
*/
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;
}
// ── The dispatch middleware ──
type DispatchInner = (req: RequestFrame, ctx: CallerContext, opts?: DispatchOptions) => Promise<ResponseFrame>;
/**
* Dispatch middleware — the exported `dispatch` is the wrapped function, so
* the socket server, the container delivery-action, and the approved replay
* are all covered without changing a call site.
*
* Outcome derives from the response frame: ok → success, forbidden → denied
* (captures pre-handler scope denials), anything else → failure.
* approval-pending responses are skipped — the requestApproval decorator owns
* every pending event. On replays, opts.approvalId becomes the terminal
* event's correlation_id.
*/
export function withAudit(inner: DispatchInner): DispatchInner {
return async (req, ctx, opts = {}) => {
// Trace out-param: dispatch reassigns `req` internally (dash-id fallback,
// group auto-fill), so the resolved command + effective args surface here.
const trace: DispatchTrace = {};
const res = await inner(req, ctx, { ...opts, trace });
if (!res.ok && res.error.code === 'approval-pending') return res;
emitAuditEvent(() => {
const cmd = trace.cmd;
const args = normalizeArgKeys(trace.args ?? req.args);
const outcome: AuditOutcome = res.ok ? 'success' : res.error.code === 'forbidden' ? 'denied' : 'failure';
const details: Record<string, unknown> = { ...args };
if (!res.ok) {
details.error = res.error.code;
details.reason = res.error.message;
}
if (!cmd) details.command = trace.command ?? req.command;
return {
actor: actorForCaller(ctx),
origin: originForCaller(ctx),
action: cmd ? cmd.action : 'cli.unknown-command',
resources: cmd ? resourcesForCli(cmd, args) : [],
outcome,
correlationId: opts.approvalId ?? null,
details,
};
});
return res;
};
}
@@ -0,0 +1,50 @@
/**
* `ncl audit` — read-only query surface over the local audit log
* (append-only NDJSON day-files under data/audit/, see src/audit/).
*
* Scope: host callers and global-scope agents only. `audit` is deliberately
* NOT on the dispatcher's group-scope allowlist, so group-scoped agents are
* refused before any handler runs (fails closed) — the log spans groups.
* Requires AUDIT_ENABLED=true; a disabled box errors rather than returning an
* empty list that would read as "no actions happened".
*/
import { listAuditEvents } from '../../audit/reader.js';
import { registerResource } from '../crud.js';
registerResource({
name: 'audit_event',
plural: 'audit',
// File-backed, not a table — safe because no generic operations are
// declared, so nothing ever queries this name.
table: '(data/audit/*.ndjson)',
description: 'Local audit log — one event per ncl command and host-routed approval. Newest first.',
idColumn: 'event_id',
columns: [
{
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', 'rejected'],
},
{ name: 'since', type: 'string', description: 'Window start: 7d / 24h / 30m relative, or ISO date' },
{ 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, newest first)' },
{ name: 'format', type: 'string', description: 'Output format', enum: ['ndjson'] },
],
operations: {},
customOperations: {
list: {
access: 'open',
description: 'Query audit events, newest first. --format ndjson streams the stored lines for SIEM export.',
handler: async (args) => listAuditEvents(args),
},
},
});
@@ -0,0 +1,86 @@
/**
* agents.create adapter for the ungated door. The inner is a stub — the
* contract is "call through, emit success/failure naming parent and child".
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../../db/messaging-groups.js', () => ({
getMessagingGroup: () => undefined,
}));
import type { Session } from '../../types.js';
import { auditCreateAgentDirect } from './create-agent.audit.js';
const SESSION: Session = {
id: 'sess-1',
agent_group_id: 'ag-parent',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: '2026-01-01T00:00:00.000Z',
};
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
});
describe('auditCreateAgentDirect', () => {
it('emits agents.create success naming parent and child', async () => {
const wrapped = auditCreateAgentDirect(async () => ({
ok: true,
agentGroupId: 'ag-child',
name: 'Scout',
localName: 'scout',
folder: 'scout',
}));
const result = await wrapped('Scout', 'be helpful', SESSION, { id: 'ag-parent' } as never, () => {});
expect(result.ok).toBe(true);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'agent', id: 'ag-parent' },
action: 'agents.create',
outcome: 'success',
correlation_id: null,
details: { name: 'Scout', parent: 'ag-parent', folder: 'scout', instructions_chars: 10 },
});
expect(event.resources).toEqual([
{ type: 'agent_group', id: 'ag-parent' },
{ type: 'agent_group', id: 'ag-child' },
]);
});
it('emits failure with the reason when creation is refused', async () => {
const wrapped = auditCreateAgentDirect(async () => ({ ok: false, reason: 'destination name collision' }));
await wrapped('Scout', null, SESSION, { id: 'ag-parent' } as never, () => {});
const [event] = events();
expect(event).toMatchObject({ outcome: 'failure', details: { reason: 'destination name collision' } });
});
});
@@ -0,0 +1,51 @@
/**
* Agent-creation audit adapter (installed by /add-audit) — agents.create for
* the ungated door. Wraps ONLY the global-scope direct call: the
* approval-gated path is covered by the pending/decide/terminal chain
* (runApprovedHandler wraps applyCreateAgent's run) — wrapping the shared
* body would double-emit.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import type { AuditResource } from '../../audit/types.js';
import { originForSession } from '../../audit/vocab.js';
import type { AgentGroup, Session } from '../../types.js';
// Type-only import from the module this adapter wraps — erased at runtime,
// so create-agent.ts importing this file back is not a cycle.
import type { CreateAgentResult } from './create-agent.js';
type PerformCreateAgentFn = (
name: string,
instructions: string | null,
session: Session,
sourceGroup: AgentGroup,
notify: (text: string) => void,
) => Promise<CreateAgentResult>;
export function auditCreateAgentDirect(inner: PerformCreateAgentFn): PerformCreateAgentFn {
return async (name, instructions, session, sourceGroup, notify) => {
const result = await inner(name, instructions, session, sourceGroup, notify);
emitAuditEvent(() => {
const resources: AuditResource[] = [{ type: 'agent_group', id: sourceGroup.id }];
const details: Record<string, unknown> = {
name,
parent: sourceGroup.id,
instructions_chars: instructions ? instructions.length : 0,
};
if (result.ok) {
resources.push({ type: 'agent_group', id: result.agentGroupId });
details.folder = result.folder;
} else {
details.reason = result.reason;
}
return {
actor: { type: 'agent', id: session.agent_group_id },
origin: originForSession(session),
action: 'agents.create',
resources,
outcome: result.ok ? 'success' : 'failure',
details,
};
});
return result;
};
}
@@ -0,0 +1,51 @@
/**
* Decision events (installed by /add-audit) — approvals.decide, one per
* resolved approval, riding the existing approval-resolved hook. Covers every
* requestApproval-backed action (cli_command, self-mod, create_agent, a2a
* gate); OneCLI never reaches notifyApprovalResolved and emits its decisions
* from its own wrappers in approvals.audit.ts.
*
* Self-registers on import (the tree's observer idiom) — imported once from
* the audit block in src/index.ts.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import { channelOriginForUser, humanOrSystemActor } from '../../audit/vocab.js';
// Direct file import (not the approvals barrel) to keep the graph tight.
import { type ApprovalResolvedEvent, registerApprovalResolvedHandler } from './primitive.js';
import { auditActionForApproval, resourcesForApproval } from './approvals.audit.js';
export function onApprovalResolved(event: ApprovalResolvedEvent): void {
emitAuditEvent(() => {
const payload = safeParse(event.approval.payload);
return {
// '' resolver = sweep/timer (e.g. the awaiting-reason ghost sweep) → system.
actor: humanOrSystemActor(event.userId),
// Decisions are card clicks on a chat platform, even system-finalized
// ones — the card lifecycle is the surface.
origin: channelOriginForUser(event.userId),
action: 'approvals.decide',
resources: [
...resourcesForApproval(event.approval.action, payload, event.session),
{ type: 'approval', id: event.approval.approval_id },
],
outcome: event.outcome === 'approve' ? 'approved' : 'rejected',
correlationId: event.approval.approval_id,
details: {
gated_action: auditActionForApproval(event.approval.action, payload),
requested_by: event.session.agent_group_id,
},
};
});
}
function safeParse(json: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(json);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
// eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break the decision event
} catch {
return {};
}
}
registerApprovalResolvedHandler(onApprovalResolved);
@@ -0,0 +1,127 @@
/**
* OneCLI credential wrappers: hold + the three resolution paths. Inners are
* stubs — each wrapper's contract is "call through, emit the right event
* (or none)". The requestApproval decorator and runApprovedHandler are
* covered end-to-end in primitive.audit.test.ts / response-handler.audit.test.ts.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
const db = vi.hoisted(() => ({ row: undefined as Record<string, unknown> | undefined }));
vi.mock('../../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock('../../db/sessions.js', () => ({
getPendingApproval: () => db.row,
}));
vi.mock('../../db/messaging-groups.js', () => ({
getMessagingGroup: () => undefined,
}));
import { auditOneCliDecision, auditOneCliExpiry, auditOneCliHold, auditOneCliSweep } from './approvals.audit.js';
const ONECLI_ROW = {
approval_id: 'oa-abc123',
agent_group_id: 'ag-1',
channel_type: 'telegram',
payload: JSON.stringify({
oneCliRequestId: 'uuid-1',
method: 'POST',
host: 'api.notion.com',
path: '/v1/pages',
bodyPreview: 'SECRET BODY CONTENT',
agent: { externalId: 'ag-1', name: 'Agent' },
approver: 'slack:U0ADMIN',
}),
};
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
db.row = undefined;
});
describe('OneCLI wrappers', () => {
it('hold: emits pending from the row — shape only, never the body preview', () => {
const inner = vi.fn();
auditOneCliHold(inner)(ONECLI_ROW);
expect(inner).toHaveBeenCalledOnce();
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'agent', id: 'ag-1' },
origin: { transport: 'container' },
action: 'onecli.credential.use',
outcome: 'pending',
correlation_id: 'oa-abc123',
details: { method: 'POST', host: 'api.notion.com', path: '/v1/pages', body_preview_chars: 19 },
});
expect(event.resources).toContainEqual({ type: 'user', id: 'slack:U0ADMIN' });
expect(JSON.stringify(event)).not.toContain('SECRET BODY CONTENT');
});
it('decision: emits approvals.decide with the clicking human when resolved', () => {
db.row = ONECLI_ROW;
const wrapped = auditOneCliDecision(() => true);
expect(wrapped('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(true);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'human', id: 'slack:U0ADMIN' },
origin: { transport: 'channel', channel: 'slack' },
action: 'approvals.decide',
outcome: 'approved',
correlation_id: 'oa-abc123',
details: { gated_action: 'onecli.credential.use' },
});
});
it('decision: emits nothing when the resolver is gone (inner returns false)', () => {
db.row = ONECLI_ROW;
expect(auditOneCliDecision(() => false)('oa-abc123', 'approve', 'slack:U0ADMIN')).toBe(false);
expect(appended.lines).toHaveLength(0);
});
it('expiry: the system actor rejects with the reason', async () => {
db.row = ONECLI_ROW;
await auditOneCliExpiry(async () => {})('oa-abc123', 'no response');
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'system', id: 'host' },
origin: { transport: 'channel', channel: 'telegram' },
outcome: 'rejected',
details: { reason: 'expired: no response' },
});
});
it('sweep: one system rejection per orphaned row', async () => {
const rows = [ONECLI_ROW, { ...ONECLI_ROW, approval_id: 'oa-second' }];
await auditOneCliSweep(
async () => {},
() => rows as never,
)();
const all = events();
expect(all).toHaveLength(2);
expect(all.map((e) => e.correlation_id)).toEqual(['oa-abc123', 'oa-second']);
expect(all.every((e) => e.actor.type === 'system' && e.details.reason === 'host restarted')).toBe(true);
});
});
@@ -0,0 +1,345 @@
/**
* Approvals audit adapter (installed by /add-audit) — owns how the approvals
* domain describes itself to the audit log: the requestApproval decorator
* (every gated hold → pending event), the post-approve handler runner (the
* gated chain's terminal event), the OneCLI credential wrappers (hold + three
* resolution paths), and the approval-payload mapping they share. Composed at
* this module's edges; business logic contains zero audit calls.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import {
type AuditActor,
type AuditEventInput,
type AuditOrigin,
type AuditOutcome,
type AuditResource,
SYSTEM_ACTOR,
} from '../../audit/types.js';
import { channelOriginForUser, humanOrSystemActor, originForSession } from '../../audit/vocab.js';
import { resourcesForCli } from '../../cli/dispatch.audit.js';
import type { RequestFrame } from '../../cli/frame.js';
import { lookup } from '../../cli/registry.js';
import { getPendingApproval } from '../../db/sessions.js';
import type { PendingApproval, Session } from '../../types.js';
// Type-only imports from the module this adapter wraps — erased at runtime,
// so primitive.ts importing this file back is not a cycle.
import type { ApprovalHandler, ApprovalHandlerContext, ApprovalHold, RequestApprovalOptions } from './primitive.js';
// ── Approval-payload mapping ──
/** Dotted audit action per approval type; cli_command derives from its frame. */
export const APPROVAL_AUDIT_ACTIONS: Record<string, string> = {
install_packages: 'self-mod.install-packages',
add_mcp_server: 'self-mod.add-mcp-server',
create_agent: 'agents.create',
a2a_message_gate: 'messages.a2a-gate',
onecli_credential: 'onecli.credential.use',
};
export function auditActionForApproval(approvalAction: string, payload: Record<string, unknown>): string {
if (approvalAction === 'cli_command') {
const frame = payload.frame as RequestFrame | undefined;
return (frame && lookup(frame.command)?.action) ?? 'cli.unknown-command';
}
return APPROVAL_AUDIT_ACTIONS[approvalAction] ?? approvalAction.replace(/_/g, '.');
}
/**
* details for an approval's pending/terminal events. Message-bearing payloads
* (a2a gate) record shape only — body_chars and attachment names, never
* content; the emit-seam redactor is defense-in-depth, not the mechanism.
*/
export function detailsForApprovalPayload(
approvalAction: string,
payload: Record<string, unknown>,
): Record<string, unknown> {
switch (approvalAction) {
case 'cli_command': {
const frame = payload.frame as RequestFrame | undefined;
return { ...(frame?.args ?? {}) };
}
case 'create_agent': {
const instructions = typeof payload.instructions === 'string' ? payload.instructions : null;
return { name: payload.name, instructions_chars: instructions ? instructions.length : 0 };
}
case 'a2a_message_gate': {
const { text, files } = messageShape(payload.content);
return { to: payload.platform_id, body_chars: text.length, attachments: files };
}
default:
// install_packages, add_mcp_server, and future types: metadata payloads
// pass through as-is — the emit-seam redactor masks sensitive keys.
return { ...payload };
}
}
export function resourcesForApproval(
approvalAction: string,
payload: Record<string, unknown>,
session: Session,
): AuditResource[] {
const base: AuditResource[] = [{ type: 'agent_group', id: session.agent_group_id }];
if (approvalAction === 'cli_command') {
const frame = payload.frame as RequestFrame | undefined;
const cmd = frame && lookup(frame.command);
if (cmd && frame) {
const cliResources = resourcesForCli(cmd, frame.args);
for (const r of cliResources) {
if (!base.some((b) => b.type === r.type && b.id === r.id)) base.push(r);
}
}
}
if (approvalAction === 'a2a_message_gate' && typeof payload.platform_id === 'string' && payload.platform_id) {
if (payload.platform_id !== session.agent_group_id) {
base.push({ type: 'agent_group', id: payload.platform_id });
}
}
return base;
}
/** Mirror of agent-route's message-content parse — shape extraction only. */
function messageShape(content: unknown): { text: string; files: string[] } {
if (typeof content !== 'string') return { text: '', files: [] };
try {
const parsed = JSON.parse(content) as { text?: unknown; files?: unknown };
return {
text: typeof parsed.text === 'string' ? parsed.text : '',
files: Array.isArray(parsed.files) ? parsed.files.filter((f): f is string => typeof f === 'string') : [],
};
// eslint-disable-next-line no-catch-all/no-catch-all -- non-JSON content is the raw body; shape extraction must not throw
} catch {
return { text: content, files: [] };
}
}
// ── requestApproval decorator ──
type RequestApprovalFn = (opts: RequestApprovalOptions) => Promise<ApprovalHold | null>;
/**
* requestApproval decorator — owns every pending event, for all gated
* surfaces at once: only this seam holds the action, payload, minted approval
* id, and the approver the card actually went to. Pass-through: callers see
* the hold exactly as the inner returns it. No hold → no event.
*/
export function auditRequestApproval(inner: RequestApprovalFn): RequestApprovalFn {
return async (opts) => {
const hold = await inner(opts);
if (!hold) return hold;
emitAuditEvent(() => ({
actor: { type: 'agent', id: opts.session.agent_group_id },
origin: originForSession(opts.session),
action: auditActionForApproval(opts.action, opts.payload),
resources: [
...resourcesForApproval(opts.action, opts.payload, opts.session),
{ type: 'approval', id: hold.approvalId },
// Who was asked: the picked approver is part of the record.
{ type: 'user', id: hold.approverUserId },
],
outcome: 'pending',
correlationId: hold.approvalId,
details: detailsForApprovalPayload(opts.action, opts.payload),
}));
return hold;
};
}
// ── Post-approve handler runner ──
/**
* Emits the terminal event of a gated chain (success/failure from whether the
* handler threw), correlated by the approval id. cli_command is skipped: its
* replay goes back through the dispatch middleware, which alone sees the real
* response frame. Rethrows so the response handler's own catch/notify
* behavior is unchanged.
*/
export async function runApprovedHandler(
handler: ApprovalHandler,
ctx: ApprovalHandlerContext,
approval: PendingApproval,
session: Session,
): Promise<void> {
const skip = approval.action === 'cli_command';
const terminal = (outcome: AuditOutcome, error?: string): void => {
if (skip) return;
emitAuditEvent(() => ({
actor: { type: 'agent', id: session.agent_group_id },
origin: originForSession(session),
action: auditActionForApproval(approval.action, ctx.payload),
resources: [
...resourcesForApproval(approval.action, ctx.payload, session),
{ type: 'approval', id: approval.approval_id },
],
outcome,
correlationId: approval.approval_id,
details: error
? { ...detailsForApprovalPayload(approval.action, ctx.payload), error }
: detailsForApprovalPayload(approval.action, ctx.payload),
}));
};
try {
await handler(ctx);
} catch (err) {
terminal('failure', err instanceof Error ? err.message : String(err));
throw err;
}
terminal('success');
}
// ── OneCLI credential approvals (hold + three resolution paths) ──
/** = APPROVAL_AUDIT_ACTIONS.onecli_credential; named for the OneCLI wrappers. */
export const ONECLI_AUDIT_ACTION = 'onecli.credential.use';
interface OneCliRowShape {
approval_id: string;
agent_group_id?: string | null;
payload: string;
}
interface OneCliPayload {
oneCliRequestId?: string;
method?: string;
host?: string;
path?: string;
bodyPreview?: string;
agent?: { externalId?: string | null; name?: string };
approver?: string;
}
function oneCliPayload(row: OneCliRowShape): OneCliPayload {
try {
const parsed: unknown = JSON.parse(row.payload);
return parsed && typeof parsed === 'object' ? (parsed as OneCliPayload) : {};
// eslint-disable-next-line no-catch-all/no-catch-all -- stored payloads are untrusted; a malformed one must not break event assembly
} catch {
return {};
}
}
/** Shape-only: the request body's presence is auditable, its content never is. */
function oneCliDetails(p: OneCliPayload): Record<string, unknown> {
return {
method: p.method,
host: p.host,
path: p.path,
one_cli_request_id: p.oneCliRequestId,
body_preview_chars: typeof p.bodyPreview === 'string' ? p.bodyPreview.length : 0,
};
}
function oneCliResources(row: OneCliRowShape): AuditResource[] {
const out: AuditResource[] = [];
if (row.agent_group_id) out.push({ type: 'agent_group', id: row.agent_group_id });
out.push({ type: 'approval', id: row.approval_id });
return out;
}
/** Pending event for a OneCLI credential hold, derived from its row. */
export function oneCliHoldEvent(row: OneCliRowShape): AuditEventInput {
const p = oneCliPayload(row);
const resources = oneCliResources(row);
if (p.approver) resources.push({ type: 'user', id: p.approver });
return {
actor: { type: 'agent', id: row.agent_group_id ?? p.agent?.externalId ?? p.agent?.name ?? 'unknown' },
// OneCLI requests come from inside an agent container but carry no session.
origin: { transport: 'container' },
action: ONECLI_AUDIT_ACTION,
resources,
outcome: 'pending',
correlationId: row.approval_id,
details: oneCliDetails(p),
};
}
/** approvals.decide for a OneCLI resolution (click, expiry, or startup sweep). */
export function oneCliDecideEvent(
row: OneCliRowShape & { channel_type?: string | null },
args: { actor: AuditActor; origin: AuditOrigin; outcome: 'approved' | 'rejected'; reason?: string },
): AuditEventInput {
const details: Record<string, unknown> = {
gated_action: ONECLI_AUDIT_ACTION,
...oneCliDetails(oneCliPayload(row)),
};
if (args.reason) details.reason = args.reason;
return {
actor: args.actor,
origin: args.origin,
action: 'approvals.decide',
resources: oneCliResources(row),
outcome: args.outcome,
correlationId: row.approval_id,
details,
};
}
/** Wraps the hold's row insert — emits the pending event derived from the row. */
export function auditOneCliHold<T extends OneCliRowShape>(inner: (row: T) => void): (row: T) => void {
return (row) => {
inner(row);
emitAuditEvent(() => oneCliHoldEvent(row));
};
}
/**
* Wraps the card-click resolver. The row is pre-read because the inner
* deletes it on resolution; `userId` is the resolving admin (already part of
* the resolver's contract — empty means a timer/sweep, i.e. the system).
*/
export function auditOneCliDecision(
inner: (approvalId: string, selectedOption: string, userId: string) => boolean,
): (approvalId: string, selectedOption: string, userId: string) => boolean {
return (approvalId, selectedOption, userId) => {
const row = getPendingApproval(approvalId);
const resolved = inner(approvalId, selectedOption, userId);
if (resolved && row) {
emitAuditEvent(() =>
oneCliDecideEvent(row, {
actor: humanOrSystemActor(userId),
origin: channelOriginForUser(userId),
outcome: selectedOption === 'approve' ? 'approved' : 'rejected',
}),
);
}
return resolved;
};
}
/** Wraps the expiry timer's finalizer — the system actor rejects. */
export function auditOneCliExpiry(
inner: (approvalId: string, reason: string) => Promise<void>,
): (approvalId: string, reason: string) => Promise<void> {
return async (approvalId, reason) => {
const row = getPendingApproval(approvalId);
await inner(approvalId, reason);
if (row) {
emitAuditEvent(() =>
oneCliDecideEvent(row, {
actor: SYSTEM_ACTOR,
origin: { transport: 'channel', channel: row.channel_type ?? undefined },
outcome: 'rejected',
reason: `expired: ${reason}`,
}),
);
}
};
}
/** Wraps the startup sweep — one system rejection per orphaned row. */
export function auditOneCliSweep(inner: () => Promise<void>, listRows: () => PendingApproval[]): () => Promise<void> {
return async () => {
const rows = listRows();
await inner();
for (const row of rows) {
emitAuditEvent(() =>
oneCliDecideEvent(row, {
actor: SYSTEM_ACTOR,
origin: { transport: 'channel', channel: row.channel_type ?? undefined },
outcome: 'rejected',
reason: 'host restarted',
}),
);
}
};
}
@@ -0,0 +1,164 @@
/**
* Pending-event coverage for the decorated requestApproval: the audit
* decorator emits exactly one `pending` event per successfully queued hold,
* naming the approval and the picked approver — and nothing when no hold
* reached an approver.
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-primitive-audit', AUDIT_ENABLED: true };
});
const TEST_DIR = '/tmp/nanoclaw-test-primitive-audit';
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('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
// No adapter: card delivery is skipped, the hold still succeeds.
vi.mock('../../delivery.js', () => ({
getDeliveryAdapter: () => null,
}));
// Resolve any approver to a reachable fake DM without the platform stack.
vi.mock('../permissions/user-dm.js', () => ({
ensureUserDm: vi.fn(async () => ({
id: 'mg-dm',
channel_type: 'telegram',
platform_id: 'dm-owner',
})),
}));
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createSession, getPendingApproval } from '../../db/sessions.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { upsertUser } from '../permissions/db/users.js';
import { requestApproval } from './primitive.js';
import type { Session } from '../../types.js';
function now(): string {
return new Date().toISOString();
}
let session: Session;
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
appended.lines.length = 0;
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
session = {
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
};
createSession(session);
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
function grantOwner(): void {
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
}
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
describe('decorated requestApproval', () => {
it('emits one pending event naming the approval and the picked approver', async () => {
grantOwner();
await requestApproval({
session,
agentName: 'Agent',
action: 'install_packages',
payload: { apt: ['jq'], npm: [], reason: 'need jq' },
title: 'Install Packages Request',
question: 'ok?',
});
expect(appended.lines).toHaveLength(1);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'agent', id: 'ag-1' },
origin: { transport: 'container', session_id: 'sess-1' },
action: 'self-mod.install-packages',
outcome: 'pending',
details: { apt: ['jq'], npm: [], reason: 'need jq' },
});
const approvalRef = event.resources.find((r: { type: string }) => r.type === 'approval');
expect(approvalRef.id).toMatch(/^appr-/);
expect(event.correlation_id).toBe(approvalRef.id);
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-1' });
expect(event.resources).toContainEqual({ type: 'user', id: 'telegram:owner' });
// The event references the row that was actually created.
expect(getPendingApproval(approvalRef.id)).toBeDefined();
});
it('emits nothing when no approver is configured (no hold was created)', async () => {
await requestApproval({
session,
agentName: 'Agent',
action: 'install_packages',
payload: { apt: ['jq'], npm: [] },
title: 'Install Packages Request',
question: 'ok?',
});
expect(appended.lines).toHaveLength(0);
});
it('records shape only for the message-bearing a2a gate — never the body', async () => {
grantOwner();
await requestApproval({
session,
agentName: 'Agent',
action: 'a2a_message_gate',
approverUserId: 'telegram:owner',
payload: {
id: 'msg-1',
platform_id: 'ag-target',
content: JSON.stringify({ text: 'hello world', files: ['report.pdf'] }),
in_reply_to: null,
},
title: 'Message approval',
question: 'ok?',
});
const [event] = events();
expect(event.action).toBe('messages.a2a-gate');
expect(event.details).toEqual({ to: 'ag-target', body_chars: 11, attachments: ['report.pdf'] });
expect(JSON.stringify(event)).not.toContain('hello world');
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-target' });
});
});
@@ -0,0 +1,202 @@
/**
* Decision + terminal audit events across the approval resolution paths:
* approvals.decide from the resolved observer, the terminal event from the
* wrapped handler run (skipped for cli_command), and the system actor for
* sweep-finalized rejects.
*/
import * as fs from 'fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-response-handler-audit', AUDIT_ENABLED: true };
});
const TEST_DIR = '/tmp/nanoclaw-test-response-handler-audit';
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('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
}));
// Self-registers the approvals.decide observer on import (the composed wiring).
import './approvals-observer.audit.js';
import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
import { createAgentGroup } from '../../db/agent-groups.js';
import { createPendingApproval, createSession, getPendingApproval, getSession } from '../../db/sessions.js';
import { grantRole } from '../permissions/db/user-roles.js';
import { upsertUser } from '../permissions/db/users.js';
import { finalizeReject } from './finalize.js';
import { registerApprovalHandler } from './primitive.js';
import { handleApprovalsResponse } from './response-handler.js';
function now(): string {
return new Date().toISOString();
}
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
appended.lines.length = 0;
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: 'ag-1', name: 'Agent', folder: 'agent', agent_provider: null, created_at: now() });
createSession({
id: 'sess-1',
agent_group_id: 'ag-1',
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: now(),
created_at: now(),
});
upsertUser({ id: 'telegram:owner', kind: 'telegram', display_name: 'Owner', created_at: now() });
grantRole({ user_id: 'telegram:owner', role: 'owner', agent_group_id: null, granted_by: null, granted_at: now() });
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true, force: true });
});
function seedApproval(id: string, action: string, payload: Record<string, unknown>): void {
createPendingApproval({
approval_id: id,
session_id: 'sess-1',
request_id: id,
action,
payload: JSON.stringify(payload),
created_at: now(),
title: 'Test approval',
options_json: JSON.stringify([]),
});
}
function click(questionId: string, value: string) {
return handleApprovalsResponse({
questionId,
value,
userId: 'owner',
channelType: 'telegram',
platformId: 'dm-owner',
threadId: null,
});
}
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
describe('approval resolution audit events', () => {
it('approve → terminal success (correlated) + approvals.decide with the human approver', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
registerApprovalHandler('install_packages', handler);
seedApproval('appr-ok', 'install_packages', { apt: ['jq'], npm: [] });
await click('appr-ok', 'approve');
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ approvalId: 'appr-ok' }));
const all = events();
expect(all).toHaveLength(2);
const [terminal, decide] = all;
expect(terminal).toMatchObject({
actor: { type: 'agent', id: 'ag-1' },
action: 'self-mod.install-packages',
outcome: 'success',
correlation_id: 'appr-ok',
});
expect(terminal.resources).toContainEqual({ type: 'approval', id: 'appr-ok' });
expect(decide).toMatchObject({
actor: { type: 'human', id: 'telegram:owner' },
origin: { transport: 'channel', channel: 'telegram' },
action: 'approvals.decide',
outcome: 'approved',
correlation_id: 'appr-ok',
details: { gated_action: 'self-mod.install-packages', requested_by: 'ag-1' },
});
});
it('handler failure → terminal failure with the error, decision still recorded', async () => {
registerApprovalHandler('add_mcp_server', async () => {
throw new Error('rebuild exploded');
});
seedApproval('appr-fail', 'add_mcp_server', { name: 'notion', command: 'npx' });
await click('appr-fail', 'approve');
const all = events();
expect(all).toHaveLength(2);
expect(all[0]).toMatchObject({
action: 'self-mod.add-mcp-server',
outcome: 'failure',
correlation_id: 'appr-fail',
});
expect(all[0].details.error).toContain('rebuild exploded');
expect(all[1]).toMatchObject({ action: 'approvals.decide', outcome: 'approved' });
});
it('cli_command approve → decide only; the terminal event belongs to the dispatch middleware', async () => {
registerApprovalHandler('cli_command', vi.fn().mockResolvedValue(undefined));
seedApproval('appr-cli', 'cli_command', {
frame: { id: '1', command: 'groups-update', args: { id: 'ag-1' } },
callerContext: { caller: 'agent', sessionId: 'sess-1', agentGroupId: 'ag-1', messagingGroupId: 'mg-1' },
});
await click('appr-cli', 'approve');
const all = events();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({ action: 'approvals.decide', outcome: 'approved', correlation_id: 'appr-cli' });
});
it('reject click → approvals.decide rejected, no terminal event', async () => {
registerApprovalHandler('install_packages', vi.fn());
seedApproval('appr-rej', 'install_packages', { apt: ['jq'], npm: [] });
await click('appr-rej', 'reject');
const all = events();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({
actor: { type: 'human', id: 'telegram:owner' },
action: 'approvals.decide',
outcome: 'rejected',
correlation_id: 'appr-rej',
});
expect(getPendingApproval('appr-rej')).toBeUndefined();
});
it('sweep-finalized reject (empty resolver id) → the system actor decides', async () => {
seedApproval('appr-ghost', 'install_packages', { apt: ['jq'], npm: [] });
const approval = getPendingApproval('appr-ghost')!;
const session = getSession('sess-1')!;
await finalizeReject(approval, session, '', 'approver never replied');
const all = events();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({
actor: { type: 'system', id: 'host' },
origin: { transport: 'channel' },
action: 'approvals.decide',
outcome: 'rejected',
correlation_id: 'appr-ghost',
});
});
});
@@ -0,0 +1,143 @@
/**
* Permissions audit adapters: card decisions and the name interceptor.
* Inners are stubs — each wrapper's contract is "call through, emit the
* right event (or none), coerce back to claimed".
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const appended = vi.hoisted(() => ({ lines: [] as string[] }));
vi.mock('../../config.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../config.js')>();
return { ...actual, AUDIT_ENABLED: true };
});
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: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { auditChannelDecision, auditChannelNameInterceptor, auditSenderDecision } from './permissions.audit.js';
const PAYLOAD = {
questionId: 'q1',
value: 'approve',
userId: 'U1',
channelType: 'slack',
platformId: 'p',
threadId: null,
};
function events(): Array<Record<string, any>> {
return appended.lines.map((l) => JSON.parse(l));
}
beforeEach(() => {
appended.lines.length = 0;
});
describe('auditSenderDecision', () => {
const decision = {
approved: true,
senderIdentity: 'slack:U0NEW',
agentGroupId: 'ag-1',
messagingGroupId: 'mg-1',
approverId: 'slack:U0ADMIN',
channelType: 'slack',
};
it('emits senders.allow success on approve and coerces claimed', async () => {
const wrapped = auditSenderDecision(async () => ({ claimed: true, decision }));
expect(await wrapped(PAYLOAD)).toBe(true);
const [event] = events();
expect(event).toMatchObject({
actor: { type: 'human', id: 'slack:U0ADMIN' },
origin: { transport: 'channel', channel: 'slack' },
action: 'senders.allow',
outcome: 'success',
correlation_id: null,
details: {},
});
expect(event.resources).toEqual([
{ type: 'user', id: 'slack:U0NEW' },
{ type: 'agent_group', id: 'ag-1' },
{ type: 'messaging_group', id: 'mg-1' },
]);
});
it('emits rejected on deny', async () => {
const wrapped = auditSenderDecision(async () => ({ claimed: true, decision: { ...decision, approved: false } }));
await wrapped(PAYLOAD);
expect(events()[0].outcome).toBe('rejected');
});
it('emits nothing without a decision (unclaimed or unauthorized click)', async () => {
const unclaimed = auditSenderDecision(async () => ({ claimed: false }));
const unauthorized = auditSenderDecision(async () => ({ claimed: true }));
expect(await unclaimed(PAYLOAD)).toBe(false);
expect(await unauthorized(PAYLOAD)).toBe(true);
expect(appended.lines).toHaveLength(0);
});
});
describe('auditChannelDecision / auditChannelNameInterceptor', () => {
const base = { messagingGroupId: 'mg-1', approverId: 'slack:U0ADMIN', channelType: 'slack' };
it('maps connected → success with the wired agent group', async () => {
const wrapped = auditChannelDecision(async () => ({
claimed: true,
decision: { kind: 'connected' as const, agentGroupId: 'ag-1', createdAgentGroup: false, ...base },
}));
await wrapped(PAYLOAD);
const [event] = events();
expect(event).toMatchObject({
action: 'channels.register',
outcome: 'success',
details: { created_agent_group: false },
});
expect(event.resources).toEqual([
{ type: 'messaging_group', id: 'mg-1' },
{ type: 'agent_group', id: 'ag-1' },
]);
});
it('maps rejected and failed outcomes', async () => {
await auditChannelDecision(async () => ({ claimed: true, decision: { kind: 'rejected' as const, ...base } }))(
PAYLOAD,
);
await auditChannelDecision(async () => ({
claimed: true,
decision: { kind: 'failed' as const, reason: 'target agent group no longer exists', ...base },
}))(PAYLOAD);
const all = events();
expect(all[0]).toMatchObject({ action: 'channels.register', outcome: 'rejected' });
expect(all[1]).toMatchObject({ action: 'channels.register', outcome: 'failure' });
expect(all[1].details.reason).toContain('no longer exists');
});
it('records the interceptor-created agent group — the third creation door', async () => {
const wrapped = auditChannelNameInterceptor(async () => ({
claimed: true,
decision: {
kind: 'connected' as const,
agentGroupId: 'ag-new',
createdAgentGroup: true,
agentName: 'Scout',
...base,
},
}));
expect(await wrapped({} as never)).toBe(true);
const [event] = events();
expect(event.details).toMatchObject({ created_agent_group: true, agent_name: 'Scout' });
expect(event.resources).toContainEqual({ type: 'agent_group', id: 'ag-new' });
});
});
@@ -0,0 +1,80 @@
/**
* Permissions audit adapter (installed by /add-audit) — senders.allow and
* channels.register events for the card/interceptor stack that never touches
* the approvals primitive. The inner handlers return domain decision
* descriptors (SenderApprovalResult / ChannelApprovalResult, defined with the
* handlers); these adapters emit the event and coerce back to the boolean the
* response registry / router expect.
*/
import { emitAuditEvent } from '../../audit/emit.js';
import type { AuditResource } from '../../audit/types.js';
import type { InboundEvent } from '../../channels/adapter.js';
import type { ResponsePayload } from '../../response-registry.js';
// Type-only import from the module this adapter wraps — erased at runtime,
// so permissions/index.ts importing this file back is not a cycle.
import type { ChannelApprovalResult, ChannelDecision, SenderApprovalResult } from './index.js';
export function auditSenderDecision(
inner: (payload: ResponsePayload) => Promise<SenderApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
return async (payload) => {
const result = await inner(payload);
const d = result.decision;
if (d) {
emitAuditEvent(() => ({
actor: { type: 'human', id: d.approverId },
origin: { transport: 'channel', channel: d.channelType || undefined },
action: 'senders.allow',
resources: [
{ type: 'user', id: d.senderIdentity },
{ type: 'agent_group', id: d.agentGroupId },
{ type: 'messaging_group', id: d.messagingGroupId },
],
outcome: d.approved ? 'success' : 'rejected',
// The withheld message is never read here — ids only.
details: {},
}));
}
return result.claimed;
};
}
function emitChannelDecision(d: ChannelDecision): void {
emitAuditEvent(() => {
const resources: AuditResource[] = [{ type: 'messaging_group', id: d.messagingGroupId }];
if (d.agentGroupId) resources.push({ type: 'agent_group', id: d.agentGroupId });
const details: Record<string, unknown> = {};
if (d.createdAgentGroup !== undefined) details.created_agent_group = d.createdAgentGroup;
if (d.agentName) details.agent_name = d.agentName;
if (d.reason) details.reason = d.reason;
return {
actor: { type: 'human', id: d.approverId },
origin: { transport: 'channel', channel: d.channelType || undefined },
action: 'channels.register',
resources,
outcome: d.kind === 'connected' ? 'success' : d.kind === 'rejected' ? 'rejected' : 'failure',
details,
};
});
}
export function auditChannelDecision(
inner: (payload: ResponsePayload) => Promise<ChannelApprovalResult>,
): (payload: ResponsePayload) => Promise<boolean> {
return async (payload) => {
const result = await inner(payload);
if (result.decision) emitChannelDecision(result.decision);
return result.claimed;
};
}
/** The free-text name reply — the third agent-creation door (new group + wiring). */
export function auditChannelNameInterceptor(
inner: (event: InboundEvent) => Promise<ChannelApprovalResult>,
): (event: InboundEvent) => Promise<boolean> {
return async (event) => {
const result = await inner(event);
if (result.decision) emitChannelDecision(result.decision);
return result.claimed;
};
}
+22
View File
@@ -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.
+168
View File
@@ -0,0 +1,168 @@
---
name: add-clidash
description: Add clidash — a zero-dependency, read-only web dashboard that derives its tabs and tables at runtime from any CLI that lists resources as JSON. Ships pre-wired for NanoClaw's ncl CLI (agent groups, sessions, channels, users, roles), plus message-activity charts, a log tail, and a read-only file viewer for group skills/CLAUDE.md/profiles.
---
# /add-clidash — CLI-derived read-only dashboard
clidash is a small, read-only web dashboard. You point it at any CLI that can
list resources as JSON (NanoClaw's `ncl`, `docker`, `kubectl`, …) and it builds
the dashboard at runtime: one tab per resource, a generic table over whatever
columns the rows have. A new `ncl` resource becomes a new tab and a new column
becomes a new table column with **zero code changes**.
It ships pre-wired for NanoClaw's `ncl` CLI and adds three NanoClaw-aware
panels driven entirely by config:
- **Agents overview** — status cards joining groups + sessions + messaging
groups + wirings (green <15m / amber <2h / red older).
- **Activity** — per-session inbound/outbound message totals and a daily series,
read directly from the session DBs (`ncl` has no messages resource).
- **Logs** — last N lines of allowlisted host log files.
- **Files** — a read-only viewer for group skills, `CLAUDE.md`, and profiles.
## Why it's safe
clidash is **read-only by construction**: the server can only `execFile` the
argv templates in its config. `{resource}` is the sole substitution and is
allowlist-validated against the discovered/static resource set before exec —
never a shell, no free-form input reaches argv. There is no auth; **the network
is the auth boundary** — it binds `127.0.0.1` by default. Only ever bind a
private interface (e.g. a tailnet IP), never a public one.
It's distinct from `/add-dashboard` (which pushes JSON snapshots to a separate
`@nanoco/nanoclaw-dashboard` npm package): clidash has **zero dependencies**, no
build step, no push pipeline, and no edits to NanoClaw source — it just reads
`ncl` and the session DBs.
## Steps
### 1. Copy the tool into place
clidash is fully self-contained — copy the whole directory in:
`tools/` is not a standard NanoClaw directory and `cp -R` won't create it, so
make it first:
```bash
mkdir -p tools
cp -R .claude/skills/add-clidash/add/tools/clidash tools/clidash
```
That is the only file change this skill makes. Nothing in NanoClaw `src/` is
touched, no dependency is added.
### 2. Create the config
The example config is pre-wired for NanoClaw with paths relative to the repo
root, so it works as-is when you run clidash from `tools/clidash/`:
```bash
cd tools/clidash
cp clidash.config.example.json clidash.config.json
```
`clidash.config.json` is your local config — add it to `.gitignore` if you
don't want to commit install-specific paths:
```bash
echo 'tools/clidash/clidash.config.json' >> ../../.gitignore
```
The example assumes `ncl` is built at `bin/ncl`. If `bin/ncl` doesn't exist,
build it first (`pnpm run build`) or point `clis.ncl.bin` at the right path.
### 3. Test
Tests use a stub CLI — no real `ncl` or `docker` needed:
```bash
npm test
```
All tests should pass (Node ≥ 22.5, `node:test`, zero dependencies).
### 4. Run and verify
```bash
node server.js # serves http://127.0.0.1:4690
```
In another shell, confirm it's live and that `ncl` discovery worked:
```bash
curl -s http://127.0.0.1:4690/api/clis | head -c 400 # CLIs + discovered resources
curl -s http://127.0.0.1:4690/api/r/ncl/groups | head -c 400 # a real resource table
```
Then open `http://127.0.0.1:4690/` in a browser. You should see the Agents
overview plus a tab per `ncl` resource.
### 5. (Optional) Run as a service
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
private (e.g. tailnet) IP via the `BIND` env var or `bind` in config — never a
public interface.
```ini
# ~/.config/systemd/user/clidash.service (Linux)
[Unit]
Description=clidash read-only CLI dashboard
[Service]
WorkingDirectory=%h/nanoclaw/tools/clidash
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
Environment=BIND=127.0.0.1
Restart=on-failure
[Install]
WantedBy=default.target
```
```bash
systemctl --user enable --now clidash
```
On macOS, wrap `node server.js` (with `WorkingDirectory` = `tools/clidash`) in a
launchd plist the same way the main NanoClaw service is configured.
## Configuration reference
`clidash.config.json` keys (see `tools/clidash/README.md` and
`clidash.config.example.json` for the full shape):
| Key | Purpose |
|-----|---------|
| `port`, `bind`, `refreshSeconds` | server bind + UI auto-refresh cadence |
| `clis.<name>.bin` / `cwd` / `env` | how to invoke the CLI (`bin` is relative to `cwd`) |
| `clis.<name>.discover` or `resources` | runtime discovery (`ncl help`) vs a static resource list |
| `clis.<name>.list` | argv template; `{resource}` is the only substitution |
| `clis.<name>.output` | `json` or `jsonlines` (docker/kubectl style) |
| `clis.<name>.unwrap` | dot-path into a response envelope (e.g. `data`) |
| `clis.<name>.enrich`/`badges`/`summary` | table decorations (ID→name joins, status colors, summary cards) |
| `activity` | `sessionsRoot` + `days` for the message-activity charts |
| `logs` | `dir`, `tailLines`, and an allowlist of `files` to tail |
| `docs` | file viewer: `root`, a `deny` glob list, and `collections` of glob patterns |
Adding a second CLI is config-only — e.g. `docker` is included as a `jsonlines`
example. View plugins (`views/<cli>-<view>.js`) are the only per-CLI code and
are optional.
## Troubleshooting
- **`ENOENT` / config not found** — run from `tools/clidash/` and make sure you
copied `clidash.config.example.json` to `clidash.config.json` (step 2), or set
`CLIDASH_CONFIG=/abs/path.json`.
- **No `ncl` resources / discovery empty** — `bin/ncl` isn't built or the path
is wrong. Build it (`pnpm run build`) or fix `clis.ncl.bin`.
- **docker tab errors** — the docker daemon isn't running, or remove the
`docker` CLI from config if you don't need it.
- **Can't reach it from another device** — it binds `127.0.0.1`; set
`BIND=<private-ip>` (tailnet), never a public interface.
- **Empty Activity/Logs/Files** — check that `activity.sessionsRoot`,
`logs.dir`, and `docs.root` resolve to your NanoClaw root (relative to where
you launch `node server.js`).
## Removal
See [REMOVE.md](REMOVE.md).
@@ -0,0 +1,109 @@
# clidash
CLI-agnostic **read-only** web dashboard. Point it at any CLI that can list
resources as JSON and it derives the dashboard at runtime: one tab per
resource, a generic table over whatever columns the rows have. New resource →
new tab; new column → new table column; **zero code changes**.
It ships pre-wired for NanoClaw's `ncl` CLI (agent groups, sessions, messaging
groups, wirings, users, roles, …) plus `docker`, but the same config shape
works for any list-as-JSON CLI.
- **Zero dependencies** — Node built-ins only (Node ≥ 22.5, for `node:sqlite`),
no build step,
vanilla-JS frontend.
- **Read-only by construction** — the server can only `execFile` the configured
argv templates; `{resource}` is the sole substitution and is validated
against the discovered/static resource allowlist. Never a shell.
- **Standalone** — no imports from NanoClaw source; the core is extractable to
its own repo. The NanoClaw-specific knowledge lives entirely in the config
and in the `views/ncl-overview.js` view plugin.
## Run
```bash
cp clidash.config.example.json clidash.config.json # then edit paths if needed
node server.js # uses ./clidash.config.json
CLIDASH_CONFIG=/path/to.json node server.js
PORT=4690 BIND=127.0.0.1 node server.js # env overrides
```
Run it from `tools/clidash/`; the example config uses paths relative to the
NanoClaw root two levels up, so it works out of the box once `ncl` is built.
## Configure (`clidash.config.json`)
```jsonc
{
"port": 4690,
"bind": "127.0.0.1", // never a public interface; a tailnet IP at most
"refreshSeconds": 60,
"clis": {
"ncl": {
"bin": "bin/ncl", // relative to cwd below
"cwd": "../..", // the NanoClaw root
"discover": { "args": ["help"], "parser": "ncl-help" }, // runtime resource discovery
"list": ["{resource}", "list", "--json"], // argv template
"output": "json", // or "jsonlines" (docker/kubectl style)
"unwrap": "data" // dot-path into a response envelope
},
"docker": {
"bin": "docker",
"resources": ["ps", "images"], // static alternative to discover
"list": ["{resource}", "--format", "{{json .}}"],
"output": "jsonlines"
}
}
}
```
`{resource}` may appear as a whole argv element or inside one — e.g. a remote
CLI via ssh: `"list": ["-i", "key.pem", "user@host", "ncl {resource} list --json"]`.
Per-CLI `env` (merged over the server's env) and `cwd` are supported. See
`clidash.config.example.json` for the full NanoClaw config, including the
`enrich`/`badges`/`summary` table decorations and the `activity`/`logs`/`docs`
sections.
## API
| Route | Returns |
|---|---|
| `GET /api/clis` | configured CLIs + discovered/static resources (discovery cached 60s) |
| `GET /api/r/<cli>/<resource>` | `{ok, rows, fetchedAt}` — coalesced, 10s exec timeout |
| `GET /api/view/<cli>/<view>` | curated view plugin from `views/<cli>-<view>.js` |
View plugins are the only per-CLI *code*, and optional: a default-exported
async function receiving `{ fetch }` (bound to that CLI) returning JSON.
`views/ncl-overview.js` joins groups + sessions + messaging-groups + wirings
into per-agent status cards (green <15m / amber <2h / red older).
## Test
```bash
npm test # unit + integration (node:test, stub CLI — no real CLI needed)
./test/smoke.sh # against a running instance
```
## Deploy as a service
clidash binds `127.0.0.1` by default. To reach it from other devices, bind a
private (e.g. tailnet) IP — **never a public interface**; the network is the
auth boundary. Example systemd user service:
```ini
# ~/.config/systemd/user/clidash.service
[Unit]
Description=clidash read-only CLI dashboard
[Service]
WorkingDirectory=%h/nanoclaw/tools/clidash
ExecStart=/usr/bin/node %h/nanoclaw/tools/clidash/server.js
Environment=BIND=127.0.0.1
Restart=on-failure
[Install]
WantedBy=default.target
```
Then `systemctl --user enable --now clidash`.
@@ -0,0 +1,80 @@
// Message-activity reader for clidash.
//
// ncl has no `messages` resource — message data lives in the per-session SQLite
// DBs (`data/v2-sessions/<group>/<session>/{inbound,outbound}.db`). We read them
// read-only with Node's built-in `node:sqlite` (no new dependency) and aggregate
// per-session in/out totals + a daily time-series for charting.
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
// Timestamps come in two shapes across tables: SQLite "YYYY-MM-DD HH:MM:SS" (UTC)
// and already-ISO "YYYY-MM-DDTHH:MM:SS.sssZ". Normalize to a comparable ISO form
// so date-bucketing and max("last") work regardless of which a row used.
function normTs(ts) {
if (typeof ts !== 'string' || ts.length < 10) return null;
if (ts.includes('T')) return ts; // already ISO
return `${ts.replace(' ', 'T')}Z`;
}
function readTable(dbPath, table) {
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const rows = db.prepare(`SELECT timestamp FROM ${table}`).all();
const byDay = new Map();
let last = null;
for (const r of rows) {
const ts = normTs(r.timestamp);
if (!ts) continue;
const day = ts.slice(0, 10); // ISO date prefix
byDay.set(day, (byDay.get(day) ?? 0) + 1);
if (last === null || ts > last) last = ts;
}
return { total: rows.length, byDay, last };
} catch {
return { total: 0, byDay: new Map(), last: null }; // missing/locked/corrupt → skip
} finally {
try { db?.close(); } catch { /* already closed */ }
}
}
function listDirs(path) {
try {
return readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
}
/**
* Aggregate message activity across all session DBs under `sessionsRoot`.
* @returns {{ sessions: Array, series: Array<{date,in,out}> }}
* sessions — per session: { agent_group_id, session_id, in, out, lastActivity }
* series — one bucket per day for the last `days` days (UTC, newest last)
*/
export function collectActivity(sessionsRoot, days, now) {
const dates = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10));
}
const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }]));
const sessions = [];
for (const group of listDirs(sessionsRoot)) {
for (const session of listDirs(join(sessionsRoot, group))) {
const base = join(sessionsRoot, group, session);
// a real session dir has at least one of the two message DBs; skip shared
// scaffolding dirs like `.claude-shared` that don't.
if (!existsSync(join(base, 'inbound.db')) && !existsSync(join(base, 'outbound.db'))) continue;
const inb = readTable(join(base, 'inbound.db'), 'messages_in');
const out = readTable(join(base, 'outbound.db'), 'messages_out');
const lastActivity = [inb.last, out.last].filter(Boolean).sort().at(-1) ?? null;
sessions.push({ agent_group_id: group, session_id: session, in: inb.total, out: out.total, lastActivity });
for (const [day, n] of inb.byDay) series.get(day)?.in !== undefined && (series.get(day).in += n);
for (const [day, n] of out.byDay) series.get(day)?.out !== undefined && (series.get(day).out += n);
}
}
return { sessions, series: dates.map((d) => series.get(d)) };
}
@@ -0,0 +1,106 @@
{
"port": 4690,
"bind": "127.0.0.1",
"refreshSeconds": 60,
"clis": {
"ncl": {
"bin": "bin/ncl",
"cwd": "../..",
"discover": { "args": ["help"], "parser": "ncl-help" },
"list": ["{resource}", "list", "--json"],
"output": "json",
"unwrap": "data",
"commands": {
"get": ["{resource}", "get", "--id", "{id}", "--json"],
"config-get": ["groups", "config", "get", "--id", "{id}", "--json"]
},
"help": ["{resource}", "help"],
"enrich": {
"sessions": {
"agent_group_id": { "ref": "groups", "label": "name" },
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
},
"wirings": {
"agent_group_id": { "ref": "groups", "label": "name" },
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
},
"roles": {
"agent_group_id": { "ref": "groups", "label": "name" },
"user_id": { "ref": "users", "label": "display_name" },
"granted_by": { "ref": "users", "label": "display_name" }
},
"members": {
"agent_group_id": { "ref": "groups", "label": "name" },
"user_id": { "ref": "users", "label": "display_name" }
},
"destinations": {
"agent_group_id": { "ref": "groups", "label": "name" }
},
"user-dms": {
"user_id": { "ref": "users", "label": "display_name" },
"messaging_group_id": { "ref": "messaging-groups", "label": "name" }
}
},
"badges": {
"container_status": { "running": "green", "idle": "green", "starting": "amber", "stopped": "gray", "error": "red" },
"status": { "active": "green", "stopped": "gray", "error": "red", "pending": "amber" }
},
"summary": {
"sessions": "container_status",
"messaging-groups": "channel_type",
"roles": "role",
"users": "kind",
"destinations": "target_type",
"dropped-messages": "reason"
}
},
"docker": {
"bin": "docker",
"resources": ["ps", "images"],
"list": ["{resource}", "--format", "{{json .}}"],
"output": "jsonlines"
}
},
"activity": {
"sessionsRoot": "../../data/v2-sessions",
"days": 14
},
"logs": {
"dir": "../../logs",
"tailLines": 500,
"files": [
{ "name": "nanoclaw.log", "label": "host log" },
{ "name": "nanoclaw.error.log", "label": "errors" }
]
},
"docs": {
"root": "../..",
"deny": ["node_modules", ".env", "*token*", "*secret*", "*.pem", "*.key", "*.lock", "pnpm-lock.yaml"],
"collections": [
{
"name": "skills",
"label": "Skills",
"lang": "markdown",
"patterns": ["groups/*/skills/*/SKILL.md", "container/skills/*/SKILL.md"]
},
{
"name": "claude-md",
"label": "CLAUDE.md",
"lang": "markdown",
"patterns": ["groups/*/CLAUDE.md", "groups/*/CLAUDE.local.md"]
},
{
"name": "profiles",
"label": "Profiles",
"lang": "json",
"patterns": ["groups/*/profile.json"]
},
{
"name": "conversations",
"label": "Conversations",
"lang": "markdown",
"patterns": ["groups/*/conversations/*.md"]
}
]
}
}
@@ -0,0 +1,102 @@
// Read-only file viewer for clidash.
//
// Surfaces on-disk documents (skills, CLAUDE.md, profile.json, conversations)
// that are NOT ncl resources. Same security posture as the rest of clidash:
// only files matching a configured collection's glob patterns are listable or
// readable; a deny-list blocks secrets; path traversal is impossible because a
// requested path must be a member of the freshly-globbed allow-set.
import { readdirSync, realpathSync } from 'node:fs';
import { join, resolve, sep } from 'node:path';
// Convert one glob segment to an anchored regex. `*` matches any run of
// non-slash chars (so it works both as a whole segment and inside a filename,
// e.g. `CLAUDE*.md`). All other regex metacharacters are escaped.
function segToRegExp(seg) {
const esc = seg.replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '[^/]*');
return new RegExp('^' + esc + '$');
}
// A path is denied if any of its segments matches any deny glob.
function isDenied(relPath, deny) {
const segs = relPath.split('/');
return deny.some((d) => {
const re = segToRegExp(d);
return segs.some((s) => re.test(s));
});
}
// Directed walk: descend only entries matching each successive pattern segment.
function walk(root, rel, segs, depth, out, deny) {
if (depth >= segs.length) return;
let entries;
try {
entries = readdirSync(join(root, rel), { withFileTypes: true });
} catch {
return;
}
const re = segToRegExp(segs[depth]);
const last = depth === segs.length - 1;
for (const e of entries) {
if (e.name === '.' || e.name === '..') continue;
if (!re.test(e.name)) continue;
const childRel = rel ? `${rel}/${e.name}` : e.name;
if (isDenied(childRel, deny)) continue;
if (last) {
if (e.isFile()) out.add(childRel);
} else if (e.isDirectory()) {
walk(root, childRel, segs, depth + 1, out, deny);
}
}
}
/**
* Relative paths under `root` matching any of `patterns`, minus `deny` matches.
* Sorted, de-duplicated. Patterns use `*` per the segment rules above; no `**`.
*/
export function globFiles(root, patterns, deny = []) {
const out = new Set();
for (const pattern of patterns) {
walk(root, '', pattern.split('/'), 0, out, deny);
}
return [...out].sort();
}
/**
* Human-friendly grouping/label for a relative path.
* `groups/<g>/...` → group `<g>`; `container/...` → group `shared`.
*/
const CONTAINER_SEGS = new Set(['skills', 'conversations']); // redundant grouping dirs
export function describeFile(relPath) {
const parts = relPath.split('/');
if (parts[0] === 'groups' && parts.length > 2) {
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
return { group: parts[1], label: `${parts[1]} / ${rest}` };
}
if (parts[0] === 'container') {
const rest = parts.slice(2).filter((s) => !CONTAINER_SEGS.has(s)).join('/');
return { group: 'shared', label: `shared / ${rest}` };
}
return { group: '', label: relPath };
}
/**
* Validate a requested doc path against a collection and return its absolute
* path, or throw. A path is allowed only if it is a member of the collection's
* freshly-globbed allow-set — this single check enforces the patterns, the
* deny-list, and traversal safety at once.
*/
export function resolveDoc(root, collection, relPath, deny = []) {
const allowed = new Set(globFiles(root, collection.patterns, deny));
if (!allowed.has(relPath)) {
throw new Error(`Path not allowed: ${relPath}`);
}
// Defence in depth: the resolved real path must still live under root.
const abs = resolve(root, relPath);
const rootReal = realpathSync(root);
const absReal = realpathSync(abs);
if (absReal !== rootReal && !absReal.startsWith(rootReal + sep)) {
throw new Error(`Path not allowed: ${relPath}`);
}
return abs;
}
@@ -0,0 +1,18 @@
// Log tailing for clidash — reads the last N lines of an allowlisted log file
// and strips ANSI color codes (the host logger writes colored output).
import { readFile } from 'node:fs/promises';
const ANSI_RE = /\x1b\[[0-9;]*m/g;
/**
* Last `maxLines` lines of a log file, ANSI-stripped.
* @returns {{ lines: string[], text: string }}
*/
export async function tailFile(path, maxLines) {
const raw = (await readFile(path, 'utf8')).replace(ANSI_RE, '');
const all = raw.split('\n');
if (all.length && all.at(-1) === '') all.pop(); // drop trailing newline's empty field
const lines = all.slice(-maxLines);
return { lines, text: lines.join('\n') };
}
@@ -0,0 +1,14 @@
{
"name": "clidash",
"version": "0.1.0",
"description": "CLI-agnostic read-only web dashboard — derives tabs and tables from any CLI that lists resources as JSON",
"type": "module",
"private": true,
"scripts": {
"start": "node server.js",
"test": "node --test 'test/*.test.js'"
},
"engines": {
"node": ">=22.5"
}
}
@@ -0,0 +1,101 @@
// Pluggable parsers for clidash.
//
// discoveryParsers — turn a CLI's "help"-style output into a resource list.
// parseOutput / unwrapPath — turn a CLI's list output into rows.
// All per-CLI knowledge beyond these small functions lives in clidash.config.json.
/**
* Discovery parsers, keyed by the `discover.parser` name in config.
* Each receives the raw discovery output and returns
* [{ name, description, verbs }] for resources that support `list`.
* They must throw loudly on unrecognized formats — silent empty results
* would render as silently-stale tabs.
*/
export const discoveryParsers = {
/**
* Parses ncl's two-column help format:
*
* Resources:
* sessions Session — the runtime unit. ...
* verbs: list, get
* Commands:
* help ...
*/
'ncl-help'(text) {
const lines = String(text).split('\n');
const start = lines.findIndex((l) => l.trim() === 'Resources:');
if (start === -1) {
throw new Error('ncl-help parser: no "Resources:" section in output — format may have changed');
}
const resources = [];
let current = null;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (line.trim() === '') continue;
if (/^\S/.test(line)) break; // next top-level section, e.g. "Commands:"
const verbsMatch = line.match(/^\s+verbs:\s*(.+)$/);
if (verbsMatch && current) {
current.verbs = verbsMatch[1].split(',').map((v) => v.trim()).filter(Boolean);
continue;
}
const resMatch = line.match(/^ (\S+)\s{2,}(\S.*)$/);
if (resMatch) {
current = { name: resMatch[1], description: resMatch[2].trim(), verbs: [] };
resources.push(current);
}
}
return resources.filter((r) => r.verbs.includes('list'));
},
};
/**
* Parses a CLI's list output per the config's `output` field.
* - 'json' — one JSON document.
* - 'jsonlines' — one JSON object per line (docker/kubectl style).
* Thrown errors carry the raw output on `err.raw` so the UI can show it.
*/
export function parseOutput(text, format) {
if (format === 'json') {
try {
return JSON.parse(text);
} catch (e) {
const err = new Error(`Invalid JSON output: ${e.message}`);
err.raw = text;
throw err;
}
}
if (format === 'jsonlines') {
const rows = [];
const lines = String(text).split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
try {
rows.push(JSON.parse(line));
} catch (e) {
const err = new Error(`Invalid JSON on line ${i + 1}: ${e.message}`);
err.raw = text;
throw err;
}
}
return rows;
}
throw new Error(`Unknown output format: ${format}`);
}
/**
* Follows a dot-path into a response envelope (e.g. 'data' for ncl's
* {id, ok, data} frame). No path → value passes through unchanged.
* Missing path throws — a changed envelope must fail loudly.
*/
export function unwrapPath(value, path) {
if (!path) return value;
let cur = value;
for (const key of path.split('.')) {
if (cur === null || typeof cur !== 'object' || !(key in cur)) {
throw new Error(`Unwrap path "${path}" not found in CLI output (missing "${key}")`);
}
cur = cur[key];
}
return cur;
}
@@ -0,0 +1,715 @@
// clidash frontend — vanilla JS, no build step.
//
// Layout: a left sidebar with top-level items (Overview, Activity) and grouped
// sections (one per CLI — ncl, docker — and a Files section for on-disk docs).
// Each page shows the exact command that produced it. Tables auto-derive from
// `ncl <resource> list --json`; rows drill into their `get` detail.
//
// Refresh UX: on first load every resource of every CLI is prefetched so nav is
// instant. 60s auto-refresh + a manual button. Background refreshes diff-and-
// inject (the data DOM rebuilds only when the data signature changes).
import { mdToHtml } from './md.js';
const $ = (id) => document.getElementById(id);
const state = {
clis: [],
docCollections: [],
activeView: 'overview', // 'overview' | 'activity' | 'r:<cli>:<resource>' | 'doc:<collection>'
paused: false,
refreshSeconds: 60,
lastUpdated: null,
refreshing: false,
snapshots: new Map(), // "cli/resource" -> { rows, fetchedAt, command }
errors: new Map(),
activity: null, // { sessions, series }
activityConfigured: false,
activityCommand: null,
logs: [], // [{ name, label }]
logCache: new Map(), // name -> { text, command }
activeDocPath: null,
openDocGroups: new Set(), // which doc groups (e.g. agents) are expanded
docCache: new Map(),
configCache: new Map(), // groupId -> container config (for the overview page)
helpCache: new Map(), // "cli/resource" -> help text | null (prefetched each cycle)
detail: null,
sidebarOpen: false,
renderedSig: null,
};
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(tag, attrs = {}, children = []) {
const node = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
for (const c of [].concat(children)) if (c != null) node.append(c);
return node;
}
// Lucide-style inline icons (static trusted markup) — crisp, themeable via currentColor.
const ICONS = {
overview: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
activity: '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3"/><path d="M13 15h4"/>',
box: '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
logs: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v5h5"/><path d="M8 13h8"/><path d="M8 17h5"/>',
};
function icon(name) {
const s = document.createElementNS(SVG_NS, 'svg');
s.setAttribute('viewBox', '0 0 24 24');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.8');
s.setAttribute('stroke-linecap', 'round');
s.setAttribute('stroke-linejoin', 'round');
s.innerHTML = ICONS[name] ?? '';
return s;
}
// ---------------------------------------------------------------- helpers
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
function relTime(iso) {
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return iso;
const s = Math.round(ms / 1000);
if (s < 0) return new Date(iso).toLocaleString();
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 48) return `${h}h ago`;
return `${Math.round(h / 24)}d ago`;
}
function coarseAgo(date) {
const s = (Date.now() - date.getTime()) / 1000;
if (s < 60) return 'less than a minute ago';
const m = Math.floor(s / 60);
if (m < 60) return m === 1 ? '1 minute ago' : `${m} minutes ago`;
const h = Math.floor(m / 60);
if (h < 24) return h === 1 ? '1 hour ago' : `${h} hours ago`;
const d = Math.floor(h / 24);
return d === 1 ? '1 day ago' : `${d} days ago`;
}
function staleness(lastActive) {
if (!lastActive) return 'gray';
const min = (Date.now() - new Date(lastActive).getTime()) / 60000;
if (Number.isNaN(min)) return 'gray';
return min < 15 ? 'green' : min < 120 ? 'amber' : 'red';
}
function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const child of [].concat(children)) {
if (child == null) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
function fmtValue(value) {
if (value === null || value === undefined) return { text: 'null', cls: 'null' };
if (typeof value === 'string' && ISO_RE.test(value)) return { iso: value };
return { text: typeof value === 'object' ? JSON.stringify(value) : String(value) };
}
function cellFor(value) {
const f = fmtValue(value);
if (f.cls === 'null') return el('td', { class: 'null' }, 'null');
if (f.iso) {
return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [
relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')),
]));
}
if (f.text.length > 42) {
const span = el('span', { class: 'trunc', title: f.text }, f.text.slice(0, 39) + '…');
span.addEventListener('click', (e) => { e.stopPropagation(); span.textContent = f.text; span.classList.remove('trunc'); });
return el('td', {}, span);
}
return el('td', {}, f.text);
}
function kvRows(obj) {
return Object.entries(obj ?? {}).map(([k, v]) => {
let valEl;
if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2));
else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`);
else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null');
else valEl = el('span', {}, String(v));
return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]);
});
}
function resolveRef(cliName, ref, id) {
const snap = state.snapshots.get(`${cliName}/${ref.ref}`);
const row = snap?.rows?.find((r) => String(r.id) === String(id));
return row ? (row[ref.label] ?? null) : null;
}
function badgeChip(value, colorMap) {
const color = colorMap[String(value).toLowerCase()] ?? 'gray';
return el('span', { class: `badge-status ${color}` }, [el('span', { class: `dot ${color}` }), String(value)]);
}
function buildCell(value, column, ctx) {
if (ctx.badges?.[column] && value != null && typeof value !== 'object') {
return el('td', {}, badgeChip(value, ctx.badges[column]));
}
if (ctx.enrich?.[column] && value != null) {
const name = resolveRef(ctx.cliName, ctx.enrich[column], value);
if (name != null) {
return el('td', { class: 'enriched', title: String(value) }, [
el('span', {}, String(name)), el('span', { class: 'raw-id' }, String(value)),
]);
}
}
return cellFor(value);
}
function summaryBar(resource, rows, col, cli) {
let label = resource.replace(/-/g, ' ');
if (rows.length === 1 && label.endsWith('s')) label = label.slice(0, -1);
const bits = [el('span', { class: 'sum-count' }, `${rows.length} ${label}`)];
if (col && rows.some((r) => col in r)) {
const counts = new Map();
for (const r of rows) { const v = r[col] ?? '—'; counts.set(v, (counts.get(v) ?? 0) + 1); }
const colorMap = cli.badges?.[col];
for (const [v, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
bits.push(el('span', { class: 'sum-sep' }, '·'));
const c = colorMap?.[String(v).toLowerCase()] ?? null;
bits.push(c
? el('span', { class: `badge-status ${c}` }, [el('span', { class: `dot ${c}` }), `${v} ×${n}`])
: el('span', { class: 'sum-chip' }, `${v} ×${n}`));
}
}
return el('div', { class: 'summary-bar' }, bits);
}
// ---------------------------------------------------------------- views
const nclCli = () => state.clis.find((c) => c.name === 'ncl') ?? state.clis[0];
function currentView() {
const v = state.activeView;
if (v === 'overview' || v === 'activity') return { type: v };
const m = v.match(/^r:([^:]+):(.+)$/);
if (m) return { type: 'resource', cli: m[1], resource: m[2] };
if (v.startsWith('doc:')) return { type: 'doc', collection: v.slice(4) };
if (v.startsWith('log:')) return { type: 'log', name: v.slice(4) };
return { type: 'overview' };
}
const activeCollection = () => {
const v = currentView();
return v.type === 'doc' ? state.docCollections.find((c) => c.name === v.collection) : null;
};
// ---------------------------------------------------------------- fetching
async function fetchJson(url) {
const res = await fetch(url);
return res.json().catch(() => ({ ok: false, error: `Bad response from ${url}` }));
}
async function refresh(force = false) {
state.refreshing = true;
if (force) renderControls();
const [cliList, docList, logList] = await Promise.all([
fetchJson('/api/clis').catch(() => null),
fetchJson('/api/docs').catch(() => null),
fetchJson('/api/logs').catch(() => null),
]);
if (cliList?.clis) {
state.clis = cliList.clis;
state.refreshSeconds = cliList.clis[0]?.refreshSeconds ?? state.refreshSeconds;
}
if (docList?.collections) state.docCollections = docList.collections;
if (logList?.files) state.logs = logList.files;
render(); // paint sidebar + active view's loading state immediately
const jobs = [];
jobs.push(fetchJson('/api/activity').then((body) => {
if (body.ok && body.configured) {
state.activity = { sessions: body.sessions, series: body.series };
state.activityConfigured = true; state.activityCommand = body.command ?? null;
} else state.activityConfigured = false;
render();
}));
for (const lg of state.logs) {
jobs.push(fetchJson(`/api/log/${encodeURIComponent(lg.name)}`).then((body) => {
if (body.ok) state.logCache.set(lg.name, { text: body.text, command: body.command });
render();
}));
}
for (const c of state.clis) {
for (const r of c.resources ?? []) {
const key = `${c.name}/${r.name}`;
jobs.push(fetchJson(`/api/r/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
if (body.ok) { state.snapshots.set(key, { rows: body.rows, fetchedAt: body.fetchedAt, command: body.command }); state.errors.set(key, null); }
else state.errors.set(key, body.raw ? `${body.error}\n\n${body.raw}` : body.error);
render();
}));
if (c.help) {
jobs.push(fetchJson(`/api/help/${c.name}/${encodeURIComponent(r.name)}`).then((body) => {
state.helpCache.set(key, body.ok ? body.text : null);
render();
}));
}
}
}
await Promise.all(jobs);
// per-group container config (for the Overview page) — small, refetched each cycle
const groups = state.snapshots.get('ncl/groups')?.rows ?? [];
await Promise.all(groups.map(async (g) => {
const c = await fetchJson(`/api/cmd/ncl/config-get?id=${encodeURIComponent(g.id)}`);
if (c.ok) state.configCache.set(g.id, c.data);
}));
state.lastUpdated = new Date();
state.refreshing = false;
render();
}
async function openDoc(collectionName, path) {
state.activeDocPath = path;
const key = `${collectionName}\0${path}`;
if (!state.docCache.has(key)) {
const body = await fetchJson(`/api/doc?c=${encodeURIComponent(collectionName)}&p=${encodeURIComponent(path)}`);
state.docCache.set(key, body.ok ? { lang: body.lang, content: body.content } : { lang: 'error', content: body.error || 'Failed to load' });
}
state.renderedSig = null;
render();
}
async function openDetail(cliName, resource, id) {
state.detail = { cli: cliName, resource, id, loading: true };
state.renderedSig = null;
render();
const rec = await fetchJson(`/api/cmd/${cliName}/get?resource=${encodeURIComponent(resource)}&id=${encodeURIComponent(id)}`);
let config = null;
if (resource === 'groups') {
const cg = await fetchJson(`/api/cmd/${cliName}/config-get?id=${encodeURIComponent(id)}`);
if (cg.ok) config = cg.data;
}
if (!state.detail || state.detail.id !== id) return;
state.detail = { cli: cliName, resource, id, record: rec.ok ? rec.data : null, error: rec.ok ? null : rec.error, config };
state.renderedSig = null;
render();
}
function closeDetail() { state.detail = null; state.renderedSig = null; render(); }
// Help panel: the description (first paragraph) is always visible; the verbs +
// fields (everything after the first blank line) sit behind a collapse.
function helpPanel(text) {
if (text === null) return null; // explicitly no help
if (text === undefined) return el('div', { class: 'help-panel' }, el('div', { class: 'help-head dim' }, 'loading help…'));
const idx = text.indexOf('\n\n');
const head = (idx >= 0 ? text.slice(0, idx) : text).trim();
const body = idx >= 0 ? text.slice(idx + 2).trim() : '';
return el('div', { class: 'help-panel' }, [
el('div', { class: 'help-head' }, head),
body ? el('details', { class: 'help-more' }, [
el('summary', {}, 'verbs & fields'),
el('pre', { class: 'help-text' }, body),
]) : null,
]);
}
function go(view) {
state.activeView = view;
state.detail = null;
state.sidebarOpen = false;
state.renderedSig = null;
const v = currentView();
if (v.type === 'doc') {
const coll = state.docCollections.find((c) => c.name === v.collection);
const first = coll && (coll.name === 'conversations' ? coll.files.at(-1) : coll.files[0]); // newest conversation
state.activeDocPath = state.activeDocPath && coll?.files.some((f) => f.path === state.activeDocPath)
? state.activeDocPath : (first?.path ?? null);
// expand only the group holding the active doc; the user picks the rest
const activeFile = coll?.files.find((f) => f.path === state.activeDocPath);
state.openDocGroups = new Set(activeFile ? [activeFile.group] : []);
render();
if (state.activeDocPath) openDoc(coll.name, state.activeDocPath);
return;
}
render();
}
// ---------------------------------------------------------------- rendering
function dataSignature() {
const v = currentView();
const key = v.type === 'resource' ? `${v.cli}/${v.resource}` : null;
const coll = activeCollection();
return JSON.stringify({
view: state.activeView, clis: state.clis.map((c) => `${c.name}:${(c.resources || []).length}`),
activityConfigured: state.activityConfigured,
rows: key ? state.snapshots.get(key)?.rows ?? null : null,
rowsError: key ? state.errors.get(key) ?? null : null,
command: key ? state.snapshots.get(key)?.command ?? null : null,
help: key ? state.helpCache.get(key) ?? null : null,
overview: v.type === 'overview' ? {
groups: state.snapshots.get('ncl/groups')?.rows ?? null,
sessions: state.snapshots.get('ncl/sessions')?.rows ?? null,
configs: [...state.configCache.entries()],
activity: state.activity?.sessions ?? null,
} : null,
activity: v.type === 'activity' ? state.activity : null,
log: v.type === 'log' ? state.logCache.get(v.name)?.text ?? null : null,
docFiles: coll ? coll.files.map((f) => f.path) : null,
docPath: state.activeDocPath,
docGroupsOpen: coll ? [...state.openDocGroups] : null,
docContent: coll ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`)?.content ?? null : null,
detail: state.detail, paused: state.paused, sidebarOpen: state.sidebarOpen,
});
}
function renderControls() {
$('updated').textContent = state.lastUpdated
? `updated ${coarseAgo(state.lastUpdated)}${state.paused ? ' · paused' : ''}` : '';
$('refresh').classList.toggle('spinning', state.refreshing);
}
function render() {
renderControls();
const sig = dataSignature();
if (sig === state.renderedSig) return;
state.renderedSig = sig;
$('sidebar').classList.toggle('open', state.sidebarOpen);
$('scrim').hidden = !state.sidebarOpen;
renderNav();
const v = currentView();
const banner = $('banner');
const tabError = v.type === 'resource' ? state.errors.get(`${v.cli}/${v.resource}`) : null;
const cli = v.type === 'resource' ? state.clis.find((c) => c.name === v.cli) : null;
const bannerMsg = cli?.error ? `Discovery failed for ${v.cli}: ${cli.error}`
: (tabError ? `CLI unreachable — showing last good snapshot. ${tabError.split('\n')[0]}` : null);
banner.hidden = !bannerMsg;
banner.textContent = bannerMsg ?? '';
renderCmdline(v);
if (v.type === 'overview') renderOverviewPage();
else if (v.type === 'activity') renderActivity();
else if (v.type === 'doc') renderDocs();
else if (v.type === 'log') renderLogPage(v.name);
else renderTable(v.cli, v.resource);
renderDetail();
}
function navItem(label, view, cls = '', iconName = null) {
return el('button', {
class: `nav-item ${cls}` + (state.activeView === view ? ' active' : ''),
onclick: () => go(view),
}, [iconName ? icon(iconName) : null, el('span', {}, label)]);
}
function renderNav() {
const nav = $('nav');
const items = [navItem('Overview', 'overview', '', 'overview')];
if (state.activityConfigured) items.push(navItem('Activity', 'activity', '', 'activity'));
for (const cli of state.clis) {
items.push(el('div', { class: 'nav-section' }, [icon(cli.name === 'docker' ? 'box' : 'terminal'), el('span', {}, cli.name)]));
for (const r of cli.resources ?? []) {
items.push(navItem(r.name, `r:${cli.name}:${r.name}`, 'nav-sub'));
}
}
if (state.docCollections.length) {
items.push(el('div', { class: 'nav-section' }, [icon('folder'), el('span', {}, 'Files')]));
for (const coll of state.docCollections) {
items.push(navItem(coll.label, `doc:${coll.name}`, 'nav-sub'));
}
}
if (state.logs.length) {
items.push(el('div', { class: 'nav-section' }, [icon('logs'), el('span', {}, 'Logs')]));
for (const lg of state.logs) {
items.push(navItem(lg.label, `log:${lg.name}`, 'nav-sub'));
}
}
nav.replaceChildren(...items);
}
function renderCmdline(v) {
const bar = $('cmdline');
let cmd = null;
if (v.type === 'resource') cmd = state.snapshots.get(`${v.cli}/${v.resource}`)?.command;
else if (v.type === 'activity') cmd = state.activityCommand;
else if (v.type === 'doc') cmd = state.activeDocPath ? `file · ${state.activeDocPath}` : null;
else if (v.type === 'log') cmd = state.logCache.get(v.name)?.command ?? null;
else if (v.type === 'overview') cmd = 'derived · ncl groups/sessions/messaging-groups/wirings + config-get + activity';
bar.hidden = !cmd;
bar.textContent = cmd ? `$ ${cmd}` : '';
}
// ---- Overview page (rich agent cards) ----
function renderOverviewPage() {
const content = $('content');
const groups = state.snapshots.get('ncl/groups')?.rows;
if (!groups) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
const sessions = state.snapshots.get('ncl/sessions')?.rows ?? [];
const wirings = state.snapshots.get('ncl/wirings')?.rows ?? [];
const mgs = state.snapshots.get('ncl/messaging-groups')?.rows ?? [];
const act = state.activity?.sessions ?? [];
const mgName = (id) => mgs.find((m) => m.id === id)?.name ?? mgs.find((m) => m.id === id)?.platform_id ?? id;
const field = (k, v, cls = '') => el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, k), el('span', { class: `v ${cls}` }, v)]);
const cards = groups.map((g) => {
const gs = sessions.filter((s) => s.agent_group_id === g.id);
const lastActive = gs.map((s) => s.last_active).filter(Boolean).sort().at(-1) ?? null;
const container = gs.some((s) => s.container_status === 'running') ? 'running' : (gs[0]?.container_status ?? 'none');
const ga = act.filter((a) => a.agent_group_id === g.id);
const msgIn = ga.reduce((a, s) => a + s.in, 0), msgOut = ga.reduce((a, s) => a + s.out, 0);
const cfg = state.configCache.get(g.id);
const chans = wirings.filter((w) => w.agent_group_id === g.id).map((w) => `${mgs.find((m) => m.id === w.messaging_group_id)?.channel_type ?? '?'}: ${mgName(w.messaging_group_id)}`);
const status = staleness(lastActive);
const containerColor = container === 'running' ? 'green' : container === 'idle' ? 'green' : container === 'none' ? 'gray' : 'gray';
const fields = [
el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'container'), badgeChip(container, { running: 'green', idle: 'green', stopped: 'gray', none: 'gray' })]),
field('sessions', String(gs.length)),
field('messages', `${msgIn} in · ${msgOut} out`),
field('last active', lastActive ? relTime(lastActive) : '—', lastActive ? '' : 'dim'),
];
if (cfg) {
fields.push(field('provider / model', `${cfg.provider ?? 'claude'} / ${cfg.model ?? 'default'}`));
fields.push(el('div', { class: 'ov-field' }, [el('span', { class: 'k' }, 'cli scope'), badgeChip(cfg.cli_scope ?? 'group', { global: 'amber', group: 'green', disabled: 'gray' })]));
const pkgs = (cfg.packages_apt?.length ?? 0) + (cfg.packages_npm?.length ?? 0);
const mcp = Object.keys(cfg.mcp_servers ?? {}).length;
if (pkgs || mcp) fields.push(field('extras', `${pkgs} pkgs · ${mcp} mcp`));
}
return el('div', { class: 'ov-card' }, [
el('div', { class: 'ov-head' }, [
el('span', { class: `dot ${status}` }),
el('span', { class: 'ov-name' }, g.name),
el('span', { class: 'ov-folder' }, g.folder),
]),
el('div', { class: 'ov-fields' }, fields),
el('div', { class: 'ov-chans' }, chans.map((c) => el('span', { class: 'badge' }, c))),
]);
});
content.replaceChildren(
el('h2', { class: 'page-title' }, 'Agents overview'),
el('div', { class: 'ov-cards' }, cards),
);
}
// ---- Activity ----
function renderActivity() {
const content = $('content');
const data = state.activity;
if (!data) { content.replaceChildren(el('div', { class: 'empty' }, 'Loading…')); return; }
const { series, sessions } = data;
const totalIn = series.reduce((a, d) => a + d.in, 0);
const totalOut = series.reduce((a, d) => a + d.out, 0);
const W = 720, H = 220, padL = 34, padB = 28, padT = 10;
const max = Math.max(1, ...series.map((d) => Math.max(d.in, d.out)));
const slot = (W - padL) / series.length;
const bw = Math.max(3, slot / 2 - 2);
const yOf = (vv) => padT + (H - padT - padB) * (1 - vv / max);
const chart = svg('svg', { viewBox: `0 0 ${W} ${H}`, class: 'activity-chart', preserveAspectRatio: 'none' });
for (const frac of [0, 0.5, 1]) {
const y = yOf(max * frac);
chart.append(svg('line', { x1: padL, y1: y, x2: W, y2: y, class: 'grid' }));
chart.append(svg('text', { x: padL - 6, y: y + 3, class: 'axis', 'text-anchor': 'end' }, String(Math.round(max * frac))));
}
series.forEach((d, i) => {
const x = padL + i * slot;
chart.append(svg('rect', { x: x + 1, y: yOf(d.in), width: bw, height: yOf(0) - yOf(d.in), class: 'bar-in' }, [svg('title', {}, `${d.date}: ${d.in} in`)]));
chart.append(svg('rect', { x: x + 1 + bw, y: yOf(d.out), width: bw, height: yOf(0) - yOf(d.out), class: 'bar-out' }, [svg('title', {}, `${d.date}: ${d.out} out`)]));
if (i % 2 === 0) chart.append(svg('text', { x: x + bw, y: H - 8, class: 'axis', 'text-anchor': 'middle' }, d.date.slice(5)));
});
const legend = el('div', { class: 'activity-legend' }, [
el('span', {}, [el('span', { class: 'lg in' }), `inbound (${totalIn})`]),
el('span', {}, [el('span', { class: 'lg out' }), `outbound (${totalOut})`]),
el('span', { class: 'dim' }, `last ${series.length} days`),
]);
const sessRows = [...sessions].sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || '')).map((s) => {
const groupName = resolveRef('ncl', { ref: 'groups', label: 'name' }, s.agent_group_id) ?? s.agent_group_id;
return el('tr', {}, [
el('td', {}, groupName),
el('td', {}, el('span', { class: 'trunc', title: s.session_id }, s.session_id.slice(0, 22) + '…')),
el('td', { class: 'num' }, String(s.in)),
el('td', { class: 'num' }, String(s.out)),
el('td', {}, s.lastActivity ? el('span', { class: 'reltime', title: s.lastActivity }, relTime(s.lastActivity)) : el('span', { class: 'null' }, '—')),
]);
});
content.replaceChildren(
el('h2', { class: 'page-title' }, 'Message activity'),
el('div', { class: 'activity-wrap' }, [
legend,
el('div', { class: 'chart-box' }, chart),
el('div', { class: 'table-wrap' }, el('table', { class: 'activity-table' }, [
el('thead', {}, el('tr', {}, ['agent', 'session', 'in', 'out', 'last activity'].map((h) => el('th', {}, h)))),
el('tbody', {}, sessRows),
])),
]),
);
}
// ---- Logs (tail of a log file) ----
function renderLogPage(name) {
const content = $('content');
const label = state.logs.find((l) => l.name === name)?.label ?? name;
const cached = state.logCache.get(name);
if (!cached) { content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'empty' }, 'Loading…')); return; }
const view = el('div', { class: 'log-view' });
for (const line of cached.text.split('\n')) {
const lvl = /\bERROR\b/i.test(line) ? 'err' : /\bWARN(ING)?\b/i.test(line) ? 'warn' : '';
view.append(el('div', { class: `log-line ${lvl}` }, line || ' '));
}
content.replaceChildren(el('h2', { class: 'page-title' }, label), el('div', { class: 'log-box' }, view));
// follow the tail — scroll to the newest line
requestAnimationFrame(() => { const b = content.querySelector('.log-box'); if (b) b.scrollTop = b.scrollHeight; });
}
// ---- Files (doc viewer) ----
function renderDocs() {
const coll = activeCollection();
const content = $('content');
if (!coll) { content.replaceChildren(el('div', { class: 'empty' }, 'No documents.')); return; }
if (!coll.files.length) { content.replaceChildren(el('div', { class: 'empty' }, `No ${coll.label.toLowerCase()}.`)); return; }
// display name: drop the group prefix, the `/SKILL.md` tail (show the skill
// dir), and the .md extension — leaving e.g. "meeting-tagger" or "2026-06-13-…"
const itemName = (label) => {
let n = label.includes('/') ? label.split('/').slice(1).join('/').trim() : label;
return n.replace(/\/SKILL\.md$/, '').replace(/\.md$/, '') || label;
};
const newestFirst = coll.name === 'conversations';
const groups = new Map();
for (const f of coll.files) { if (!groups.has(f.group)) groups.set(f.group, []); groups.get(f.group).push(f); }
const toggleGroup = (g) => {
state.openDocGroups.has(g) ? state.openDocGroups.delete(g) : state.openDocGroups.add(g);
state.renderedSig = null; render();
};
const list = el('div', { class: 'doc-list' });
for (const [group, files] of groups) {
const open = state.openDocGroups.has(group);
list.append(el('button', { class: 'doc-group-toggle' + (open ? ' open' : ''), onclick: () => toggleGroup(group) }, [
el('span', { class: 'chev' }, open ? '▾' : '▸'),
el('span', { class: 'g-name' }, group || '—'),
el('span', { class: 'g-count' }, String(files.length)),
]));
if (open) {
const ordered = newestFirst ? [...files].reverse() : files;
for (const f of ordered) {
list.append(el('button', { class: 'doc-item' + (f.path === state.activeDocPath ? ' active' : ''), title: f.path, onclick: () => openDoc(coll.name, f.path) }, itemName(f.label) || f.path));
}
}
}
const pane = el('div', { class: 'doc-content' });
const cached = state.activeDocPath ? state.docCache.get(`${coll.name}\0${state.activeDocPath}`) : null;
if (!state.activeDocPath) pane.append(el('div', { class: 'empty' }, 'Select a document.'));
else if (!cached) pane.append(el('div', { class: 'empty' }, 'Loading…'));
else if (cached.lang === 'error') pane.append(el('div', { class: 'tab-error' }, cached.content));
else if (cached.lang === 'json') {
let pretty = cached.content;
try { pretty = JSON.stringify(JSON.parse(cached.content), null, 2); } catch { /* keep raw */ }
pane.append(el('pre', { class: 'code json' }, pretty));
} else if (cached.lang === 'markdown') {
const md = el('div', { class: 'markdown' }); md.innerHTML = mdToHtml(cached.content); pane.append(md);
} else pane.append(el('pre', { class: 'code' }, cached.content));
content.replaceChildren(el('h2', { class: 'page-title' }, coll.label), el('div', { class: 'doc-viewer' }, [list, pane]));
}
// ---- resource table ----
function renderTable(cliName, resource) {
const content = $('content');
const cli = state.clis.find((c) => c.name === cliName);
if (!cli) { content.replaceChildren(el('div', { class: 'empty' }, 'No such CLI.')); return; }
const key = `${cliName}/${resource}`;
const snapshot = state.snapshots.get(key);
const error = state.errors.get(key);
const canDrill = (cli.commands || []).includes('get');
const parts = [el('h2', { class: 'page-title' }, resource)];
if (cli.help) parts.push(helpPanel(state.helpCache.get(key)));
if (error && snapshot) parts.push(el('div', { class: 'stale-note' }, `⚠ live fetch failing — snapshot from ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`));
if (!snapshot) {
parts.push(error ? el('div', { class: 'tab-error' }, [`Failed to load ${resource}.`, el('pre', {}, error)]) : el('div', { class: 'empty' }, 'Loading…'));
content.replaceChildren(...parts); return;
}
const rows = snapshot.rows;
parts.push(summaryBar(resource, rows, cli.summary?.[resource], cli));
if (rows.length === 0) { parts.push(el('div', { class: 'empty' }, `No ${resource}.`)); content.replaceChildren(...parts); return; }
const columns = [];
for (const row of rows) for (const k of Object.keys(row)) if (!columns.includes(k)) columns.push(k);
const ctx = { cliName, enrich: cli.enrich?.[resource], badges: cli.badges };
const body = rows.map((row) => {
const id = row.id; const canRow = canDrill && id != null;
return el('tr', { class: canRow ? 'drillable' : '', ...(canRow ? { onclick: () => openDetail(cliName, resource, String(id)) } : {}) },
columns.map((c) => buildCell(row[c], c, ctx)));
});
parts.push(el('div', { class: 'table-wrap' }, el('table', {}, [
el('thead', {}, el('tr', {}, columns.map((c) => el('th', {}, c)))),
el('tbody', {}, body),
])));
content.replaceChildren(...parts);
}
// ---- drill-down detail overlay ----
function renderDetail() {
const overlay = $('detail');
if (!state.detail) { overlay.hidden = true; overlay.replaceChildren(); return; }
overlay.hidden = false;
const d = state.detail;
const panel = el('div', { class: 'detail-panel' });
panel.append(el('div', { class: 'detail-head' }, [
el('div', {}, [el('span', { class: 'detail-res' }, d.resource), ' ', el('span', { class: 'detail-id' }, d.id)]),
el('button', { class: 'detail-close', onclick: closeDetail, title: 'Close' }, '✕'),
]));
const sub = el('div', { class: 'detail-body' });
if (d.loading) sub.append(el('div', { class: 'empty' }, 'Loading…'));
else if (d.error) sub.append(el('div', { class: 'tab-error' }, d.error));
else if (d.record) sub.append(el('div', { class: 'kv' }, kvRows(d.record)));
if (d.config) {
sub.append(el('div', { class: 'detail-section' }, 'Container config'));
sub.append(el('div', { class: 'kv' }, kvRows(d.config)));
}
panel.append(sub);
overlay.replaceChildren(panel);
}
// ---------------------------------------------------------------- boot
$('pause').addEventListener('click', () => {
state.paused = !state.paused;
$('pause').textContent = state.paused ? '▶ resume' : '⏸ pause';
$('pause').classList.toggle('paused', state.paused);
state.renderedSig = null; render();
});
$('refresh').addEventListener('click', () => { if (!state.refreshing) refresh(true); });
$('hamburger').addEventListener('click', () => { state.sidebarOpen = !state.sidebarOpen; state.renderedSig = null; render(); });
$('scrim').addEventListener('click', () => { state.sidebarOpen = false; state.renderedSig = null; render(); });
$('detail').addEventListener('click', (e) => { if (e.target === $('detail')) closeDetail(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (state.detail) closeDetail(); else if (state.sidebarOpen) { state.sidebarOpen = false; state.renderedSig = null; render(); } } });
async function tick() {
if (!state.paused) { try { await refresh(); } catch { /* keep snapshots; retry next tick */ } }
else renderControls();
setTimeout(tick, state.refreshSeconds * 1000);
}
tick();
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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
export function mdToHtml(src) {
const lines = escapeHtml(src).split('\n');
const out = [];
let i = 0;
const inline = (t) => t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, (m, text, url) =>
/^https?:\/\//i.test(url) ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>` : m);
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) {
const buf = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${buf.join('\n')}</code></pre>`);
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); i++; continue; }
if (/^\s*([-*])\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*([-*])\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*([-*])\s+/, ''))}</li>`);
i++;
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
i++;
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
if (/^\s*>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^\s*>\s?/, ''))}</blockquote>`); i++; continue; }
if (line.trim() === '') { i++; continue; }
const para = [line];
i++;
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s|\s*>)/.test(lines[i])) {
para.push(lines[i++]);
}
out.push(`<p>${inline(para.join(' '))}</p>`);
}
return out.join('\n');
}
@@ -0,0 +1,11 @@
{
"name": "clidash",
"short_name": "ncl",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#0a0c11",
"background_color": "#0a0c11",
"display": "standalone"
}
@@ -0,0 +1,328 @@
/* clidash — refined terminal-ops console.
IBM Plex superfamily (Sans for UI, Mono for the CLI identity), a deep layered
dark palette, real depth, and precise micro-interactions. */
:root {
/* layered surfaces */
--bg: #0a0c11;
--bg-grad: #10141d;
--panel: #13171f;
--panel-2: #1a1f2a;
--border: #222834;
--border-strong: #2e3644;
/* text */
--text: #e8edf5;
--dim: #98a2b3;
--faint: #5c6675;
/* accent + semantics */
--accent: #5b9dff;
--accent-soft: rgba(91, 157, 255, 0.14);
--green: #4cc97a;
--amber: #e0a93a;
--red: #f76d6d;
--purple: #c08cff;
--gray: #6b7585;
/* shape + motion */
--radius: 11px;
--radius-sm: 8px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow: 0 6px 22px -8px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 18px 44px -12px rgba(0, 0, 0, 0.6);
--ease: 160ms cubic-bezier(0.2, 0.6, 0.2, 1);
--font-sans: "IBM Plex Sans", -apple-system, system-ui, Segoe UI, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
html { scrollbar-color: var(--border-strong) transparent; }
body {
background:
radial-gradient(120% 80% at 50% -10%, var(--bg-grad) 0%, transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--text);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--accent-soft); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
h1 {
font-family: var(--font-mono); font-size: 17px; font-weight: 600;
letter-spacing: -0.3px; color: var(--text);
}
h1::before { content: "▍"; color: var(--accent); margin-right: 4px; }
/* ---- layout ---- */
.app { display: flex; align-items: stretch; min-height: 100vh; min-height: 100dvh; }
.sidebar {
width: 236px; flex-shrink: 0; height: 100vh; height: 100dvh;
position: sticky; top: 0; align-self: flex-start; overflow-y: auto;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 200px), var(--bg);
border-right: 1px solid var(--border);
padding: 18px 12px 40px; display: flex; flex-direction: column; gap: 10px;
}
.sidebar .brand { padding: 2px 8px 6px; }
.sidebar .controls { display: flex; gap: 8px; padding: 0 4px; }
.sidebar .updated { color: var(--faint); font-size: 11.5px; padding: 0 8px; min-height: 16px; font-family: var(--font-mono); }
.pause, .refresh {
flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--dim); padding: 6px 10px; font-size: 12.5px; font-family: var(--font-mono);
cursor: pointer; transition: color var(--ease), border-color var(--ease), background var(--ease);
}
.pause:hover, .refresh:hover { color: var(--text); border-color: var(--border-strong); background: var(--panel-2); }
.pause.paused { color: var(--amber); border-color: var(--amber); }
.refresh.spinning { color: var(--accent); border-color: var(--accent); animation: pulse 0.85s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.5; } }
.nav { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
.nav-section {
color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px;
font-weight: 600; margin: 16px 10px 5px; font-family: var(--font-mono);
display: flex; align-items: center; gap: 7px;
}
.nav-section svg { width: 13px; height: 13px; opacity: 0.7; }
.nav-item {
display: flex; align-items: center; gap: 9px; text-align: left;
background: none; border: none; border-radius: var(--radius-sm);
color: var(--dim); padding: 7px 10px; font-size: 13.5px; cursor: pointer; width: 100%;
font-family: var(--font-sans); position: relative;
transition: color var(--ease), background var(--ease);
}
.nav-item svg { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; }
.nav-item:hover { background: var(--panel); color: var(--text); }
.nav-item.active { background: var(--accent-soft); color: var(--text); }
.nav-item.active::before {
content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; background: var(--accent); border-radius: 0 3px 3px 0;
}
.nav-item.nav-sub { padding-left: 16px; font-size: 13px; }
.nav-item.nav-sub.active { color: var(--accent); }
.hamburger {
display: none; position: fixed; top: 12px; left: 12px; z-index: 60;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text); width: 40px; height: 40px; cursor: pointer; align-items: center; justify-content: center;
}
.hamburger svg { width: 20px; height: 20px; }
.scrim { display: none; }
.main { flex: 1; min-width: 0; padding: 26px 28px 64px; max-width: 1500px; }
.page-title {
font-size: 20px; font-weight: 600; letter-spacing: -0.4px; margin-bottom: 16px;
animation: fadeUp 0.3s var(--ease) both;
}
.banner {
background: rgba(247, 109, 109, 0.1); border: 1px solid rgba(247, 109, 109, 0.4);
border-radius: var(--radius-sm); color: var(--red); padding: 9px 14px; font-size: 13.5px; margin-bottom: 16px;
}
.cmdline {
font-family: var(--font-mono); font-size: 12px; color: var(--dim);
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 9px 14px; margin-bottom: 18px; overflow-x: auto; white-space: nowrap; box-shadow: var(--shadow-sm);
}
.cmdline::first-letter { color: var(--green); }
@keyframes fadeUp { from { opacity: 0; transform: translateY(7px); } }
/* ---- overview cards ---- */
.ov-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; }
.ov-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 40%), var(--panel);
border: 1px solid var(--border); border-radius: var(--radius); padding: 17px 19px;
box-shadow: var(--shadow); transition: border-color var(--ease), transform var(--ease);
animation: fadeUp 0.4s var(--ease) both;
}
.ov-card:nth-child(2) { animation-delay: 0.05s; }
.ov-card:nth-child(3) { animation-delay: 0.1s; }
.ov-card:nth-child(4) { animation-delay: 0.15s; }
.ov-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
.ov-head { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; }
.ov-head .ov-name { font-weight: 600; font-size: 15px; }
.ov-head .ov-folder { color: var(--faint); font-size: 12px; font-family: var(--font-mono); }
.ov-fields { display: flex; flex-direction: column; gap: 7px; }
.ov-field { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
.ov-field .k { color: var(--dim); }
.ov-field .v { color: var(--text); font-variant-numeric: tabular-nums; } .ov-field .v.dim { color: var(--faint); }
.ov-chans { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.dot, .ov-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.dot.green { background: var(--green); box-shadow: 0 0 8px -1px var(--green); }
.dot.amber { background: var(--amber); box-shadow: 0 0 8px -1px var(--amber); }
.dot.red { background: var(--red); box-shadow: 0 0 8px -1px var(--red); }
.dot.gray { background: var(--gray); }
.badge {
font-size: 11.5px; border: 1px solid var(--border-strong); border-radius: 99px;
padding: 2px 10px; color: var(--dim); background: var(--panel-2); font-family: var(--font-mono);
}
/* ---- status badges + enriched cells ---- */
.badge-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
.badge-status .dot { width: 7px; height: 7px; }
.badge-status.green { color: var(--green); } .badge-status.amber { color: var(--amber); }
.badge-status.red { color: var(--red); } .badge-status.gray { color: var(--gray); }
td.enriched span:first-child { color: var(--text); }
td.enriched .raw-id { display: block; color: var(--faint); font-size: 11px; font-family: var(--font-mono); }
/* ---- summary bar ---- */
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 2px 0 16px; font-size: 13px; }
.summary-bar .sum-count { color: var(--text); font-weight: 600; }
.summary-bar .sum-sep { color: var(--border-strong); }
.summary-bar .sum-chip { color: var(--dim); font-family: var(--font-mono); font-size: 12px; }
/* ---- per-resource help panel ---- */
.help-panel { margin-bottom: 18px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 13px 16px; box-shadow: var(--shadow-sm); }
.help-head { color: var(--dim); font-size: 13px; line-height: 1.6; }
.help-head.dim { color: var(--faint); }
.help-more { margin-top: 9px; }
.help-more > summary { cursor: pointer; color: var(--accent); font-size: 12px; list-style: none; user-select: none; font-family: var(--font-mono); }
.help-more > summary::-webkit-details-marker { display: none; }
.help-more > summary::before { content: "▸ "; }
.help-more[open] > summary::before { content: "▾ "; }
.help-text {
margin: 9px 0 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 13px 15px; font-size: 12px; line-height: 1.6; color: var(--dim);
white-space: pre-wrap; overflow-x: auto; font-family: var(--font-mono);
}
/* ---- document viewer ---- */
.doc-viewer { display: grid; grid-template-columns: 264px 1fr; gap: 20px; align-items: start; }
.doc-list { display: flex; flex-direction: column; gap: 2px; max-height: 76vh; overflow-y: auto; padding-right: 4px; }
.doc-group-toggle {
flex-shrink: 0; display: flex; align-items: center; gap: 7px; width: 100%;
background: none; border: none; cursor: pointer; text-align: left;
color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 1px;
font-weight: 600; font-family: var(--font-mono); margin: 12px 0 4px; padding: 5px 8px;
border-radius: var(--radius-sm); transition: color var(--ease), background var(--ease);
}
.doc-group-toggle:first-child { margin-top: 0; }
.doc-group-toggle:hover { color: var(--text); background: var(--panel); }
.doc-group-toggle.open { color: var(--text); }
.doc-group-toggle .chev { color: var(--accent); width: 10px; }
.doc-group-toggle .g-name { flex: 1; }
.doc-group-toggle .g-count {
color: var(--faint); background: var(--panel-2); border-radius: 99px;
padding: 1px 8px; font-size: 10.5px; letter-spacing: 0;
}
.doc-item {
flex-shrink: 0; text-align: left; background: none; border: none; border-radius: var(--radius-sm);
color: var(--dim); padding: 6px 11px; font-size: 13px; line-height: 1.5; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--ease), background var(--ease);
}
.doc-item:hover { background: var(--panel); color: var(--text); }
.doc-item.active { background: var(--accent-soft); color: var(--accent); }
.doc-content {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 22px 26px; min-height: 220px; max-height: 78vh; overflow-y: auto; box-shadow: var(--shadow);
}
.markdown { font-size: 14px; line-height: 1.7; color: var(--text); }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { margin: 20px 0 8px; line-height: 1.3; font-family: var(--font-mono); }
.markdown h1 { font-size: 21px; } .markdown h2 { font-size: 17px; color: var(--accent); } .markdown h3 { font-size: 15px; }
.markdown h1:first-child, .markdown h2:first-child { margin-top: 0; }
.markdown p { color: var(--text); margin: 8px 0; }
.markdown ul, .markdown ol { margin: 8px 0 8px 22px; }
.markdown li { margin: 3px 0; }
.markdown a { color: var(--accent); }
.markdown hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
.markdown blockquote { border-left: 3px solid var(--border-strong); padding-left: 12px; color: var(--dim); margin: 8px 0; }
.markdown code { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; font-size: 12.5px; font-family: var(--font-mono); }
.markdown pre.code, .doc-content pre.code {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 14px; overflow-x: auto; font-size: 12.5px; line-height: 1.6; margin: 12px 0; font-family: var(--font-mono);
}
.markdown pre.code code { background: none; border: none; padding: 0; }
.doc-content pre.json { color: var(--text); white-space: pre; font-family: var(--font-mono); }
/* ---- activity ---- */
.activity-wrap { max-width: 780px; }
.activity-legend { display: flex; gap: 18px; align-items: center; margin-bottom: 12px; font-size: 13px; color: var(--dim); }
.activity-legend .lg { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.activity-legend .lg.in { background: var(--accent); } .activity-legend .lg.out { background: var(--purple); }
.chart-box { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; box-shadow: var(--shadow); }
.activity-chart { width: 100%; height: 220px; display: block; }
.activity-chart .bar-in { fill: var(--accent); } .activity-chart .bar-out { fill: var(--purple); }
.activity-chart .grid { stroke: var(--border); stroke-width: 1; }
.activity-chart .axis { fill: var(--faint); font-size: 9px; font-family: var(--font-mono); }
.activity-table td.num, .activity-table th:nth-child(3), .activity-table th:nth-child(4) { text-align: right; }
.activity-table td.num { font-variant-numeric: tabular-nums; }
/* ---- log viewer ---- */
.log-box {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
max-height: 78vh; overflow: auto; padding: 12px 0; box-shadow: var(--shadow);
}
.log-view { font-family: var(--font-mono); font-size: 12px; line-height: 1.65; min-width: max-content; }
.log-line { padding: 0 16px; white-space: pre; color: var(--dim); }
.log-line:hover { background: var(--panel); }
.log-line.err { color: var(--red); background: rgba(247, 109, 109, 0.06); }
.log-line.warn { color: var(--amber); }
/* ---- tables ---- */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
tbody tr:last-child td { border-bottom: none; }
th { color: var(--dim); font-weight: 600; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.6px; position: sticky; top: 0; background: var(--panel-2); font-family: var(--font-mono); }
td { font-variant-numeric: tabular-nums; }
td.null { color: var(--faint); font-style: italic; }
tr.drillable { cursor: pointer; transition: background var(--ease); }
tr.drillable:hover td { background: var(--panel); }
.reltime .abs { color: var(--faint); font-size: 11.5px; margin-left: 6px; font-family: var(--font-mono); }
td .trunc { cursor: pointer; border-bottom: 1px dotted var(--faint); }
.stale-note { color: var(--amber); font-size: 12.5px; margin-bottom: 8px; }
.empty, .tab-error { color: var(--dim); padding: 26px 2px; font-size: 14px; }
.tab-error { color: var(--red); }
.tab-error pre { margin-top: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; color: var(--dim); font-size: 12px; overflow-x: auto; white-space: pre-wrap; font-family: var(--font-mono); }
/* ---- drill-down detail overlay ---- */
.detail-overlay { position: fixed; inset: 0; background: rgba(2, 4, 8, 0.62); display: flex; justify-content: flex-end; z-index: 50; backdrop-filter: blur(2px); animation: fade 0.18s ease; }
@keyframes fade { from { opacity: 0; } }
.detail-panel { width: min(580px, 100%); height: 100%; background: var(--bg); border-left: 1px solid var(--border-strong); overflow-y: auto; box-shadow: var(--shadow-lg); animation: slideIn 0.22s var(--ease); }
@keyframes slideIn { from { transform: translateX(24px); opacity: 0.6; } }
.detail-head { display: flex; justify-content: space-between; align-items: center; padding: 17px 22px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); }
.detail-res { font-weight: 600; font-size: 15px; }
.detail-id { color: var(--dim); font-family: var(--font-mono); font-size: 13px; }
.detail-close { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--dim); width: 30px; height: 30px; cursor: pointer; font-size: 14px; transition: color var(--ease), border-color var(--ease); }
.detail-close:hover { color: var(--text); border-color: var(--accent); }
.detail-body { padding: 17px 22px 44px; }
.detail-section { margin: 24px 0 8px; color: var(--faint); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; font-weight: 600; border-top: 1px solid var(--border); padding-top: 17px; font-family: var(--font-mono); }
.kv { display: flex; flex-direction: column; gap: 2px; }
.kv-row { display: grid; grid-template-columns: 168px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.kv-key { color: var(--dim); font-family: var(--font-mono); font-size: 12.5px; }
.kv-json { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; font-size: 12px; overflow-x: auto; margin: 0; font-family: var(--font-mono); }
/* ---- mobile ---- */
@media (max-width: 640px) {
.hamburger { display: flex; }
.sidebar {
position: fixed; top: 0; left: 0; z-index: 70; transform: translateX(-100%);
transition: transform 0.22s var(--ease); box-shadow: var(--shadow-lg);
}
.sidebar.open { transform: translateX(0); }
.scrim { display: block; position: fixed; inset: 0; background: rgba(2, 4, 8, 0.55); z-index: 65; backdrop-filter: blur(1px); }
.scrim[hidden] { display: none; }
.main { padding: 58px 16px 48px; }
.ov-cards { grid-template-columns: 1fr; }
.doc-viewer { grid-template-columns: 1fr; gap: 12px; }
.doc-list { max-height: 220px; flex-direction: row; flex-wrap: wrap; }
.doc-content { max-height: none; padding: 18px; }
.detail-panel { width: 100%; }
.kv-row { grid-template-columns: 1fr; gap: 2px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
@@ -0,0 +1,437 @@
// clidash — CLI-agnostic read-only web dashboard.
// Node built-ins only. All per-CLI knowledge lives in clidash.config.json;
// the only per-CLI code is optional view plugins (views/) and discovery
// parsers (parsers.js).
//
// Security model: the server can only exec the configured argv templates.
// `{resource}` is the sole substitution and is validated against the
// discovered/static resource set before exec. execFile, never a shell.
import { createServer } from 'node:http';
import { execFile } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { dirname, join, resolve, sep, basename } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { discoveryParsers, parseOutput, unwrapPath } from './parsers.js';
import { globFiles, describeFile, resolveDoc } from './docs.js';
import { collectActivity } from './activity.js';
import { tailFile } from './logs.js';
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
const MAX_DOC_BYTES = 2 * 1024 * 1024; // cap a single served document at 2 MB
const DEFAULTS = {
bind: '127.0.0.1',
port: 4690,
refreshSeconds: 60,
execTimeoutMs: 10_000,
discoveryTtlMs: 60_000,
};
const CONTENT_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.webmanifest': 'application/manifest+json',
};
export function createApp(userConfig) {
const config = { ...DEFAULTS, ...userConfig };
const publicDir = resolve(config.publicDir ?? join(MODULE_DIR, 'public'));
const viewsDir = resolve(config.viewsDir ?? join(MODULE_DIR, 'views'));
// Human-readable form of a command, for display in the UI ("the command run").
const displayCmd = (bin, args) => `${basename(bin)} ${args.join(' ')}`;
// ---- exec --------------------------------------------------------------
function execCli(cliCfg, args, label) {
return new Promise((resolvePromise, rejectPromise) => {
execFile(cliCfg.bin, args, {
cwd: cliCfg.cwd,
timeout: config.execTimeoutMs,
maxBuffer: 32 * 1024 * 1024,
env: { ...process.env, ...cliCfg.env },
}, (error, stdout, stderr) => {
if (error) {
const timedOut = error.killed || error.signal === 'SIGTERM';
const detail = stderr.trim() || error.message;
const msg = timedOut
? `${label} timed out after ${config.execTimeoutMs}ms`
: `${label} failed: ${detail}`;
rejectPromise(new Error(msg));
return;
}
resolvePromise(stdout);
});
});
}
// ---- resource discovery (cached, coalesced, keeps last good) -----------
const discoveryCache = new Map(); // cli -> { at, resources }
const discoveryInflight = new Map(); // cli -> Promise
async function discoverResources(cliName) {
const cliCfg = config.clis[cliName];
if (cliCfg.resources) {
return cliCfg.resources.map((name) =>
typeof name === 'string' ? { name, description: '' } : name,
);
}
const cached = discoveryCache.get(cliName);
if (cached && Date.now() - cached.at < config.discoveryTtlMs) return cached.resources;
if (discoveryInflight.has(cliName)) return discoveryInflight.get(cliName);
const parser = discoveryParsers[cliCfg.discover.parser];
if (!parser) throw new Error(`Unknown discovery parser: ${cliCfg.discover.parser}`);
const promise = execCli(cliCfg, cliCfg.discover.args, `${cliName} discovery`)
.then((stdout) => {
const resources = parser(stdout);
discoveryCache.set(cliName, { at: Date.now(), resources });
return resources;
})
.finally(() => discoveryInflight.delete(cliName));
discoveryInflight.set(cliName, promise);
return promise;
}
// ---- row fetching (coalesced per cli+resource) --------------------------
const listInflight = new Map(); // "cli\0resource" -> Promise
async function fetchRows(cliName, resourceName) {
const cliCfg = config.clis[cliName];
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const err = new Error(`Unknown resource "${resourceName}" for CLI "${cliName}"`);
err.statusCode = 404;
throw err;
}
const key = `${cliName}\0${resourceName}`;
if (listInflight.has(key)) return listInflight.get(key);
// {resource} may appear as a whole arg or inside one (e.g. an ssh remote
// command). Safe either way — the value is allowlist-validated above.
const args = cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName));
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} list`)
.then((stdout) => {
const parsed = parseOutput(stdout, cliCfg.output ?? 'json');
const rows = unwrapPath(parsed, cliCfg.unwrap);
if (!Array.isArray(rows)) {
const err = new Error(`${cliName} ${resourceName}: expected an array of rows`);
err.raw = stdout;
throw err;
}
return rows;
})
.finally(() => listInflight.delete(key));
listInflight.set(key, promise);
return promise;
}
// ---- detail commands (drill-down: get, config-get, …) -------------------
const cmdInflight = new Map();
const ID_RE = /^[A-Za-z0-9:_.-]+$/; // ncl ids / uuids; no shell metas (and execFile never shells)
async function runCommand(cliName, cmdName, resourceName, id) {
const cliCfg = config.clis[cliName];
const template = cliCfg.commands?.[cmdName];
if (!template) {
const err = new Error(`Unknown command "${cmdName}"`);
err.statusCode = 404;
throw err;
}
const needsResource = template.includes('{resource}');
if (needsResource) {
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const err = new Error(`Unknown resource "${resourceName}"`);
err.statusCode = 404;
throw err;
}
}
if (template.includes('{id}') && !ID_RE.test(id ?? '')) {
const err = new Error('Invalid id');
err.statusCode = 400;
throw err;
}
const key = `${cliName}\0${cmdName}\0${resourceName}\0${id}`;
if (cmdInflight.has(key)) return cmdInflight.get(key);
const args = template.map((a) => a.replaceAll('{resource}', resourceName ?? '').replaceAll('{id}', id ?? ''));
const promise = execCli(cliCfg, args, `${cliName} ${cmdName}`)
.then((stdout) => unwrapPath(parseOutput(stdout, cliCfg.output ?? 'json'), cliCfg.unwrap))
.finally(() => cmdInflight.delete(key));
cmdInflight.set(key, promise);
return promise;
}
// ---- per-resource help (raw text from `<cli> <resource> help`) -----------
const helpInflight = new Map();
async function runHelp(cliName, resourceName) {
const cliCfg = config.clis[cliName];
if (!cliCfg.help) { const e = new Error(`No help for "${cliName}"`); e.statusCode = 404; throw e; }
const resources = await discoverResources(cliName);
if (!resources.some((r) => r.name === resourceName)) {
const e = new Error(`Unknown resource "${resourceName}"`); e.statusCode = 404; throw e;
}
const key = `${cliName}\0${resourceName}`;
if (helpInflight.has(key)) return helpInflight.get(key);
const args = cliCfg.help.map((a) => a.replaceAll('{resource}', resourceName));
const promise = execCli(cliCfg, args, `${cliName} ${resourceName} help`).finally(() => helpInflight.delete(key));
helpInflight.set(key, promise);
return promise;
}
// ---- view plugins --------------------------------------------------------
async function listViews(cliName) {
try {
const files = await readdir(viewsDir);
return files
.filter((f) => f.startsWith(`${cliName}-`) && f.endsWith('.js'))
.map((f) => f.slice(cliName.length + 1, -3));
} catch {
return [];
}
}
async function runView(cliName, viewName) {
if (!/^[a-zA-Z0-9_-]+$/.test(viewName)) {
const err = new Error(`Invalid view name`);
err.statusCode = 404;
throw err;
}
const file = join(viewsDir, `${cliName}-${viewName}.js`);
let mod;
try {
mod = await import(pathToFileURL(file).href);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
const err = new Error(`No view "${viewName}" for CLI "${cliName}"`);
err.statusCode = 404;
throw err;
}
throw e;
}
return mod.default({ fetch: (resource) => fetchRows(cliName, resource) });
}
// ---- http ----------------------------------------------------------------
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(payload);
}
function sendError(res, err) {
const status = err.statusCode ?? 502;
const body = { ok: false, error: err.message };
if (err.raw !== undefined) body.raw = String(err.raw).slice(0, 64 * 1024);
sendJson(res, status, body);
}
async function serveStatic(res, urlPath) {
const relative = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath.slice(1));
const file = resolve(publicDir, relative);
if (file !== publicDir && !file.startsWith(publicDir + sep)) {
sendJson(res, 403, { ok: false, error: 'Forbidden' });
return;
}
try {
const content = await readFile(file);
const ext = file.slice(file.lastIndexOf('.'));
// always revalidate so a redeploy is picked up immediately (no stale JS/CSS)
res.writeHead(200, { 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(content);
} catch {
sendJson(res, 404, { ok: false, error: 'Not found' });
}
}
return createServer(async (req, res) => {
try {
if (req.method !== 'GET') {
sendJson(res, 405, { ok: false, error: 'Read-only dashboard: GET only' });
return;
}
const urlPath = req.url.split('?')[0];
const segments = urlPath.split('/').map((s) => decodeURIComponent(s));
if (urlPath === '/api/clis') {
const clis = await Promise.all(Object.keys(config.clis).map(async (name) => {
const entry = {
name,
refreshSeconds: config.refreshSeconds,
views: await listViews(name),
commands: Object.keys(config.clis[name].commands ?? {}),
enrich: config.clis[name].enrich ?? null,
badges: config.clis[name].badges ?? null,
summary: config.clis[name].summary ?? null,
help: !!config.clis[name].help,
};
try {
entry.resources = await discoverResources(name);
} catch (e) {
// keep last good discovery (≤TTL old) if we have one; always surface the error
entry.resources = discoveryCache.get(name)?.resources ?? [];
entry.error = e.message;
}
return entry;
}));
sendJson(res, 200, { clis });
return;
}
if (segments[1] === 'api' && segments[2] === 'r' && segments.length === 5) {
const [, , , cliName, resourceName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const rows = await fetchRows(cliName, resourceName);
const cliCfg = config.clis[cliName];
const command = displayCmd(cliCfg.bin, cliCfg.list.map((a) => a.replaceAll('{resource}', resourceName)));
sendJson(res, 200, { ok: true, rows, command, fetchedAt: new Date().toISOString() });
return;
}
if (segments[1] === 'api' && segments[2] === 'cmd' && segments.length === 5) {
const [, , , cliName, cmdName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const q = new URL(req.url, 'http://localhost').searchParams;
const data = await runCommand(cliName, cmdName, q.get('resource'), q.get('id'));
const tmpl = config.clis[cliName].commands?.[cmdName] ?? [];
const command = displayCmd(config.clis[cliName].bin,
tmpl.map((a) => a.replaceAll('{resource}', q.get('resource') ?? '').replaceAll('{id}', q.get('id') ?? '')));
sendJson(res, 200, { ok: true, data, command, fetchedAt: new Date().toISOString() });
return;
}
if (segments[1] === 'api' && segments[2] === 'help' && segments.length === 5) {
const [, , , cliName, resourceName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const text = await runHelp(cliName, resourceName);
sendJson(res, 200, { ok: true, text });
return;
}
if (segments[1] === 'api' && segments[2] === 'view' && segments.length === 5) {
const [, , , cliName, viewName] = segments;
if (!config.clis[cliName]) {
sendJson(res, 404, { ok: false, error: `Unknown CLI "${cliName}"` });
return;
}
const result = await runView(cliName, viewName);
sendJson(res, 200, { ok: true, result, fetchedAt: new Date().toISOString() });
return;
}
// Log tails (allowlisted files under logs.dir).
if (urlPath === '/api/logs') {
sendJson(res, 200, { files: (config.logs?.files ?? []).map((f) => ({ name: f.name, label: f.label ?? f.name })) });
return;
}
if (segments[1] === 'api' && segments[2] === 'log' && segments.length === 4) {
const name = segments[3];
const file = config.logs?.files?.find((f) => f.name === name);
if (!file) { sendJson(res, 404, { ok: false, error: `Unknown log "${name}"` }); return; }
const lines = config.logs.tailLines ?? 400;
const { text } = await tailFile(join(config.logs.dir, name), lines);
sendJson(res, 200, { ok: true, text, command: `tail -n ${lines} ${join(config.logs.dir, name)}`, fetchedAt: new Date().toISOString() });
return;
}
// Message activity (read per-session DBs; ncl has no messages resource).
if (urlPath === '/api/activity') {
if (!config.activity) { sendJson(res, 200, { ok: true, configured: false }); return; }
const days = config.activity.days ?? 14;
const { sessions, series } = collectActivity(config.activity.sessionsRoot, days, new Date());
const command = `node:sqlite · ${config.activity.sessionsRoot}/*/*/{inbound,outbound}.db (last ${days}d)`;
sendJson(res, 200, { ok: true, configured: true, sessions, series, command, fetchedAt: new Date().toISOString() });
return;
}
// Read-only file viewer (skills, CLAUDE.md, profiles, conversations).
if (urlPath === '/api/docs') {
const docs = config.docs;
const collections = (docs?.collections ?? []).map((coll) => ({
name: coll.name,
label: coll.label ?? coll.name,
lang: coll.lang ?? 'text',
files: globFiles(docs.root, coll.patterns, docs.deny ?? []).map((path) => ({
path,
...describeFile(path),
})),
}));
sendJson(res, 200, { collections });
return;
}
if (urlPath === '/api/doc') {
const docs = config.docs;
const query = new URL(req.url, 'http://localhost').searchParams;
const collName = query.get('c');
const relPath = query.get('p') ?? '';
const collection = docs?.collections?.find((c) => c.name === collName);
if (!collection) {
sendJson(res, 404, { ok: false, error: `Unknown collection "${collName}"` });
return;
}
let abs;
try {
abs = resolveDoc(docs.root, collection, relPath, docs.deny ?? []);
} catch {
sendJson(res, 404, { ok: false, error: 'Not found' });
return;
}
const content = await readFile(abs, 'utf8');
sendJson(res, 200, {
ok: true,
path: relPath,
lang: collection.lang ?? 'text',
content: content.length > MAX_DOC_BYTES ? content.slice(0, MAX_DOC_BYTES) : content,
});
return;
}
if (urlPath.startsWith('/api/')) {
sendJson(res, 404, { ok: false, error: 'Not found' });
return;
}
await serveStatic(res, urlPath);
} catch (err) {
sendError(res, err);
}
});
}
// ---- standalone entry point ------------------------------------------------
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
if (isMain) {
const configPath = process.env.CLIDASH_CONFIG ?? join(MODULE_DIR, 'clidash.config.json');
const config = JSON.parse(readFileSync(configPath, 'utf8'));
if (process.env.PORT) config.port = Number(process.env.PORT);
if (process.env.BIND) config.bind = process.env.BIND;
const finalConfig = { ...DEFAULTS, ...config };
const server = createApp(finalConfig);
server.listen(finalConfig.port, finalConfig.bind, () => {
console.log(`clidash listening on http://${finalConfig.bind}:${finalConfig.port}`);
});
}
@@ -0,0 +1,46 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createApp } from '../server.js';
let root;
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-'));
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); };
const today = new Date().toISOString().slice(0, 10);
mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]);
mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]);
});
after(() => rmSync(root, { recursive: true, force: true }));
async function withServer(config, fn) {
const server = createApp({ port: 0, bind: '127.0.0.1', clis: {}, ...config });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/activity: returns per-session totals + a daily series', async () => {
await withServer({ activity: { sessionsRoot: root, days: 14 } }, async (base) => {
const body = await (await fetch(`${base}/api/activity`)).json();
assert.equal(body.ok, true);
assert.equal(body.configured, true);
assert.equal(body.series.length, 14);
assert.equal(body.sessions[0].in, 2);
assert.equal(body.sessions[0].out, 1);
assert.equal(body.series.at(-1).in, 2); // today
assert.equal(body.series.at(-1).out, 1);
});
});
test('/api/activity: not configured → configured:false, no crash', async () => {
await withServer({}, async (base) => {
const body = await (await fetch(`${base}/api/activity`)).json();
assert.equal(body.ok, true);
assert.equal(body.configured, false);
});
});
@@ -0,0 +1,75 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { collectActivity } from '../activity.js';
let root;
const NOW = new Date('2026-06-14T12:00:00Z');
function makeDb(path, table, timestamps) {
const db = new DatabaseSync(path);
db.exec(`CREATE TABLE ${table} (id TEXT, timestamp TEXT)`);
const ins = db.prepare(`INSERT INTO ${table} (id, timestamp) VALUES (?, ?)`);
timestamps.forEach((t, i) => ins.run(String(i), t));
db.close();
}
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-act-'));
// session 1 (group ag-1): 3 inbound across 2 days, 2 outbound today
mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true });
makeDb(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in',
['2026-06-14 09:01:23', '2026-06-14 10:00:00', '2026-06-13 08:00:00']);
makeDb(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out',
['2026-06-14 09:05:00', '2026-06-14 10:05:00']);
// session 2 (group ag-2): 1 inbound 20 days ago (outside 14d window), 0 outbound
mkdirSync(join(root, 'ag-2', 'sess-2'), { recursive: true });
makeDb(join(root, 'ag-2', 'sess-2', 'inbound.db'), 'messages_in', ['2026-05-25 08:00:00']);
makeDb(join(root, 'ag-2', 'sess-2', 'outbound.db'), 'messages_out', []);
});
after(() => rmSync(root, { recursive: true, force: true }));
test('collectActivity: per-session in/out totals + last activity', () => {
const { sessions } = collectActivity(root, 14, NOW);
const s1 = sessions.find((s) => s.session_id === 'sess-1');
assert.equal(s1.agent_group_id, 'ag-1');
assert.equal(s1.in, 3);
assert.equal(s1.out, 2);
assert.equal(s1.lastActivity, '2026-06-14T10:05:00Z'); // normalized to ISO
const s2 = sessions.find((s) => s.session_id === 'sess-2');
assert.equal(s2.in, 1);
assert.equal(s2.out, 0);
});
test('collectActivity: series has one bucket per day for `days`, newest last', () => {
const { series } = collectActivity(root, 14, NOW);
assert.equal(series.length, 14);
assert.equal(series[0].date, '2026-06-01');
assert.equal(series[13].date, '2026-06-14');
});
test('collectActivity: counts land in the right day buckets', () => {
const { series } = collectActivity(root, 14, NOW);
const byDate = Object.fromEntries(series.map((d) => [d.date, d]));
assert.equal(byDate['2026-06-14'].in, 2);
assert.equal(byDate['2026-06-14'].out, 2);
assert.equal(byDate['2026-06-13'].in, 1);
assert.equal(byDate['2026-06-13'].out, 0);
});
test('collectActivity: messages outside the window are counted in totals but not the series', () => {
const { series, sessions } = collectActivity(root, 14, NOW);
const total = series.reduce((a, d) => a + d.in + d.out, 0);
assert.equal(total, 5); // the 20-day-old message is excluded from series
assert.equal(sessions.find((s) => s.session_id === 'sess-2').in, 1); // but still in the total count
});
test('collectActivity: a dir with no message DBs is not a session (skipped)', () => {
mkdirSync(join(root, 'ag-1', '.claude-shared'), { recursive: true }); // scaffolding, no db files
const { sessions } = collectActivity(root, 14, NOW);
assert.ok(!sessions.some((s) => s.session_id === '.claude-shared'));
});
@@ -0,0 +1,91 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createApp } from '../server.js';
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
const tmp = mkdtempSync(join(tmpdir(), 'clidash-cmd-'));
after(() => rmSync(tmp, { recursive: true, force: true }));
function cli(extra = {}) {
return {
bin: process.execPath,
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
list: [STUB, '{resource}', 'list', '--json'],
output: 'json',
unwrap: 'data',
commands: {
get: [STUB, '{resource}', 'get', '{id}', '--json'],
'config-get': [STUB, 'groups', 'config', 'get', '--id', '{id}', '--json'],
},
...extra,
};
}
async function withServer(clis, fn, extra = {}) {
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis, ...extra });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/cmd: runs an allowlisted command with {resource} + {id}', async () => {
await withServer({ ncl: cli() }, async (base) => {
const body = await (await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=sess-123`)).json();
assert.equal(body.ok, true);
assert.equal(body.data.id, 'sessions-detail');
assert.match(body.data.args, /sessions get sess-123/);
});
});
test('/api/cmd: config-get needs no resource', async () => {
await withServer({ ncl: cli() }, async (base) => {
const body = await (await fetch(`${base}/api/cmd/ncl/config-get?id=ag-1`)).json();
assert.equal(body.ok, true);
assert.match(body.data.args, /groups config get --id ag-1/);
});
});
test('/api/cmd: unknown command name → 404 (allowlist)', async () => {
await withServer({ ncl: cli() }, async (base) => {
const res = await fetch(`${base}/api/cmd/ncl/delete?resource=groups&id=ag-1`);
assert.equal(res.status, 404);
});
});
test('/api/cmd: a {resource} not in the discovered set is rejected without exec', async () => {
const countFile = join(tmp, 'cmd-count.txt');
const c = cli();
c.env = { STUB_COUNT_FILE: countFile };
await withServer({ ncl: c }, async (base) => {
const res = await fetch(`${base}/api/cmd/ncl/get?resource=evil&id=x`);
assert.equal(res.status, 404);
// only discovery ran, never a get for the bogus resource
const calls = readFileSync(countFile, 'utf8').trim().split('\n');
assert.deepEqual(calls, ['help']);
});
});
test('/api/cmd: an id with illegal characters is rejected', async () => {
await withServer({ ncl: cli() }, async (base) => {
const res = await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=${encodeURIComponent('a b;rm -rf')}`);
assert.equal(res.status, 400);
});
});
test('/api/cmd: unknown cli → 404', async () => {
await withServer({ ncl: cli() }, async (base) => {
assert.equal((await fetch(`${base}/api/cmd/nope/get?resource=sessions&id=x`)).status, 404);
});
});
test('/api/cmd: a cli without a commands map → 404', async () => {
const c = cli();
delete c.commands;
await withServer({ ncl: c }, async (base) => {
assert.equal((await fetch(`${base}/api/cmd/ncl/get?resource=sessions&id=x`)).status, 404);
});
});
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const css = readFileSync(fileURLToPath(new URL('../public/style.css', import.meta.url)), 'utf8');
// Regression: the `hidden` attribute must override author `display` rules.
// `.detail-overlay` and `.cli-switcher` set `display:flex`, which beats the
// browser's default `[hidden]{display:none}` — without this reset a hidden
// overlay stays on top of the page and silently eats every click.
test('style.css forces [hidden] to display:none with !important', () => {
assert.match(css, /\[hidden\]\s*\{\s*display:\s*none\s*!important;?\s*\}/);
});
// Guard the premise: if these stop using display:flex the reset is less load-
// bearing, but this documents WHY the reset exists.
test('the overlays that motivated the reset still use display:flex', () => {
assert.match(css, /\.detail-overlay\s*\{[^}]*display:\s*flex/);
});
@@ -0,0 +1,111 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createApp } from '../server.js';
let root;
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-docsrv-'));
const w = (rel, body) => {
const abs = join(root, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
};
w('groups/alpha/skills/tagger/SKILL.md', '# tagger\nhello');
w('container/skills/welcome/SKILL.md', '# welcome');
w('groups/alpha/profile.json', '{"name":"Alpha"}');
w('groups/alpha/.env', 'SECRET=nope');
});
after(() => rmSync(root, { recursive: true, force: true }));
function docsConfig() {
return {
port: 0,
bind: '127.0.0.1',
clis: {},
docs: {
root,
deny: ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'],
collections: [
{ name: 'skills', label: 'Skills', lang: 'markdown', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] },
{ name: 'profiles', label: 'Profiles', lang: 'json', patterns: ['groups/*/profile.json'] },
],
},
};
}
async function withServer(config, fn) {
const server = createApp(config);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try {
return await fn(base);
} finally {
await new Promise((r) => server.close(r));
}
}
test('/api/docs: lists collections with their files', async () => {
await withServer(docsConfig(), async (base) => {
const body = await (await fetch(`${base}/api/docs`)).json();
const skills = body.collections.find((c) => c.name === 'skills');
assert.equal(skills.label, 'Skills');
assert.equal(skills.lang, 'markdown');
const paths = skills.files.map((f) => f.path);
assert.ok(paths.includes('groups/alpha/skills/tagger/SKILL.md'));
assert.ok(paths.includes('container/skills/welcome/SKILL.md'));
// each file carries a readable label + group
const f = skills.files.find((x) => x.path.includes('tagger'));
assert.equal(f.group, 'alpha');
assert.match(f.label, /tagger/);
});
});
test('/api/doc: returns file content + lang', async () => {
await withServer(docsConfig(), async (base) => {
const url = `${base}/api/doc?c=skills&p=${encodeURIComponent('groups/alpha/skills/tagger/SKILL.md')}`;
const body = await (await fetch(url)).json();
assert.equal(body.ok, true);
assert.equal(body.lang, 'markdown');
assert.match(body.content, /# tagger/);
});
});
test('/api/doc: a denied file is not readable even though it sits under root', async () => {
await withServer(docsConfig(), async (base) => {
// .env is excluded by the deny-list and not in any collection pattern
const coll = docsConfig();
coll.docs.collections.push({ name: 'all', label: 'All', lang: 'text', patterns: ['groups/*/*'] });
await withServer(coll, async (base2) => {
const res = await fetch(`${base2}/api/doc?c=all&p=${encodeURIComponent('groups/alpha/.env')}`);
assert.equal(res.status, 404);
assert.equal((await res.json()).ok, false);
});
});
});
test('/api/doc: path traversal is rejected', async () => {
await withServer(docsConfig(), async (base) => {
const res = await fetch(`${base}/api/doc?c=skills&p=${encodeURIComponent('../../../../etc/passwd')}`);
assert.equal(res.status, 404);
assert.equal((await res.json()).ok, false);
});
});
test('/api/doc: unknown collection → 404', async () => {
await withServer(docsConfig(), async (base) => {
const res = await fetch(`${base}/api/doc?c=nope&p=x`);
assert.equal(res.status, 404);
});
});
test('/api/docs: absent docs config → empty collections, no crash', async () => {
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
const body = await (await fetch(`${base}/api/docs`)).json();
assert.deepEqual(body.collections, []);
});
});
@@ -0,0 +1,111 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { globFiles, describeFile, resolveDoc } from '../docs.js';
let root;
before(() => {
root = mkdtempSync(join(tmpdir(), 'clidash-docs-'));
const w = (rel, body = 'x') => {
const abs = join(root, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
};
w('groups/alpha/skills/example-skill/SKILL.md', '# example-skill\nbody');
w('groups/alpha/skills/tagger/SKILL.md');
w('groups/alpha/CLAUDE.md', '# Alpha');
w('groups/alpha/CLAUDE.local.md');
w('groups/alpha/profile.json', '{"name":"Alpha"}');
w('groups/alpha/conversations/2026-06-01.md');
w('groups/bravo/skills/tagger/SKILL.md');
w('groups/bravo/profile.json');
w('container/skills/agent-browser/SKILL.md');
w('container/skills/welcome/SKILL.md');
// things that must NEVER be served
w('groups/alpha/.env', 'SECRET=1');
w('groups/alpha/skills/example-skill/node_modules/dep/SKILL.md');
w('groups/alpha/notion-token.txt', 'ntn_xxx');
});
after(() => rmSync(root, { recursive: true, force: true }));
const DENY = ['node_modules', '.env', '*token*', '*secret*', '*.pem', '*.key'];
// --------------------------------------------------------------- globFiles
test('globFiles: matches a nested *-segment pattern', () => {
const files = globFiles(root, ['groups/*/skills/*/SKILL.md'], DENY);
assert.deepEqual(files, [
'groups/alpha/skills/example-skill/SKILL.md',
'groups/alpha/skills/tagger/SKILL.md',
'groups/bravo/skills/tagger/SKILL.md',
]);
});
test('globFiles: multiple patterns union, sorted', () => {
const files = globFiles(root, ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'], DENY);
assert.ok(files.includes('container/skills/agent-browser/SKILL.md'));
assert.ok(files.includes('groups/alpha/skills/example-skill/SKILL.md'));
});
test('globFiles: wildcard inside a filename segment', () => {
const files = globFiles(root, ['groups/*/CLAUDE*.md'], DENY);
assert.deepEqual(files, ['groups/alpha/CLAUDE.local.md', 'groups/alpha/CLAUDE.md']);
});
test('globFiles: deny list excludes node_modules and secret-ish files', () => {
const files = globFiles(root, ['groups/*/skills/*/**', 'groups/*/*'], DENY);
assert.ok(!files.some((f) => f.includes('node_modules')));
assert.ok(!files.some((f) => f.endsWith('.env')));
assert.ok(!files.some((f) => f.includes('token')));
});
test('globFiles: no match returns empty array', () => {
assert.deepEqual(globFiles(root, ['nope/*/x.md'], DENY), []);
});
// ------------------------------------------------------------- describeFile
test('describeFile: per-group skill → group + readable label', () => {
const d = describeFile('groups/alpha/skills/tagger/SKILL.md');
assert.equal(d.group, 'alpha');
assert.match(d.label, /alpha/);
assert.match(d.label, /tagger/);
});
test('describeFile: container skill → shared', () => {
const d = describeFile('container/skills/agent-browser/SKILL.md');
assert.equal(d.group, 'shared');
assert.match(d.label, /agent-browser/);
});
// --------------------------------------------------------------- resolveDoc
const SKILLS = { name: 'skills', patterns: ['groups/*/skills/*/SKILL.md', 'container/skills/*/SKILL.md'] };
test('resolveDoc: returns an absolute path for an allowed file', () => {
const abs = resolveDoc(root, SKILLS, 'groups/alpha/skills/example-skill/SKILL.md', DENY);
assert.ok(abs.endsWith('/groups/alpha/skills/example-skill/SKILL.md'));
assert.ok(abs.startsWith(root));
});
test('resolveDoc: rejects a path not matching the collection patterns', () => {
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/profile.json', DENY), /not allowed/i);
});
test('resolveDoc: rejects path traversal', () => {
assert.throws(() => resolveDoc(root, SKILLS, '../../etc/passwd', DENY), /not allowed/i);
assert.throws(() => resolveDoc(root, SKILLS, 'groups/alpha/skills/../../../.env', DENY), /not allowed/i);
});
test('resolveDoc: rejects an absolute path', () => {
assert.throws(() => resolveDoc(root, SKILLS, '/etc/passwd', DENY), /not allowed/i);
});
test('resolveDoc: a denied file is not resolvable even if pattern-shaped', () => {
const coll = { name: 'all', patterns: ['groups/*/*'] };
assert.throws(() => resolveDoc(root, coll, 'groups/alpha/.env', DENY), /not allowed/i);
});
@@ -0,0 +1,28 @@
Resources:
approvals Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.
verbs: list, get
destinations Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.
verbs: list, add, remove
dropped-messages Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).
verbs: list
groups Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.
verbs: list, get, create, update, delete, restart, config get, config update, config add-mcp-server, config remove-mcp-server, config add-package, config remove-package
members Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".
verbs: list, add, remove
messaging-groups Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.
verbs: list, get, create, update, delete
roles User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).
verbs: list, grant, revoke
sessions Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.
verbs: list, get
user-dms User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.
verbs: list
users User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).
verbs: list, get, create, update
wirings Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.
verbs: list, get, create, update, delete
Commands:
help List available resources and commands.
Run `ncl <resource> help` for detailed field information.
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Stub CLI for clidash tests. Impersonates ncl (envelope json) or a
// jsonlines CLI, with failure/slowness/garbage modes driven by env vars.
import { readFileSync, appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const args = process.argv.slice(2);
if (process.env.STUB_COUNT_FILE) {
appendFileSync(process.env.STUB_COUNT_FILE, args.join(' ') + '\n');
}
const sleepMs = Number(process.env.STUB_SLEEP_MS || 0);
setTimeout(() => {
if (process.env.STUB_FAIL) {
process.stderr.write('boom: socket down\n');
process.exit(2);
}
if (args[0] === 'help') {
process.stdout.write(
readFileSync(fileURLToPath(new URL('./ncl-help.txt', import.meta.url)), 'utf8'),
);
process.exit(0);
}
if (args[1] === 'help') { // `<resource> help` → raw per-resource help text
process.stdout.write(`${args[0]}: help for ${args[0]}\n\nVerbs:\n list\n get <id>\n`);
process.exit(0);
}
if (process.env.STUB_RAW) {
process.stdout.write(process.env.STUB_RAW + '\n');
process.exit(0);
}
const resource = args[0];
// `get`/detail commands → single-object envelope
if (args.includes('get') || args.includes('config')) {
process.stdout.write(JSON.stringify({
id: 'req-1', ok: true,
data: { id: `${resource}-detail`, args: args.join(' '), extra: 'field' },
}) + '\n');
process.exit(0);
}
if (process.env.STUB_JSONLINES) {
process.stdout.write(JSON.stringify({ id: `${resource}-1`, name: 'row one' }) + '\n');
process.stdout.write(JSON.stringify({ id: `${resource}-2`, name: 'row two' }) + '\n');
process.exit(0);
}
process.stdout.write(JSON.stringify({
id: 'req-1',
ok: true,
data: [
{ id: `${resource}-1`, name: 'row one' },
{ id: `${resource}-2`, name: 'row two' },
],
}) + '\n');
process.exit(0);
}, sleepMs);
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { createApp } from '../server.js';
const STUB = fileURLToPath(new URL('./fixtures/stub-cli.js', import.meta.url));
function cli(extra = {}) {
return {
bin: process.execPath,
discover: { args: [STUB, 'help'], parser: 'ncl-help' },
list: [STUB, '{resource}', 'list', '--json'],
output: 'json', unwrap: 'data',
help: [STUB, '{resource}', 'help'],
...extra,
};
}
async function withServer(clis, fn) {
const server = createApp({ port: 0, bind: '127.0.0.1', execTimeoutMs: 2000, clis });
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/help: returns raw per-resource help text', async () => {
await withServer({ ncl: cli() }, async (base) => {
const body = await (await fetch(`${base}/api/help/ncl/sessions`)).json();
assert.equal(body.ok, true);
assert.match(body.text, /sessions: help for sessions/);
assert.match(body.text, /Verbs:/);
});
});
test('/api/help: undiscovered resource → 404', async () => {
await withServer({ ncl: cli() }, async (base) => {
assert.equal((await fetch(`${base}/api/help/ncl/evil`)).status, 404);
});
});
test('/api/help: a cli without a help template → 404', async () => {
const c = cli(); delete c.help;
await withServer({ ncl: c }, async (base) => {
assert.equal((await fetch(`${base}/api/help/ncl/sessions`)).status, 404);
});
});
test('/api/help: unknown cli → 404', async () => {
await withServer({ ncl: cli() }, async (base) => {
assert.equal((await fetch(`${base}/api/help/nope/sessions`)).status, 404);
});
});
test('/api/clis: reports help availability per cli', async () => {
const noHelp = cli(); delete noHelp.help;
await withServer({ ncl: cli(), docker: noHelp }, async (base) => {
const body = await (await fetch(`${base}/api/clis`)).json();
assert.equal(body.clis.find((c) => c.name === 'ncl').help, true);
assert.equal(body.clis.find((c) => c.name === 'docker').help, false);
});
});
@@ -0,0 +1,75 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tailFile } from '../logs.js';
import { createApp } from '../server.js';
let dir;
before(() => {
dir = mkdtempSync(join(tmpdir(), 'clidash-logs-'));
// 10 lines, some with ANSI color codes
const lines = Array.from({ length: 10 }, (_, i) =>
`[12:00:0${i}] \x1b[32mINFO\x1b[39m line ${i}`);
writeFileSync(join(dir, 'app.log'), lines.join('\n') + '\n');
writeFileSync(join(dir, 'error.log'), 'boom\n');
});
after(() => rmSync(dir, { recursive: true, force: true }));
test('tailFile: returns the last N lines, ANSI stripped, no trailing blank', async () => {
const { lines, text } = await tailFile(join(dir, 'app.log'), 3);
assert.equal(lines.length, 3);
assert.deepEqual(lines, ['[12:00:07] INFO line 7', '[12:00:08] INFO line 8', '[12:00:09] INFO line 9']);
assert.ok(!text.includes('\x1b'));
});
test('tailFile: maxLines larger than file returns all lines', async () => {
const { lines } = await tailFile(join(dir, 'app.log'), 100);
assert.equal(lines.length, 10);
});
// ---- server endpoints ----
function cfg() {
return {
port: 0, bind: '127.0.0.1', clis: {},
logs: { dir, tailLines: 5, files: [{ name: 'app.log', label: 'app' }, { name: 'error.log', label: 'errors' }] },
};
}
async function withServer(config, fn) {
const server = createApp(config);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try { return await fn(base); } finally { await new Promise((r) => server.close(r)); }
}
test('/api/logs: lists the configured log files', async () => {
await withServer(cfg(), async (base) => {
const body = await (await fetch(`${base}/api/logs`)).json();
assert.deepEqual(body.files.map((f) => f.name), ['app.log', 'error.log']);
});
});
test('/api/logs: absent logs config → empty list', async () => {
await withServer({ port: 0, bind: '127.0.0.1', clis: {} }, async (base) => {
assert.deepEqual((await (await fetch(`${base}/api/logs`)).json()).files, []);
});
});
test('/api/log: returns the tail text + a tail command', async () => {
await withServer(cfg(), async (base) => {
const body = await (await fetch(`${base}/api/log/app.log`)).json();
assert.equal(body.ok, true);
assert.match(body.text, /line 9$/);
assert.equal(body.text.split('\n').length, 5); // tailLines
assert.match(body.command, /tail -n 5 .*app\.log/);
});
});
test('/api/log: a name not in the allowlist is rejected (no traversal)', async () => {
await withServer(cfg(), async (base) => {
assert.equal((await fetch(`${base}/api/log/${encodeURIComponent('../../etc/passwd')}`)).status, 404);
assert.equal((await fetch(`${base}/api/log/secrets.log`)).status, 404);
});
});
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { escapeHtml, mdToHtml } from '../public/md.js';
// ---- escaping -------------------------------------------------------------
test('escapeHtml: neutralizes all HTML metacharacters', () => {
assert.equal(escapeHtml(`<script>"&'`), '&lt;script&gt;&quot;&amp;&#39;');
});
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('&lt;script&gt;'));
});
// ---- 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 &quot; 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 &quot; (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&quot;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');
});
});
+21
View File
@@ -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 };
}
+78
View File
@@ -0,0 +1,78 @@
# Remove the Codex agent provider
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. Switch codex groups back to the default
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`
## 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/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.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
```
This skill itself (`.claude/skills/add-codex/`) stays — it ships with trunk so the provider can be re-added later.
`container/AGENTS.md` stays only if another installed provider uses agent surfaces; otherwise remove it too.
## 4. Remove the CLI manifest entry
Delete the `@openai/codex` entry from `container/cli-tools.json`:
```bash
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");
'
```
## 5. Vault secret (optional)
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.
## 6. Rebuild and verify
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
pnpm test
cd container/agent-runner && bun test
```
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).
+103 -120
View File
@@ -1,161 +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`
- `container/agent-runner/src/providers/codex.ts`
- `container/agent-runner/src/providers/codex-app-server.ts`
- `container/agent-runner/src/providers/codex.factory.test.ts`
- `import './codex.js';` line in `src/providers/index.ts`
- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts`
- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile`
- `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
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: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
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");
}
'
```
### 3. Append the self-registration imports
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.
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
### Build
```bash
pnpm run build # host
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck
./container/build.sh # agent image
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
## Configuration
### Restart the host
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:
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
codex login
# macOS (launchd)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux (systemd)
systemctl --user restart nanoclaw
```
This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched.
No `.env` variables required for this mode.
### Option B — API key (recommended for CI or API billing)
```env
OPENAI_API_KEY=sk-...
CODEX_MODEL=gpt-5.4-mini
```
The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription.
### Option C — BYO OpenAI-compatible endpoint (experimental)
Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc.
```env
OPENAI_API_KEY=...
OPENAI_BASE_URL=https://api.groq.com/openai/v1
CODEX_MODEL=llama-3.3-70b-versatile
```
Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration.
**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing.
### Per group / per session
Set `"provider": "codex"` in the group's **`container.json`** (`groups/<folder>/container.json`) — the in-container runner reads `provider` from there, not from the DB. The DB columns **`agent_groups.agent_provider`** and **`sessions.agent_provider`** (session overrides group) only drive host-side provider contribution — per-session `~/.codex` mount, `OPENAI_*` / `CODEX_MODEL` env passthrough — and do not propagate into `container.json` at spawn time. Set both, or just edit `container.json`; if they disagree, the runner uses `container.json` and the host-side resolver falls back through session → group → `container.json``'claude'`.
`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group.
Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers.
## Operational notes
- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions.
- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config.
- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error.
- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode).
- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped.
- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has.
## Verify
### Validate
```bash
grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK"
grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK"
grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK"
cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd -
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/
```
After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like:
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.
- `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
## Authenticate
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.
> **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.
```bash
pnpm exec tsx setup/index.ts --step provider-auth codex
```
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.
## Use it
Per group:
```bash
ncl groups config update --id <group-id> --provider codex
ncl groups restart --id <group-id>
```
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.
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.
## Troubleshooting
- **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.-]+)?$/);
});
});
+33 -50
View File
@@ -28,61 +28,34 @@ NanoClaw (pusher) Dashboard (npm package)
pnpm install @nanoco/nanoclaw-dashboard
```
### 2. Copy the pusher module
### 2. Copy the pusher module and its tests
Copy the resource file into src:
Copy all three resource files into `src/`. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly.
```
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts
.claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts
```
### 3. Add exports to src/db/index.ts
- `dashboard-pusher.test.ts` — behavior: starts the pusher, posts a real snapshot to a fake dashboard.
- `dashboard-wiring.test.ts` — the code edit in step 3: asserts (via the TS AST) that `index.ts` dynamically imports `./dashboard-pusher.js` and `await`s `startDashboard()` as colocated statements of `main()`, after DB init and before the boot-complete log. Delete or misplace the edit and this goes red.
Add these two export blocks if not already present:
### 3. Wire into src/index.ts
This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in `dashboard-pusher.ts`, and the import is **colocated** with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove).
Add this block inside `main()`, just before the `log.info('NanoClaw running')` line:
```typescript
// After the messaging-groups exports, add:
export {
getMessagingGroupsByAgentGroup,
} from './messaging-groups.js';
// Before the credentials exports, add:
export {
createDestination,
getDestinations,
getDestinationByName,
getDestinationByTarget,
hasDestination,
deleteDestination,
} from './agent-destinations.js';
// Dashboard (optional; no-ops without DASHBOARD_SECRET)
const { startDashboard } = await import('./dashboard-pusher.js');
await startDashboard();
```
### 4. Wire into src/index.ts
`startDashboard()` reads `DASHBOARD_SECRET`/`DASHBOARD_PORT` itself and no-ops if the secret is unset, so nothing else in core needs to change.
Add the `readEnvFile` import at the top if not already present:
```typescript
import { readEnvFile } from './env.js';
```
Add after step 7 (OneCLI approval handler), before the `log.info('NanoClaw running')` line:
```typescript
// 8. Dashboard (optional)
const dashboardEnv = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const dashboardSecret = process.env.DASHBOARD_SECRET || dashboardEnv.DASHBOARD_SECRET;
const dashboardPort = parseInt(process.env.DASHBOARD_PORT || dashboardEnv.DASHBOARD_PORT || '3100', 10);
if (dashboardSecret) {
const { startDashboard } = await import('@nanoco/nanoclaw-dashboard');
const { startDashboardPusher } = await import('./dashboard-pusher.js');
startDashboard({ port: dashboardPort, secret: dashboardSecret });
startDashboardPusher({ port: dashboardPort, secret: dashboardSecret, intervalMs: 60000 });
} else {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
}
```
### 5. Add environment variables to .env
### 4. Add environment variables to .env
```
DASHBOARD_SECRET=<generate-a-random-secret>
@@ -91,18 +64,23 @@ DASHBOARD_PORT=3100
Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"`
### 6. Build and restart
### 5. Build, test, and restart
Run from your NanoClaw project root:
```bash
pnpm run build
pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
```
### 7. Verify
Run `build` **before** the tests: it's what guards the `@nanoco/nanoclaw-dashboard` dependency. `dashboard-pusher.ts` reaches the package through `await import('@nanoco/nanoclaw-dashboard')`, so if step 4 was skipped, `pnpm run build` fails with `TS2307: Cannot find module`. The behavior test deliberately *mocks* that package — its `startDashboard` binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step.
### 6. Verify (runtime smoke check)
Once the service is restarted, confirm the dashboard is live:
```bash
curl -s http://localhost:3100/api/status
@@ -132,10 +110,15 @@ Open `http://localhost:3100/dashboard` in a browser.
## Removal
Reverse the apply steps. Safe to re-run even if some pieces are already gone.
```bash
rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts
pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true
```
Then, by hand, remove the single dashboard block the skill added to `main()` in `src/index.ts` (the `// Dashboard (optional…)` comment, the `await import('./dashboard-pusher.js')` line, and the `await startDashboard();` call), and remove `DASHBOARD_SECRET` and `DASHBOARD_PORT` from `.env`.
```bash
pnpm uninstall @nanoco/nanoclaw-dashboard
rm src/dashboard-pusher.ts
# Remove the dashboard block from src/index.ts
# Remove DASHBOARD_SECRET and DASHBOARD_PORT from .env
pnpm run build
```
@@ -0,0 +1,124 @@
/**
* Integration test for the add-dashboard skill's integration point —
* `startDashboard()`, the single call wired into src/index.ts.
*
* Archetype: in-process seam. It drives the *real* entry point against a
* *real* (in-memory) central DB and a *fake* dashboard HTTP endpoint. The
* only things stubbed are the external dashboard package (not needed to prove
* the wiring) and env-file reads (so the test doesn't depend on the real
* .env). This proves the skill works once applied: with a secret set it
* collects a DB snapshot and posts it; with no secret it does nothing.
*
* Ships with the add-dashboard skill; apply copies it to src/ alongside the
* pusher so it runs against the composed project.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import http from 'http';
import type { AddressInfo } from 'net';
vi.mock('./config.js', async () => {
const actual = await vi.importActual<typeof import('./config.js')>('./config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-dashboard', ASSISTANT_NAME: 'TestBot' };
});
// The dashboard server package isn't needed to prove the integration point.
vi.mock('@nanoco/nanoclaw-dashboard', () => ({ startDashboard: vi.fn() }));
// Don't read the real .env — the test controls config via process.env only.
vi.mock('./env.js', () => ({ readEnvFile: () => ({}) }));
const TEST_DIR = '/tmp/nanoclaw-test-dashboard';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
import { startDashboard, stopDashboardPusher } from './dashboard-pusher.js';
function now(): string {
return new Date().toISOString();
}
interface CapturedPost {
path: string;
auth: string | undefined;
body: Record<string, unknown>;
}
/** A fake dashboard server that captures the bodies the pusher POSTs. */
function startFakeDashboard(): Promise<{ port: number; posts: CapturedPost[]; close: () => Promise<void> }> {
const posts: CapturedPost[] = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (c) => { raw += c; });
req.on('end', () => {
let body: Record<string, unknown> = {};
try { body = JSON.parse(raw); } catch { /* leave empty */ }
posts.push({ path: req.url || '', auth: req.headers.authorization, body });
res.writeHead(200);
res.end('ok');
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as AddressInfo).port;
resolve({ port, posts, close: () => new Promise<void>((r) => server.close(() => r())) });
});
});
}
async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!pred()) {
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
await new Promise((r) => setTimeout(r, 20));
}
}
describe('add-dashboard integration point (startDashboard)', () => {
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
});
afterEach(() => {
stopDashboardPusher();
closeDb();
delete process.env.DASHBOARD_SECRET;
delete process.env.DASHBOARD_PORT;
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('posts a snapshot of the seeded state when DASHBOARD_SECRET is set', async () => {
createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now() });
const dash = await startFakeDashboard();
process.env.DASHBOARD_SECRET = 'test-secret';
process.env.DASHBOARD_PORT = String(dash.port);
await startDashboard();
await waitFor(() => dash.posts.some((p) => p.path === '/api/ingest'));
const ingest = dash.posts.find((p) => p.path === '/api/ingest')!;
expect(ingest.auth).toBe('Bearer test-secret');
expect(ingest.body.assistant_name).toBe('TestBot');
const groups = ingest.body.agent_groups as Array<{ id: string }>;
expect(groups.map((g) => g.id)).toContain('ag-1');
for (const key of ['timestamp', 'sessions', 'channels', 'users', 'tokens', 'context_windows', 'activity', 'messages']) {
expect(ingest.body).toHaveProperty(key);
}
await dash.close();
});
it('does nothing when DASHBOARD_SECRET is not set', async () => {
const dash = await startFakeDashboard();
// no DASHBOARD_SECRET in env, and readEnvFile is stubbed to {}
await startDashboard();
await new Promise((r) => setTimeout(r, 100));
expect(dash.posts).toHaveLength(0);
await dash.close();
});
});
@@ -10,15 +10,17 @@ import Database from 'better-sqlite3';
import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js';
import { getSessionsByAgentGroup } from './db/sessions.js';
import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js';
import { getDestinations } from './db/agent-destinations.js';
import { getMembers } from './db/agent-group-members.js';
import { getAllUsers, getUser } from './db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './db/user-roles.js';
import { getUserDmsForUser } from './db/user-dms.js';
import { getDestinations } from './modules/agent-to-agent/db/agent-destinations.js';
import { getMembers } from './modules/permissions/db/agent-group-members.js';
import { getAllUsers, getUser } from './modules/permissions/db/users.js';
import { getUserRoles, getAdminsOfAgentGroup } from './modules/permissions/db/user-roles.js';
import { getUserDmsForUser } from './modules/permissions/db/user-dms.js';
import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js';
import { DATA_DIR, ASSISTANT_NAME } from './config.js';
import { getDb } from './db/connection.js';
import { getContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { readEnvFile } from './env.js';
interface PusherConfig {
port: number;
@@ -56,6 +58,26 @@ export function stopDashboardPusher(): void {
}
}
/**
* Skill entry point — the single call wired into the host boot sequence.
*
* All of the dashboard's startup logic lives here, in the skill's own file,
* so the integration point in src/index.ts is just `await startDashboard()`.
* No-ops (and says so) when DASHBOARD_SECRET is unset.
*/
export async function startDashboard(): Promise<void> {
const env = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']);
const secret = process.env.DASHBOARD_SECRET || env.DASHBOARD_SECRET;
const port = parseInt(process.env.DASHBOARD_PORT || env.DASHBOARD_PORT || '3100', 10);
if (!secret) {
log.info('Dashboard disabled (no DASHBOARD_SECRET)');
return;
}
const { startDashboard: startServer } = await import('@nanoco/nanoclaw-dashboard');
startServer({ port, secret });
startDashboardPusher({ port, secret, intervalMs: 60000 });
}
/** Fire-and-forget POST to the dashboard. */
function postJson(config: PusherConfig, urlPath: string, data: unknown): void {
const body = JSON.stringify(data);
@@ -157,7 +179,7 @@ function collectAgentGroups() {
name: g.name,
folder: g.folder,
agent_provider: g.agent_provider,
container_config: g.container_config ? JSON.parse(g.container_config) : null,
container_config: getContainerConfig(g.id) ?? null,
sessionCount: sessions.length,
runningSessions: running.length,
wirings,
@@ -0,0 +1,81 @@
/**
* Wiring test for the add-dashboard skill's code-edit integration point.
*
* The skill inserts one colocated block into src/index.ts (a dynamic
* `import('./dashboard-pusher.js')` + `await startDashboard()` in main()). A
* behavioral test of the pusher can't see whether that edit is actually
* present and correctly placed — booting the real host is too heavy — so this
* asserts the edit *structurally*, via the TypeScript AST. It verifies not
* just that the call exists, but that:
* - the pusher module is dynamically imported by its correct path,
* - startDashboard() is awaited,
* - both are DIRECT statements of main()'s body (right scope/level, not
* nested or stranded in another function),
* - the import precedes the call, and the whole block sits after DB init
* and before the boot-complete log (right place).
*
* Delete or misplace the edit and this goes red. Combined with the unit test
* (behavior of startDashboard) and the build (the call still type-checks),
* the three together cover deletion, misplacement, drift, and behavior — for
* a true code edit, with no registry required.
*
* Ships with the skill; apply copies it to src/.
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import ts from 'typescript';
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
const source = fs.readFileSync(indexPath, 'utf8');
const sf = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
function mainBody(): ts.NodeArray<ts.Statement> {
let body: ts.NodeArray<ts.Statement> | undefined;
sf.forEachChild((n) => {
if (ts.isFunctionDeclaration(n) && n.name?.text === 'main' && n.body) {
body = n.body.statements;
}
});
if (!body) throw new Error('main() not found in src/index.ts');
return body;
}
function isAwaitedStartDashboard(s: ts.Statement): boolean {
return (
ts.isExpressionStatement(s) &&
ts.isAwaitExpression(s.expression) &&
ts.isCallExpression(s.expression.expression) &&
ts.isIdentifier(s.expression.expression.expression) &&
s.expression.expression.expression.text === 'startDashboard'
);
}
/** `const { ... } = await import('./dashboard-pusher.js')` as a statement. */
function isDynamicImportOfPusher(s: ts.Statement): boolean {
if (!ts.isVariableStatement(s)) return false;
const init = s.declarationList.declarations[0]?.initializer;
if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false;
const call = init.expression;
if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false;
const arg = call.arguments[0];
return !!arg && ts.isStringLiteral(arg) && arg.text === './dashboard-pusher.js';
}
describe('add-dashboard wiring in src/index.ts', () => {
it('dynamically imports the pusher and awaits startDashboard(), colocated in main(), after DB init and before the boot-complete log', () => {
const stmts = mainBody();
const importIdx = stmts.findIndex(isDynamicImportOfPusher);
const callIdx = stmts.findIndex(isAwaitedStartDashboard);
const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations('));
const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')"));
expect(importIdx, "dynamic import('./dashboard-pusher.js') must be a statement of main()").toBeGreaterThanOrEqual(0);
expect(callIdx, 'await startDashboard() must be a statement of main()').toBeGreaterThanOrEqual(0);
expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0);
expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0);
expect(importIdx, 'the dynamic import must come after DB init').toBeGreaterThan(migrateIdx);
expect(callIdx, 'the call must come after its import (colocated)').toBeGreaterThan(importIdx);
expect(callIdx, 'startDashboard() must run before the boot-complete log').toBeLessThan(runningIdx);
});
});
+9 -3
View File
@@ -1,11 +1,17 @@
# Remove DeltaChat
## 1. Disable the adapter
## 1. Remove the adapter
Comment out the import in `src/channels/index.ts`:
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
// import './deltachat.js';
import './deltachat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/deltachat.ts src/channels/deltachat-registration.test.ts
```
## 2. Remove credentials
+10 -3
View File
@@ -14,6 +14,7 @@ The adapter drives the `@deltachat/stdio-rpc-server` JSON-RPC subprocess directl
Skip to **Credentials** if all of these are already in place:
- `src/channels/deltachat.ts` exists
- `src/channels/deltachat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './deltachat.js';`
- `@deltachat/stdio-rpc-server` is listed in `package.json` dependencies
@@ -25,10 +26,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
git show origin/channels:src/channels/deltachat.ts > src/channels/deltachat.ts
git show origin/channels:src/channels/deltachat-registration.test.ts > src/channels/deltachat-registration.test.ts
```
### 3. Append the self-registration import
@@ -45,12 +47,17 @@ import './deltachat.js';
pnpm install @deltachat/stdio-rpc-server@2.49.0
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/deltachat-registration.test.ts
```
Both must be clean before proceeding. `deltachat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `deltachat`. It goes red if the `import './deltachat.js';` line is deleted or drifts, if the barrel fails to evaluate (so the channel genuinely would not register), or if `@deltachat/stdio-rpc-server` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. Importing is safe: deltachat instantiates the rpc client only in `setup()` (at host startup), never at import.
End-to-end message delivery against a real email account is verified manually once the service is running — see Wiring and Troubleshooting.
## Account Setup
A dedicated email account is strongly recommended — it will accumulate DeltaChat-formatted messages and store encryption keys. Not all providers work well with DeltaChat; check https://providers.delta.chat/ before picking one.
-54
View File
@@ -1,54 +0,0 @@
# Verify DeltaChat
## 1. Check the adapter started
```bash
grep "Channel adapter started.*deltachat" logs/nanoclaw.log | tail -1
```
Expected: `Channel adapter started { channel: 'deltachat', type: 'deltachat' }`
## 2. Check IMAP/SMTP connectivity
Replace with your provider's hostnames from `.env`:
```bash
DC_IMAP=$(grep '^DC_IMAP_HOST=' .env | cut -d= -f2)
DC_SMTP=$(grep '^DC_SMTP_HOST=' .env | cut -d= -f2)
bash -c "echo >/dev/tcp/$DC_IMAP/993" && echo "IMAP open" || echo "IMAP blocked"
bash -c "echo >/dev/tcp/$DC_SMTP/587" && echo "SMTP open" || echo "SMTP blocked"
```
## 3. End-to-end message test
1. Open DeltaChat on your device
2. Add the bot email address as a contact
3. Send a message
4. The bot should respond within a few seconds
If nothing arrives, check:
```bash
grep "DeltaChat" logs/nanoclaw.log | tail -20
grep "DeltaChat" logs/nanoclaw.error.log | tail -10
```
## 4. Check messaging group was created
```bash
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
If a row appears, the inbound routing is working. If not, the adapter isn't receiving the message — check logs for `DeltaChat: error handling incoming message`.
## 5. Verify user access
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.
+37 -4
View File
@@ -1,7 +1,40 @@
# Remove Discord
1. Comment out `import './discord.js'` in `src/channels/index.ts`
2. Remove `DISCORD_BOT_TOKEN` from `.env`
3. Rebuild and restart
Every step is idempotent — safe to re-run.
No package to uninstall — Discord is built in.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './discord.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/discord.ts src/channels/discord-registration.test.ts
```
## 2. Remove credentials
Remove `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, and `DISCORD_PUBLIC_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/discord
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+9 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Discord adapter i
Skip to **Credentials** if all of these are already in place:
- `src/channels/discord.ts` exists
- `src/channels/discord-registration.test.ts` exists
- `src/channels/index.ts` contains `import './discord.js';`
- `@chat-adapter/discord` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
git show origin/channels:src/channels/discord-registration.test.ts > src/channels/discord-registration.test.ts
```
### 3. Append the self-registration import
@@ -44,15 +46,18 @@ 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
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/discord-registration.test.ts
```
Both must be clean before proceeding. `discord-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `discord`. It goes red if the `import './discord.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/discord` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
## Credentials
### Create Discord Bot
-3
View File
@@ -1,3 +0,0 @@
# Verify Discord
Send a message in a channel where the bot has access, or DM the bot directly. The bot should respond within a few seconds.
+63
View File
@@ -0,0 +1,63 @@
# Remove Emacs
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './emacs.js';
```
Then delete the copied adapter, its tests, and the Lisp client:
```bash
rm -f src/channels/emacs.ts src/channels/emacs.test.ts src/channels/emacs-registration.test.ts emacs/nanoclaw.el
```
## 2. Remove credentials
Remove the `EMACS_*` lines from `.env`:
```bash
EMACS_ENABLED
EMACS_CHANNEL_PORT
EMACS_AUTH_TOKEN
EMACS_PLATFORM_ID
```
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
```
## 4. Remove the Emacs config (optional)
Remove the NanoClaw block from your Emacs config (`config.el`, `~/.spacemacs`, or `init.el`):
```elisp
;; NanoClaw — personal AI assistant channel
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; ...and the associated keybindings / nanoclaw-auth-token / nanoclaw-port settings
```
Reload your config or restart Emacs.
## 5. Remove the messaging group (optional)
To clean up the wired messaging group:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
+12 -20
View File
@@ -24,6 +24,8 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and
Skip to **Enable** if all of these are already in place:
- `src/channels/emacs.ts` exists
- `src/channels/emacs.test.ts` exists
- `src/channels/emacs-registration.test.ts` exists
- `emacs/nanoclaw.el` exists
- `src/channels/index.ts` contains `import './emacs.js';`
@@ -39,9 +41,10 @@ git fetch origin channels
```bash
mkdir -p emacs
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
git show origin/channels:src/channels/emacs-registration.test.ts > src/channels/emacs-registration.test.ts
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
```
### 3. Append the self-registration import
@@ -52,13 +55,16 @@ Append to `src/channels/index.ts` (skip if the line is already present):
import './emacs.js';
```
### 4. Build
### 4. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/emacs-registration.test.ts
```
No npm package to install — the adapter uses only Node builtins (`http`).
Both must be clean before proceeding. `emacs-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `emacs`. It goes red if the `import './emacs.js';` line is deleted or drifts, or if the barrel fails to evaluate (so the channel genuinely would not register). The adapter uses only Node builtins (`http`), so there is no npm dependency to guard for this channel.
End-to-end message delivery from a real Emacs buffer is verified manually once the service is running — see Verify and Troubleshooting.
## Enable
@@ -285,18 +291,4 @@ If an agent outputs org-mode directly, markers get double-converted and render i
## Removal
Run from your NanoClaw project root:
```bash
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
# Remove the `import './emacs.js';` line from src/channels/index.ts
# Remove EMACS_* lines from .env
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
pnpm exec tsx scripts/q.ts data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
```
See [REMOVE.md](REMOVE.md) to uninstall this channel.
+67
View File
@@ -0,0 +1,67 @@
# Remove Google Calendar Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Unregister the MCP server (per group)
For each group that had Calendar wired (`ncl groups list` to enumerate):
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
## 2. Remove the `.calendar-mcp` mount from the DB (per group)
There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until it ships, drop the entry via the in-tree wrapper (`scripts/q.ts`):
```bash
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') \
WHERE agent_group_id = '<group-id>';"
```
## 3. Delete the copied test file
```bash
rm -f src/gcal-dockerfile.test.ts
```
## 4. Revert the Dockerfile edits
Remove the `ARG CALENDAR_MCP_VERSION=...` line and the `@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}` entry from the pnpm global-install block in `container/Dockerfile`. If Calendar shared the gmail install block, leave the gmail entry intact; if it had a standalone `RUN ... pnpm install -g "@cocal/google-calendar-mcp@..."` block, delete that whole `RUN` line.
## 5. Rebuild and restart
```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)
```
Kill any running agent containers so they respawn without the `calendar` MCP server:
```bash
docker ps -q --filter 'name=nanoclaw-v2-' | xargs -r docker kill
```
## 6. Optional: remove stubs and disconnect OneCLI
```bash
rm -rf ~/.calendar-mcp/
onecli apps disconnect --provider google-calendar
```
## Verification
After removal, in a wired agent asking it to "list my calendars" should report no calendar tool, and the dependency-guard test is gone:
```bash
ls src/gcal-dockerfile.test.ts 2>&1 # No such file or directory
```
+14 -19
View File
@@ -120,7 +120,18 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
`container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`.
### Install the dependency-guard test
`@cocal/google-calendar-mcp` is a stdio CLI installed in the image, not an imported module, so `tsc` and the runtime tests never reference it — only the Dockerfile edit above proves it is present. Copy the guard test into the host test tree (vitest) so the Dockerfile `ARG` + install line stay covered:
```bash
cp .claude/skills/add-gcal-tool/gcal-dockerfile.test.ts src/gcal-dockerfile.test.ts
pnpm exec vitest run src/gcal-dockerfile.test.ts
```
`cp` overwrites in place, so re-running this skill is safe.
### Rebuild the container image
@@ -213,26 +224,10 @@ Common signals:
## Removal
1. For each group that had Calendar wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
2. Remove the `.calendar-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```bash
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') \
WHERE agent_group_id = '<group-id>';"
```
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
See [REMOVE.md](REMOVE.md) — unregisters the MCP server, drops the `.calendar-mcp` mount, deletes the copied test, reverts the Dockerfile edits, and rebuilds.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
- **Why not gongrzhe:** earlier versions of this skill used `@gongrzhe/server-calendar-autoauth-mcp@1.0.2` which only supports the primary calendar with 5 event-level tools. The cocal server supersedes it.
- **Why not gongrzhe:** `@gongrzhe/server-calendar-autoauth-mcp` only supports the primary calendar with 5 event-level tools. The cocal server supports multi-account and multi-calendar with the full tool surface.
- **Skill pattern:** direct sibling of [`/add-gmail-tool`](../add-gmail-tool/SKILL.md); same OneCLI stub mechanism.
@@ -0,0 +1,36 @@
/**
* Dependency guard for the Google Calendar MCP server (host/vitest tree).
*
* `@cocal/google-calendar-mcp` is a stdio CLI installed globally in the image,
* not an imported module, so no behavior test can drive it and `tsc` never sees
* it. The only in-tree footprint of this skill is the Dockerfile edit, so the
* guard is structural: assert the pinned `ARG` and the pnpm global-install line
* both exist. Drop either Phase 2 Dockerfile edit and this goes red.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
function dockerfile(): string {
const p = path.resolve(process.cwd(), 'container/Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs @cocal/google-calendar-mcp', () => {
const text = dockerfile();
it('pins the version via an ARG', () => {
expect(text).toMatch(/^\s*ARG\s+CALENDAR_MCP_VERSION=/m);
});
it('installs the package pinned to that ARG in a pnpm global-install block', () => {
// Match `pnpm install -g ... "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"`,
// tolerating line continuations between `install -g` and the package.
const installsCalendar =
/pnpm\s+install\s+-g[\s\S]*?@cocal\/google-calendar-mcp@\$\{CALENDAR_MCP_VERSION\}/.test(
text,
);
expect(installsCalendar).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove Google Chat Channel
# Remove Google Chat
1. Comment out `import './gchat.js'` in `src/channels/index.ts`
2. Remove `GCHAT_CREDENTIALS` from `.env`
3. `pnpm uninstall @chat-adapter/gchat`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './gchat.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/gchat.ts src/channels/gchat-registration.test.ts
```
## 2. Remove credentials
Remove `GCHAT_CREDENTIALS` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/gchat
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+11 -4
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the Google Chat adapt
Skip to **Credentials** if all of these are already in place:
- `src/channels/gchat.ts` exists
- `src/channels/gchat-registration.test.ts` exists
- `src/channels/index.ts` contains `import './gchat.js';`
- `@chat-adapter/gchat` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
git show origin/channels:src/channels/gchat-registration.test.ts > src/channels/gchat-registration.test.ts
```
### 3. Append the self-registration import
@@ -44,15 +46,20 @@ 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
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/gchat-registration.test.ts
```
Both must be clean before proceeding. `gchat-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `gchat`. It goes red if the `import './gchat.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/gchat` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real Google Chat space is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
> 1. Go to [Google Cloud Console](https://console.cloud.google.com)
-3
View File
@@ -1,3 +0,0 @@
# Verify Google Chat Channel
Add the bot to a Google Chat space, then send a message or @mention the bot. The bot should respond within a few seconds.
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove GitHub Channel
# Remove GitHub
1. Comment out `import './github.js'` in `src/channels/index.ts`
2. Remove `GITHUB_TOKEN` and `GITHUB_WEBHOOK_SECRET` from `.env`
3. `pnpm uninstall @chat-adapter/github`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './github.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/github.ts src/channels/github-registration.test.ts
```
## 2. Remove credentials
Remove `GITHUB_TOKEN`, `GITHUB_WEBHOOK_SECRET`, and `GITHUB_BOT_USERNAME` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall @chat-adapter/github
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+13 -6
View File
@@ -20,6 +20,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in
Skip to **Credentials** if all of these are already in place:
- `src/channels/github.ts` exists
- `src/channels/github-registration.test.ts` exists
- `src/channels/index.ts` contains `import './github.js';`
- `@chat-adapter/github` is listed in `package.json` dependencies
@@ -31,10 +32,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github.ts > src/channels/github.ts
git show origin/channels:src/channels/github-registration.test.ts > src/channels/github-registration.test.ts
```
### 3. Append the self-registration import
@@ -48,15 +50,20 @@ 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
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/github-registration.test.ts
```
Both must be clean before proceeding. `github-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `github`. It goes red if the `import './github.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `@chat-adapter/github` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real GitHub repo is verified manually once the service is running — see Next Steps and the webhook setup above.
## Credentials
### 1. Create a Personal Access Token for the bot account
@@ -104,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
```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'));
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
-- 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)
-3
View File
@@ -1,3 +0,0 @@
# Verify GitHub Channel
@mention the bot in a PR comment or issue comment. The bot should respond within a few seconds.
+57
View File
@@ -0,0 +1,57 @@
# Remove Gmail Tool
Idempotent — safe to run even if some steps were never applied.
## 1. Delete the copied tests
```bash
rm -f container/agent-runner/src/providers/gmail-dockerfile.test.ts \
container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
## 2. Unregister the MCP server (per group)
`ncl groups list` shows the groups. For each group that had Gmail wired:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
## 3. Remove the `.gmail-mcp` mount (per group)
There is no `ncl groups config remove-mount` verb yet ([#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Edit the central DB via the in-tree wrapper (`scripts/q.ts` — NanoClaw avoids depending on the `sqlite3` CLI, `setup/verify.ts:5`). Run from your NanoClaw project root (where `data/v2.db` lives):
```bash
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') \
WHERE agent_group_id = '$GROUP_ID';"
```
## 4. Remove the Dockerfile install
In `container/Dockerfile`, delete the `ARG GMAIL_MCP_VERSION=...` line and the `pnpm install -g` `RUN` block that installs `@gongrzhe/server-gmail-autoauth-mcp` and `zod-to-json-schema`.
## 5. Rebuild and restart
Run from your NanoClaw project root:
```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)
```
## 6. (Optional) Drop the host stubs and disconnect
```bash
rm -rf ~/.gmail-mcp/ # only if no other host tool needs the stubs
onecli apps disconnect --provider gmail # revoke the OneCLI Gmail connection
```
+23 -23
View File
@@ -101,14 +101,27 @@ grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
echo "ALREADY APPLIED — skip to Phase 3"
```
### Copy the skill's tests into the container tree
Both integration points this skill relies on live in the container (Bun) tree — the Dockerfile package install and the dynamic allow-pattern derivation in `claude.ts` — so the guards go there. `cp` overwrites, so re-running is safe.
```bash
S=.claude/skills/add-gmail-tool
cp $S/gmail-dockerfile.test.ts container/agent-runner/src/providers/gmail-dockerfile.test.ts
cp $S/gmail-allow-pattern.test.ts container/agent-runner/src/providers/gmail-allow-pattern.test.ts
```
- `gmail-dockerfile.test.ts` asserts the `GMAIL_MCP_VERSION` ARG and the pinned `pnpm install -g` line are present — the `gmail-mcp` binary is a Dockerfile-installed CLI, not importable or typed, so this structural guard is what goes red if the install is dropped.
- `gmail-allow-pattern.test.ts` asserts `claude.ts` still spreads `Object.keys(this.mcpServers).map(mcpAllowPattern)` into `allowedTools` — the derivation that makes registering `gmail` (Phase 3) enough to expose `mcp__gmail__*`.
### Add MCP server to Dockerfile
Edit `container/Dockerfile`. Find the pinned-version ARG block:
```dockerfile
ARG CLAUDE_CODE_VERSION=2.1.116
ARG CLAUDE_CODE_VERSION=2.1.154
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
```
@@ -118,7 +131,7 @@ Add a new line:
ARG GMAIL_MCP_VERSION=1.1.11
```
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block after it, before `# ---- Entrypoint`:
Then find the last pnpm global-install `RUN` block (the one that installs `@anthropic-ai/claude-code`) and add a new block directly after it (before the `# ---- ncl CLI wrapper` section):
```dockerfile
RUN --mount=type=cache,target=/root/.cache/pnpm \
@@ -131,7 +144,7 @@ Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates tr
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 automatically allows `mcp__gmail__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
The Gmail allow-pattern is derived automatically. `container/agent-runner/src/providers/claude.ts` builds `allowedTools` from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 exposes `mcp__gmail__*` to the agent.
### Rebuild the container image
@@ -188,12 +201,16 @@ Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` place
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
## Phase 4: Build and Restart
## Phase 4: Build, Validate, Restart
```bash
pnpm run build
pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
(cd container/agent-runner && bun test src/providers/gmail-dockerfile.test.ts src/providers/gmail-allow-pattern.test.ts)
```
All must be clean before proceeding. `gmail-dockerfile.test.ts` confirms the package install is wired into the image; `gmail-allow-pattern.test.ts` confirms the allow-pattern derivation that exposes `mcp__gmail__*`. A failure means one drifted.
Run from your NanoClaw project root:
```bash
@@ -228,24 +245,7 @@ Common signals:
## Removal
1. For each group that had Gmail wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
2. Remove the `.gmail-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```bash
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') \
WHERE agent_group_id = '<group-id>';"
```
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
See [REMOVE.md](REMOVE.md) for the idempotent removal procedure (delete the copied tests, unregister the MCP server per group, drop the mount, remove the Dockerfile install, rebuild, and optionally drop the stubs and disconnect OneCLI).
## Notes
@@ -0,0 +1,55 @@
/**
* Guard for the dynamic MCP allow-pattern derivation this skill depends on.
*
* Registering `gmail` in a group's mcpServers map is the *only* wiring needed to expose
* `mcp__gmail__*` to the agent — there is no static TOOL_ALLOWLIST edit. That holds solely
* because `claude.ts` derives the allow-pattern from the registered servers at query time:
*
* allowedTools: [ ...TOOL_ALLOWLIST, ...Object.keys(this.mcpServers).map(mcpAllowPattern) ]
*
* `mcpAllowPattern` is not exported and the call site lives inside the SDK query options,
* so we assert the derivation structurally. Delete or rename the derivation and this goes
* red — surfacing that `gmail` tools would silently be filtered out despite being registered.
*
* `mcpAllowPattern` itself is exercised directly to prove `gmail` -> `mcp__gmail__*`.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
import ts from 'typescript';
function source(): { sf: ts.SourceFile; text: string } {
const p = path.join(import.meta.dir, 'claude.ts');
const text = fs.readFileSync(p, 'utf8');
return { sf: ts.createSourceFile(p, text, ts.ScriptTarget.Latest, true), text };
}
/** Reimplement the sanitizer the provider applies, to assert the gmail name maps cleanly. */
function expectedPattern(name: string): string {
return `mcp__${name.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
}
describe('claude.ts derives MCP allow-patterns from the registered servers', () => {
const { sf, text } = source();
it('defines an mcpAllowPattern function', () => {
let found = false;
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name?.text === 'mcpAllowPattern') found = true;
if (!found) ts.forEachChild(node, visit);
};
visit(sf);
expect(found).toBe(true);
});
it('spreads Object.keys(this.mcpServers).map(mcpAllowPattern) into allowedTools', () => {
// Normalize whitespace so formatting changes don't break the assertion.
const flat = text.replace(/\s+/g, ' ');
expect(flat).toContain('Object.keys(this.mcpServers).map(mcpAllowPattern)');
});
it('maps a gmail server name to mcp__gmail__*', () => {
expect(expectedPattern('gmail')).toBe('mcp__gmail__*');
});
});
@@ -0,0 +1,36 @@
/**
* Structural guard for the Gmail MCP package-install integration point (container image).
*
* `@gongrzhe/server-gmail-autoauth-mcp` is a CLI binary installed into the image via the
* Dockerfile — it is not importable or typed from this tree, so the build leg can't catch
* its removal and there's no runtime seam to behavior-test. This asserts the Dockerfile
* still carries the ARG and the pinned pnpm global-install line. Drop either and this goes
* red, signalling the agent would boot without the `gmail-mcp` binary on PATH.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
function dockerfile(): string {
// container/agent-runner/src/providers/ -> ../../../Dockerfile == container/Dockerfile
const p = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
return fs.readFileSync(p, 'utf8');
}
describe('container/Dockerfile installs the Gmail MCP server', () => {
const text = dockerfile();
it('declares the GMAIL_MCP_VERSION ARG', () => {
expect(/ARG\s+GMAIL_MCP_VERSION=/.test(text)).toBe(true);
});
it('pnpm-installs @gongrzhe/server-gmail-autoauth-mcp pinned to the ARG', () => {
expect(text).toContain('pnpm install -g');
expect(/@gongrzhe\/server-gmail-autoauth-mcp@\$\{GMAIL_MCP_VERSION\}/.test(text)).toBe(true);
});
it('pins the zod-to-json-schema workaround version', () => {
expect(/zod-to-json-schema@3\.22\.5/.test(text)).toBe(true);
});
});
+39 -5
View File
@@ -1,6 +1,40 @@
# Remove iMessage Channel
# Remove iMessage
1. Comment out `import './imessage.js'` in `src/channels/index.ts`
2. Remove iMessage env vars (`IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, `IMESSAGE_API_KEY`) from `.env`
3. `pnpm uninstall chat-adapter-imessage`
4. Rebuild and restart
Every step is idempotent — safe to re-run.
## 1. Remove the adapter
Delete the self-registration import from `src/channels/index.ts` (skip if already gone):
```typescript
import './imessage.js';
```
Then delete the copied adapter and its registration test:
```bash
rm -f src/channels/imessage.ts src/channels/imessage-registration.test.ts
```
## 2. Remove credentials
Remove `IMESSAGE_ENABLED`, `IMESSAGE_LOCAL`, `IMESSAGE_SERVER_URL`, and `IMESSAGE_API_KEY` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
```bash
pnpm uninstall chat-adapter-imessage
```
## 4. Rebuild and restart
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
```
+10 -3
View File
@@ -16,6 +16,7 @@ NanoClaw doesn't ship channels in trunk. This skill copies the iMessage adapter
Skip to **Credentials** if all of these are already in place:
- `src/channels/imessage.ts` exists
- `src/channels/imessage-registration.test.ts` exists
- `src/channels/index.ts` contains `import './imessage.js';`
- `chat-adapter-imessage` is listed in `package.json` dependencies
@@ -27,10 +28,11 @@ Otherwise continue. Every step below is safe to re-run.
git fetch origin channels
```
### 2. Copy the adapter
### 2. Copy the adapter and its registration test
```bash
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
git show origin/channels:src/channels/imessage-registration.test.ts > src/channels/imessage-registration.test.ts
```
### 3. Append the self-registration import
@@ -47,12 +49,17 @@ import './imessage.js';
pnpm install chat-adapter-imessage@0.1.1
```
### 5. Build
### 5. Build and validate
```bash
pnpm run build
pnpm exec vitest run src/channels/imessage-registration.test.ts
```
Both must be clean before proceeding. `imessage-registration.test.ts` is the one integration test: it imports the real channel barrel and asserts the registry contains `imessage`. It goes red if the `import './imessage.js';` line is deleted or drifts, if the barrel fails to evaluate, or if `chat-adapter-imessage` isn't installed (the import throws) — so it also implicitly verifies the dependency from step 4. The adapter also calls core's `createChatSdkBridge(...)`; that typed core-API consumption is guarded by `pnpm run build`.
End-to-end message delivery against a real iMessage account is verified manually once the service is running — see Next Steps.
## Credentials
### Local Mode (macOS)

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