@lkyhfx/dsh-bb-repl 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bb-src/domain/runtime.clj +103 -0
- package/cordis.patch.yml +38 -0
- package/package.json +23 -4
- package/src/env.js +37 -0
- package/src/index.js +221 -0
- package/src/nrepl.js +221 -0
- package/src/sessions.js +475 -0
- package/README.md +0 -3
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
(ns domain.runtime
|
|
2
|
+
"Session-scoped runtime observability, loaded automatically by bb-repl.
|
|
3
|
+
|
|
4
|
+
This namespace answers the questions a persistent-REPL agent actually has:
|
|
5
|
+
where does the workspace live, which definitions already exist, what does
|
|
6
|
+
each mounted domain report, and how do I deliberately drop a broken session.
|
|
7
|
+
It is a thin layer over babashka.fs / babashka.process: no policy of its own."
|
|
8
|
+
(:require [babashka.fs :as fs]
|
|
9
|
+
[babashka.process :as p]
|
|
10
|
+
[clojure.string :as str])
|
|
11
|
+
(:import [java.io File]))
|
|
12
|
+
|
|
13
|
+
(def ^:private workspace-root
|
|
14
|
+
(let [root (System/getenv "DSH_BB_WORKSPACE_ROOT")]
|
|
15
|
+
(when (str/blank? root)
|
|
16
|
+
(throw (ex-info "Workspace root not set by DSH" {:code :missing-root})))
|
|
17
|
+
(.toPath (.getCanonicalFile (File. root)))))
|
|
18
|
+
|
|
19
|
+
(defonce ^:private registry (atom {}))
|
|
20
|
+
|
|
21
|
+
(defn register!
|
|
22
|
+
"Register one mounted domain. Called by the bb-repl host, not by the agent."
|
|
23
|
+
[{:keys [id version state-version instructions status]}]
|
|
24
|
+
(swap! registry assoc id (cond-> {:id id
|
|
25
|
+
:version (str version)
|
|
26
|
+
:state-version (str state-version)
|
|
27
|
+
:instructions (str instructions)}
|
|
28
|
+
status (assoc :status status)))
|
|
29
|
+
id)
|
|
30
|
+
|
|
31
|
+
(defn workspace
|
|
32
|
+
"Absolute path of the workspace this session is scoped to.
|
|
33
|
+
Raw Babashka process/fs calls start in a private scratch directory;
|
|
34
|
+
pass (workspace) explicitly as :dir / fs/path when you need the project."
|
|
35
|
+
[]
|
|
36
|
+
(str workspace-root))
|
|
37
|
+
|
|
38
|
+
(defn path
|
|
39
|
+
"Join workspace-relative segments into an absolute path without touching disk."
|
|
40
|
+
[& segments]
|
|
41
|
+
(str (apply fs/path (str workspace-root) (map str segments))))
|
|
42
|
+
|
|
43
|
+
(defn shell
|
|
44
|
+
"Run a command with the workspace as working directory.
|
|
45
|
+
(shell {:out :string} \"git\" \"status\" \"--short\")"
|
|
46
|
+
[opts & args]
|
|
47
|
+
(apply p/shell (merge {:dir (str workspace-root)} opts) args))
|
|
48
|
+
|
|
49
|
+
(defn domains
|
|
50
|
+
"Ids of the domain namespaces mounted into this session."
|
|
51
|
+
[]
|
|
52
|
+
(vec (sort (keys @registry))))
|
|
53
|
+
|
|
54
|
+
(defn- user-var?
|
|
55
|
+
[n]
|
|
56
|
+
(let [s (name n)]
|
|
57
|
+
(and (not (str/starts-with? s "*")) (not (str/starts-with? s "->")))))
|
|
58
|
+
|
|
59
|
+
(defn vars
|
|
60
|
+
"Agent-defined public vars in the user namespace, with arglists and docstrings.
|
|
61
|
+
Use this before redefining something that may already exist."
|
|
62
|
+
[]
|
|
63
|
+
(->> (ns-publics 'user)
|
|
64
|
+
(filter (fn [[n _]] (user-var? n)))
|
|
65
|
+
(map (fn [[n v]]
|
|
66
|
+
(let [m (meta v)]
|
|
67
|
+
(cond-> {:name n}
|
|
68
|
+
(:arglists m) (assoc :arglists (:arglists m))
|
|
69
|
+
(:doc m) (assoc :doc (:doc m))))))
|
|
70
|
+
(sort-by :name)
|
|
71
|
+
vec))
|
|
72
|
+
|
|
73
|
+
(defn status
|
|
74
|
+
"One map with the workspace, the mounted domains, each domain's own counters,
|
|
75
|
+
and how many definitions this session currently holds. Read-only."
|
|
76
|
+
[]
|
|
77
|
+
{:workspace (str workspace-root)
|
|
78
|
+
:domains (into (sorted-map)
|
|
79
|
+
(map (fn [[id d]] [id (if-let [f (:status d)] (f) {:version (:version d)})])
|
|
80
|
+
@registry))
|
|
81
|
+
:defs (count (vars))})
|
|
82
|
+
|
|
83
|
+
(defn help
|
|
84
|
+
"Domain API guide. No argument lists every mounted domain; a domain id
|
|
85
|
+
(:code / \"code\") prints that domain's guide."
|
|
86
|
+
([] (into (sorted-map)
|
|
87
|
+
(map (fn [[id d]] [id {:version (:version d)
|
|
88
|
+
:state-version (:state-version d)
|
|
89
|
+
:instructions (:instructions d)}]))
|
|
90
|
+
@registry))
|
|
91
|
+
([id]
|
|
92
|
+
(let [d (get @registry (if (keyword? id) (name id) (str id)))]
|
|
93
|
+
(when-not d (throw (ex-info (str "Unknown domain " id) {:domains (domains)})))
|
|
94
|
+
(:instructions d))))
|
|
95
|
+
|
|
96
|
+
(defn reset!
|
|
97
|
+
"Deliberately discard this session: definitions, atoms and domain counters.
|
|
98
|
+
The host starts a fresh interpreter on the next call. Use it when the REPL
|
|
99
|
+
holds state you know to be wrong, instead of working around it."
|
|
100
|
+
([] (reset! "requested by the agent"))
|
|
101
|
+
([reason]
|
|
102
|
+
(throw (ex-info (str "DSH_BB_RESET: " (or reason "requested by the agent"))
|
|
103
|
+
{:code :dsh.bb/reset-requested :reason (str reason)}))))
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Bundle patch for the dsh-bb-repl stack.
|
|
2
|
+
#
|
|
3
|
+
# `dsh plugin --profile <name> add @lkyhfx/dsh-bb-repl` appends this package to
|
|
4
|
+
# `dsh.profile.bundles`, so this layer mounts the stack without editing the
|
|
5
|
+
# profile patch by hand.
|
|
6
|
+
#
|
|
7
|
+
# The group isolates `bbDomain`: each Agent stack sees its own domain registry,
|
|
8
|
+
# so a child domain plugin cannot leak its manifest into another group.
|
|
9
|
+
#
|
|
10
|
+
# This layer does NOT remove the existing persistent-bash / persistent-pwsh
|
|
11
|
+
# tool. Disable those rows in your profile patch or Agent preset if you want
|
|
12
|
+
# `bb_eval` to be the only shell the model sees.
|
|
13
|
+
#
|
|
14
|
+
# Config knobs (all optional, defaults shown):
|
|
15
|
+
# bbExecutable bb Babashka executable name or absolute path
|
|
16
|
+
# timeoutMs 120000 per-eval timeout; a timeout resets the session
|
|
17
|
+
# startupMs 12000 interpreter start budget
|
|
18
|
+
# maxOutputChars 16000 output clipping budget
|
|
19
|
+
# sandbox inherit 'inherit' goes through ctx.sandbox under the
|
|
20
|
+
# session's ctx.sandboxPolicy and fails closed when no
|
|
21
|
+
# backend exists; 'off' runs unconfined (tests only)
|
|
22
|
+
- insert:
|
|
23
|
+
- id: bb-domain-stack
|
|
24
|
+
name: cordis:group
|
|
25
|
+
group: true
|
|
26
|
+
isolate:
|
|
27
|
+
bbDomain: true
|
|
28
|
+
config:
|
|
29
|
+
- id: bb-repl
|
|
30
|
+
name: '@lkyhfx/dsh-bb-repl'
|
|
31
|
+
config:
|
|
32
|
+
bbExecutable: bb
|
|
33
|
+
timeoutMs: 120000
|
|
34
|
+
startupMs: 12000
|
|
35
|
+
maxOutputChars: 16000
|
|
36
|
+
sandbox: inherit
|
|
37
|
+
- id: bb-code
|
|
38
|
+
name: '@lkyhfx/dsh-bb-domain-code'
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lkyhfx/dsh-bb-repl",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "DSH Cordis plugin: owner-scoped persistent Babashka nREPL tool (bb_eval)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src", "bb-src", "cordis.patch.yml"],
|
|
13
|
+
"keywords": ["dsh", "cordis", "babashka", "nrepl", "clojure", "repl"],
|
|
14
|
+
"repository": {"type": "git", "url": "git+https://github.com/lkyhfx/dsh-bb-repl.git", "directory": "packages/repl"},
|
|
15
|
+
"publishConfig": {"access": "public"},
|
|
16
|
+
"dsh": {"bundle": {"patch": "./cordis.patch.yml"}},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@deepseek-ai/schemastery": "*",
|
|
19
|
+
"@lkyhfx/dsh-bb-domain-code": "^0.1.1"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@deepseek-ai/cordis": "*",
|
|
23
|
+
"@deepseek-ai/dsh-tools": "*"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/env.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Child environment policy for the Babashka runtime. */
|
|
2
|
+
|
|
3
|
+
// Same credential-shaped scrub the DSH subprocess seam applies to every child
|
|
4
|
+
// (`SENSITIVE_ENV_PATTERN` in @deepseek-ai/dsh-subprocess): the interpreter must
|
|
5
|
+
// not expose the host's API keys to agent-authored Clojure.
|
|
6
|
+
const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
|
|
7
|
+
const DSH_ENV_PREFIX = 'DSH_'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The parent environment minus credential-shaped names and minus every `DSH_*`
|
|
11
|
+
* name. PATH, HOME, locale and proxy variables survive so `bb`, `rg`, `git` and
|
|
12
|
+
* `diff` keep working; Harness identity and credentials never leak implicitly.
|
|
13
|
+
* Harness-owned facts are re-added explicitly by the caller.
|
|
14
|
+
*/
|
|
15
|
+
export function scrubbedChildEnv(parent = process.env) {
|
|
16
|
+
const env = {}
|
|
17
|
+
for (const [key, value] of Object.entries(parent)) {
|
|
18
|
+
if (value === undefined) continue
|
|
19
|
+
if (SENSITIVE_ENV_PATTERN.test(key)) continue
|
|
20
|
+
if (key.toUpperCase().startsWith(DSH_ENV_PREFIX)) continue
|
|
21
|
+
env[key] = value
|
|
22
|
+
}
|
|
23
|
+
return env
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Explicit, non-secret facts this runtime hands to its Babashka child. */
|
|
27
|
+
export function runtimeChildEnv({ workspace, scratch, port }) {
|
|
28
|
+
return {
|
|
29
|
+
...scrubbedChildEnv(),
|
|
30
|
+
DSH_BB_WORKSPACE_ROOT: workspace,
|
|
31
|
+
DSH_BB_SCRATCH: scratch,
|
|
32
|
+
DSH_BB_NREPL_PORT: String(port),
|
|
33
|
+
PAGER: 'cat',
|
|
34
|
+
GIT_PAGER: 'cat',
|
|
35
|
+
NO_COLOR: '1',
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/** DSH Cordis plugin: one owner-scoped persistent Babashka nREPL tool. */
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import z from '@deepseek-ai/schemastery'
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import { DomainSessions } from './sessions.js'
|
|
6
|
+
|
|
7
|
+
export const name = 'bb-repl'
|
|
8
|
+
// `sandbox` and `sandboxPolicy` are resolved through ctx.get at each session
|
|
9
|
+
// start rather than injected: they are optional (config `sandbox: 'off'` runs
|
|
10
|
+
// unconfined for tests and for deployments that accept that), and Cordis'
|
|
11
|
+
// array `inject` can only express required services.
|
|
12
|
+
export const inject = ['tools', 'workingDirectory']
|
|
13
|
+
|
|
14
|
+
const GUIDE = `Execute Clojure programs in a persistent Babashka REPL isolated to this Agent.
|
|
15
|
+
|
|
16
|
+
PERSISTENCE:
|
|
17
|
+
- Functions defined with defn and values defined with def persist across calls
|
|
18
|
+
in the same Agent session.
|
|
19
|
+
- let bindings are local to one expression; they do not survive a call.
|
|
20
|
+
- Use defonce and atoms when mutable persistent state is actually required.
|
|
21
|
+
- Inspect existing definitions before recreating them: (domain/vars).
|
|
22
|
+
- Ordinary Clojure evaluation errors do NOT reset the REPL: a var defined
|
|
23
|
+
before the error is still there, so fix the form and keep going.
|
|
24
|
+
- Timeouts, cancellations, transport failures and incompatible domain updates
|
|
25
|
+
DO reset the session. The call result says "session reset" or "session
|
|
26
|
+
rebuilt" when that happened, and then nothing is left but domain state.
|
|
27
|
+
- Inspect (domain/status) or (domain/vars) whenever state is uncertain.
|
|
28
|
+
|
|
29
|
+
COMPOSITION:
|
|
30
|
+
- Compose domain primitives with let, do, ->, ->>, map, filter, reduce and the
|
|
31
|
+
rest of clojure.core.
|
|
32
|
+
- Prefer one evaluation for a predictable multi-step task; return a compact
|
|
33
|
+
final value, not bulk file content.
|
|
34
|
+
- Reuse functions and intermediate results across calls instead of repeating
|
|
35
|
+
searches and reads.
|
|
36
|
+
- Use domain primitives for workspace file operations; they carry the guards.
|
|
37
|
+
|
|
38
|
+
DISCOVERY:
|
|
39
|
+
- (domain/status) -> workspace, mounted domains, their counters, def count.
|
|
40
|
+
- (domain/help) or (domain/help :code) -> the domain API guides.
|
|
41
|
+
- (domain/vars) -> agent-defined vars with arglists and docstrings.
|
|
42
|
+
- (code/status) -> code-domain counters and workspace.
|
|
43
|
+
- (meta #'code/search) -> signature and docstring of one primitive.
|
|
44
|
+
|
|
45
|
+
STATE RESET:
|
|
46
|
+
- (domain/reset! "why") deliberately discards this session's definitions and
|
|
47
|
+
starts a fresh interpreter on the next call. Prefer it over working around
|
|
48
|
+
known-bad state.
|
|
49
|
+
|
|
50
|
+
SAFETY:
|
|
51
|
+
- Clojure is dynamically typed; type errors surface at runtime.
|
|
52
|
+
- Code runs with the configured runtime permissions. Never perform
|
|
53
|
+
unauthorized destructive operations.`
|
|
54
|
+
|
|
55
|
+
export const Config = z.object({
|
|
56
|
+
bbExecutable: z.string().default('bb'),
|
|
57
|
+
timeoutMs: z.number().default(120000),
|
|
58
|
+
startupMs: z.number().default(12000),
|
|
59
|
+
maxOutputChars: z.number().default(16000),
|
|
60
|
+
sandbox: z.union([z.const('inherit'), z.const('off')]).default('inherit'),
|
|
61
|
+
description: z.string().default(GUIDE),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const clip = (s, n) => s.length <= n ? s : `${s.slice(0, n)}\n[Output truncated; narrow your query]`
|
|
65
|
+
|
|
66
|
+
export function apply(ctx, config = {}) {
|
|
67
|
+
const cfg = {
|
|
68
|
+
bbExecutable: config.bbExecutable ?? 'bb',
|
|
69
|
+
timeoutMs: config.timeoutMs ?? 120000,
|
|
70
|
+
startupMs: config.startupMs ?? 12000,
|
|
71
|
+
maxOutputChars: config.maxOutputChars ?? 16000,
|
|
72
|
+
sandbox: config.sandbox ?? 'inherit',
|
|
73
|
+
description: config.description ?? GUIDE,
|
|
74
|
+
}
|
|
75
|
+
for (const field of ['timeoutMs', 'startupMs', 'maxOutputChars']) {
|
|
76
|
+
if (!Number.isSafeInteger(cfg[field]) || cfg[field] < 1) throw new Error(`bb-repl: invalid ${field}`)
|
|
77
|
+
}
|
|
78
|
+
if (cfg.sandbox !== 'inherit' && cfg.sandbox !== 'off') throw new Error('bb-repl: sandbox must be "inherit" or "off"')
|
|
79
|
+
|
|
80
|
+
const modules = new Map()
|
|
81
|
+
|
|
82
|
+
const policyService = () => ctx.get?.('sandboxPolicy') ?? ctx.sandboxPolicy
|
|
83
|
+
const sandboxService = () => ctx.get?.('sandbox') ?? ctx.sandbox
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The session's file-effect policy, or a synthetic one when confinement is off.
|
|
87
|
+
*
|
|
88
|
+
* `key` is the reuse identity of a live interpreter: it covers every input
|
|
89
|
+
* that changes what the *running* process is allowed to touch, so a revoked or
|
|
90
|
+
* widened grant can never be served by a process started under the old one.
|
|
91
|
+
* DSH's `SandboxExecutionPolicy` carries `additionalWritableRoots` and
|
|
92
|
+
* `sessionId` besides mode/root, so both are part of the key.
|
|
93
|
+
*/
|
|
94
|
+
const resolvePolicy = (owner, workspace) => {
|
|
95
|
+
if (cfg.sandbox === 'off') return { mode: 'danger-full-access', key: 'unconfined' }
|
|
96
|
+
const service = policyService()
|
|
97
|
+
if (!service) {
|
|
98
|
+
throw new Error('bb-repl: sandbox mode "inherit" requires ctx.sandboxPolicy; mount @deepseek-ai/dsh-sandbox-policy or set config sandbox: "off"')
|
|
99
|
+
}
|
|
100
|
+
const resolved = service.resolve({ session: owner.session })
|
|
101
|
+
// The Agent's working directory IS the workspace-write boundary; the policy
|
|
102
|
+
// root is only the agentless fallback, matching ctx.sandboxPolicy semantics.
|
|
103
|
+
const root = resolve(workspace ?? resolved.workspaceRoot)
|
|
104
|
+
const extraRoots = [...new Set((resolved.additionalWritableRoots ?? []).map(r => resolve(r)))].sort()
|
|
105
|
+
const sessionId = resolved.sessionId === undefined ? '' : String(resolved.sessionId)
|
|
106
|
+
const key = [resolved.mode, root, ...extraRoots, sessionId].join('\u0000')
|
|
107
|
+
return { ...resolved, workspaceRoot: root, additionalWritableRoots: extraRoots, key }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wrap the interpreter argv under the session's policy, mirroring the DSH
|
|
112
|
+
* sandbox contract: `danger-full-access` spawns the original argv, any other
|
|
113
|
+
* mode must be enforced or the start fails closed.
|
|
114
|
+
*/
|
|
115
|
+
const confineArgv = async ({ argv, workspaceRoot, owner, policy, signal }) => {
|
|
116
|
+
const resolved = policy ?? resolvePolicy(owner, workspaceRoot)
|
|
117
|
+
if (resolved.mode === 'danger-full-access') return { argv, policy: resolved }
|
|
118
|
+
const sandbox = sandboxService()
|
|
119
|
+
if (!sandbox) {
|
|
120
|
+
throw new Error(`bb-repl: sandbox mode "${resolved.mode}" is requested but no sandbox backend is usable in this composition; refusing to run Babashka unconfined. Set config sandbox: "off" only if you accept unconfined code execution.`)
|
|
121
|
+
}
|
|
122
|
+
const confined = await sandbox.confine(argv, { ...resolved, mode: resolved.mode }, signal)
|
|
123
|
+
if (!confined?.argv?.length) throw new Error('bb-repl: sandbox returned an empty argv')
|
|
124
|
+
return { argv: confined.argv, policy: resolved, enforcement: confined.enforcement }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const sessions = new DomainSessions({
|
|
128
|
+
bbExecutable: cfg.bbExecutable,
|
|
129
|
+
timeoutMs: cfg.timeoutMs,
|
|
130
|
+
startupMs: cfg.startupMs,
|
|
131
|
+
modules: () => [...modules.values()],
|
|
132
|
+
onLog: message => ctx.logger.warn(message),
|
|
133
|
+
confine: confineArgv,
|
|
134
|
+
policyFor: resolvePolicy,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
let disposeTool
|
|
138
|
+
const buildDescription = () => [
|
|
139
|
+
cfg.description,
|
|
140
|
+
...[...modules.values()].map(m => `DOMAIN ${m.id}:\n${m.instructions}`),
|
|
141
|
+
].join('\n\n')
|
|
142
|
+
|
|
143
|
+
const bindTool = () => {
|
|
144
|
+
disposeTool?.()
|
|
145
|
+
disposeTool = ctx.tools.register(defineTool({
|
|
146
|
+
name: 'bb_eval',
|
|
147
|
+
description: buildDescription(),
|
|
148
|
+
parameters: {
|
|
149
|
+
code: { type: 'string', required: true, description: 'Clojure forms to execute in the persistent Babashka session.' },
|
|
150
|
+
},
|
|
151
|
+
output: {
|
|
152
|
+
schema: { type: 'string' },
|
|
153
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
154
|
+
},
|
|
155
|
+
async execute(args, exec) {
|
|
156
|
+
if (typeof args.code !== 'string' || !args.code.trim()) throw new Error('code is required')
|
|
157
|
+
if (!exec.agent) throw new Error('bb_eval requires an owning Agent')
|
|
158
|
+
const workspace = await ctx.workingDirectory.ensure(exec.agent, exec.signal)
|
|
159
|
+
try {
|
|
160
|
+
const result = await sessions.evaluate(exec.agent, workspace, args.code, exec.signal)
|
|
161
|
+
const notes = result.notes ?? []
|
|
162
|
+
const body = [
|
|
163
|
+
notes.length ? `runtime:\n${notes.join('\n')}` : '',
|
|
164
|
+
result.out ? `stdout:\n${result.out}` : '',
|
|
165
|
+
result.value ? `value:\n${result.value}` : '',
|
|
166
|
+
result.err ? `stderr:\n${result.err}` : '',
|
|
167
|
+
result.exception ? `exception: ${result.exception}` : '',
|
|
168
|
+
result.error ? `error: ${result.error.code}` : '',
|
|
169
|
+
`status: ${result.ok ? 'ok' : 'error'}`,
|
|
170
|
+
].filter(Boolean).join('\n')
|
|
171
|
+
return clip(body, cfg.maxOutputChars)
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (exec.signal.aborted) return '' // Host handles ABORTED.
|
|
174
|
+
const notes = error.notes?.length ? ` (${error.notes.join('; ')})` : ''
|
|
175
|
+
throw new Error(`Babashka runtime: ${error.message}${notes}. The session was reset; re-run with a fresh interpreter.`)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
presentCall: args => ({ card: 'terminal', title: args.code.slice(0, 160) }),
|
|
179
|
+
}))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const registry = {
|
|
183
|
+
/**
|
|
184
|
+
* Register one domain manifest. A changed `version`/`stateVersion` for an
|
|
185
|
+
* already mounted id replaces it: live sessions rebuild before the next
|
|
186
|
+
* evaluation and the tool description is rebound.
|
|
187
|
+
*/
|
|
188
|
+
register(manifest) {
|
|
189
|
+
const required = ['id', 'instructions']
|
|
190
|
+
for (const field of required) {
|
|
191
|
+
if (!manifest?.[field]) throw new Error(`Domain manifest requires ${required.join(', ')}`)
|
|
192
|
+
}
|
|
193
|
+
if (!manifest.classpath && !manifest.init) throw new Error(`Domain ${manifest.id} requires classpath or init`)
|
|
194
|
+
const next = {
|
|
195
|
+
...manifest,
|
|
196
|
+
version: manifest.version ?? '0.0.0',
|
|
197
|
+
stateVersion: manifest.stateVersion ?? 0,
|
|
198
|
+
}
|
|
199
|
+
const previous = modules.get(next.id)
|
|
200
|
+
if (previous && previous.version === next.version && previous.stateVersion === next.stateVersion
|
|
201
|
+
&& previous.classpath === next.classpath && previous.init === next.init
|
|
202
|
+
&& previous.instructions === next.instructions) {
|
|
203
|
+
throw new Error(`Duplicate Babashka domain: ${next.id}@${next.version}`)
|
|
204
|
+
}
|
|
205
|
+
modules.set(next.id, next)
|
|
206
|
+
bindTool() // Tool description doubles as the SKILL.md-equivalent guide.
|
|
207
|
+
return () => {
|
|
208
|
+
if (modules.get(next.id) === next) modules.delete(next.id)
|
|
209
|
+
bindTool()
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
list: () => [...modules.values()].map(({ id, version, stateVersion, instructions }) => ({ id, version, stateVersion, instructions })),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
ctx.provide('bbDomain', registry)
|
|
216
|
+
bindTool()
|
|
217
|
+
ctx.effect(() => async () => {
|
|
218
|
+
disposeTool?.()
|
|
219
|
+
await sessions.close()
|
|
220
|
+
}, 'bb-repl owner session cleanup')
|
|
221
|
+
}
|
package/src/nrepl.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import net from 'node:net'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
// Minimal bencode implementation for the nREPL wire protocol. No runtime deps.
|
|
5
|
+
export function encode(value) {
|
|
6
|
+
if (typeof value === 'number') return Buffer.from(`i${value}e`)
|
|
7
|
+
if (typeof value === 'string') {
|
|
8
|
+
const b = Buffer.from(value, 'utf8')
|
|
9
|
+
return Buffer.concat([Buffer.from(`${b.length}:`), b])
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) return Buffer.concat([Buffer.from('l'), ...value.map(encode), Buffer.from('e')])
|
|
12
|
+
if (value && typeof value === 'object') {
|
|
13
|
+
const chunks = [Buffer.from('d')]
|
|
14
|
+
for (const key of Object.keys(value).sort()) {
|
|
15
|
+
if (value[key] === undefined || value[key] === null) continue
|
|
16
|
+
chunks.push(encode(key), encode(value[key]))
|
|
17
|
+
}
|
|
18
|
+
return Buffer.concat([...chunks, Buffer.from('e')])
|
|
19
|
+
}
|
|
20
|
+
throw new TypeError('Unsupported bencode value')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MORE = Symbol('incomplete bencode frame')
|
|
24
|
+
|
|
25
|
+
/** Text bytes one nREPL response contributes to a request's accumulated output. */
|
|
26
|
+
function responseBytes(item) {
|
|
27
|
+
let total = 0
|
|
28
|
+
for (const field of ['out', 'err', 'value', 'ex']) {
|
|
29
|
+
const text = item[field]
|
|
30
|
+
if (typeof text === 'string') total += Buffer.byteLength(text)
|
|
31
|
+
}
|
|
32
|
+
return total
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parse(input, from = 0) {
|
|
36
|
+
if (from >= input.length) throw MORE
|
|
37
|
+
const marker = input[from]
|
|
38
|
+
if (marker === 105) { // i
|
|
39
|
+
const end = input.indexOf(101, from + 1)
|
|
40
|
+
if (end < 0) throw MORE
|
|
41
|
+
const raw = input.toString('ascii', from + 1, end)
|
|
42
|
+
if (!/^-?(0|[1-9][0-9]*)$/.test(raw)) throw new Error('Invalid bencode integer')
|
|
43
|
+
return [Number(raw), end + 1]
|
|
44
|
+
}
|
|
45
|
+
if (marker === 108 || marker === 100) { // l or d
|
|
46
|
+
const entries = []
|
|
47
|
+
let next = from + 1
|
|
48
|
+
while (true) {
|
|
49
|
+
if (next >= input.length) throw MORE
|
|
50
|
+
if (input[next] === 101) break
|
|
51
|
+
const [item, end] = parse(input, next)
|
|
52
|
+
entries.push(item)
|
|
53
|
+
next = end
|
|
54
|
+
}
|
|
55
|
+
if (marker === 108) return [entries, next + 1]
|
|
56
|
+
if (entries.length % 2 !== 0) throw new Error('Odd number of bencode dictionary entries')
|
|
57
|
+
const dict = {}
|
|
58
|
+
for (let i = 0; i < entries.length; i += 2) {
|
|
59
|
+
if (typeof entries[i] !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(entries[i])) {
|
|
60
|
+
throw new Error('Invalid bencode dictionary key')
|
|
61
|
+
}
|
|
62
|
+
dict[entries[i]] = entries[i + 1]
|
|
63
|
+
}
|
|
64
|
+
return [dict, next + 1]
|
|
65
|
+
}
|
|
66
|
+
if (marker >= 48 && marker <= 57) {
|
|
67
|
+
const colon = input.indexOf(58, from)
|
|
68
|
+
if (colon < 0) throw MORE
|
|
69
|
+
const raw = input.toString('ascii', from, colon)
|
|
70
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw)) throw new Error('Invalid bencode string length')
|
|
71
|
+
const count = Number(raw)
|
|
72
|
+
if (!Number.isSafeInteger(count) || count > 32 * 1024 * 1024) throw new Error('nREPL string exceeds limit')
|
|
73
|
+
const end = colon + 1 + count
|
|
74
|
+
if (end > input.length) throw MORE
|
|
75
|
+
return [input.toString('utf8', colon + 1, end), end]
|
|
76
|
+
}
|
|
77
|
+
throw new Error('Invalid bencode marker')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function decodeFrames(buffer) {
|
|
81
|
+
const results = []
|
|
82
|
+
let pos = 0
|
|
83
|
+
while (pos < buffer.length) {
|
|
84
|
+
try {
|
|
85
|
+
const [message, end] = parse(buffer, pos)
|
|
86
|
+
results.push(message)
|
|
87
|
+
pos = end
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error === MORE) break
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { messages: results, rest: buffer.subarray(pos) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// nREPL status values that mean the evaluation did not complete.
|
|
97
|
+
const FAILURES = ['eval-error', 'namespace-not-found', 'error', 'unknown-op', 'session-missing']
|
|
98
|
+
|
|
99
|
+
export class NReplClient {
|
|
100
|
+
/**
|
|
101
|
+
* @param socket - the connected nREPL socket.
|
|
102
|
+
* @param maxBufferBytes - cap for undecoded bytes waiting in the frame buffer.
|
|
103
|
+
* @param maxResponseBytes - cap for the text accumulated across the responses
|
|
104
|
+
* of ONE request. Without it a program that keeps printing never finishes
|
|
105
|
+
* collecting, because the caps on the frame buffer and on the final tool
|
|
106
|
+
* output both apply only after the whole reply has been buffered.
|
|
107
|
+
*/
|
|
108
|
+
constructor(socket, maxBufferBytes = 16 * 1024 * 1024, maxResponseBytes = 4 * 1024 * 1024) {
|
|
109
|
+
this.socket = socket
|
|
110
|
+
this.maxBufferBytes = maxBufferBytes
|
|
111
|
+
this.maxResponseBytes = maxResponseBytes
|
|
112
|
+
this.buffer = Buffer.alloc(0)
|
|
113
|
+
this.pending = new Map()
|
|
114
|
+
socket.on('data', bytes => this.onData(bytes))
|
|
115
|
+
socket.on('error', err => this.failAll(err))
|
|
116
|
+
socket.on('close', () => this.failAll(new Error('nREPL socket closed')))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Connect to an nREPL endpoint.
|
|
121
|
+
* @param port - loopback port the server listens on.
|
|
122
|
+
* @param signal - optional cancellation while connecting.
|
|
123
|
+
* @param options - optional `maxBufferBytes` / `maxResponseBytes` overrides.
|
|
124
|
+
*/
|
|
125
|
+
static async connect(port, signal, options = {}) {
|
|
126
|
+
return await new Promise((resolve, reject) => {
|
|
127
|
+
const socket = net.createConnection({ host: '127.0.0.1', port })
|
|
128
|
+
let settled = false
|
|
129
|
+
const cleanup = () => signal?.removeEventListener('abort', onAbort)
|
|
130
|
+
const done = (fn, value) => {
|
|
131
|
+
if (settled) return
|
|
132
|
+
settled = true
|
|
133
|
+
cleanup()
|
|
134
|
+
fn(value)
|
|
135
|
+
}
|
|
136
|
+
const onAbort = () => {
|
|
137
|
+
socket.destroy()
|
|
138
|
+
done(reject, signal.reason ?? new Error('Aborted'))
|
|
139
|
+
}
|
|
140
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
141
|
+
socket.once('connect', () => done(resolve, new NReplClient(socket, options.maxBufferBytes, options.maxResponseBytes)))
|
|
142
|
+
socket.once('error', e => done(reject, e))
|
|
143
|
+
if (signal?.aborted) onAbort()
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
onData(data) {
|
|
148
|
+
try {
|
|
149
|
+
this.buffer = Buffer.concat([this.buffer, data])
|
|
150
|
+
if (this.buffer.length > this.maxBufferBytes) throw new Error('nREPL response exceeds buffer limit')
|
|
151
|
+
const { messages, rest } = decodeFrames(this.buffer)
|
|
152
|
+
this.buffer = Buffer.from(rest)
|
|
153
|
+
for (const item of messages) {
|
|
154
|
+
const wait = this.pending.get(item.id)
|
|
155
|
+
if (!wait) continue
|
|
156
|
+
wait.parts.push(item)
|
|
157
|
+
wait.bytes += responseBytes(item)
|
|
158
|
+
if (wait.bytes > this.maxResponseBytes) {
|
|
159
|
+
// Stop the flood for good: this request cannot be salvaged and the
|
|
160
|
+
// interpreter's state is unknown, so the caller resets the session.
|
|
161
|
+
this.pending.delete(item.id)
|
|
162
|
+
const error = new Error(`nREPL response exceeded ${this.maxResponseBytes} bytes; evaluation aborted`)
|
|
163
|
+
error.code = 'NREPL_OUTPUT_LIMIT'
|
|
164
|
+
error.truncated = true
|
|
165
|
+
wait.reject(error)
|
|
166
|
+
this.socket.destroy(error)
|
|
167
|
+
this.failAll(error)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(item.status) && item.status.includes('done')) {
|
|
171
|
+
this.pending.delete(item.id)
|
|
172
|
+
wait.resolve(wait.parts)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.socket.destroy(err)
|
|
177
|
+
this.failAll(err)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
failAll(error) {
|
|
182
|
+
for (const item of this.pending.values()) item.reject(error)
|
|
183
|
+
this.pending.clear()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
request(fields) {
|
|
187
|
+
const id = randomUUID()
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
if (this.socket.destroyed) return reject(new Error('nREPL connection is closed'))
|
|
190
|
+
this.pending.set(id, { resolve, reject, parts: [], bytes: 0 })
|
|
191
|
+
try { this.socket.write(encode({ ...fields, id })) }
|
|
192
|
+
catch (e) { this.pending.delete(id); reject(e) }
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async cloneSession() {
|
|
197
|
+
const responses = await this.request({ op: 'clone' })
|
|
198
|
+
const session = responses.find(x => x['new-session'])?.['new-session']
|
|
199
|
+
if (!session) throw new Error('nREPL server did not return a session id')
|
|
200
|
+
return session
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async eval(session, code) {
|
|
204
|
+
const responses = await this.request({ op: 'eval', session, code })
|
|
205
|
+
const statuses = responses.flatMap(x => Array.isArray(x.status) ? x.status : [])
|
|
206
|
+
const failure = statuses.find(x => FAILURES.includes(x))
|
|
207
|
+
const err = responses.map(x => x.err ?? '').join('')
|
|
208
|
+
return {
|
|
209
|
+
ok: failure === undefined,
|
|
210
|
+
value: responses.filter(x => typeof x.value === 'string').map(x => x.value).join('\n'),
|
|
211
|
+
out: responses.map(x => x.out ?? '').join(''),
|
|
212
|
+
err,
|
|
213
|
+
exception: responses.find(x => x.ex)?.ex,
|
|
214
|
+
status: [...new Set(statuses)],
|
|
215
|
+
// Machine-readable failure identity, so callers do not have to parse text.
|
|
216
|
+
error: failure === undefined ? undefined : { code: failure, message: err.trim().split('\n')[0] ?? '' },
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
close() { this.socket.destroy() }
|
|
221
|
+
}
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createServer } from 'node:net'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join, delimiter, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
|
|
7
|
+
import { NReplClient } from './nrepl.js'
|
|
8
|
+
import { runtimeChildEnv } from './env.js'
|
|
9
|
+
|
|
10
|
+
// domain.runtime ships with this package; a caller may add more, never less.
|
|
11
|
+
const runtimeClasspath = resolve(dirname(fileURLToPath(import.meta.url)), '../bb-src')
|
|
12
|
+
|
|
13
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
14
|
+
|
|
15
|
+
/** How long a session may take to die after SIGTERM before SIGKILL. */
|
|
16
|
+
const TERM_GRACE_MS = 1500
|
|
17
|
+
const GROUP_POLL_MS = 50
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check whether the interpreter's process group still contains a runnable
|
|
21
|
+
* process. Linux /proc lets us ignore zombies (they cannot execute or write).
|
|
22
|
+
* Other POSIX systems use kill(0) against the group; Windows can only observe
|
|
23
|
+
* the direct child here.
|
|
24
|
+
*/
|
|
25
|
+
async function processGroupAlive(proc) {
|
|
26
|
+
if (!proc.pid) return false
|
|
27
|
+
if (process.platform === 'win32') return proc.exitCode === null && proc.signalCode === null
|
|
28
|
+
if (proc.exitCode === null && proc.signalCode === null) return true
|
|
29
|
+
if (process.platform === 'linux') {
|
|
30
|
+
try {
|
|
31
|
+
const pids = (await readdir('/proc')).filter(pid => /^\d+$/.test(pid))
|
|
32
|
+
const stats = await Promise.all(pids.map(pid =>
|
|
33
|
+
readFile(`/proc/${pid}/stat`, 'utf8').catch(() => null)))
|
|
34
|
+
let inspected = false
|
|
35
|
+
for (const stat of stats) {
|
|
36
|
+
if (!stat) continue
|
|
37
|
+
inspected = true
|
|
38
|
+
const close = stat.lastIndexOf(')')
|
|
39
|
+
if (close < 0) continue
|
|
40
|
+
const fields = stat.slice(close + 2).trim().split(/\s+/)
|
|
41
|
+
if (Number(fields[2]) === proc.pid && fields[0] !== 'Z' && fields[0] !== 'X') return true
|
|
42
|
+
}
|
|
43
|
+
if (inspected) return false
|
|
44
|
+
} catch {
|
|
45
|
+
// /proc may be unavailable in restricted containers; use kill(0) below.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-proc.pid, 0)
|
|
50
|
+
return true
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ESRCH') return false
|
|
53
|
+
if (error.code === 'EPERM') return true
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Signal the whole POSIX process group, even if its original leader has exited. */
|
|
59
|
+
function signalTree(proc, signal) {
|
|
60
|
+
if (process.platform !== 'win32' && proc.pid) {
|
|
61
|
+
try { process.kill(-proc.pid, signal) }
|
|
62
|
+
catch (error) { if (error.code !== 'ESRCH') throw error }
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
66
|
+
try { proc.kill(signal) } catch (error) {
|
|
67
|
+
if (error.code !== 'ESRCH') throw error
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Wait for the process group (not merely its leader) to stop running. */
|
|
73
|
+
async function waitForTreeExit(proc, graceMs) {
|
|
74
|
+
const deadline = Date.now() + graceMs
|
|
75
|
+
while (await processGroupAlive(proc)) {
|
|
76
|
+
if (Date.now() >= deadline) return false
|
|
77
|
+
await sleep(GROUP_POLL_MS)
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Terminate one interpreter's process group: SIGTERM, wait for every runnable
|
|
84
|
+
* member to exit, then SIGKILL any survivors. The main child exiting is not
|
|
85
|
+
* sufficient: background children may outlive it.
|
|
86
|
+
*/
|
|
87
|
+
async function terminateTree(proc, onLog) {
|
|
88
|
+
if (!await processGroupAlive(proc)) return
|
|
89
|
+
signalTree(proc, 'SIGTERM')
|
|
90
|
+
if (await waitForTreeExit(proc, TERM_GRACE_MS)) return
|
|
91
|
+
onLog?.('Babashka process group still had live members after SIGTERM; sending SIGKILL')
|
|
92
|
+
signalTree(proc, 'SIGKILL')
|
|
93
|
+
if (!await waitForTreeExit(proc, TERM_GRACE_MS)) {
|
|
94
|
+
throw new Error('Babashka process group could not be terminated; refusing to start a new session')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const q = value => JSON.stringify(value) // strings use Clojure-compatible JSON escaping
|
|
98
|
+
|
|
99
|
+
/** Stable identity of a mounted domain implementation, used to detect hot swaps. */
|
|
100
|
+
const signature = m => [m.version ?? '', m.stateVersion ?? '', m.classpath ?? '', m.init ?? ''].join('\u0000')
|
|
101
|
+
const label = m => `${m.id}@${m.version ?? '0.0.0'}`
|
|
102
|
+
|
|
103
|
+
async function freeLocalPort() {
|
|
104
|
+
const server = createServer()
|
|
105
|
+
try {
|
|
106
|
+
await new Promise((resolve, reject) => {
|
|
107
|
+
server.once('error', reject)
|
|
108
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
109
|
+
})
|
|
110
|
+
return server.address().port
|
|
111
|
+
} finally { await new Promise(r => server.close(r)) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-Agent Babashka + nREPL sessions.
|
|
116
|
+
*
|
|
117
|
+
* Everything about *how* the child process is launched (sandbox confinement,
|
|
118
|
+
* environment policy) is supplied by the plugin: this class only decides when a
|
|
119
|
+
* session starts, which domains it carries, and when it must be rebuilt.
|
|
120
|
+
*/
|
|
121
|
+
export class DomainSessions {
|
|
122
|
+
constructor({
|
|
123
|
+
bbExecutable = 'bb', startupMs = 12000, timeoutMs = 120000, moduleSyncMs,
|
|
124
|
+
runtimeClasspath: extraRuntimeClasspath = '', modules, onLog = () => {}, confine, policyFor,
|
|
125
|
+
}) {
|
|
126
|
+
this.bbExecutable = bbExecutable
|
|
127
|
+
this.startupMs = startupMs
|
|
128
|
+
this.timeoutMs = timeoutMs
|
|
129
|
+
// Hot-loading a domain runs real Clojure before the user code deadline
|
|
130
|
+
// starts; bound it so a domain that hangs cannot block the Agent forever.
|
|
131
|
+
this.moduleSyncMs = moduleSyncMs ?? Math.min(startupMs, 30000)
|
|
132
|
+
this.runtimeClasspath = [runtimeClasspath, extraRuntimeClasspath].filter(Boolean).join(delimiter)
|
|
133
|
+
this.modules = modules
|
|
134
|
+
this.onLog = onLog
|
|
135
|
+
this.confine = confine
|
|
136
|
+
// Resolved per evaluation so a session-mode change rebuilds the process.
|
|
137
|
+
this.policyFor = policyFor
|
|
138
|
+
this.live = new Map()
|
|
139
|
+
this.pending = new WeakMap()
|
|
140
|
+
this.queues = new WeakMap()
|
|
141
|
+
this.creating = new Set()
|
|
142
|
+
// Owners already wired to a lifecycle cleanup; a rebuilt session must not
|
|
143
|
+
// register a second disposer for the same Agent.
|
|
144
|
+
this.owned = new WeakSet()
|
|
145
|
+
this.disposed = false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
spawnArgv(manifests, port) {
|
|
149
|
+
const classpath = [this.runtimeClasspath, ...manifests.map(m => m.classpath)].filter(Boolean).join(delimiter)
|
|
150
|
+
return [this.bbExecutable, ...(classpath ? ['--classpath', classpath] : []), 'nrepl-server', `127.0.0.1:${port}`]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Register a mounted domain with domain.runtime so the Agent can inspect it. */
|
|
154
|
+
domainRegistration(m) {
|
|
155
|
+
const fields = [
|
|
156
|
+
`:id ${q(m.id)}`,
|
|
157
|
+
`:version ${q(String(m.version ?? '0.0.0'))}`,
|
|
158
|
+
`:state-version ${q(String(m.stateVersion ?? 0))}`,
|
|
159
|
+
`:instructions ${q(m.instructions)}`,
|
|
160
|
+
]
|
|
161
|
+
if (m.statusVar) fields.push(`:status (requiring-resolve '${m.statusVar})`)
|
|
162
|
+
return `(domain.runtime/register! {${fields.join(' ')}})`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async start(owner, workspace, signal, policy) {
|
|
166
|
+
if (this.disposed) throw new Error('Babashka runtime disposed')
|
|
167
|
+
signal?.throwIfAborted()
|
|
168
|
+
const scratch = await mkdtemp(join(tmpdir(), 'dsh-bb-'))
|
|
169
|
+
const port = await freeLocalPort()
|
|
170
|
+
const manifests = this.modules()
|
|
171
|
+
const argv = this.spawnArgv(manifests, port)
|
|
172
|
+
let launched = { argv, policy: policy ?? { mode: 'danger-full-access' } }
|
|
173
|
+
try {
|
|
174
|
+
if (this.confine) launched = await this.confine({ argv, workspaceRoot: resolve(workspace), owner, policy, signal })
|
|
175
|
+
signal?.throwIfAborted()
|
|
176
|
+
} catch (error) {
|
|
177
|
+
await rm(scratch, { recursive: true, force: true })
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
const proc = spawn(launched.argv[0], launched.argv.slice(1), {
|
|
181
|
+
cwd: scratch,
|
|
182
|
+
env: runtimeChildEnv({ workspace: resolve(workspace), scratch, port }),
|
|
183
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
// Own process group, so termination reaches the JVM and every child it
|
|
186
|
+
// forked (rg, git, diff, shells) instead of just the JVM.
|
|
187
|
+
detached: process.platform !== 'win32',
|
|
188
|
+
})
|
|
189
|
+
let startupLog = ''
|
|
190
|
+
let exitError
|
|
191
|
+
const append = data => { startupLog = (startupLog + String(data)).slice(-4000) }
|
|
192
|
+
proc.stdout.on('data', append)
|
|
193
|
+
proc.stderr.on('data', append)
|
|
194
|
+
proc.on('error', err => { exitError = err })
|
|
195
|
+
proc.on('exit', (code, sig) => { exitError = new Error(`Babashka exited (code ${code}, signal ${sig})`) })
|
|
196
|
+
const session = {
|
|
197
|
+
proc, client: undefined, sessionId: '', workspace: resolve(workspace), scratch,
|
|
198
|
+
modules: new Map(), policyKey: policy?.key ?? 'unconfined', sandbox: launched.policy ?? null,
|
|
199
|
+
enforcement: launched.enforcement,
|
|
200
|
+
}
|
|
201
|
+
// An unexpected leader exit does not prove its children are gone. Run the
|
|
202
|
+
// same process-group shutdown path used by reset() before releasing scratch.
|
|
203
|
+
proc.once('exit', () => {
|
|
204
|
+
void this.stopSession(owner, session).catch(error => this.onLog(String(error?.message ?? error)))
|
|
205
|
+
})
|
|
206
|
+
let client
|
|
207
|
+
try {
|
|
208
|
+
const deadline = Date.now() + this.startupMs
|
|
209
|
+
while (Date.now() < deadline) {
|
|
210
|
+
if (signal?.aborted) throw signal.reason ?? new Error('Cancelled')
|
|
211
|
+
if (exitError) throw exitError
|
|
212
|
+
try { client = await NReplClient.connect(port, signal); break }
|
|
213
|
+
catch (e) { if (signal?.aborted) throw e; await sleep(80) }
|
|
214
|
+
}
|
|
215
|
+
if (!client) throw new Error(`nREPL startup timeout: ${startupLog}`)
|
|
216
|
+
session.client = client
|
|
217
|
+
await this.bounded(async () => {
|
|
218
|
+
session.sessionId = await client.cloneSession()
|
|
219
|
+
const init = [
|
|
220
|
+
// `domain` is the session runtime facade; the alias lives in the
|
|
221
|
+
// session's own namespace so the Agent can call (domain/status).
|
|
222
|
+
`(require '[domain.runtime :as domain])`,
|
|
223
|
+
...manifests.map(m => m.init).filter(Boolean),
|
|
224
|
+
...manifests.map(m => this.domainRegistration(m)),
|
|
225
|
+
':ready',
|
|
226
|
+
]
|
|
227
|
+
const res = await client.eval(session.sessionId, `(do ${init.join(' ')})`)
|
|
228
|
+
if (!res.ok) throw new Error(`Domain namespace initialization failed: ${res.err || res.exception || res.status.join(', ')}`)
|
|
229
|
+
for (const m of manifests) session.modules.set(m.id, { signature: signature(m), version: m.version })
|
|
230
|
+
}, this.startupMs, 'nREPL session initialization', signal)
|
|
231
|
+
if (proc.exitCode !== null || proc.signalCode !== null || session.reaped) {
|
|
232
|
+
throw exitError ?? new Error('Babashka exited during session initialization')
|
|
233
|
+
}
|
|
234
|
+
this.live.set(owner, session)
|
|
235
|
+
if (!this.owned.has(owner)) {
|
|
236
|
+
this.owned.add(owner)
|
|
237
|
+
owner.ctx?.effect?.(() => () => { void this.reset(owner).catch(error => this.onLog(String(error?.message ?? error))) }, 'dsh-bb owner session cleanup')
|
|
238
|
+
}
|
|
239
|
+
return session
|
|
240
|
+
} catch (error) {
|
|
241
|
+
client?.close()
|
|
242
|
+
const message = `${error.message}${startupLog ? `\n${startupLog}` : ''}`
|
|
243
|
+
try {
|
|
244
|
+
await this.stopSession(owner, session)
|
|
245
|
+
} catch (cleanupError) {
|
|
246
|
+
// Keep an unkillable session registered: ensure() will retry cleanup and
|
|
247
|
+
// must never start another interpreter beside this unknown process group.
|
|
248
|
+
this.live.set(owner, session)
|
|
249
|
+
throw new Error(`${message}; cleanup failed: ${cleanupError.message}`)
|
|
250
|
+
}
|
|
251
|
+
throw new Error(message)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Stop a session once; concurrent exit/reset paths share the same promise. */
|
|
256
|
+
async stopSession(owner, session) {
|
|
257
|
+
if (session.cleanupPromise) return session.cleanupPromise
|
|
258
|
+
const cleanup = (async () => {
|
|
259
|
+
session.client?.close()
|
|
260
|
+
await terminateTree(session.proc, this.onLog)
|
|
261
|
+
await this.reap(owner, session)
|
|
262
|
+
})()
|
|
263
|
+
session.cleanupPromise = cleanup
|
|
264
|
+
try {
|
|
265
|
+
await cleanup
|
|
266
|
+
} catch (error) {
|
|
267
|
+
session.cleanupPromise = undefined
|
|
268
|
+
// Quarantine the owner until a later call can confirm the group is gone.
|
|
269
|
+
if (!this.live.has(owner) || this.live.get(owner) === session) this.live.set(owner, session)
|
|
270
|
+
throw error
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Release one session's resources exactly once, whatever killed the process. */
|
|
274
|
+
async reap(owner, session) {
|
|
275
|
+
if (session.reaped) return
|
|
276
|
+
session.reaped = true
|
|
277
|
+
if (this.live.get(owner) === session) this.live.delete(owner)
|
|
278
|
+
session.client?.close()
|
|
279
|
+
await rm(session.scratch, { recursive: true, force: true }).catch(e => this.onLog(String(e)))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Run `work` under a deadline and an abort signal.
|
|
284
|
+
* Used for module synchronization, which must not outlive a tool call.
|
|
285
|
+
*/
|
|
286
|
+
async bounded(work, ms, label, signal) {
|
|
287
|
+
signal?.throwIfAborted()
|
|
288
|
+
let timer
|
|
289
|
+
let aborted
|
|
290
|
+
const deadline = new Promise((_, reject) => {
|
|
291
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms)
|
|
292
|
+
aborted = () => reject(signal?.reason ?? new Error(`${label} aborted`))
|
|
293
|
+
signal?.addEventListener('abort', aborted, { once: true })
|
|
294
|
+
if (signal?.aborted) aborted()
|
|
295
|
+
})
|
|
296
|
+
try {
|
|
297
|
+
// Defer work until after the abort listener is installed.
|
|
298
|
+
return await Promise.race([Promise.resolve().then(work), deadline])
|
|
299
|
+
} finally {
|
|
300
|
+
clearTimeout(timer)
|
|
301
|
+
signal?.removeEventListener('abort', aborted)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async ensure(owner, workspace, signal, policy) {
|
|
306
|
+
const existing = this.live.get(owner)
|
|
307
|
+
if (existing) {
|
|
308
|
+
const mustStop = existing.cleanupPromise || existing.proc.killed
|
|
309
|
+
|| existing.proc.exitCode !== null || existing.proc.signalCode !== null
|
|
310
|
+
|| existing.workspace !== resolve(workspace)
|
|
311
|
+
|| existing.policyKey !== (policy?.key ?? 'unconfined')
|
|
312
|
+
if (mustStop) await this.stopSession(owner, existing)
|
|
313
|
+
else return existing
|
|
314
|
+
}
|
|
315
|
+
const pending = this.pending.get(owner)
|
|
316
|
+
if (pending) return pending
|
|
317
|
+
const creation = this.start(owner, workspace, signal, policy).finally(() => {
|
|
318
|
+
this.pending.delete(owner)
|
|
319
|
+
this.creating.delete(creation)
|
|
320
|
+
})
|
|
321
|
+
this.creating.add(creation)
|
|
322
|
+
this.pending.set(owner, creation)
|
|
323
|
+
return creation
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Reconcile the live session with the currently registered domains.
|
|
328
|
+
* - new domain: add classpath and load it, keeping session state;
|
|
329
|
+
* - changed or removed domain: report a rebuild so the caller resets before
|
|
330
|
+
* evaluating, instead of failing the call and making the Agent retry.
|
|
331
|
+
*/
|
|
332
|
+
async syncModules(session) {
|
|
333
|
+
const active = new Map(this.modules().map(m => [m.id, m]))
|
|
334
|
+
const removed = [...session.modules.keys()].filter(id => !active.has(id))
|
|
335
|
+
const changed = [...active.values()].filter(m => {
|
|
336
|
+
const known = session.modules.get(m.id)
|
|
337
|
+
return known && known.signature !== signature(m)
|
|
338
|
+
})
|
|
339
|
+
if (removed.length || changed.length) {
|
|
340
|
+
const parts = []
|
|
341
|
+
if (removed.length) parts.push(`removed ${removed.join(', ')}`)
|
|
342
|
+
if (changed.length) {
|
|
343
|
+
parts.push(changed.map(m => `${m.id} changed to ${m.version ?? '0.0.0'} (state version ${m.stateVersion ?? 0})`).join(', '))
|
|
344
|
+
}
|
|
345
|
+
return { reset: parts.join('; '), added: [] }
|
|
346
|
+
}
|
|
347
|
+
const added = [...active.values()].filter(m => !session.modules.has(m.id))
|
|
348
|
+
for (const m of added) {
|
|
349
|
+
if (m.classpath) {
|
|
350
|
+
const res = await session.client.eval(session.sessionId,
|
|
351
|
+
`(do (require 'babashka.classpath) (babashka.classpath/add-classpath ${q(m.classpath)}) :classpath-added)`)
|
|
352
|
+
if (!res.ok) throw new Error(`Cannot hot-load classpath ${m.id}: ${res.err}`)
|
|
353
|
+
}
|
|
354
|
+
if (m.init) {
|
|
355
|
+
const res = await session.client.eval(session.sessionId, m.init)
|
|
356
|
+
if (!res.ok) throw new Error(`Cannot hot-load domain ${m.id}: ${res.err}`)
|
|
357
|
+
}
|
|
358
|
+
const reg = await session.client.eval(session.sessionId, this.domainRegistration(m))
|
|
359
|
+
if (!reg.ok) throw new Error(`Cannot register domain ${m.id}: ${reg.err}`)
|
|
360
|
+
session.modules.set(m.id, { signature: signature(m), version: m.version })
|
|
361
|
+
}
|
|
362
|
+
return { reset: null, added: added.map(m => label(m)) }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Reset detection for a session the Agent deliberately discarded (domain.runtime/reset!). */
|
|
366
|
+
static isResetRequest(result) {
|
|
367
|
+
const text = [result?.err, result?.exception, result?.out].filter(Boolean).join('\n')
|
|
368
|
+
return /DSH_BB_RESET:/.test(text)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async evaluate(owner, workspace, code, signal, explicitPolicy) {
|
|
372
|
+
const prior = this.queues.get(owner) ?? Promise.resolve()
|
|
373
|
+
const work = prior.then(async () => {
|
|
374
|
+
if (signal?.aborted) throw signal.reason ?? new Error('Cancelled')
|
|
375
|
+
const policy = explicitPolicy ?? this.policyFor?.(owner, workspace)
|
|
376
|
+
const notes = []
|
|
377
|
+
const syncOnce = async session => this.bounded(
|
|
378
|
+
() => this.syncModules(session), this.moduleSyncMs, 'Domain synchronization', signal)
|
|
379
|
+
let session
|
|
380
|
+
let sync
|
|
381
|
+
try {
|
|
382
|
+
session = await this.ensure(owner, workspace, signal, policy)
|
|
383
|
+
sync = await syncOnce(session)
|
|
384
|
+
if (sync.reset) {
|
|
385
|
+
notes.push(`session rebuilt: ${sync.reset}`)
|
|
386
|
+
await this.reset(owner)
|
|
387
|
+
session = await this.ensure(owner, workspace, signal, policy)
|
|
388
|
+
sync = await syncOnce(session)
|
|
389
|
+
}
|
|
390
|
+
} catch (error) {
|
|
391
|
+
// Promise.race does not cancel a timed-out module init. Closing its
|
|
392
|
+
// nREPL socket and killing the process group is the cancellation.
|
|
393
|
+
let reset = false
|
|
394
|
+
try {
|
|
395
|
+
await this.reset(owner)
|
|
396
|
+
reset = true
|
|
397
|
+
} catch (resetError) {
|
|
398
|
+
error.message = `${error.message} (${resetError.message})`
|
|
399
|
+
}
|
|
400
|
+
notes.push(reset
|
|
401
|
+
? 'session reset: domain synchronization failed'
|
|
402
|
+
: 'session cleanup failed; owner quarantined')
|
|
403
|
+
error.notes = notes
|
|
404
|
+
throw error
|
|
405
|
+
}
|
|
406
|
+
if (sync.added.length) notes.push(`domain hot-loaded: ${sync.added.join(', ')}`)
|
|
407
|
+
let timeoutId
|
|
408
|
+
let aborted
|
|
409
|
+
try {
|
|
410
|
+
const result = await Promise.race([
|
|
411
|
+
session.client.eval(session.sessionId, code),
|
|
412
|
+
new Promise((_, reject) => {
|
|
413
|
+
timeoutId = setTimeout(() => reject(new Error('Babashka evaluation timed out')), this.timeoutMs)
|
|
414
|
+
aborted = () => reject(signal?.reason ?? new Error('Evaluation aborted'))
|
|
415
|
+
signal?.addEventListener('abort', aborted, { once: true })
|
|
416
|
+
if (signal?.aborted) aborted()
|
|
417
|
+
}),
|
|
418
|
+
])
|
|
419
|
+
if (!result.ok && DomainSessions.isResetRequest(result)) {
|
|
420
|
+
await this.reset(owner)
|
|
421
|
+
notes.push('session reset: requested by domain.runtime/reset!')
|
|
422
|
+
return { ...result, notes, sessionReset: true }
|
|
423
|
+
}
|
|
424
|
+
return { ...result, notes: [...notes, ...(result.notes ?? [])], sessionReset: false }
|
|
425
|
+
} catch (error) {
|
|
426
|
+
// Timeout, abort or any transport failure leaves unknown interpreter state.
|
|
427
|
+
let reset = false
|
|
428
|
+
try {
|
|
429
|
+
await this.reset(owner)
|
|
430
|
+
reset = true
|
|
431
|
+
} catch (resetError) {
|
|
432
|
+
error.message = `${error.message} (${resetError.message})`
|
|
433
|
+
}
|
|
434
|
+
notes.push(reset
|
|
435
|
+
? 'session reset: previous interpreter state discarded'
|
|
436
|
+
: 'session cleanup failed; owner quarantined')
|
|
437
|
+
error.notes = notes
|
|
438
|
+
throw error
|
|
439
|
+
} finally {
|
|
440
|
+
clearTimeout(timeoutId)
|
|
441
|
+
signal?.removeEventListener('abort', aborted)
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
const tail = work.then(() => {}, () => {})
|
|
445
|
+
this.queues.set(owner, tail)
|
|
446
|
+
try { return await work } finally { if (this.queues.get(owner) === tail) this.queues.delete(owner) }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Stop one Agent's interpreter and wait until it is really gone.
|
|
451
|
+
*
|
|
452
|
+
* The nREPL socket is closed first so an in-flight evaluation cannot deliver
|
|
453
|
+
* more output, then the whole process group is terminated and awaited. Only
|
|
454
|
+
* after the exit is confirmed do we delete the scratch directory and let the
|
|
455
|
+
* next call start a fresh process: overlapping writers are the failure this
|
|
456
|
+
* ordering exists to prevent.
|
|
457
|
+
*/
|
|
458
|
+
async reset(owner) {
|
|
459
|
+
const session = this.live.get(owner)
|
|
460
|
+
if (!session) return
|
|
461
|
+
try {
|
|
462
|
+
await this.stopSession(owner, session)
|
|
463
|
+
} catch (error) {
|
|
464
|
+
// Keep the session quarantined when any process-group member survives.
|
|
465
|
+
this.onLog(String(error?.message ?? error))
|
|
466
|
+
throw error
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async close() {
|
|
471
|
+
this.disposed = true
|
|
472
|
+
await Promise.allSettled([...this.creating])
|
|
473
|
+
await Promise.allSettled([...new Set([...this.live.keys()])].map(owner => this.reset(owner)))
|
|
474
|
+
}
|
|
475
|
+
}
|
package/README.md
DELETED