Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions autolith.asd
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@
(:file "acp/session")
(:file "acp/observer")
(:file "acp/mcp")
(:file "acp/remote")
(:file "acp/runtime")
(:file "acp/extensions")
(:file "acp/input")
Expand Down Expand Up @@ -561,6 +562,7 @@
(:file "acp-input-tests")
(:file "acp-mcp-tests")
(:file "acp-launcher-tests")
(:file "acp-remote-tests")
(:file "tests")
(:file "test-runner")
(:file "fixture-tests")
Expand Down
6 changes: 4 additions & 2 deletions bin/autolith
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,10 @@ restore_terminal

# A detached session relays its exit to the attached terminal, whose own
# launcher updates, rolls back, or recovers; recovering here as well would race
# it for the conversation without a terminal to show.
if [[ "$data_requested" == true || "$localgroup_handoff_requested" == true ]]; then
# it for the conversation without a terminal to show. ACP owns standard I/O
# for its client, so its failures end the process instead of opening recovery.
if [[ "$data_requested" == true || "$acp_requested" == true ||
"$localgroup_handoff_requested" == true ]]; then
exit "$status"
fi

Expand Down
20 changes: 12 additions & 8 deletions bin/autolith-active
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@
(asdf:load-system :autolith)
(setf main (list (symbol-function (find-symbol "MAIN" "AUTOLITH"))
(uiop:command-line-arguments))))))
(if acp-p
(let ((*standard-output* *error-output*)
(*trace-output* *error-output*))
(load-autolith)
(apply (first main) (rest main)))
(progn
(load-autolith)
(apply (first main) (rest main))))))
;; Ctrl-C while the system loads ends the launch like an interrupted command.
(handler-case
(if acp-p
(let ((*standard-output* *error-output*)
(*trace-output* *error-output*))
(load-autolith)
(apply (first main) (rest main)))
(progn
(load-autolith)
(apply (first main) (rest main))))
(sb-sys:interactive-interrupt ()
(uiop:quit 130)))))
29 changes: 29 additions & 0 deletions docs/guide.org
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,35 @@ redacted from server output. Cancel a prompt to stop its provider, tools, and
owned jobs; close the session or disconnect to release its resources and lease.
One primary prompt, setting mutation, tool invocation, or session setup runs at a
time in each ACP process. Catalog reads and owned-job controls can run alongside it.
Ctrl-C ends an ACP process with status 130 after it releases its sessions.

** Remote clients

=autolith acp --http= serves remote clients on one endpoint, =/acp=, over the
transports of the ACP proposal "Streamable HTTP & WebSocket Transport":
Streamable HTTP over HTTP/1.1 or cleartext HTTP/2, and WebSocket.
=autolith acp --websocket= serves WebSocket alone. Both listen on
=127.0.0.1:8737= by default. Name another port, or a host and a port, to change
that:

#+BEGIN_SRC sh
autolith acp --websocket 9000
autolith acp --http 0.0.0.0:8737 --token "$ACP_TOKEN"
autolith acp --http [::1]:8737
#+END_SRC

Autolith prints the endpoint URL on stderr and serves until Ctrl-C. Each client
gets its own sessions. All clients share the process's one primary operation, so
while one client's prompt runs, another client's prompt is refused as busy.

With =--token=, a client must send =Authorization: Bearer TOKEN= or add
=?token=TOKEN= to the URL. Other users of the machine can read the command line,
the token included. Requests from web pages, which carry an =Origin= header, are
refused.

Any host but loopback makes the agent reachable from other machines, and
Autolith warns that the endpoint is not secure. The traffic, the token included,
is unencrypted: put a reverse proxy with TLS in front of it, and use a token.

** Typed client extensions

Expand Down
23 changes: 20 additions & 3 deletions nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ let
"cl-llm-provider-api" "cl-lsp" "cl-resources" "cl-rfc8252" "cl-rfc8628"
"cl-skills" "cl-termdown" "cl-worktree" "clasted" "clermaid" "clifff" "clinedi"
"clinker-transcript" "colordiff" "colorlisp" "daphne" "defingerprinter" "fetch-gist"
"idsmall" "image-daemon" "lambda-debugger" "ls-compat" "ls-flock" "mcparen"
"idsmall" "image-daemon" "lambda-debugger" "llh2" "ls-compat" "ls-flock" "mcparen"
"org-templater" "parenchek" "sbcl-generations" "sbcl-workers" "setinka" "sexp-config"
"sexp-store" "sophisticated-clipboard" "structlisp" "surgeon" "yolokuva"
];
Expand Down Expand Up @@ -114,12 +114,28 @@ let
pname = "agentcomms";
version = qlotVersion "agentcomms";
src = qlotSource "agentcomms";
lispLibs = [ argo ] ++ (with lispPackages; [
lispLibs = [ argo llh2 lsCompat ] ++ (with lispPackages; [
babel
bordeaux-threads
cl-base64
ironclad
serapeum
usocket
]);
};

llh2 = pkgs.sbcl.buildASDFSystem {
pname = "llh2";
version = qlotVersion "llh2";
src = qlotSource "llh2";
systems = [ "llh2" ];
lispLibs = with lispPackages; [
bordeaux-threads
cl-base64
serapeum
];
};

clColorist = pkgs.sbcl.buildASDFSystem {
pname = "cl-colorist";
version = qlotVersion "cl-colorist";
Expand Down Expand Up @@ -248,10 +264,11 @@ let
pname = "ls-compat";
version = qlotVersion "ls-compat";
src = qlotSource "ls-compat";
systems = [ "ls-compat" "ls-compat/posix" "ls-compat/files" ];
systems = [ "ls-compat" "ls-compat/posix" "ls-compat/files" "ls-compat/tcp" ];
lispLibs = with lispPackages; [
babel
serapeum
usocket
];
};

Expand Down
3 changes: 2 additions & 1 deletion qlfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ql trivial-gray-streams 2026-01-01
ql winhttp 2026-01-01
ql yason 2026-01-01

git agentcomms https://github.com/lambda-symbolics/agentcomms.git :ref 18676b8de2b773e521f3af4473e2877878ae3f22
git agentcomms https://github.com/lambda-symbolics/agentcomms.git :ref 0bf29a654d166173420f2af82b92daaa31e2d1a3
git fetch-gist https://github.com/lambda-symbolics/fetch-gist.git :ref 082f4d45d4cf0076c0c891f00e05d71957de6009
git cl-colorist https://github.com/lambda-symbolics/cl-colorist.git :ref 90f17af4fa3d2c0433030e15fffc0328f3863e29
git cl+ssl https://github.com/cl-plus-ssl/cl-plus-ssl.git :ref d7a92f9ed0cbae10f07b1b9a9c3c5cc179798748
Expand Down Expand Up @@ -51,6 +51,7 @@ git sophisticated-clipboard https://github.com/lambda-symbolics/sophisticated-cl
git sbcl-workers https://github.com/lambda-symbolics/sbcl-workers.git :ref cded09d0c0d2a3c889f6fbb3414342b957978d74
git surgeon https://github.com/lambda-symbolics/surgeon.git :ref b159c64231f35d2b20aedd41544f8621d16a0c29
git structlisp https://github.com/lambda-symbolics/structlisp.git :ref 987829546bd07030e25ed1b2e4d3246d11b532db
git llh2 https://github.com/lambda-symbolics/llh2.git :ref f516c92cff438d65fb42d31f4c552b39001e2edc
git argo https://github.com/lambda-symbolics/argo.git :ref c8034c208f43436b19af2858bfb6112ac7e66e1f
git setinka https://github.com/lambda-symbolics/setinka.git :ref ce63dce34287b20ad81f08f6d208e6e18bc3163f
git lambda-debugger https://github.com/lambda-symbolics/lambda-debugger.git :ref 8299ae17be78a489321de5e035f1f6cc8ede88bc
Expand Down
8 changes: 6 additions & 2 deletions qlfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@
:version "ql-2025-06-22"))
("agentcomms" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/lambda-symbolics/agentcomms.git" :ref "18676b8de2b773e521f3af4473e2877878ae3f22")
:version "git-18676b8de2b773e521f3af4473e2877878ae3f22"))
:initargs (:remote-url "https://github.com/lambda-symbolics/agentcomms.git" :ref "0bf29a654d166173420f2af82b92daaa31e2d1a3")
:version "git-0bf29a654d166173420f2af82b92daaa31e2d1a3"))
("fetch-gist" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/lambda-symbolics/fetch-gist.git" :ref "082f4d45d4cf0076c0c891f00e05d71957de6009")
Expand Down Expand Up @@ -210,6 +210,10 @@
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/lambda-symbolics/structlisp.git" :ref "987829546bd07030e25ed1b2e4d3246d11b532db")
:version "git-987829546bd07030e25ed1b2e4d3246d11b532db"))
("llh2" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/lambda-symbolics/llh2.git" :ref "f516c92cff438d65fb42d31f4c552b39001e2edc")
:version "git-f516c92cff438d65fb42d31f4c552b39001e2edc"))
("argo" .
(:class qlot/source/git:source-git
:initargs (:remote-url "https://github.com/lambda-symbolics/argo.git" :ref "c8034c208f43436b19af2858bfb6112ac7e66e1f")
Expand Down
2 changes: 1 addition & 1 deletion recovery/launcher.lisp
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
(unless (probe-file quicklisp-setup)
(error "Recovery bootstrap needs Quicklisp at ~A." quicklisp-setup))
(load quicklisp-setup)
(uiop:symbol-call '#:ql '#:quickload :serapeum :silent t)
(uiop:symbol-call '#:ql '#:quickload '(:serapeum :sbcl-generations) :silent t)
(let ((package (or (find-package "AUTOLITH")
(make-package "AUTOLITH" :use '("CL")))))
(export (mapcar (lambda (name) (intern name package))
Expand Down
3 changes: 3 additions & 0 deletions recovery/runtime.lisp
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,9 @@ Colocation lets older committed source use its original Git provenance code."
(restart-case
(handler-case
(uiop:quit (recovery-run (uiop:command-line-arguments)))
;; Ctrl-C stops recovery after its cleanup unwinds, without a backtrace.
(sb-sys:interactive-interrupt ()
(uiop:quit 130))
(error (condition)
(format *error-output* "Recovery could not continue: ~A~%"
(recovery-sanitize-text condition))
Expand Down
2 changes: 1 addition & 1 deletion script/launcher-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ autolith_launcher_parse()
remaining_arguments+=("$argument")
;;
--permissions|--image|-i|--site-config-root|--model|--provider|--id|--input|--output|\
--events|--generation|--status|--capsule|--original-argument|--workspace)
--events|--generation|--status|--capsule|--original-argument|--workspace|--token)
# Forward a value verbatim, even when it resembles a launcher flag.
# These are arities only, not a second application option parser.
take_value=true
Expand Down
2 changes: 1 addition & 1 deletion script/launcher.lisp
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
(defparameter *launcher-value-options*
'("--permissions" "--image" "-i" "--localgroup-handoff" "--id" "--input"
"--output" "--events" "--generation" "--status" "--capsule" "--original-argument"
"--workspace")
"--workspace" "--token")
"Options whose next argument is forwarded verbatim, as script/launcher-cli.sh does.")

(defparameter *launcher-terminal-statuses* '(64 76 130 143)
Expand Down
125 changes: 125 additions & 0 deletions src/acp/remote.lisp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
(in-package #:autolith)

;;;; -- Remote ACP Endpoints --

(defparameter *acp-remote-default-host* "127.0.0.1"
"The address remote ACP listens on when the endpoint names no host.")

(defclass acp-endpoint ()
((transport
:initarg :transport
:reader acp-endpoint-transport
:type (member :http :websocket)
:documentation "Streamable HTTP with WebSocket on the same endpoint, or WebSocket alone.")
(host
:initarg :host
:reader acp-endpoint-host
:type string
:documentation "The address to listen on.")
(port
:initarg :port
:reader acp-endpoint-port
:type (integer 0 65535)
:documentation "The port to listen on; 0 lets the system choose.")
(token
:initarg :token
:initform nil
:reader acp-endpoint-token
:type (or null string)
:documentation "The bearer token clients must present, or NIL for none."))
(:documentation "Where and how a remote ACP server listens."))

(-> acp-endpoint-parse (keyword (or null string) &key (:token (or null string))) acp-endpoint)
(defun acp-endpoint-parse (transport text &key token)
"Return the TRANSPORT endpoint TEXT names as [HOST:]PORT.

Without TEXT, or without a host, the endpoint is loopback only. An IPv6
address goes in brackets, as in [::1]:8737."
(flet ((fail (control &rest arguments)
(error 'configuration-error :message (apply #'format nil control arguments))))
(multiple-value-bind (host port-text)
(cond
((null text)
(values *acp-remote-default-host* nil))
((and (plusp (length text)) (char= (char text 0) #\[))
(let ((close (position #\] text)))
(unless (and close
(< (1+ close) (length text))
(char= (char text (1+ close)) #\:))
(fail "Write an IPv6 endpoint as [ADDRESS]:PORT, not ~A." text))
(values (subseq text 1 close) (subseq text (+ close 2)))))
((> (count #\: text) 1)
(fail "Put the IPv6 address in ~A in brackets, as in [::1]:8737." text))
((find #\: text)
(let ((colon (position #\: text)))
(values (subseq text 0 colon) (subseq text (1+ colon)))))
(t
(values *acp-remote-default-host* text)))
(when (zerop (length host))
(fail "The endpoint ~A names no host before its colon." text))
(let ((port (cond
((null port-text)
agentcomms:*acp-remote-default-port*)
((and (plusp (length port-text))
(<= (length port-text) 5)
(every #'digit-char-p port-text)
(<= (parse-integer port-text) 65535))
(parse-integer port-text))
(t
(fail "The endpoint ~A has no port from 0 to 65535." text)))))
(when (and token (zerop (length token)))
(fail "The token is empty."))
(make-instance 'acp-endpoint :transport transport
:host host
:port port
:token token)))))

(-> acp-endpoint-loopback-p (acp-endpoint) boolean)
(defun acp-endpoint-loopback-p (endpoint)
"Return whether ENDPOINT listens only on this machine's loopback interface."
(let ((host (string-downcase (acp-endpoint-host endpoint))))
(and (or (string= host "localhost")
(string= host "::1")
(eql 0 (search "127." host)))
t)))

(-> acp-endpoint-warning (acp-endpoint string) (or null string))
(defun acp-endpoint-warning (endpoint url)
"Return the warning for serving ENDPOINT at URL beyond loopback, or NIL on loopback."
(unless (acp-endpoint-loopback-p endpoint)
(if (acp-endpoint-token endpoint)
(format nil "Warning: ~A is not secure. The token and all traffic cross the network ~
unencrypted. Put a TLS proxy in front of it."
url)
(format nil "Warning: ~A is not secure. Anyone who can reach it controls an agent that ~
runs commands as you, and the traffic is unencrypted. Pass --token and put a ~
TLS proxy in front of it."
url))))

(-> acp-serve-remote (acp-endpoint function) null)
(defun acp-serve-remote (endpoint serve-function)
"Serve remote clients at ENDPOINT, calling SERVE-FUNCTION with each client's channel, until interrupted.

Stopping waits until every client released its sessions."
(let* ((http-p (eq (acp-endpoint-transport endpoint) ':http))
(server (handler-case
(agentcomms:acp-remote-server-start
serve-function
:host (acp-endpoint-host endpoint)
:port (acp-endpoint-port endpoint)
:token (acp-endpoint-token endpoint)
:transports (if http-p '(:http :websocket) '(:websocket))
:log-function #'agentcomms:acp-standard-error-log)
(agentcomms:acp-error (condition)
(error 'configuration-error :message (agentcomms:acp-error-message condition))))))
(unwind-protect
(let ((url (agentcomms:acp-remote-server-url server :scheme (if http-p "http" "ws"))))
(format *error-output* "Autolith ACP is listening at ~A~:[~; and ~A~]~%"
url http-p (agentcomms:acp-remote-server-url server :scheme "ws"))
(let ((warning (acp-endpoint-warning endpoint url)))
(when warning
(format *error-output* "~A~%" warning)))
(finish-output *error-output*)
(loop (sleep 3600)))
(agentcomms:acp-remote-server-stop server)))
nil)
Loading
Loading