Compare commits

..

22 Commits

Author SHA1 Message Date
gavrielc 2c3300bde9 Merge pull request #1609 from qwibitai/fix/apple-container-proxy-bind
fix: require CREDENTIAL_PROXY_HOST for Apple Container networking
2026-04-03 12:31:50 +03:00
Gavriel Cohen c0b58bd7ae fix: require CREDENTIAL_PROXY_HOST for Apple Container networking
bridge100 only exists while containers run, but the credential proxy must
start before any container. Binding to the bridge IP fails with EADDRNOTAVAIL
on cold boot and first-time setup.

Fail immediately with a clear error pointing to /convert-to-apple-container,
which guides users through setting CREDENTIAL_PROXY_HOST and optionally
configuring a macOS firewall rule on untrusted networks.

Co-Authored-By: MrBlaise <3867275+MrBlaise@users.noreply.github.com>
Co-Authored-By: lbsnrs <47463+lbsnrs@users.noreply.github.com>
Co-Authored-By: spencer-whitman <28708638+spencer-whitman@users.noreply.github.com>
Co-Authored-By: lazure-ocean <43110733+lazure-ocean@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:29:13 +03:00
gavrielc e3e6ccdbd9 Merge pull request #1523 from qwibitai/apple-container-fixes
fix: Apple Container networking and .env mount
2026-03-29 00:08:46 +03:00
gavrielc 9fc60f6af2 security: bind credential proxy to bridge IP, not 0.0.0.0
Binding to 0.0.0.0 exposed the credential proxy (which holds API
keys/OAuth tokens) to the entire local network. Now binds to the
bridge interface IP (same as CONTAINER_HOST_GATEWAY) so only Apple
Container VMs can reach it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:02:32 +03:00
gavrielc 5c56d4564d fix: Apple Container networking and .env mount
- Detect host gateway via bridge100/bridge0 interface instead of
  hardcoded host.docker.internal (not available in Apple Container VMs)
- Bind credential proxy to 0.0.0.0 so VMs can reach it via bridge network
- Export PROXY_BIND_HOST for credential proxy to use
- Remove /dev/null .env shadow mount — Apple Container only supports
  directory mounts; entrypoint handles .env shadowing via mount --bind

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:59:11 +03:00
gavrielc d0a61c6f57 merge: catch up with upstream main
Picks up main's changes while preserving Apple Container runtime:
- Built-in logger replacing pino/pino-pretty
- Removed unused deps (yaml, zod, @vitest/coverage-v8)
- stopContainer bug fix (exec wrapper removed)
- Kept branch's credential proxy (not OneCLI) and Apple Container
  runtime commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:42:15 +03:00
gavrielc b341bc1585 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:40 +03:00
github-actions[bot] 51e776b286 Merge branch 'main' into skill/apple-container 2026-03-14 15:24:09 +00:00
github-actions[bot] 5a16f98838 Merge branch 'main' into skill/apple-container 2026-03-14 13:16:50 +00:00
github-actions[bot] ed02382e68 Merge branch 'main' into skill/apple-container 2026-03-13 11:59:37 +00:00
github-actions[bot] efab26c97d Merge branch 'main' into skill/apple-container 2026-03-13 11:59:04 +00:00
github-actions[bot] 0432d13617 Merge branch 'main' into skill/apple-container 2026-03-11 10:30:39 +00:00
github-actions[bot] 79ddc47703 Merge branch 'main' into skill/apple-container 2026-03-11 10:25:35 +00:00
github-actions[bot] 90d38388ad Merge branch 'main' into skill/apple-container 2026-03-10 20:59:38 +00:00
github-actions[bot] 5d226ba56c Merge branch 'main' into skill/apple-container 2026-03-10 20:52:02 +00:00
github-actions[bot] 6c8216e255 Merge branch 'main' into skill/apple-container 2026-03-10 20:39:54 +00:00
github-actions[bot] b66b123886 Merge branch 'main' into skill/apple-container 2026-03-10 00:25:36 +00:00
gavrielc b0c2c835ff Merge commit '4cdd09c' into rebuild-fork 2026-03-10 01:15:20 +02:00
gavrielc 4cdd09c45c Merge remote-tracking branch 'origin/main' into skill/apple-container
# Conflicts:
#	src/container-runner.ts
2026-03-09 23:20:34 +02:00
gavrielc 2f1933775c Merge remote-tracking branch 'origin/main' into skill/apple-container 2026-03-09 00:07:58 +02:00
gavrielc 7c04dafa3d Merge remote-tracking branch 'origin/main' into skill/apple-container 2026-03-08 23:24:40 +02:00
gavrielc 0161ba508a skill/apple-container: switch runtime from Docker to Apple Container
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:18:06 +02:00
13 changed files with 510 additions and 1408 deletions
+4 -5
View File
@@ -7,7 +7,6 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
@@ -55,14 +54,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/
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
# 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
# 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:-docker}"
CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-container}"
echo "Building NanoClaw agent container image..."
echo "Image: ${IMAGE_NAME}:${TAG}"
-504
View File
@@ -1,504 +0,0 @@
;;; 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
@@ -1,531 +0,0 @@
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
@@ -1,249 +0,0 @@
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,6 +10,3 @@
// telegram
// whatsapp
// emacs
import './emacs.js';
+4
View File
@@ -51,6 +51,10 @@ 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(
+6 -11
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,21 +53,16 @@ vi.mock('./mount-security.js', () => ({
// Mock container-runtime
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'docker',
CONTAINER_RUNTIME_BIN: 'container',
CONTAINER_HOST_GATEWAY: 'host.docker.internal',
hostGatewayArgs: () => [],
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
stopContainer: vi.fn(),
}));
// 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 });
},
// Mock credential-proxy
vi.mock('./credential-proxy.js', () => ({
detectAuthMode: vi.fn(() => 'api-key'),
}));
// Create a controllable fake ChildProcess
+31 -39
View File
@@ -10,26 +10,25 @@ 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 { OneCLI } from '@onecli-sh/sdk';
import { detectAuthMode } from './credential-proxy.js';
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---';
@@ -78,16 +77,8 @@ function buildVolumeMounts(
readonly: true,
});
// 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,
});
}
// .env shadowing is handled inside the container entrypoint via mount --bind
// (Apple Container only supports directory mounts, not file mounts like /dev/null)
// Main also gets its group folder as the working directory
mounts.push({
@@ -223,29 +214,31 @@ function buildVolumeMounts(
return mounts;
}
async function buildContainerArgs(
function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
agentIdentifier?: string,
): Promise<string[]> {
isMain: boolean,
): 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}`);
// 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');
// 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');
} else {
logger.warn(
{ containerName },
'OneCLI gateway not reachable — container will have no credentials',
);
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
}
// Runtime-specific args for host gateway resolution
@@ -257,7 +250,14 @@ async function buildContainerArgs(
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
args.push('--user', `${hostUid}:${hostGid}`);
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('-e', 'HOME=/home/node');
}
@@ -288,15 +288,7 @@ 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()}`;
// 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,
);
const containerArgs = buildContainerArgs(mounts, containerName, input.isMain);
logger.debug(
{
+58 -24
View File
@@ -32,9 +32,12 @@ beforeEach(() => {
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns -v flag with :ro suffix', () => {
it('returns --mount flag with type=bind and readonly', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
expect(args).toEqual([
'--mount',
'type=bind,source=/host/path,target=/container/path,readonly',
]);
});
});
@@ -42,14 +45,18 @@ describe('stopContainer', () => {
it('calls docker stop for valid container names', () => {
stopContainer('nanoclaw-test-123');
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`,
`${CONTAINER_RUNTIME_BIN} stop 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();
});
@@ -64,18 +71,37 @@ describe('ensureContainerRuntimeRunning', () => {
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
);
expect(logger.debug).toHaveBeenCalledWith(
'Container runtime already running',
);
});
it('throws when docker info fails', () => {
it('auto-starts when system status fails', () => {
// First call (system status) fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('Cannot connect to the Docker daemon');
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');
});
expect(() => ensureContainerRuntimeRunning()).toThrow(
@@ -88,36 +114,40 @@ describe('ensureContainerRuntimeRunning', () => {
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers', () => {
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce(
'nanoclaw-group1-111\nnanoclaw-group2-222\n',
);
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);
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ps + 2 stop calls
// ls + 2 stop calls (only running nanoclaw- containers)
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group1-111`,
{ stdio: 'pipe' },
);
expect(mockExecSync).toHaveBeenNthCalledWith(
3,
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`,
`${CONTAINER_RUNTIME_BIN} stop nanoclaw-group3-333`,
{ stdio: 'pipe' },
);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group3-333'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('');
mockExecSync.mockReturnValueOnce('[]');
cleanupOrphans();
@@ -125,9 +155,9 @@ describe('cleanupOrphans', () => {
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ps fails', () => {
it('warns and continues when ls fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('docker not available');
throw new Error('container not available');
});
cleanupOrphans(); // should not throw
@@ -139,7 +169,11 @@ describe('cleanupOrphans', () => {
});
it('continues stopping remaining containers when one stop fails', () => {
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
const lsOutput = JSON.stringify([
{ status: 'running', configuration: { id: 'nanoclaw-a-1' } },
{ status: 'running', configuration: { id: 'nanoclaw-b-2' } },
]);
mockExecSync.mockReturnValueOnce(lsOutput);
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
+89 -41
View File
@@ -3,12 +3,46 @@
* 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 = 'docker';
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.',
);
}
/** CLI args needed for the container to resolve the host gateway. */
export function hostGatewayArgs(): string[] {
@@ -24,7 +58,10 @@ export function readonlyMountArgs(
hostPath: string,
containerPath: string,
): string[] {
return ['-v', `${hostPath}:${containerPath}:ro`];
return [
'--mount',
`type=bind,source=${hostPath},target=${containerPath},readonly`,
];
}
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
@@ -32,57 +69,68 @@ 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 -t 1 ${name}`, { stdio: 'pipe' });
execSync(`${CONTAINER_RUNTIME_BIN} stop ${name}`, { stdio: 'pipe' });
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
execSync(`${CONTAINER_RUNTIME_BIN} system status`, { stdio: 'pipe' });
logger.debug('Container runtime already running');
} 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,
});
} 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');
}
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(
`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const orphans = output.trim().split('\n').filter(Boolean);
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);
for (const name of orphans) {
try {
stopContainer(name);
+192
View File
@@ -0,0 +1,192 @@
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
@@ -0,0 +1,125 @@
/**
* 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';
}