fix: e2b terminal access && git push

This commit is contained in:
Tienson Qin
2026-03-08 16:34:45 +08:00
parent d74fe5a278
commit f5a8e44f68
6 changed files with 335 additions and 59 deletions

View File

@@ -123,6 +123,7 @@
(str "http://127.0.0.1:" port path))
(def ^:private default-repo-base-dir "/workspace")
(def ^:private e2b-repo-base-dir "/home/user/workspace")
(def ^:private vercel-repo-base-dir "/vercel/sandbox")
(def ^:private cloudflare-local-host "http://localhost")
(def ^:private cloudflare-snapshot-ttl-seconds (* 30 24 60 60))
@@ -372,19 +373,22 @@
(defn- repo-base-dir
[provider]
(if (= "vercel" (normalize-provider provider))
vercel-repo-base-dir
(case (normalize-provider provider)
"e2b" e2b-repo-base-dir
"vercel" vercel-repo-base-dir
default-repo-base-dir))
(defn- get-repo-dir
([session-id]
(get-repo-dir session-id nil nil))
([session-id task provider]
(if (= "vercel" (normalize-provider provider))
(str vercel-repo-base-dir "/" (task-repo-name task))
(let [session-id (some-> session-id str)]
(when (string? session-id)
(str default-repo-base-dir "/" (sanitize-name session-id)))))))
(case (normalize-provider provider)
"e2b" (str e2b-repo-base-dir "/" (task-repo-name task))
"vercel" (str vercel-repo-base-dir "/" (task-repo-name task))
(let [session-id (some-> session-id str)
base-dir (repo-base-dir provider)]
(when (and (string? session-id) (string? base-dir))
(str base-dir "/" (sanitize-name session-id)))))))
(defn- repo-cd-command
([session-id]
@@ -965,7 +969,7 @@
(defn- e2b-sandbox-timeout-ms
[^js env]
(parse-int (env-str env "E2B_SANDBOX_TIMEOUT_MS") (* 30 60 1000)))
(parse-int (env-str env "E2B_SANDBOX_TIMEOUT_MS") (* 5 60 1000)))
(defn- e2b-api-opts
[^js env]
@@ -990,13 +994,30 @@
(or (aget sandbox "sandboxId")
(aget sandbox "id")))
(defn- e2b-command-error-data
[command error]
(let [stdout (or (some-> error (aget "stdout")) "")
stderr (or (some-> error (aget "stderr")) "")
exit-code (or (some-> error (aget "exitCode"))
(some-> error (aget "exit_code")))
name (some-> error (aget "name"))
message (or (some-> error (aget "message"))
(str error))]
(cond-> {:reason :e2b-command-failed
:command command
:message message
:stdout stdout
:stderr stderr}
(string? name) (assoc :error-name name)
(number? exit-code) (assoc :exit-code exit-code))))
(defn- e2b-sandbox-host
[sandbox port]
(let [get-host (js-method sandbox "getHost")]
(when-not (fn? get-host)
(throw (ex-info "e2b sandbox missing getHost method"
{:reason :missing-e2b-get-host})))
(.call get-host sandbox port)))
(sandbox/normalize-base-url (.call get-host sandbox port))))
(defn <e2b-create-sandbox!
[^js env template opts]
@@ -1032,6 +1053,15 @@
(->promise (.call kill sandbox-class sandbox-id opts))
(p/resolved nil))))
(defn <e2b-pause-sandbox!
[^js env sandbox-id]
(let [sandbox-class (e2b-sandbox-class)
pause (js-method sandbox-class "pause")
opts (clj->js (e2b-api-opts env))]
(if (and (string? sandbox-id) (fn? pause))
(->promise (.call pause sandbox-class sandbox-id opts))
(p/resolved nil))))
(defn <e2b-run-shell!
[sandbox command & [opts]]
(let [commands (when sandbox (aget sandbox "commands"))
@@ -1048,7 +1078,12 @@
{:reason :missing-e2b-run-command})))
(if (true? (:background opts))
(->promise (.call run-command commands command (clj->js params)))
(p/let [result (->promise (.call run-command commands command (clj->js params)))
(p/let [result (-> (.call run-command commands command (clj->js params))
->promise
(p/catch (fn [error]
(throw (ex-info "e2b sandbox command failed"
(e2b-command-error-data command error)
error)))))
stdout (or (aget result "stdout") "")
stderr (or (aget result "stderr") "")
exit-code (or (aget result "exitCode")
@@ -1219,7 +1254,7 @@
port (e2b-agent-port env runtime)
sandbox-id (:sandbox-id runtime)]
(if (string? cached)
(p/resolved cached)
(p/resolved (sandbox/normalize-base-url cached))
(p/let [sandbox (<e2b-connect-sandbox! env sandbox-id)]
(e2b-sandbox-host sandbox port)))))
@@ -1283,28 +1318,93 @@
true)))
(defn- <e2b-open-terminal!
[^js env runtime request]
(let [session-id (:session-id runtime)]
[^js env runtime request {:keys [cols rows]}]
(let [sandbox-id (:sandbox-id runtime)
session-id (:session-id runtime)
cwd (or (:backup-dir runtime)
(get-repo-dir session-id nil "e2b"))]
(when-not (string? sandbox-id)
(throw (ex-info "missing sandbox-id on runtime" {:runtime runtime})))
(when-not (string? session-id)
(throw (ex-info "missing runtime session-id on runtime"
{:runtime runtime})))
(let [agent-token (e2b-agent-token env runtime)]
(p/let [base-url (<e2b-runtime-base-url! env runtime)
req-url (platform/request-url request)
terminal-url (str (sandbox/session-url base-url session-id) "/terminal" (.-search req-url))
headers (js/Headers. (.-headers request))
_ (when (string? agent-token)
(.set headers "authorization" (str "Bearer " agent-token)))
req (js/Request. terminal-url
#js {:method (.-method request)
:headers headers})
resp (js/fetch req)
status (.-status resp)]
(if (or (= status 101) (<= 200 status 299))
resp
(throw (ex-info "e2b open-terminal failed"
{:status status
:session-id session-id})))))))
(when-not (= "websocket"
(some-> request .-headers (.get "Upgrade") str string/lower-case))
(throw (ex-info "e2b terminal requires websocket upgrade"
{:reason :unsupported-terminal
:session-id session-id})))
(p/let [sandbox (<e2b-connect-sandbox! env sandbox-id)]
(let [pty (some-> sandbox (aget "pty"))
create-pty (js-method pty "create")]
(when-not (fn? create-pty)
(throw (ex-info "e2b sandbox missing pty.create"
{:reason :unsupported-terminal
:sandbox-id sandbox-id
:session-id session-id})))
(let [pair (js/WebSocketPair.)
client (aget pair 0)
server (aget pair 1)]
(.accept server)
(set! (.-binaryType server) "arraybuffer")
(-> (->promise
(.call create-pty pty
(clj->js (cond-> {:cols (or cols 120)
:rows (or rows 40)
:cwd cwd
:onData (fn [payload]
(.send server payload))}
(number? cols) (assoc :cols cols)
(number? rows) (assoc :rows rows)))))
(p/then
(fn [handle]
(let [pid (aget handle "pid")
send-input (js-method pty "sendInput")
resize (js-method pty "resize")
kill-handle (js-method handle "kill")
close-handler (fn []
(when (fn? kill-handle)
(-> (->promise (.call kill-handle handle))
(p/catch (fn [_] nil)))))]
(.addEventListener
server
"message"
(fn [event]
(let [payload (.-data event)]
(cond
(string? payload)
(let [parsed (parse-json-safe payload)]
(if (= "resize" (:type parsed))
(when (and (number? pid) (fn? resize))
(-> (->promise (.call resize pty pid
(clj->js {:cols (:cols parsed)
:rows (:rows parsed)})))
(p/catch (fn [_] nil))))
(when (and (number? pid) (fn? send-input))
(-> (->promise (.call send-input pty pid payload))
(p/catch (fn [_] nil))))))
(instance? js/ArrayBuffer payload)
(when (and (number? pid) (fn? send-input))
(-> (->promise (.call send-input pty pid (js/Uint8Array. payload)))
(p/catch (fn [_] nil))))
(instance? js/Uint8Array payload)
(when (and (number? pid) (fn? send-input))
(-> (->promise (.call send-input pty pid payload))
(p/catch (fn [_] nil))))
:else nil))))
(.addEventListener server "close" close-handler)
(.addEventListener server "error" (fn [_] (close-handler)))
(.send server (js/JSON.stringify #js {:type "ready"}))
(js/Response. nil #js {:status 101
:webSocket client}))))
(p/catch
(fn [error]
(try
(.close server 1011 "terminal init failed")
(catch :default _ nil))
(throw error)))))))))
(defn- vercel-agent-token [^js env runtime]
(or (:agent-token runtime)
@@ -2546,14 +2646,16 @@
(fn [error]
(log/error :agent/e2b-repo-clone-failed
{:session-id session-id
:error (str error)})
:error (str error)
:error-data (ex-data error)})
nil)))
_ (p/catch
(<e2b-run-project-init-setup! sandbox session-id task)
(fn [error]
(log/error :agent/e2b-project-init-setup-failed
{:session-id session-id
:error (str error)})
:error (str error)
:error-data (ex-data error)})
nil))
base-url (e2b-sandbox-host sandbox port)
response (sandbox/<create-session base-url agent-token session-id payload)
@@ -2583,8 +2685,8 @@
(p/let [base-url (<e2b-runtime-base-url! env runtime)]
(sandbox/<send-message base-url agent-token (:session-id runtime) message))))
(<open-terminal! [_ runtime request _opts]
(<e2b-open-terminal! env runtime request))
(<open-terminal! [_ runtime request opts]
(<e2b-open-terminal! env runtime request opts))
(<snapshot-runtime! [_ runtime opts]
(let [session-id (:session-id runtime)
@@ -2666,7 +2768,7 @@
(if-not (string? sandbox-id)
(p/resolved nil)
(p/catch
(<e2b-kill-sandbox! env sandbox-id)
(<e2b-pause-sandbox! env sandbox-id)
(fn [_] nil))))))
(defrecord VercelProvider [env]

View File

@@ -3,8 +3,17 @@
[logseq.sync.platform.core :as platform]
[promesa.core :as p]))
(def ^:private absolute-url-re #"^[A-Za-z][A-Za-z0-9+.-]*://")
(def ^:private local-host-re #"^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:/.*)?$")
(defn normalize-base-url [base]
(string/replace (or base "") #"/+$" ""))
(let [base (some-> base str string/trim not-empty)
base (some-> base (string/replace #"/+$" ""))]
(cond
(not (string? base)) ""
(re-find absolute-url-re base) base
(re-find local-host-re base) (str "http://" base)
:else (str "https://" base))))
(defn sessions-base-url [base]
(str (normalize-base-url base) "/v1/sessions"))

View File

@@ -31,7 +31,8 @@
env #js {"E2B_API_KEY" "e2b-key"
"SANDBOX_AGENT_TOKEN" "agent-token"}
provider (runtime-provider/create-provider env "e2b")
task {:agent {:provider "codex"}}
task {:agent {:provider "codex"}
:project {:repo-url "https://github.com/logseq/agent-test"}}
sandbox-class (runtime-provider/e2b-sandbox-class)
original-create (aget sandbox-class "create")
original-fetch js/fetch
@@ -77,6 +78,12 @@
(is (= "https://e2b-agent.local" (:base-url runtime)))
(is (= "sess-e2b-1" (:session-id runtime)))
(is (= 2468 (:sandbox-port runtime)))
(is (some #(and (= :command (:type %))
(string/includes? (:cmd %) "mkdir -p '/home/user/workspace'"))
@calls))
(is (some #(and (= :command (:type %))
(string/includes? (:cmd %) "'/home/user/workspace/agent-test'"))
@calls))
(is (some #(= {:type :host :port 2468} %) @calls))
(done)))
(.catch (fn [error]
@@ -84,6 +91,95 @@
(is false (str "unexpected error: " error))
(done)))))))
(deftest e2b-provider-provision-normalizes-bare-host-test
(async done
(let [env #js {"E2B_API_KEY" "e2b-key"
"SANDBOX_AGENT_TOKEN" "agent-token"}
provider (runtime-provider/create-provider env "e2b")
task {:agent {:provider "codex"}
:project {:repo-url "https://github.com/logseq/agent-test"}}
sandbox-class (runtime-provider/e2b-sandbox-class)
original-create (aget sandbox-class "create")
original-fetch js/fetch
restore! (fn []
(aset sandbox-class "create" original-create)
(set! js/fetch original-fetch))]
(aset sandbox-class "create"
(fn [& _args]
(js/Promise.resolve
#js {:sandboxId "e2b-sbx-2"
:getHost (fn [_port]
"2468-if9ct9du77wx2o6oorw10.e2b.app")
:commands
#js {:run (fn [cmd _opts]
(if (string/includes? cmd "/v1/health")
(js/Promise.resolve #js {:stdout "__HEALTH_OK__"
:stderr ""
:exitCode 0})
(js/Promise.resolve #js {:stdout ""
:stderr ""
:exitCode 0})))}})))
(set! js/fetch
(fn [request]
(is (= "https://2468-if9ct9du77wx2o6oorw10.e2b.app/v1/sessions/sess-e2b-2"
(.-url request)))
(js/Promise.resolve
(js/Response.
(js/JSON.stringify #js {:ok true})
#js {:status 200
:headers #js {"content-type" "application/json"}}))))
(-> (runtime-provider/<provision-runtime! provider "sess-e2b-2" task)
(.then (fn [runtime]
(restore!)
(is (= "https://2468-if9ct9du77wx2o6oorw10.e2b.app"
(:base-url runtime)))
(is (= "/home/user/workspace/agent-test"
(:backup-dir runtime)))
(done)))
(.catch (fn [error]
(restore!)
(is false (str "unexpected error: " error))
(done)))))))
(deftest e2b-run-shell-wraps-commandexiterror-test
(async done
(let [env #js {"E2B_API_KEY" "e2b-key"}
provider (runtime-provider/create-provider env "e2b")
sandbox-class (runtime-provider/e2b-sandbox-class)
original-connect (aget sandbox-class "connect")
restore! (fn []
(aset sandbox-class "connect" original-connect))]
(aset sandbox-class "connect"
(fn [_sandbox-id _opts]
(js/Promise.resolve
#js {:sandboxId "e2b-sbx-3"
:commands
#js {:run (fn [_cmd _opts]
(js/Promise.reject
#js {:name "CommandExitError"
:message "CommandExitError: exit status 1"
:stdout ""
:stderr "fatal: repository not found"
:exitCode 1}))}})))
(-> (runtime-provider/<snapshot-runtime! provider
{:provider "e2b"
:sandbox-id "e2b-sbx-3"
:session-id "sess-e2b-error"
:backup-dir "/home/user/workspace/agent-test"}
{:task {:project {:repo-url "https://github.com/logseq/agent-test"
:base-branch "main"}}})
(.then (fn [_result]
(restore!)
(is false "expected command failure")
(done)))
(.catch (fn [error]
(restore!)
(is (= :e2b-command-failed (:reason (ex-data error))))
(is (= 1 (:exit-code (ex-data error))))
(is (= "fatal: repository not found" (:stderr (ex-data error))))
(is (string/includes? (:message (ex-data error)) "exit status 1"))
(done)))))))
(deftest e2b-provider-open-terminal-test
(async done
(let [env #js {"E2B_API_KEY" "e2b-key"
@@ -92,31 +188,83 @@
runtime {:provider "e2b"
:sandbox-id "e2b-sbx-1"
:base-url "https://e2b-agent.local"
:session-id "sess-term-1"}
:session-id "sess-term-1"
:backup-dir "/home/user/workspace/agent-test"}
request (js/Request. "https://api.logseq.local/sessions/sess-term-1/terminal?cols=120&rows=40"
#js {:method "GET"})
calls (atom {})
original-fetch js/fetch]
(set! js/fetch
(fn [req]
(reset! calls {:url (.-url req)
:method (.-method req)
:auth (.get (.-headers req) "authorization")})
(js/Promise.resolve (js/Response. "ok" #js {:status 200}))))
sandbox-class (runtime-provider/e2b-sandbox-class)
original-connect (aget sandbox-class "connect")
original-websocket-pair js/WebSocketPair
client-socket #js {}
server-socket #js {:accept (fn [] (swap! calls assoc :accepted true))
:send (fn [payload] (swap! calls assoc :ready payload))
:addEventListener (fn [_type _listener] nil)
:close (fn [& _args] nil)}
restore! (fn []
(aset sandbox-class "connect" original-connect)
(set! js/WebSocketPair original-websocket-pair))]
(set! js/WebSocketPair
(fn []
(clj->js [client-socket server-socket])))
(aset sandbox-class "connect"
(fn [_sandbox-id _opts]
(js/Promise.resolve
#js {:sandboxId "e2b-sbx-1"
:pty
#js {:create (fn [opts]
(swap! calls assoc :pty-opts (js->clj opts :keywordize-keys true))
(js/Promise.resolve
#js {:pid 42
:kill (fn []
(swap! calls update :kills (fnil inc 0))
(js/Promise.resolve true))}))}})))
(-> (runtime-provider/<open-terminal! provider runtime request {:cols 120 :rows 40})
(.then (fn [resp]
(set! js/fetch original-fetch)
(is (= 200 (.-status resp)))
(is (= "GET" (:method @calls)))
(is (= "Bearer agent-token" (:auth @calls)))
(is (= "https://e2b-agent.local/v1/sessions/sess-term-1/terminal?cols=120&rows=40"
(:url @calls)))
(restore!)
(is (= 101 (.-status resp)))
(is (= client-socket (aget resp "webSocket")))
(is (true? (:accepted @calls)))
(is (= 120 (get-in @calls [:pty-opts :cols])))
(is (= 40 (get-in @calls [:pty-opts :rows])))
(is (= "/home/user/workspace/agent-test" (get-in @calls [:pty-opts :cwd])))
(is (= "{\"type\":\"ready\"}" (:ready @calls)))
(done)))
(.catch (fn [error]
(set! js/fetch original-fetch)
(restore!)
(is false (str "unexpected error: " error))
(done)))))))
(deftest e2b-provider-terminate-pauses-sandbox-test
(async done
(let [env #js {"E2B_API_KEY" "e2b-key"}
provider (runtime-provider/create-provider env "e2b")
sandbox-class (runtime-provider/e2b-sandbox-class)
original-pause (aget sandbox-class "pause")
original-kill (aget sandbox-class "kill")
calls (atom [])
restore! (fn []
(aset sandbox-class "pause" original-pause)
(aset sandbox-class "kill" original-kill))]
(aset sandbox-class "pause"
(fn [sandbox-id _opts]
(swap! calls conj {:type :pause :sandbox-id sandbox-id})
(js/Promise.resolve true)))
(aset sandbox-class "kill"
(fn [sandbox-id _opts]
(swap! calls conj {:type :kill :sandbox-id sandbox-id})
(js/Promise.resolve true)))
(-> (runtime-provider/<terminate-runtime! provider {:provider "e2b"
:sandbox-id "e2b-sbx-pause"})
(.then (fn [_]
(restore!)
(is (= [{:type :pause :sandbox-id "e2b-sbx-pause"}] @calls))
(done)))
(.catch (fn [error]
(restore!)
(is false (str "unexpected terminate error: " error))
(done)))))))
(deftest e2b-provider-snapshot-runtime-test
(async done
(let [env #js {"E2B_API_KEY" "e2b-key"}
@@ -124,8 +272,8 @@
runtime {:provider "e2b"
:sandbox-id "e2b-sbx-1"
:session-id "sess-e2b-snapshot"
:backup-dir "/workspace/sess-e2b-snapshot"}
task {:project {:repo-url "https://github.com/example/repo"
:backup-dir "/home/user/workspace/agent-test"}
task {:project {:repo-url "https://github.com/logseq/agent-test"
:base-branch "main"}}
sandbox-class (runtime-provider/e2b-sandbox-class)
original-connect (aget sandbox-class "connect")
@@ -142,7 +290,7 @@
(restore!)
(is (= "e2b" (:provider result)))
(is (= "e2b-snap-1" (:snapshot-id result)))
(is (= "/workspace/sess-e2b-snapshot" (:backup-dir result)))
(is (= "/home/user/workspace/agent-test" (:backup-dir result)))
(done)))
(.catch (fn [error]
(restore!)
@@ -155,7 +303,7 @@
provider (runtime-provider/create-provider env "e2b")
runtime {:provider "e2b"
:sandbox-id "e2b-sbx-1"
:backup-dir "/workspace/sess-e2b-bundle"
:backup-dir "/home/user/workspace/agent-test"
:session-id "sess-e2b-bundle"}
task {:project {:base-branch "main"}}
sandbox-class (runtime-provider/e2b-sandbox-class)

View File

@@ -6,7 +6,10 @@
(testing "normalizes sandbox base urls"
(is (= "https://sandbox.example" (sandbox/normalize-base-url "https://sandbox.example")))
(is (= "https://sandbox.example" (sandbox/normalize-base-url "https://sandbox.example/")))
(is (= "http://localhost:8787" (sandbox/normalize-base-url "http://localhost:8787//")))))
(is (= "http://localhost:8787" (sandbox/normalize-base-url "http://localhost:8787//")))
(is (= "https://2468-sbx.e2b.app" (sandbox/normalize-base-url "2468-sbx.e2b.app")))
(is (= "https://2468-sbx.e2b.app" (sandbox/normalize-base-url " 2468-sbx.e2b.app/ ")))
(is (= "http://localhost:8787" (sandbox/normalize-base-url "localhost:8787")))))
(deftest session-endpoint-test
(testing "builds sandbox session endpoints"

View File

@@ -643,6 +643,7 @@
session-created? (or session-started?
(agent-handler/task-session-created? block))
terminal-enabled? (agent-handler/session-terminal-enabled? session)
snapshot-enabled? (agent-handler/session-snapshot-enabled? session)
session-chat-messages (->> session-messages
(map message->chat-message)
(remove nil?))
@@ -1020,7 +1021,7 @@
:class "h-7 px-2 text-xs"
:on-click (fn [_] (close-terminal!))}
"Disconnect"))])
(when-not pr-created?
(when (and (not pr-created?) snapshot-enabled?)
(shui/button
{:size :sm
:variant :outline

View File

@@ -354,7 +354,11 @@
(some-> provider str string/trim string/lower-case not-empty))
(defn- runtime-provider-terminal-enabled? [provider]
(= "cloudflare" (normalize-runtime-provider provider)))
(contains? #{"cloudflare" "e2b"}
(normalize-runtime-provider provider)))
(defn- runtime-provider-snapshot-enabled? [provider]
(not= "e2b" (normalize-runtime-provider provider)))
(defn- event-runtime-provider [event]
(when (= "session.provisioned" (:type event))
@@ -381,6 +385,15 @@
first))]
(runtime-provider-terminal-enabled? provider)))
(defn session-snapshot-enabled?
[session]
(let [provider (or (normalize-runtime-provider (:runtime-provider session))
(some->> (:events session)
reverse
(keep event-runtime-provider)
first))]
(runtime-provider-snapshot-enabled? provider)))
(defn- status->label [status-ident]
(some-> (db/entity status-ident) :block/title))