Compare commits

..

3 Commits

Author SHA1 Message Date
gavrielc 0612bfce5f merge: catch up with upstream main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:27:35 +03:00
gavrielc 4e7dce5cae chore: remove direct pino/pino-pretty dependency
Pino was replaced with a built-in logger on main. For branches
with baileys (WhatsApp), pino resolves as a transitive dependency
of @whiskeysockets/baileys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:39:53 +03:00
gavrielc 55a7e7013c feat: add Emacs channel (HTTP bridge + nanoclaw.el)
Adds EmacsBridgeChannel — a lightweight HTTP server (port 8766) that
lets Emacs send messages to NanoClaw and poll for responses.

Includes emacs/nanoclaw.el with:
- nanoclaw-chat: interactive chat buffer with org-mode rendering
- nanoclaw-org-send: send org subtree, insert response as child heading
- Markdown-to-org conversion, thinking indicator, UTF-8 support

Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.

Co-Authored-By: Ken Bolton <ken@bscientific.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 23:03:10 +02:00
13 changed files with 1408 additions and 510 deletions
+5 -4
View File
@@ -7,6 +7,7 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
@@ -54,14 +55,14 @@ RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/
# Container input (prompt, group info) is passed via stdin JSON.
# Credentials are injected by the host's credential proxy — never passed here.
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
# Apple Container only supports directory mounts (VirtioFS), so .env cannot be
# shadowed with a host-side /dev/null file mount. Instead the entrypoint starts
# as root, uses mount --bind to shadow .env, then drops to the host user via setpriv.
RUN printf '#!/bin/bash\nset -e\n\n# Shadow .env so the agent cannot read host secrets (requires root)\nif [ "$(id -u)" = "0" ] && [ -f /workspace/project/.env ]; then\n mount --bind /dev/null /workspace/project/.env\nfi\n\n# Compile agent-runner\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\n\n# Capture stdin (secrets JSON) to temp file\ncat > /tmp/input.json\n\n# Drop privileges if running as root (main-group containers)\nif [ "$(id -u)" = "0" ] && [ -n "$RUN_UID" ]; then\n chown "$RUN_UID:$RUN_GID" /tmp/input.json /tmp/dist\n exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --clear-groups -- node /tmp/dist/index.js < /tmp/input.json\nfi\n\nexec node /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node
# Switch to non-root user (required for --dangerously-skip-permissions)
USER node
# Set working directory to group workspace
WORKDIR /workspace/group
+1 -1
View File
@@ -8,7 +8,7 @@ cd "$SCRIPT_DIR"
IMAGE_NAME="nanoclaw-agent"
TAG="${1:-latest}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"
+504
View File
@@ -0,0 +1,504 @@
;;; nanoclaw.el --- Emacs interface for NanoClaw AI assistant -*- lexical-binding: t -*-
;; Author: NanoClaw
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: ai, assistant, chat
;;
;; Vanilla Emacs (init.el):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (global-set-key (kbd "C-c n c") #'nanoclaw-chat)
;; (global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
;;
;; Spacemacs (~/.spacemacs, in dotspacemacs/user-config):
;; (load-file "~/src/nanoclaw/emacs/nanoclaw.el")
;; (spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
;; (spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
;;
;; Doom Emacs (config.el):
;; (load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
;; (map! :leader
;; :prefix ("N" . "NanoClaw")
;; :desc "Chat buffer" "c" #'nanoclaw-chat
;; :desc "Send org" "o" #'nanoclaw-org-send)
;; ;; Evil users: teach evil about the C-c C-c send binding
;; (after! evil
;; (evil-define-key '(normal insert) nanoclaw-chat-mode-map
;; (kbd "C-c C-c") #'nanoclaw-chat-send))
;;; Code:
(require 'cl-lib)
(require 'url)
(require 'json)
(require 'org)
;; ---------------------------------------------------------------------------
;; Customization
(defgroup nanoclaw nil
"NanoClaw AI assistant interface."
:group 'tools
:prefix "nanoclaw-")
(defcustom nanoclaw-host "localhost"
"Hostname where NanoClaw is running."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-port 8766
"Port for the NanoClaw Emacs channel HTTP server."
:type 'integer
:group 'nanoclaw)
(defcustom nanoclaw-auth-token nil
"Bearer token for NanoClaw authentication (matches EMACS_AUTH_TOKEN in .env).
Leave nil if EMACS_AUTH_TOKEN is not set."
:type '(choice (const nil) string)
:group 'nanoclaw)
(defcustom nanoclaw-poll-interval 1.5
"Seconds between response polls when waiting for a reply."
:type 'number
:group 'nanoclaw)
(defcustom nanoclaw-agent-name "Andy"
"Display name for the NanoClaw agent (matches ASSISTANT_NAME in .env)."
:type 'string
:group 'nanoclaw)
(defcustom nanoclaw-convert-to-org t
"When non-nil, convert agent responses to org-mode format.
Uses pandoc when available; falls back to regex substitutions."
:type 'boolean
:group 'nanoclaw)
(defcustom nanoclaw-timestamp-format "%H:%M"
"Format string for timestamps shown next to agent replies in the chat buffer.
Passed to `format-time-string'. Set to nil to suppress timestamps."
:type '(choice (const nil) string)
:group 'nanoclaw)
;; ---------------------------------------------------------------------------
;; Formatting helpers
(defun nanoclaw--to-org (text)
"Convert TEXT (markdown or plain) to org-mode markup.
Tries pandoc -f gfm -t org when available; falls back to regex."
(if (not nanoclaw-convert-to-org)
text
(if (executable-find "pandoc")
(with-temp-buffer
(insert text)
(let* ((coding-system-for-read 'utf-8)
(coding-system-for-write 'utf-8)
(exit (call-process-region
(point-min) (point-max)
"pandoc" t t nil "-f" "gfm" "-t" "org" "--wrap=none")))
(if (zerop exit)
(string-trim (buffer-string))
text)))
(nanoclaw--md-to-org-regex text))))
;; NOTE: This function expects standard markdown as input (e.g. **bold**, *italic*).
;; Agents responding on this channel must output markdown, not org-mode syntax.
;; If the agent outputs org-mode directly, markers like *bold* will be incorrectly
;; re-converted to /bold/ by the italic rule.
(defun nanoclaw--md-to-org-regex (text)
"Lightweight markdown → org conversion using regexp substitutions."
(let ((s text))
;; Fenced code blocks ```lang\n…\n``` → #+begin_src lang\n…\n#+end_src
;; (must run before inline-code to avoid mangling backticks)
(setq s (replace-regexp-in-string
"```\\([a-zA-Z0-9_-]*\\)\n\\(\\(?:.\\|\n\\)*?\\)```"
(lambda (m)
(let ((lang (match-string 1 m))
(body (match-string 2 m)))
(concat "#+begin_src " (if (string-empty-p lang) "text" lang)
"\n" body "#+end_src")))
s t))
;; Bold **text** → *text*, italic *text* → /text/
;; Two-pass to prevent the italic regex from re-matching the bold result:
;; 1. Mark bold spans with a placeholder (control char \x01)
(setq s (replace-regexp-in-string "\\*\\*\\(.+?\\)\\*\\*" "\x01\\1\x01" s))
;; 2. Convert remaining single-star spans to italic
(setq s (replace-regexp-in-string "\\*\\(.+?\\)\\*" "/\\1/" s))
;; 3. Resolve bold placeholders to org bold markers
(setq s (replace-regexp-in-string "\x01\\(.+?\\)\x01" "*\\1*" s))
;; Strikethrough ~~text~~ → +text+
(setq s (replace-regexp-in-string "~~\\(.+?\\)~~" "+\\1+" s))
;; Underline __text__ → _text_
(setq s (replace-regexp-in-string "__\\(.+?\\)__" "_\\1_" s))
;; Inline code `code` → ~code~
(setq s (replace-regexp-in-string "`\\([^`]+\\)`" "~\\1~" s))
;; ATX headings ## … → ** …
(setq s (replace-regexp-in-string
"^\\(#+\\) "
(lambda (m) (concat (make-string (length (match-string 1 m)) ?*) " "))
s))
;; Links [text](url) → [[url][text]]
(setq s (replace-regexp-in-string
"\\[\\([^]]+\\)\\](\\([^)]+\\))" "[[\\2][\\1]]" s))
s))
(defun nanoclaw--format-timestamp ()
"Return a formatted timestamp string, or nil if disabled."
(when nanoclaw-timestamp-format
(format-time-string nanoclaw-timestamp-format)))
;; ---------------------------------------------------------------------------
;; Internal state
(defvar nanoclaw--poll-timer nil
"Timer used to poll for responses in the chat buffer.")
(defvar nanoclaw--last-timestamp 0
"Epoch ms of the most recently received message.")
(defvar nanoclaw--pending nil
"Non-nil while waiting for a response.")
(defvar-local nanoclaw--thinking-dot-count 0
"Dot cycle counter for the animated thinking indicator.")
(defvar-local nanoclaw--input-beg nil
"Marker for the start of the current user input area.")
;; ---------------------------------------------------------------------------
;; HTTP helpers
(defun nanoclaw--url (path)
"Return the full URL for PATH on the NanoClaw server."
(format "http://%s:%d%s" nanoclaw-host nanoclaw-port path))
(defun nanoclaw--headers ()
"Return alist of HTTP headers for NanoClaw requests."
(let ((hdrs '(("Content-Type" . "application/json"))))
(when nanoclaw-auth-token
(push (cons "Authorization" (concat "Bearer " nanoclaw-auth-token)) hdrs))
hdrs))
(defun nanoclaw--post (text callback)
"POST TEXT to NanoClaw and call CALLBACK with the response alist."
(let* ((url-request-method "POST")
(url-request-extra-headers (nanoclaw--headers))
(url-request-data (encode-coding-string
(json-encode `((text . ,text)))
'utf-8)))
(url-retrieve
(nanoclaw--url "/api/message")
(lambda (status)
(if (plist-get status :error)
(message "NanoClaw: POST error %s" (plist-get status :error))
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let ((data (ignore-errors (json-read))))
(funcall callback data))))
nil t t)))
(defun nanoclaw--poll (since callback)
"GET messages newer than SINCE (epoch ms) and call CALLBACK with the list."
(let* ((url-request-method "GET")
(url-request-extra-headers (nanoclaw--headers)))
(url-retrieve
(nanoclaw--url (format "/api/messages?since=%d" since))
(lambda (status)
(unless (plist-get status :error)
(goto-char (point-min))
(re-search-forward "\n\n" nil t)
(let* ((raw (buffer-substring-no-properties (point) (point-max)))
(body (decode-coding-string raw 'utf-8))
(data (ignore-errors (json-read-from-string body)))
(msgs (cdr (assq 'messages data))))
(when msgs (funcall callback (append msgs nil))))))
nil t t)))
;; ---------------------------------------------------------------------------
;; Chat buffer
(defvar nanoclaw-chat-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'newline)
(define-key map (kbd "<return>") #'newline)
(define-key map (kbd "C-c C-c") #'nanoclaw-chat-send)
map)
"Keymap for `nanoclaw-chat-mode'.")
(define-derived-mode nanoclaw-chat-mode org-mode "NanoClaw"
"Major mode for the NanoClaw chat buffer.
Derives from org-mode so that org markup (headings, bold, code blocks,
etc.) is fontified automatically. RET and <return> insert plain newlines
for multi-line input; send with C-c C-c."
(setq-local word-wrap t)
(visual-line-mode 1)
;; Disable org features that conflict with a linear chat buffer
(setq-local org-return-follows-link nil)
(setq-local org-cycle-emulate-tab nil)
;; Ensure send binding beats org-mode's C-c C-c via the buffer-local map
(local-set-key (kbd "C-c C-c") #'nanoclaw-chat-send))
(defun nanoclaw--advance-input-beg ()
"Move `nanoclaw--input-beg' to point-max in the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(when nanoclaw--input-beg (set-marker nanoclaw--input-beg nil))
(setq nanoclaw--input-beg (copy-marker (point-max)))))
(defun nanoclaw--chat-buffer ()
"Return the NanoClaw chat buffer, creating it if necessary."
(or (get-buffer "*NanoClaw*")
(with-current-buffer (get-buffer-create "*NanoClaw*")
(nanoclaw-chat-mode)
(set-buffer-file-coding-system 'utf-8)
(add-hook 'kill-buffer-hook #'nanoclaw--stop-poll nil t)
(nanoclaw--insert-header)
(setq nanoclaw--input-beg (copy-marker (point-max)))
(current-buffer))))
(defun nanoclaw--insert-header ()
"Insert the welcome header into the chat buffer."
(let ((inhibit-read-only t))
(insert (propertize
(format "── NanoClaw (%s) ──────────────────────────────\n\n"
nanoclaw-agent-name)
'face 'font-lock-comment-face))))
(defun nanoclaw--chat-insert (speaker text)
"Append SPEAKER: TEXT to the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let* ((inhibit-read-only t)
(is-agent (not (string= speaker "You")))
(display-text (if is-agent (nanoclaw--to-org text) text))
(ts (nanoclaw--format-timestamp))
(label (if ts (format "%s [%s]" speaker ts) speaker))
(face (if is-agent 'font-lock-string-face 'font-lock-keyword-face)))
(goto-char (point-max))
(insert (propertize (concat label ": ") 'face face))
(insert display-text "\n\n")
(goto-char (point-max))
(when is-agent
(nanoclaw--advance-input-beg)))))
;;;###autoload
(defun nanoclaw-chat ()
"Open the NanoClaw chat buffer."
(interactive)
(pop-to-buffer (nanoclaw--chat-buffer))
(goto-char (point-max)))
(defun nanoclaw-chat-send ()
"Send the accumulated input area as a message to NanoClaw.
Use C-c C-c to send; RET inserts a plain newline for multi-line messages."
(interactive)
(when nanoclaw--pending
(message "NanoClaw: waiting for previous response...")
(cl-return-from nanoclaw-chat-send))
(let* ((beg (if (and nanoclaw--input-beg (marker-buffer nanoclaw--input-beg))
(marker-position nanoclaw--input-beg)
(line-beginning-position)))
(text (string-trim (buffer-substring-no-properties beg (point-max)))))
(when (string-empty-p text)
(user-error "Nothing to send"))
(let ((inhibit-read-only t))
(delete-region beg (point-max)))
(nanoclaw--chat-insert "You" text)
(nanoclaw--advance-input-beg)
(setq nanoclaw--pending t)
(nanoclaw--post text
(lambda (data)
(when data
(setq nanoclaw--last-timestamp
(or (cdr (assq 'timestamp data))
nanoclaw--last-timestamp))
(nanoclaw--start-thinking)
(nanoclaw--start-poll))))))
(defun nanoclaw--start-poll ()
"Start polling for new messages."
(nanoclaw--stop-poll)
(setq nanoclaw--poll-timer
(run-with-timer nanoclaw-poll-interval nanoclaw-poll-interval
#'nanoclaw--poll-tick)))
(defun nanoclaw--stop-poll ()
"Stop the polling timer."
(when nanoclaw--poll-timer
(cancel-timer nanoclaw--poll-timer)
(setq nanoclaw--poll-timer nil)))
(defun nanoclaw--start-thinking ()
"Insert an animated thinking indicator at the end of the chat buffer."
(with-current-buffer (nanoclaw--chat-buffer)
(let ((inhibit-read-only t))
(goto-char (point-max))
(setq nanoclaw--thinking-dot-count 1)
(insert (propertize (format "%s: .\n\n" nanoclaw-agent-name)
'nanoclaw-thinking t
'face 'font-lock-string-face)))))
(defun nanoclaw--tick-thinking ()
"Advance the dot animation in the thinking indicator."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(when nanoclaw--pending
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(let* ((end (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))
(n (1+ (mod nanoclaw--thinking-dot-count 3))))
(setq nanoclaw--thinking-dot-count n)
(delete-region pos end)
(save-excursion
(goto-char pos)
(insert (propertize
(format "%s: %s\n\n" nanoclaw-agent-name
(make-string n ?.))
'nanoclaw-thinking t
'face 'font-lock-string-face)))))))))))
(defun nanoclaw--clear-thinking ()
"Remove the thinking indicator from the chat buffer."
(let ((buf (get-buffer "*NanoClaw*")))
(when buf
(with-current-buffer buf
(let* ((inhibit-read-only t)
(pos (text-property-any (point-min) (point-max)
'nanoclaw-thinking t)))
(when pos
(delete-region pos (or (next-single-property-change
pos 'nanoclaw-thinking) (point-max)))))))))
(defun nanoclaw--poll-tick ()
"Poll for new messages and insert them into the chat buffer."
(nanoclaw--tick-thinking)
(nanoclaw--poll
nanoclaw--last-timestamp
(lambda (msgs)
(dolist (msg msgs)
(let ((text (cdr (assq 'text msg)))
(ts (cdr (assq 'timestamp msg))))
(when (and text (> ts nanoclaw--last-timestamp))
(setq nanoclaw--last-timestamp ts)
(nanoclaw--clear-thinking)
(nanoclaw--chat-insert nanoclaw-agent-name text))))
(when msgs
(setq nanoclaw--pending nil)
(nanoclaw--stop-poll)))))
;; ---------------------------------------------------------------------------
;; Org integration
;;;###autoload
(defun nanoclaw-org-send ()
"Send the current org subtree to NanoClaw and insert the response as a child.
If a region is active, send the region text instead."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Not in an org-mode buffer"))
(let ((text (if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(nanoclaw--org-subtree-text))))
(when (string-empty-p (string-trim text))
(user-error "Nothing to send"))
(message "NanoClaw: sending to %s..." nanoclaw-agent-name)
(let ((marker (point-marker))
(buf (current-buffer)))
(nanoclaw--post
text
(lambda (data)
(let* ((ts (or (cdr (assq 'timestamp data)) (nanoclaw--now-ms)))
(level (with-current-buffer buf
(save-excursion (goto-char marker) (org-outline-level))))
(ph (with-current-buffer buf
(save-excursion
(goto-char marker)
(nanoclaw--org-insert-placeholder level)))))
(nanoclaw--poll-until-response
ts
(lambda (response)
(with-current-buffer buf
(save-excursion
(when (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil))
(goto-char marker)
(nanoclaw--org-insert-response response))))
(lambda ()
(message "NanoClaw: timed out waiting for response")
(when (marker-buffer ph)
(with-current-buffer (marker-buffer ph)
(let* ((inhibit-read-only t)
(beg (marker-position ph))
(end (save-excursion
(goto-char (1+ beg))
(org-next-visible-heading 1)
(point))))
(delete-region beg end))
(set-marker ph nil)))))))))))
(defun nanoclaw--org-insert-placeholder (level)
"Insert a processing child heading at LEVEL+1 and return a marker at its start."
(org-back-to-heading t)
(org-end-of-subtree t t)
(let ((beg (point)))
(insert "\n" (make-string (1+ level) ?*) " "
nanoclaw-agent-name " [processing...]\n\n")
(copy-marker beg)))
(defun nanoclaw--org-subtree-text ()
"Return the text of the org subtree at point (heading + body)."
(org-with-wide-buffer
(org-back-to-heading t)
(let ((start (point))
(end (progn (org-end-of-subtree t t) (point))))
(buffer-substring-no-properties start end))))
(defun nanoclaw--org-insert-response (text)
"Insert TEXT as a child org heading under the current subtree."
(org-back-to-heading t)
(let* ((level (org-outline-level))
(child-stars (make-string (1+ level) ?*))
(timestamp (format-time-string "[%Y-%m-%d %a %H:%M]"))
(body (nanoclaw--to-org text)))
(org-end-of-subtree t t)
(insert "\n" child-stars " " nanoclaw-agent-name " " timestamp "\n"
body "\n")))
(defun nanoclaw--now-ms ()
"Return current time as milliseconds since epoch."
(let ((time (current-time)))
(+ (* (+ (* (car time) 65536) (cadr time)) 1000)
(/ (caddr time) 1000))))
(defun nanoclaw--poll-until-response (since callback timeout-fn &optional attempts)
"Poll until a message newer than SINCE arrives, then call CALLBACK.
Calls TIMEOUT-FN after 60 attempts (~90s)."
(let ((n (or attempts 0)))
(if (>= n 60)
(funcall timeout-fn)
(nanoclaw--poll
since
(lambda (msgs)
(let ((fresh (seq-filter (lambda (m) (> (cdr (assq 'timestamp m)) since))
msgs)))
(if fresh
(let ((text (mapconcat (lambda (m) (cdr (assq 'text m)))
fresh "\n")))
(funcall callback text))
(run-with-timer nanoclaw-poll-interval nil
#'nanoclaw--poll-until-response
since callback timeout-fn (1+ n)))))))))
;; ---------------------------------------------------------------------------
(provide 'nanoclaw)
;;; nanoclaw.el ends here
+531
View File
@@ -0,0 +1,531 @@
import { execFileSync, execSync } from 'child_process';
import http from 'http';
import type { AddressInfo } from 'net';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// --- Mocks (hoisted — must appear before any imports of the modules they replace) ---
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) }));
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
GROUPS_DIR: '/tmp/test-groups',
}));
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../db.js', () => ({ setRegisteredGroup: vi.fn() }));
// Stub out all filesystem calls so tests never touch disk.
vi.mock('fs', () => ({
default: {
// Simulate missing symlink by default — triggers creation path
lstatSync: vi.fn(() => {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
}),
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
symlinkSync: vi.fn(),
},
}));
import { setRegisteredGroup } from '../db.js';
import type { ChannelOpts } from './registry.js';
import { EmacsBridgeChannel } from './emacs.js';
// ---------------------------------------------------------------------------
// Helpers
function createTestOpts(overrides?: Partial<ChannelOpts>): ChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: vi.fn(() => ({
'main:jid': {
name: 'main',
folder: 'main',
trigger: '',
added_at: '2024-01-01T00:00:00.000Z',
isMain: true,
},
})),
...overrides,
};
}
/** Make an HTTP request to the test server; returns status code and parsed body. */
async function req(
port: number,
method: string,
path: string,
body?: string,
extraHeaders: Record<string, string> = {},
): Promise<{ status: number; data: any }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...extraHeaders,
};
const request = http.request(
{ host: '127.0.0.1', port, method, path, headers },
(res) => {
let raw = '';
res.on('data', (chunk: Buffer) => (raw += chunk));
res.on('end', () => {
try {
resolve({ status: res.statusCode!, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode!, data: raw });
}
});
},
);
request.on('error', reject);
if (body) request.write(body);
request.end();
});
}
/** Read the actual bound port after connect() (server listens on port 0). */
function boundPort(channel: EmacsBridgeChannel): number {
return (((channel as any).server as http.Server).address() as AddressInfo)
.port;
}
// ---------------------------------------------------------------------------
describe('EmacsBridgeChannel', () => {
let opts: ChannelOpts;
let channel: EmacsBridgeChannel;
beforeEach(() => {
vi.clearAllMocks();
opts = createTestOpts();
// Port 0 tells the OS to pick a free ephemeral port — no conflicts between test runs
channel = new EmacsBridgeChannel(0, null, opts);
});
afterEach(async () => {
if (channel.isConnected()) await channel.disconnect();
});
// -------------------------------------------------------------------------
describe('connect / disconnect / isConnected', () => {
it('isConnected returns false before connect', () => {
expect(channel.isConnected()).toBe(false);
});
it('isConnected returns true after connect', async () => {
await channel.connect();
expect(channel.isConnected()).toBe(true);
});
it('isConnected returns false after disconnect', async () => {
await channel.connect();
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
it('disconnect is a no-op when not connected', async () => {
await expect(channel.disconnect()).resolves.not.toThrow();
});
});
// -------------------------------------------------------------------------
describe('ownsJid', () => {
it('returns true for emacs:default', () => {
expect(channel.ownsJid('emacs:default')).toBe(true);
});
it('returns false for non-emacs JIDs', () => {
expect(channel.ownsJid('tg:123456')).toBe(false);
expect(channel.ownsJid('main:jid')).toBe(false);
expect(channel.ownsJid('')).toBe(false);
expect(channel.ownsJid('emacs:other')).toBe(false);
expect(channel.ownsJid('123456@g.us')).toBe(false);
});
});
// -------------------------------------------------------------------------
describe('group auto-registration', () => {
it('calls setRegisteredGroup when emacs:default is absent', async () => {
await channel.connect();
expect(setRegisteredGroup).toHaveBeenCalledWith(
'emacs:default',
expect.objectContaining({
name: 'emacs',
folder: 'emacs',
requiresTrigger: false,
}),
);
});
it('mutates the live registeredGroups map immediately (no restart needed)', async () => {
const groups: Record<string, any> = {};
const localOpts = createTestOpts({
registeredGroups: vi.fn(() => groups),
});
const c = new EmacsBridgeChannel(0, null, localOpts);
await c.connect();
expect(groups['emacs:default']).toBeDefined();
await c.disconnect();
});
it('skips registration when emacs:default is already present', async () => {
const localOpts = createTestOpts({
registeredGroups: vi.fn(() => ({
'emacs:default': {
name: 'emacs',
folder: 'emacs',
trigger: '',
added_at: '2024-01-01T00:00:00.000Z',
},
})),
});
const c = new EmacsBridgeChannel(0, null, localOpts);
await c.connect();
expect(setRegisteredGroup).not.toHaveBeenCalled();
await c.disconnect();
});
});
// -------------------------------------------------------------------------
describe('POST /api/message', () => {
let port: number;
beforeEach(async () => {
await channel.connect();
port = boundPort(channel);
});
it('returns 200 with messageId and timestamp for valid text', async () => {
const { status, data } = await req(
port,
'POST',
'/api/message',
JSON.stringify({ text: 'hello' }),
);
expect(status).toBe(200);
expect(data).toHaveProperty('messageId');
expect(data).toHaveProperty('timestamp');
expect(typeof data.timestamp).toBe('number');
});
it('calls opts.onMessage with correct structure', async () => {
await req(port, 'POST', '/api/message', JSON.stringify({ text: 'ping' }));
expect(opts.onMessage).toHaveBeenCalledWith(
'emacs:default',
expect.objectContaining({
chat_jid: 'emacs:default',
content: 'ping',
sender: 'emacs',
sender_name: 'Emacs',
is_from_me: false,
}),
);
});
it('calls opts.onChatMetadata before opts.onMessage', async () => {
const order: string[] = [];
(opts.onChatMetadata as ReturnType<typeof vi.fn>).mockImplementation(() =>
order.push('meta'),
);
(opts.onMessage as ReturnType<typeof vi.fn>).mockImplementation(() =>
order.push('msg'),
);
await req(port, 'POST', '/api/message', JSON.stringify({ text: 'hi' }));
expect(order).toEqual(['meta', 'msg']);
});
it('returns 400 for empty text', async () => {
const { status } = await req(
port,
'POST',
'/api/message',
JSON.stringify({ text: '' }),
);
expect(status).toBe(400);
});
it('returns 400 for whitespace-only text', async () => {
const { status } = await req(
port,
'POST',
'/api/message',
JSON.stringify({ text: ' ' }),
);
expect(status).toBe(400);
});
it('returns 400 for invalid JSON', async () => {
const { status } = await req(port, 'POST', '/api/message', 'not-json');
expect(status).toBe(400);
});
it('returns 404 for unknown paths', async () => {
const { status } = await req(
port,
'POST',
'/api/unknown',
JSON.stringify({ text: 'hi' }),
);
expect(status).toBe(404);
});
});
// -------------------------------------------------------------------------
describe('GET /api/messages', () => {
let port: number;
beforeEach(async () => {
await channel.connect();
port = boundPort(channel);
});
it('returns empty messages array when nothing has been sent', async () => {
const { status, data } = await req(port, 'GET', '/api/messages?since=0');
expect(status).toBe(200);
expect(data).toEqual({ messages: [] });
});
it('returns messages added via sendMessage', async () => {
await channel.sendMessage('emacs:default', 'hello back');
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect(data.messages).toHaveLength(1);
expect(data.messages[0].text).toBe('hello back');
});
it('filters out messages at or before the since timestamp', async () => {
await channel.sendMessage('emacs:default', 'old');
// Capture `since` after the first push, then wait to guarantee the
// second push lands at a strictly later timestamp
const since = Date.now();
await new Promise((r) => setTimeout(r, 2));
await channel.sendMessage('emacs:default', 'new');
const { data } = await req(port, 'GET', `/api/messages?since=${since}`);
expect(data.messages.map((m: any) => m.text)).not.toContain('old');
expect(data.messages.map((m: any) => m.text)).toContain('new');
});
it('caps buffer at 200 messages, dropping the oldest', async () => {
for (let i = 0; i < 201; i++) {
await channel.sendMessage('emacs:default', `msg-${i}`);
}
const { data } = await req(port, 'GET', '/api/messages?since=0');
expect(data.messages).toHaveLength(200);
// msg-0 was the first in and should have been evicted
expect(data.messages.map((m: any) => m.text)).not.toContain('msg-0');
expect(data.messages.map((m: any) => m.text)).toContain('msg-1');
expect(data.messages.map((m: any) => m.text)).toContain('msg-200');
});
});
// -------------------------------------------------------------------------
describe('sendMessage', () => {
beforeEach(async () => {
await channel.connect();
});
it('pushes exact text to the buffer', async () => {
await channel.sendMessage('emacs:default', 'response text');
const { data } = await req(
boundPort(channel),
'GET',
'/api/messages?since=0',
);
expect(data.messages[0].text).toBe('response text');
});
it('attaches a numeric epoch-ms timestamp', async () => {
const before = Date.now();
await channel.sendMessage('emacs:default', 'ts-check');
const after = Date.now();
const { data } = await req(
boundPort(channel),
'GET',
'/api/messages?since=0',
);
expect(data.messages[0].timestamp).toBeGreaterThanOrEqual(before);
expect(data.messages[0].timestamp).toBeLessThanOrEqual(after);
});
});
// -------------------------------------------------------------------------
describe('authentication', () => {
let authChannel: EmacsBridgeChannel;
let port: number;
beforeEach(async () => {
authChannel = new EmacsBridgeChannel(0, 'secret', opts);
await authChannel.connect();
port = boundPort(authChannel);
});
afterEach(async () => {
if (authChannel.isConnected()) await authChannel.disconnect();
});
it('rejects POST without Authorization header (401)', async () => {
const { status } = await req(
port,
'POST',
'/api/message',
JSON.stringify({ text: 'hi' }),
);
expect(status).toBe(401);
});
it('rejects POST with wrong token (401)', async () => {
const { status } = await req(
port,
'POST',
'/api/message',
JSON.stringify({ text: 'hi' }),
{ Authorization: 'Bearer wrong' },
);
expect(status).toBe(401);
});
it('accepts POST with correct Bearer token (200)', async () => {
const { status } = await req(
port,
'POST',
'/api/message',
JSON.stringify({ text: 'hi' }),
{ Authorization: 'Bearer secret' },
);
expect(status).toBe(200);
});
it('rejects GET without Authorization header (401)', async () => {
const { status } = await req(port, 'GET', '/api/messages?since=0');
expect(status).toBe(401);
});
it('accepts GET with correct Bearer token (200)', async () => {
const { status } = await req(
port,
'GET',
'/api/messages?since=0',
undefined,
{ Authorization: 'Bearer secret' },
);
expect(status).toBe(200);
});
it('channel without authToken ignores Authorization header entirely', async () => {
const noAuthChannel = new EmacsBridgeChannel(0, null, opts);
await noAuthChannel.connect();
const noAuthPort = boundPort(noAuthChannel);
try {
const { status } = await req(
noAuthPort,
'GET',
'/api/messages?since=0',
);
expect(status).toBe(200);
} finally {
await noAuthChannel.disconnect();
}
});
});
});
// ---------------------------------------------------------------------------
// nanoclaw--md-to-org-regex (Emacs Lisp, tested via emacs --batch)
function emacsAvailable(): boolean {
try {
execSync('emacs --version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function mdToOrg(input: string): string {
const elFile = path.resolve('emacs/nanoclaw.el');
// Escape input as an Emacs string literal — no shell involved so no shell quoting needed
const escaped = input
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n');
// execFileSync passes args as an array (no shell), bypassing both shell quoting
// and the vi.mock('fs') stub that would block writeFileSync
return execFileSync(
'emacs',
[
'--batch',
'--load',
elFile,
'--eval',
`(princ (nanoclaw--md-to-org-regex "${escaped}"))`,
],
{ encoding: 'utf8' },
);
}
describe.skipIf(!emacsAvailable())('nanoclaw--md-to-org-regex', () => {
it('converts bold **text** → *text*', () => {
expect(mdToOrg('**hello**')).toBe('*hello*');
});
it('converts italic *text* → /text/', () => {
expect(mdToOrg('*hello*')).toBe('/hello/');
});
it('handles bold before italic in the same string', () => {
expect(mdToOrg('**bold** and *italic*')).toBe('*bold* and /italic/');
});
it('converts strikethrough ~~text~~ → +text+', () => {
expect(mdToOrg('~~gone~~')).toBe('+gone+');
});
it('converts underline __text__ → _text_', () => {
expect(mdToOrg('__under__')).toBe('_under_');
});
it('converts inline code `code` → ~code~', () => {
expect(mdToOrg('`foo()`')).toBe('~foo()~');
});
it('converts fenced code block with language', () => {
expect(mdToOrg('```typescript\nconst x = 1;\n```')).toBe(
'#+begin_src typescript\nconst x = 1;\n#+end_src',
);
});
it('converts fenced code block without language', () => {
expect(mdToOrg('```\nhello\n```')).toBe(
'#+begin_src text\nhello\n#+end_src',
);
});
it('converts ## heading → ** heading', () => {
expect(mdToOrg('## Section')).toBe('** Section');
});
it('converts ### heading → *** heading', () => {
expect(mdToOrg('### Deep')).toBe('*** Deep');
});
it('leaves list items unchanged', () => {
expect(mdToOrg('- item one')).toBe('- item one');
});
it('converts links [text](url) → [[url][text]]', () => {
expect(mdToOrg('[NanoClaw](https://example.com)')).toBe(
'[[https://example.com][NanoClaw]]',
);
});
});
+249
View File
@@ -0,0 +1,249 @@
import fs from 'fs';
import http from 'http';
import path from 'path';
import { GROUPS_DIR } from '../config.js';
import { setRegisteredGroup } from '../db.js';
import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { Channel, RegisteredGroup } from '../types.js';
import { ChannelOpts, registerChannel } from './registry.js';
const EMACS_JID = 'emacs:default';
interface BufferedMessage {
text: string;
timestamp: number;
}
export class EmacsBridgeChannel implements Channel {
name = 'emacs';
private server: http.Server | null = null;
private port: number;
private authToken: string | null;
private opts: ChannelOpts;
private buffer: BufferedMessage[] = [];
constructor(port: number, authToken: string | null, opts: ChannelOpts) {
this.port = port;
this.authToken = authToken;
this.opts = opts;
}
async connect(): Promise<void> {
this.ensureGroupRegistered();
this.ensureSymlink();
this.ensureClaudeMd();
this.server = http.createServer((req, res) => {
if (!this.checkAuth(req, res)) return;
const url = new URL(req.url ?? '/', `http://localhost:${this.port}`);
if (req.method === 'POST' && url.pathname === '/api/message') {
this.handlePost(req, res);
} else if (req.method === 'GET' && url.pathname === '/api/messages') {
this.handlePoll(url, res);
} else {
res.writeHead(404).end(JSON.stringify({ error: 'Not found' }));
}
});
await new Promise<void>((resolve, reject) => {
this.server!.listen(this.port, '127.0.0.1', () => {
logger.info(
{ port: this.port },
'Emacs channel listening — load emacs/nanoclaw.el to connect',
);
resolve();
});
this.server!.once('error', reject);
});
}
async disconnect(): Promise<void> {
if (this.server) {
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
this.server = null;
logger.info('Emacs channel stopped');
}
}
async sendMessage(_jid: string, text: string): Promise<void> {
this.buffer.push({ text, timestamp: Date.now() });
// Keep buffer bounded — 200 messages max
if (this.buffer.length > 200) this.buffer.shift();
}
isConnected(): boolean {
return this.server?.listening ?? false;
}
ownsJid(jid: string): boolean {
return jid === EMACS_JID;
}
// --- Private helpers ---
private checkAuth(
req: http.IncomingMessage,
res: http.ServerResponse,
): boolean {
if (!this.authToken) return true;
const header = req.headers['authorization'] ?? '';
if (header === `Bearer ${this.authToken}`) return true;
res.writeHead(401).end(JSON.stringify({ error: 'Unauthorized' }));
return false;
}
private handlePost(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
try {
const { text } = JSON.parse(body) as { text?: string };
if (!text?.trim()) {
res.writeHead(400).end(JSON.stringify({ error: 'text required' }));
return;
}
const timestamp = new Date().toISOString();
const msgId = `emacs-${Date.now()}`;
this.opts.onChatMetadata(EMACS_JID, timestamp, 'Emacs', 'emacs', false);
this.opts.onMessage(EMACS_JID, {
id: msgId,
chat_jid: EMACS_JID,
sender: 'emacs',
sender_name: 'Emacs',
content: text,
timestamp,
is_from_me: false,
});
res
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ messageId: msgId, timestamp: Date.now() }));
logger.info({ length: text.length }, 'Emacs message received');
} catch (err) {
logger.error({ err }, 'Emacs channel: failed to parse POST body');
res.writeHead(400).end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
}
private handlePoll(url: URL, res: http.ServerResponse): void {
const since = parseInt(url.searchParams.get('since') ?? '0', 10);
const messages = this.buffer.filter((m) => m.timestamp > since);
res
.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
.end(JSON.stringify({ messages }));
}
private ensureClaudeMd(): void {
const claudeMd = path.join(GROUPS_DIR, 'emacs', 'CLAUDE.md');
// groups/emacs symlinks to the main group folder on typical installs, so
// this is a no-op when that CLAUDE.md already exists. On a fresh setup it
// bootstraps the file so the agent knows to output markdown, not org-mode.
if (fs.existsSync(claudeMd)) return;
const content = [
'## Message Formatting',
'',
'This is an Emacs channel. Responses are automatically converted from markdown',
'to org-mode by the bridge before display.',
'',
'**Always format responses in standard markdown:**',
'- `**bold**` not `*bold*`',
'- `*italic*` not `/italic/`',
'- `~~strikethrough~~` not `+strikethrough+`',
'- `` `code` `` not `~code~`',
'- ` ```lang ` fenced code blocks',
'- `- ` for bullet points',
'',
'Do NOT output org-mode syntax directly. The bridge handles conversion.',
'',
].join('\n');
try {
fs.writeFileSync(claudeMd, content, 'utf8');
logger.info('Emacs channel: wrote CLAUDE.md');
} catch (err) {
logger.warn({ err }, 'Emacs channel: could not write CLAUDE.md');
}
}
private ensureGroupRegistered(): void {
const groups = this.opts.registeredGroups();
if (groups[EMACS_JID]) return;
const newGroup: RegisteredGroup = {
name: 'emacs',
folder: 'emacs',
trigger: '',
added_at: new Date().toISOString(),
requiresTrigger: false,
};
try {
setRegisteredGroup(EMACS_JID, newGroup);
// Mutate the live cache so the message loop sees it immediately
groups[EMACS_JID] = newGroup;
logger.info('Emacs group auto-registered');
} catch (err) {
logger.error({ err }, 'Emacs channel: failed to auto-register group');
}
}
private ensureSymlink(): void {
const emacsDir = path.join(GROUPS_DIR, 'emacs');
// Find the main group's folder name
const groups = this.opts.registeredGroups();
const mainGroup = Object.values(groups).find((g) => g.isMain);
const targetFolder = mainGroup?.folder ?? 'main';
const targetDir = path.join(GROUPS_DIR, targetFolder);
try {
const stat = fs.lstatSync(emacsDir);
if (stat.isSymbolicLink()) return; // already set up
// Exists as a real directory — leave it alone
logger.debug(
{ emacsDir },
'Emacs groups dir already exists as a directory',
);
return;
} catch {
// Does not exist — create it
}
// Ensure the target exists before symlinking
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
try {
fs.symlinkSync(targetDir, emacsDir);
logger.info({ target: targetDir }, 'Created groups/emacs symlink');
} catch (err) {
logger.error(
{ err },
'Emacs channel: failed to create groups/emacs symlink',
);
}
}
}
registerChannel('emacs', (opts: ChannelOpts) => {
const envVars = readEnvFile(['EMACS_CHANNEL_PORT', 'EMACS_AUTH_TOKEN']);
const portStr =
process.env.EMACS_CHANNEL_PORT || envVars.EMACS_CHANNEL_PORT || '8766';
const port = parseInt(portStr, 10);
const authToken =
process.env.EMACS_AUTH_TOKEN || envVars.EMACS_AUTH_TOKEN || null;
return new EmacsBridgeChannel(port, authToken, opts);
});
+3
View File
@@ -10,3 +10,6 @@
// telegram
// whatsapp
// emacs
import './emacs.js';
-4
View File
@@ -51,10 +51,6 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
10,
); // 10MB default
export const CREDENTIAL_PROXY_PORT = parseInt(
process.env.CREDENTIAL_PROXY_PORT || '3001',
10,
);
export const ONECLI_URL =
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
export const MAX_MESSAGES_PER_PROMPT = Math.max(
+11 -6
View File
@@ -11,10 +11,10 @@ vi.mock('./config.js', () => ({
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
CONTAINER_TIMEOUT: 1800000, // 30min
CREDENTIAL_PROXY_PORT: 3001,
DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min
ONECLI_URL: 'http://localhost:10254',
TIMEZONE: 'America/Los_Angeles',
}));
@@ -53,16 +53,21 @@ vi.mock('./mount-security.js', () => ({
// Mock container-runtime
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'container',
CONTAINER_HOST_GATEWAY: 'host.docker.internal',
CONTAINER_RUNTIME_BIN: 'docker',
hostGatewayArgs: () => [],
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
stopContainer: vi.fn(),
}));
// Mock credential-proxy
vi.mock('./credential-proxy.js', () => ({
detectAuthMode: vi.fn(() => 'api-key'),
// Mock OneCLI SDK
vi.mock('@onecli-sh/sdk', () => ({
OneCLI: class {
applyContainerConfig = vi.fn().mockResolvedValue(true);
createAgent = vi.fn().mockResolvedValue({ id: 'test' });
ensureAgent = vi
.fn()
.mockResolvedValue({ name: 'test', identifier: 'test', created: true });
},
}));
// Create a controllable fake ChildProcess
+39 -31
View File
@@ -10,25 +10,26 @@ import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
CREDENTIAL_PROXY_PORT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import {
CONTAINER_HOST_GATEWAY,
CONTAINER_RUNTIME_BIN,
hostGatewayArgs,
readonlyMountArgs,
stopContainer,
} from './container-runtime.js';
import { detectAuthMode } from './credential-proxy.js';
import { OneCLI } from '@onecli-sh/sdk';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL });
// Sentinel markers for robust output parsing (must match agent-runner)
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
@@ -77,8 +78,16 @@ function buildVolumeMounts(
readonly: true,
});
// .env shadowing is handled inside the container entrypoint via mount --bind
// (Apple Container only supports directory mounts, not file mounts like /dev/null)
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the OneCLI gateway, never exposed to containers.
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// Main also gets its group folder as the working directory
mounts.push({
@@ -214,31 +223,29 @@ function buildVolumeMounts(
return mounts;
}
function buildContainerArgs(
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
isMain: boolean,
): string[] {
agentIdentifier?: string,
): Promise<string[]> {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
// Pass host timezone so container's local time matches the user's
args.push('-e', `TZ=${TIMEZONE}`);
// Route API traffic through the credential proxy (containers never see real secrets)
args.push(
'-e',
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
);
// Mirror the host's auth method with a placeholder value.
// API key mode: SDK sends x-api-key, proxy replaces with real key.
// OAuth mode: SDK exchanges placeholder token for temp API key,
// proxy injects real OAuth token on that exchange request.
const authMode = detectAuthMode();
if (authMode === 'api-key') {
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
// OneCLI gateway handles credential injection — containers never see real secrets.
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
const onecliApplied = await onecli.applyContainerConfig(args, {
addHostMapping: false, // Nanoclaw already handles host gateway
agent: agentIdentifier,
});
if (onecliApplied) {
logger.info({ containerName }, 'OneCLI gateway config applied');
} else {
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
logger.warn(
{ containerName },
'OneCLI gateway not reachable — container will have no credentials',
);
}
// Runtime-specific args for host gateway resolution
@@ -250,14 +257,7 @@ function buildContainerArgs(
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
if (isMain) {
// Main containers start as root so the entrypoint can mount --bind
// to shadow .env. Privileges are dropped via setpriv in entrypoint.sh.
args.push('-e', `RUN_UID=${hostUid}`);
args.push('-e', `RUN_GID=${hostGid}`);
} else {
args.push('--user', `${hostUid}:${hostGid}`);
}
args.push('--user', `${hostUid}:${hostGid}`);
args.push('-e', 'HOME=/home/node');
}
@@ -288,7 +288,15 @@ export async function runContainerAgent(
const mounts = buildVolumeMounts(group, input.isMain);
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
const containerArgs = buildContainerArgs(mounts, containerName, input.isMain);
// Main group uses the default OneCLI agent; others use their own agent.
const agentIdentifier = input.isMain
? undefined
: group.folder.toLowerCase().replace(/_/g, '-');
const containerArgs = await buildContainerArgs(
mounts,
containerName,
agentIdentifier,
);
logger.debug(
{
+24 -58
View File
@@ -32,12 +32,9 @@ beforeEach(() => {
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns --mount flag with type=bind and readonly', () => {
it('returns -v flag with :ro suffix', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual([
'--mount',
'type=bind,source=/host/path,target=/container/path,readonly',
]);
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
});
});
@@ -45,18 +42,14 @@ describe('stopContainer', () => {
it('calls docker stop for valid container names', () => {
stopContainer('nanoclaw-test-123');
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-test-123`,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`,
{ stdio: 'pipe' },
);
});
it('rejects names with shell metacharacters', () => {
expect(() => stopContainer('foo; rm -rf /')).toThrow(
'Invalid container name',
);
expect(() => stopContainer('foo$(whoami)')).toThrow(
'Invalid container name',
);
expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name');
expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name');
expect(() => stopContainer('foo`id`')).toThrow('Invalid container name');
expect(mockExecSync).not.toHaveBeenCalled();
});
@@ -71,37 +64,18 @@ describe('ensureContainerRuntimeRunning', () => {
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
);
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
expect(logger.debug).toHaveBeenCalledWith(
'Container runtime already running',
);
});
it('auto-starts when system status fails', () => {
// First call (system status) fails
it('throws when docker info fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('not running');
});
// Second call (system start) succeeds
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(2);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} system start`,
{ stdio: 'pipe', timeout: 30000 },
);
expect(logger.info).toHaveBeenCalledWith('Container runtime started');
});
it('throws when both status and start fail', () => {
mockExecSync.mockImplementation(() => {
throw new Error('failed');
throw new Error('Cannot connect to the Docker daemon');
});
expect(() => ensureContainerRuntimeRunning()).toThrow(
@@ -114,40 +88,36 @@ describe('ensureContainerRuntimeRunning', () => {
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers from JSON output', () => {
// Apple Container ls returns JSON
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-group1-111' } },
{ status: 'stopped', configuration: { id: 'nanoclaw-group2-222' } },
{ status: 'running', configuration: { id: 'nanoclaw-group3-333' } },
{ status: 'running', configuration: { id: 'other-container' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
it('stops orphaned nanoclaw containers', () => {
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce(
'nanoclaw-group1-111\nnanoclaw-group2-222\n',
);
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ls + 2 stop calls (only running nanoclaw- containers)
// ps + 2 stop calls
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`,
{ stdio: 'pipe' },
);
expect(mockExecSync).toHaveBeenNthCalledWith(
3,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`,
{ stdio: 'pipe' },
);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('[]');
mockExecSync.mockReturnValueOnce('');
cleanupOrphans();
@@ -155,9 +125,9 @@ describe('cleanupOrphans', () => {
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ls fails', () => {
it('warns and continues when ps fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('container not available');
throw new Error('docker not available');
});
cleanupOrphans(); // should not throw
@@ -169,11 +139,7 @@ describe('cleanupOrphans', () => {
});
it('continues stopping remaining containers when one stop fails', () => {
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
+41 -89
View File
@@ -3,46 +3,12 @@
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'container';
/**
* IP address containers use to reach the host machine.
* Apple Container VMs use a bridge network (192.168.64.x); the host is at the gateway.
* Detected from the bridge0 interface, falling back to 192.168.64.1.
*/
export const CONTAINER_HOST_GATEWAY = detectHostGateway();
function detectHostGateway(): string {
// Apple Container on macOS: containers reach the host via the bridge network gateway
const ifaces = os.networkInterfaces();
const bridge = ifaces['bridge100'] || ifaces['bridge0'];
if (bridge) {
const ipv4 = bridge.find((a) => a.family === 'IPv4');
if (ipv4) return ipv4.address;
}
// Fallback: Apple Container's default gateway
return '192.168.64.1';
}
/**
* Address the credential proxy binds to.
* Must be set via CREDENTIAL_PROXY_HOST in .env — there is no safe default
* for Apple Container because bridge100 only exists while containers run,
* but the proxy must start before any container.
* The /convert-to-apple-container skill sets this during setup.
*/
export const PROXY_BIND_HOST = process.env.CREDENTIAL_PROXY_HOST;
if (!PROXY_BIND_HOST) {
throw new Error(
'CREDENTIAL_PROXY_HOST is not set in .env. Run /convert-to-apple-container to configure.',
);
}
export const CONTAINER_RUNTIME_BIN = 'docker';
/** CLI args needed for the container to resolve the host gateway. */
export function hostGatewayArgs(): string[] {
@@ -58,10 +24,7 @@ export function readonlyMountArgs(
hostPath: string,
containerPath: string,
): string[] {
return [
'--mount',
`type=bind,source=${hostPath},target=${containerPath},readonly`,
];
return ['-v', `${hostPath}:${containerPath}:ro`];
}
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
@@ -69,68 +32,57 @@ export function stopContainer(name: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid container name: ${name}`);
}
execSync(`${CONTAINER_RUNTIME_BIN} stop ${name}`, { stdio: 'pipe' });
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
logger.debug('Container runtime already running');
} catch {
logger.info('Starting container runtime...');
try {
execSync(`${CONTAINER_RUNTIME_BIN} system start`, {
stdio: 'pipe',
timeout: 30000,
});
logger.info('Container runtime started');
} catch (err) {
logger.error({ err }, 'Failed to start container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Apple Container is installed ║',
);
console.error(
'║ 2. Run: container system start ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
}
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
console.error(
'\n╔════════════════════════════════════════════════════════════════╗',
);
console.error(
'║ FATAL: Container runtime failed to start ║',
);
console.error(
'║ ║',
);
console.error(
'║ Agents cannot run without a container runtime. To fix: ║',
);
console.error(
'║ 1. Ensure Docker is installed and running ║',
);
console.error(
'║ 2. Run: docker info ║',
);
console.error(
'║ 3. Restart NanoClaw ║',
);
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start', {
cause: err,
});
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ls --format json`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] =
JSON.parse(output || '[]');
const orphans = containers
.filter(
(c) =>
c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'),
)
.map((c) => c.configuration.id);
const output = execSync(
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
stopContainer(name);
-192
View File
@@ -1,192 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import http from 'http';
import type { AddressInfo } from 'net';
const mockEnv: Record<string, string> = {};
vi.mock('./env.js', () => ({
readEnvFile: vi.fn(() => ({ ...mockEnv })),
}));
vi.mock('./logger.js', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
import { startCredentialProxy } from './credential-proxy.js';
function makeRequest(
port: number,
options: http.RequestOptions,
body = '',
): Promise<{
statusCode: number;
body: string;
headers: http.IncomingHttpHeaders;
}> {
return new Promise((resolve, reject) => {
const req = http.request(
{ ...options, hostname: '127.0.0.1', port },
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
resolve({
statusCode: res.statusCode!,
body: Buffer.concat(chunks).toString(),
headers: res.headers,
});
});
},
);
req.on('error', reject);
req.write(body);
req.end();
});
}
describe('credential-proxy', () => {
let proxyServer: http.Server;
let upstreamServer: http.Server;
let proxyPort: number;
let upstreamPort: number;
let lastUpstreamHeaders: http.IncomingHttpHeaders;
beforeEach(async () => {
lastUpstreamHeaders = {};
upstreamServer = http.createServer((req, res) => {
lastUpstreamHeaders = { ...req.headers };
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve) =>
upstreamServer.listen(0, '127.0.0.1', resolve),
);
upstreamPort = (upstreamServer.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise<void>((r) => proxyServer?.close(() => r()));
await new Promise<void>((r) => upstreamServer?.close(() => r()));
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
});
async function startProxy(env: Record<string, string>): Promise<number> {
Object.assign(mockEnv, env, {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
});
proxyServer = await startCredentialProxy(0);
return (proxyServer.address() as AddressInfo).port;
}
it('API-key mode injects x-api-key and strips placeholder', async () => {
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
await makeRequest(
proxyPort,
{
method: 'POST',
path: '/v1/messages',
headers: {
'content-type': 'application/json',
'x-api-key': 'placeholder',
},
},
'{}',
);
expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
});
it('OAuth mode replaces Authorization when container sends one', async () => {
proxyPort = await startProxy({
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
});
await makeRequest(
proxyPort,
{
method: 'POST',
path: '/api/oauth/claude_cli/create_api_key',
headers: {
'content-type': 'application/json',
authorization: 'Bearer placeholder',
},
},
'{}',
);
expect(lastUpstreamHeaders['authorization']).toBe(
'Bearer real-oauth-token',
);
});
it('OAuth mode does not inject Authorization when container omits it', async () => {
proxyPort = await startProxy({
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
});
// Post-exchange: container uses x-api-key only, no Authorization header
await makeRequest(
proxyPort,
{
method: 'POST',
path: '/v1/messages',
headers: {
'content-type': 'application/json',
'x-api-key': 'temp-key-from-exchange',
},
},
'{}',
);
expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
expect(lastUpstreamHeaders['authorization']).toBeUndefined();
});
it('strips hop-by-hop headers', async () => {
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });
await makeRequest(
proxyPort,
{
method: 'POST',
path: '/v1/messages',
headers: {
'content-type': 'application/json',
connection: 'keep-alive',
'keep-alive': 'timeout=5',
'transfer-encoding': 'chunked',
},
},
'{}',
);
// Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
// its own Connection header (standard HTTP/1.1 behavior), but the client's
// custom keep-alive and transfer-encoding must not be forwarded.
expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
});
it('returns 502 when upstream is unreachable', async () => {
Object.assign(mockEnv, {
ANTHROPIC_API_KEY: 'sk-ant-real-key',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
});
proxyServer = await startCredentialProxy(0);
proxyPort = (proxyServer.address() as AddressInfo).port;
const res = await makeRequest(
proxyPort,
{
method: 'POST',
path: '/v1/messages',
headers: { 'content-type': 'application/json' },
},
'{}',
);
expect(res.statusCode).toBe(502);
expect(res.body).toBe('Bad Gateway');
});
});
-125
View File
@@ -1,125 +0,0 @@
/**
* Credential proxy for container isolation.
* Containers connect here instead of directly to the Anthropic API.
* The proxy injects real credentials so containers never see them.
*
* Two auth modes:
* API key: Proxy injects x-api-key on every request.
* OAuth: Container CLI exchanges its placeholder token for a temp
* API key via /api/oauth/claude_cli/create_api_key.
* Proxy injects real OAuth token on that exchange request;
* subsequent requests carry the temp key which is valid as-is.
*/
import { createServer, Server } from 'http';
import { request as httpsRequest } from 'https';
import { request as httpRequest, RequestOptions } from 'http';
import { readEnvFile } from './env.js';
import { logger } from './logger.js';
export type AuthMode = 'api-key' | 'oauth';
export interface ProxyConfig {
authMode: AuthMode;
}
export function startCredentialProxy(
port: number,
host = '127.0.0.1',
): Promise<Server> {
const secrets = readEnvFile([
'ANTHROPIC_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
]);
const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
const oauthToken =
secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN;
const upstreamUrl = new URL(
secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
);
const isHttps = upstreamUrl.protocol === 'https:';
const makeRequest = isHttps ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
const headers: Record<string, string | number | string[] | undefined> =
{
...(req.headers as Record<string, string>),
host: upstreamUrl.host,
'content-length': body.length,
};
// Strip hop-by-hop headers that must not be forwarded by proxies
delete headers['connection'];
delete headers['keep-alive'];
delete headers['transfer-encoding'];
if (authMode === 'api-key') {
// API key mode: inject x-api-key on every request
delete headers['x-api-key'];
headers['x-api-key'] = secrets.ANTHROPIC_API_KEY;
} else {
// OAuth mode: replace placeholder Bearer token with the real one
// only when the container actually sends an Authorization header
// (exchange request + auth probes). Post-exchange requests use
// x-api-key only, so they pass through without token injection.
if (headers['authorization']) {
delete headers['authorization'];
if (oauthToken) {
headers['authorization'] = `Bearer ${oauthToken}`;
}
}
}
const upstream = makeRequest(
{
hostname: upstreamUrl.hostname,
port: upstreamUrl.port || (isHttps ? 443 : 80),
path: req.url,
method: req.method,
headers,
} as RequestOptions,
(upRes) => {
res.writeHead(upRes.statusCode!, upRes.headers);
upRes.pipe(res);
},
);
upstream.on('error', (err) => {
logger.error(
{ err, url: req.url },
'Credential proxy upstream error',
);
if (!res.headersSent) {
res.writeHead(502);
res.end('Bad Gateway');
}
});
upstream.write(body);
upstream.end();
});
});
server.listen(port, host, () => {
logger.info({ port, host, authMode }, 'Credential proxy started');
resolve(server);
});
server.on('error', reject);
});
}
/** Detect which auth mode the host is configured for. */
export function detectAuthMode(): AuthMode {
const secrets = readEnvFile(['ANTHROPIC_API_KEY']);
return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
}