mirab 0.0.0 → 0.2.0
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/LICENSE +201 -0
- package/README.md +223 -3
- package/package.json +56 -7
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
- package/index.js +0 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// What a person approved a project's agent to reach: its manifest's domains
|
|
2
|
+
// (the station's whitelist) and packages (its image). The manifest is read
|
|
3
|
+
// from the agent's own code in its VM, and that code can change: in the VM,
|
|
4
|
+
// through a file it imports from outside agent/, through package.json. So
|
|
5
|
+
// whatever the manifest asks for now is checked against what the person
|
|
6
|
+
// approved; anything new waits (`mirab manifest approve`), never quietly in
|
|
7
|
+
// effect. Its gateway section (rixse's rules and secrets) is approved as a
|
|
8
|
+
// whole: the one in force is the one the person approved, and any change,
|
|
9
|
+
// even one that looks narrower, waits. Tools need no approval: they run in the VM, and what they can reach
|
|
10
|
+
// is still the whitelist and the station.
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { fileName, projectAgent, ruleProblem, writeJson } from 'rixse'
|
|
14
|
+
import { UNSHOWABLE } from './define'
|
|
15
|
+
import { manifestSchema, type Manifest } from './manifest'
|
|
16
|
+
|
|
17
|
+
export type Gateway = Manifest['gateway']
|
|
18
|
+
|
|
19
|
+
export interface Reach {
|
|
20
|
+
domains: string[]
|
|
21
|
+
packages: string[]
|
|
22
|
+
/** As approved; `{}` is no rules (rixse's defaults). */
|
|
23
|
+
gateway: Gateway
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const reachPath = (configDir: string, project: string, kind: string) =>
|
|
27
|
+
join(
|
|
28
|
+
configDir,
|
|
29
|
+
'manifests',
|
|
30
|
+
`${fileName(projectAgent(project))}.${kind}.json`,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
/** One spelling for one gateway section: rules in a fixed order (rixse
|
|
34
|
+
* decides by specificity, never by order), secrets by host. */
|
|
35
|
+
function canonical(g: Gateway): Gateway {
|
|
36
|
+
const out: Gateway = {}
|
|
37
|
+
if (g.rules?.length)
|
|
38
|
+
out.rules = [
|
|
39
|
+
...new Map(
|
|
40
|
+
g.rules.map((r) => {
|
|
41
|
+
const rule = {
|
|
42
|
+
host: r.host,
|
|
43
|
+
...(r.method !== undefined && { method: r.method }),
|
|
44
|
+
...(r.path !== undefined && { path: r.path }),
|
|
45
|
+
effect: r.effect,
|
|
46
|
+
}
|
|
47
|
+
return [JSON.stringify(rule), rule] as const
|
|
48
|
+
}),
|
|
49
|
+
).entries(),
|
|
50
|
+
]
|
|
51
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
52
|
+
.map(([, r]) => r)
|
|
53
|
+
const hosts = Object.keys(g.secrets ?? {}).sort()
|
|
54
|
+
if (hosts.length)
|
|
55
|
+
out.secrets = Object.fromEntries(
|
|
56
|
+
hosts.map((h) => {
|
|
57
|
+
const { agent, approved } = g.secrets![h]
|
|
58
|
+
return [h, { agent, ...(approved !== undefined && { approved }) }]
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Why rixse would refuse `g` when a session starts, or null: checked before
|
|
65
|
+
* a section is recorded or waits, so one can never be approved and then stop
|
|
66
|
+
* every session. */
|
|
67
|
+
export function gatewayProblem(g: Gateway): string | null {
|
|
68
|
+
for (const r of g.rules ?? []) {
|
|
69
|
+
const problem = ruleProblem(r)
|
|
70
|
+
if (problem) return problem
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const same = (a: Gateway, b: Gateway) =>
|
|
76
|
+
JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
|
|
77
|
+
|
|
78
|
+
const reachOf = (
|
|
79
|
+
m: Pick<Manifest, 'domains' | 'packages' | 'gateway'>,
|
|
80
|
+
): Reach => ({
|
|
81
|
+
domains: [...new Set(m.domains)].sort(),
|
|
82
|
+
packages: [...new Set(m.packages)].sort(),
|
|
83
|
+
gateway: canonical(m.gateway),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/** A reach file: missing (nothing yet), or what it holds; a file that is
|
|
87
|
+
* there but doesn't read as one is an error, never "nothing yet", so a
|
|
88
|
+
* damaged or tampered file can't turn into a first run that approves all. */
|
|
89
|
+
function read(path: string): Reach | undefined {
|
|
90
|
+
let text: string
|
|
91
|
+
try {
|
|
92
|
+
text = readFileSync(path, 'utf8')
|
|
93
|
+
} catch (e) {
|
|
94
|
+
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
95
|
+
throw e
|
|
96
|
+
}
|
|
97
|
+
let r: unknown
|
|
98
|
+
try {
|
|
99
|
+
r = JSON.parse(text)
|
|
100
|
+
} catch {
|
|
101
|
+
r = undefined
|
|
102
|
+
}
|
|
103
|
+
if (r === null) return undefined
|
|
104
|
+
const ok = (v: unknown): v is string[] =>
|
|
105
|
+
Array.isArray(v) && v.every((x) => typeof x === 'string')
|
|
106
|
+
const reach = r as Partial<Reach> | undefined
|
|
107
|
+
if (!reach || !ok(reach.domains) || !ok(reach.packages))
|
|
108
|
+
throw new Error(`${path} doesn't read as an approval; fix or remove it`)
|
|
109
|
+
// An approval from before gateway sections approved none.
|
|
110
|
+
const gateway = reach.gateway ?? {}
|
|
111
|
+
const parsed = manifestSchema.shape.gateway.safeParse(gateway)
|
|
112
|
+
if (!parsed.success || gatewayProblem(parsed.data))
|
|
113
|
+
throw new Error(`${path} doesn't read as an approval; fix or remove it`)
|
|
114
|
+
return {
|
|
115
|
+
domains: reach.domains,
|
|
116
|
+
packages: reach.packages,
|
|
117
|
+
gateway: canonical(parsed.data),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What the person approved for `project`'s agent, if anything yet. */
|
|
122
|
+
export const approvedReach = (configDir: string, project: string) =>
|
|
123
|
+
read(reachPath(configDir, project, 'approved'))
|
|
124
|
+
|
|
125
|
+
/** What the agent last asked for beyond its approval, waiting. */
|
|
126
|
+
export const pendingReach = (configDir: string, project: string) =>
|
|
127
|
+
read(reachPath(configDir, project, 'pending'))
|
|
128
|
+
|
|
129
|
+
export interface Beyond {
|
|
130
|
+
domains: string[]
|
|
131
|
+
packages: string[]
|
|
132
|
+
/** The gateway section asked for, when it isn't the approved one. */
|
|
133
|
+
gateway?: Gateway
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What `asked` adds to `approved`. */
|
|
137
|
+
export const beyond = (approved: Reach, asked: Reach): Beyond => ({
|
|
138
|
+
domains: asked.domains.filter((d) => !approved.domains.includes(d)),
|
|
139
|
+
packages: asked.packages.filter((p) => !approved.packages.includes(p)),
|
|
140
|
+
...(!same(approved.gateway, asked.gateway) && { gateway: asked.gateway }),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const isEmpty = (r: Beyond) =>
|
|
144
|
+
!r.domains.length && !r.packages.length && !r.gateway
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The manifest as it may run: its domains and packages narrowed to what the
|
|
148
|
+
* person approved. Only a session the person starts (`record`, mirab dev)
|
|
149
|
+
* records a first approval, and says what it was; anything else (looking at
|
|
150
|
+
* the manifest, building its image) approves nothing. After that, anything
|
|
151
|
+
* new is left out, kept as pending, and said; what the agent no longer asks
|
|
152
|
+
* for is no longer approved, so dropping something and adding it back asks
|
|
153
|
+
* again.
|
|
154
|
+
*/
|
|
155
|
+
export function withinApproval(
|
|
156
|
+
configDir: string,
|
|
157
|
+
project: string,
|
|
158
|
+
manifest: Manifest,
|
|
159
|
+
o: { record: boolean; log?: (line: string) => void },
|
|
160
|
+
): Manifest {
|
|
161
|
+
const asked = reachOf(manifest)
|
|
162
|
+
// A section rixse would refuse is never recorded nor waits: the one
|
|
163
|
+
// approved before, if any, stays, and the person is told why.
|
|
164
|
+
const problem = gatewayProblem(asked.gateway)
|
|
165
|
+
if (problem) {
|
|
166
|
+
asked.gateway = approvedReach(configDir, project)?.gateway ?? {}
|
|
167
|
+
o.log?.(`the agent's gateway section can't be used (${problem}): left out`)
|
|
168
|
+
}
|
|
169
|
+
const approvedPath = reachPath(configDir, project, 'approved')
|
|
170
|
+
const pendingPath = reachPath(configDir, project, 'pending')
|
|
171
|
+
let approved = approvedReach(configDir, project)
|
|
172
|
+
if (!approved) {
|
|
173
|
+
if (!o.record) {
|
|
174
|
+
approved = { domains: [], packages: [], gateway: {} }
|
|
175
|
+
} else {
|
|
176
|
+
writeJson(approvedPath, asked)
|
|
177
|
+
writeJson(pendingPath, null)
|
|
178
|
+
if (!isEmpty(asked))
|
|
179
|
+
o.log?.(
|
|
180
|
+
`first run here: approved ${describe(asked)} (later changes wait for mirab manifest approve)`,
|
|
181
|
+
)
|
|
182
|
+
return { ...manifest, gateway: asked.gateway }
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// What it stopped asking for stops being approved.
|
|
186
|
+
const kept: Reach = {
|
|
187
|
+
domains: approved.domains.filter((d) => asked.domains.includes(d)),
|
|
188
|
+
packages: approved.packages.filter((p) => asked.packages.includes(p)),
|
|
189
|
+
// The approved section stays in force until a new one is approved.
|
|
190
|
+
gateway: approved.gateway,
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
kept.domains.length !== approved.domains.length ||
|
|
194
|
+
kept.packages.length !== approved.packages.length
|
|
195
|
+
) {
|
|
196
|
+
writeJson(approvedPath, kept)
|
|
197
|
+
approved = kept
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const extra = beyond(approved, asked)
|
|
201
|
+
if (isEmpty(extra)) {
|
|
202
|
+
// Nothing waits any more: an old ask can't be approved later by mistake.
|
|
203
|
+
writeJson(pendingPath, null)
|
|
204
|
+
} else {
|
|
205
|
+
writeJson(pendingPath, asked)
|
|
206
|
+
o.log?.(
|
|
207
|
+
`the agent's definition asks for ${describe(extra)}, which you haven't approved: left out. If that was you, run mirab manifest approve here (never in the VM)`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
...manifest,
|
|
212
|
+
domains: manifest.domains.filter((d) => approved.domains.includes(d)),
|
|
213
|
+
packages: manifest.packages.filter((p) => approved.packages.includes(p)),
|
|
214
|
+
gateway: approved.gateway,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The person's yes to what waits, exactly as they were shown it: `shown`
|
|
219
|
+
* must equal what is pending now, or nothing is approved. */
|
|
220
|
+
export function approvePending(
|
|
221
|
+
configDir: string,
|
|
222
|
+
project: string,
|
|
223
|
+
shown: Reach,
|
|
224
|
+
): Reach | undefined {
|
|
225
|
+
const pending = pendingReach(configDir, project)
|
|
226
|
+
if (!pending) return undefined
|
|
227
|
+
if (JSON.stringify(pending) !== JSON.stringify(shown))
|
|
228
|
+
throw new Error('what waits changed since it was shown; look again')
|
|
229
|
+
writeJson(reachPath(configDir, project, 'approved'), pending)
|
|
230
|
+
writeJson(reachPath(configDir, project, 'pending'), null)
|
|
231
|
+
return pending
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function describe(r: Beyond) {
|
|
235
|
+
const parts = [
|
|
236
|
+
r.domains.length && `domains ${r.domains.join(', ')}`,
|
|
237
|
+
r.packages.length && `packages ${r.packages.join(', ')}`,
|
|
238
|
+
r.gateway && `the gateway section ${describeGateway(r.gateway)}`,
|
|
239
|
+
].filter(Boolean)
|
|
240
|
+
return parts.join(' and ')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A gateway section in full, as a person approves it: every rule and key. */
|
|
244
|
+
export function describeGateway(g: Gateway) {
|
|
245
|
+
// The schema refuses these; an approval file is still escaped, never trusted.
|
|
246
|
+
const shown = (s: string) =>
|
|
247
|
+
s.replace(
|
|
248
|
+
new RegExp(UNSHOWABLE.source, 'g'),
|
|
249
|
+
(c) => `\\u{${c.codePointAt(0)!.toString(16)}}`,
|
|
250
|
+
)
|
|
251
|
+
const c = canonical(g)
|
|
252
|
+
const rules = (c.rules ?? []).map(
|
|
253
|
+
(r) => `${r.effect} ${r.method ?? '*'} ${r.host}${shown(r.path ?? '/*')}`,
|
|
254
|
+
)
|
|
255
|
+
const secrets = Object.entries(c.secrets ?? {}).map(
|
|
256
|
+
([h, k]) =>
|
|
257
|
+
`${h} uses ${shown(k.agent)}${k.approved ? ` (approved sends: ${shown(k.approved)})` : ''}`,
|
|
258
|
+
)
|
|
259
|
+
const all = [...rules, ...secrets]
|
|
260
|
+
return all.length ? `(${all.join('; ')})` : '(no rules: rixse defaults)'
|
|
261
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// What an agent folder declares: `agent/agent.ts` exports defineAgent(...),
|
|
2
|
+
// and each file in `agent/tools/` exports defineTool(...). The shape follows
|
|
3
|
+
// Vercel's eve, so its users know it; everything runs inside the agent's VM.
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
/** One harar label: [a-z0-9-], 1–63 characters, not starting or ending with `-`. */
|
|
7
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
|
8
|
+
|
|
9
|
+
/** The kinds harar reserves; none of them is ever an org label. These follow
|
|
10
|
+
* harar's design (docs/architecture.md section 2, with app and channel from
|
|
11
|
+
* section 3b), which harar's code (harar-client model.rs) is catching up to. */
|
|
12
|
+
const KINDS = new Set([
|
|
13
|
+
'human',
|
|
14
|
+
'user',
|
|
15
|
+
'agent',
|
|
16
|
+
'service',
|
|
17
|
+
'broker',
|
|
18
|
+
'zone',
|
|
19
|
+
'role',
|
|
20
|
+
'profile',
|
|
21
|
+
'app',
|
|
22
|
+
'channel',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
/** Why `name` isn't a harar name of `kind` (`<org>.<kind>.<local>`), or null. */
|
|
26
|
+
export function nameProblem(
|
|
27
|
+
name: string,
|
|
28
|
+
kind: 'agent' | 'channel' | 'human',
|
|
29
|
+
example: string,
|
|
30
|
+
): string | null {
|
|
31
|
+
if (name.length > 253) return 'is longer than 253 characters'
|
|
32
|
+
const labels = name.split('.')
|
|
33
|
+
const at = labels.findIndex((label) => KINDS.has(label))
|
|
34
|
+
if (at < 0 || labels[at] !== kind)
|
|
35
|
+
return `needs the form <org>.${kind}.<name>, like ${example}`
|
|
36
|
+
if (at < 2) return `needs an org of two or more labels, like ${example}`
|
|
37
|
+
if (at !== labels.length - 2)
|
|
38
|
+
return `has one label after .${kind}., like ${example}`
|
|
39
|
+
const bad = labels.find((label) => !LABEL.test(label))
|
|
40
|
+
if (bad !== undefined)
|
|
41
|
+
return `has a label that isn't [a-z0-9-] (1–63, no leading or trailing -): ${JSON.stringify(bad)}`
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Why `name` isn't an agent name (`<org>.agent.<local>`, harar's rules), or null. */
|
|
46
|
+
export function agentNameProblem(name: string): string | null {
|
|
47
|
+
return nameProblem(name, 'agent', 'gg.addis.agent.ops')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Why `name` isn't a human's harar name (`<org>.human.<local>`), or null. */
|
|
51
|
+
export function humanNameProblem(name: string): string | null {
|
|
52
|
+
return nameProblem(name, 'human', 'gg.addis.human.sami')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const agentName = z.string().superRefine((name, ctx) => {
|
|
56
|
+
const problem = agentNameProblem(name)
|
|
57
|
+
if (problem)
|
|
58
|
+
ctx.addIssue({ code: 'custom', message: `agent name ${problem}` })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** `provider/model`, e.g. `anthropic/claude-sonnet-5-5`. */
|
|
62
|
+
export const MODEL = /^[a-z0-9-]+\/[A-Za-z0-9._-]+$/
|
|
63
|
+
const model = z
|
|
64
|
+
.string()
|
|
65
|
+
.regex(MODEL, 'model is provider/model, like anthropic/claude-sonnet-5-5')
|
|
66
|
+
|
|
67
|
+
/** A tool's name is its file's name, or its key in `tools`; the model sees exactly that. */
|
|
68
|
+
export const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/
|
|
69
|
+
|
|
70
|
+
/** Why `name` can't be a tool's name, or null. `__proto__` is refused: as a
|
|
71
|
+
* key in plain data it is dropped or changes the object, never a tool. */
|
|
72
|
+
export function toolNameProblem(name: string): string | null {
|
|
73
|
+
if (!TOOL_NAME.test(name))
|
|
74
|
+
return "a tool's name is [a-zA-Z0-9_-] up to 64 characters"
|
|
75
|
+
if (name === '__proto__') return "__proto__ can't be a tool's name"
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A tool's input: a JSON Schema for an object, which is what models take. */
|
|
80
|
+
export const toolInput = z
|
|
81
|
+
.record(z.string(), z.unknown())
|
|
82
|
+
.refine((schema) => schema.type === 'object', {
|
|
83
|
+
message: 'a tool input is a JSON Schema with type "object"',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/** A tool that is an executable in the VM: the model's input as JSON on its
|
|
87
|
+
* stdin (checked against `input` first), its answer as JSON on stdout. */
|
|
88
|
+
export const commandToolSchema = z.strictObject({
|
|
89
|
+
description: z.string().trim().min(1, 'a tool needs a description'),
|
|
90
|
+
/** An executable: on the VM's PATH, absolute, or `./` from the project. */
|
|
91
|
+
command: z.string().min(1),
|
|
92
|
+
args: z.array(z.string()).optional(),
|
|
93
|
+
input: toolInput.superRefine((schema, ctx) => {
|
|
94
|
+
try {
|
|
95
|
+
z.fromJSONSchema(schema)
|
|
96
|
+
} catch (e) {
|
|
97
|
+
ctx.addIssue({
|
|
98
|
+
code: 'custom',
|
|
99
|
+
message: `the input schema can't be checked: ${(e as Error).message}`,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
/** How long one call may take; TOOL_TIMEOUT_MS if unset. */
|
|
104
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
105
|
+
})
|
|
106
|
+
export type CommandTool = z.infer<typeof commandToolSchema>
|
|
107
|
+
|
|
108
|
+
const toolName = z.string().superRefine((name, ctx) => {
|
|
109
|
+
const problem = toolNameProblem(name)
|
|
110
|
+
if (problem) ctx.addIssue({ code: 'custom', message: problem })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/** Tools by name. zod's record drops a `__proto__` key before checking it,
|
|
114
|
+
* so the raw data is checked for one first. */
|
|
115
|
+
export function toolRecord<T extends z.ZodType>(tool: T) {
|
|
116
|
+
return z
|
|
117
|
+
.unknown()
|
|
118
|
+
.superRefine((data, ctx) => {
|
|
119
|
+
if (data && typeof data === 'object' && Object.hasOwn(data, '__proto__'))
|
|
120
|
+
ctx.addIssue({ code: 'custom', message: toolNameProblem('__proto__')! })
|
|
121
|
+
})
|
|
122
|
+
.pipe(z.record(toolName, tool))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A package in the agent's VM image, by name (a nixpkgs attribute, later). */
|
|
126
|
+
const packageName = z.string().regex(
|
|
127
|
+
// Never ending in -, which apt reads as "remove" (g++ is fine).
|
|
128
|
+
/^[a-zA-Z0-9](?:[a-zA-Z0-9._+-]{0,126}[a-zA-Z0-9+])?$/,
|
|
129
|
+
'a package is a name, like ripgrep',
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
/** A whitelist entry as the station matches it: `api.github.com`, or
|
|
133
|
+
* `*.github.com` for every name under it. Names only, never an address. */
|
|
134
|
+
export function domainProblem(domain: string): string | null {
|
|
135
|
+
const labels = (domain.startsWith('*.') ? domain.slice(2) : domain).split('.')
|
|
136
|
+
if (domain.length > 253) return 'is longer than 253 characters'
|
|
137
|
+
if (labels.length < 2) return 'needs two or more labels, like api.github.com'
|
|
138
|
+
if (labels.some((label) => !LABEL.test(label)))
|
|
139
|
+
return 'is lowercase labels of [a-z0-9-], with only a leading *. as a wildcard'
|
|
140
|
+
// A name's last label starts with a letter; 127.0.0x1 and 1.0x7f are addresses.
|
|
141
|
+
if (!/^[a-z]/.test(labels.at(-1)!)) return 'is a name, not an address'
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
const domain = z.string().superRefine((d, ctx) => {
|
|
145
|
+
const problem = domainProblem(d)
|
|
146
|
+
if (problem) ctx.addIssue({ code: 'custom', message: `domain ${problem}` })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const channel = z.string().superRefine((name, ctx) => {
|
|
150
|
+
const problem = nameProblem(name, 'channel', 'gg.addis.channel.ops')
|
|
151
|
+
if (problem) ctx.addIssue({ code: 'custom', message: `channel ${problem}` })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/** Characters that can make a terminal show something other than the text:
|
|
155
|
+
* controls, C1 controls, line separators and bidirectional overrides. A
|
|
156
|
+
* person approves rules by reading them, so none may appear in one. */
|
|
157
|
+
export const UNSHOWABLE =
|
|
158
|
+
/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/
|
|
159
|
+
|
|
160
|
+
const showable = (what: string) =>
|
|
161
|
+
z
|
|
162
|
+
.string()
|
|
163
|
+
.refine(
|
|
164
|
+
(s) => !UNSHOWABLE.test(s),
|
|
165
|
+
`${what} has a character a terminal can't show as itself`,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
/** A harar secret path: `/`-separated segments, none empty, `.` or `..`. */
|
|
169
|
+
const secretPath = showable('a secret').refine(
|
|
170
|
+
(p) => p.split('/').every((s) => s !== '' && s !== '.' && s !== '..'),
|
|
171
|
+
'a secret is a harar path, like prod/shop/STRIPE_KEY',
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
/** One of rixse's gateway rules. Its shape is checked here, in the VM; the
|
|
175
|
+
* gateway checks its meaning when a session starts, on the host. */
|
|
176
|
+
const gatewayRule = z.strictObject({
|
|
177
|
+
host: domain,
|
|
178
|
+
method: z
|
|
179
|
+
.string()
|
|
180
|
+
.regex(/^(\*|[A-Za-z]+)$/, 'method is a method name or *')
|
|
181
|
+
.optional(),
|
|
182
|
+
path: showable('a path').startsWith('/', 'path starts with /').optional(),
|
|
183
|
+
effect: z.enum(['apply', 'request', 'forbidden']),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
/** What agent/agent.ts declares. TypeScript tools are files in agent/tools/;
|
|
187
|
+
* `tools` here holds command tools. */
|
|
188
|
+
export const agentSchema = z.strictObject({
|
|
189
|
+
/** The agent's harar name: its identity everywhere it goes. */
|
|
190
|
+
name: agentName,
|
|
191
|
+
model,
|
|
192
|
+
tools: toolRecord(commandToolSchema).optional(),
|
|
193
|
+
/** What its VM image holds besides the base. */
|
|
194
|
+
packages: z.array(packageName).optional(),
|
|
195
|
+
/** Its zone's whitelist: the only names the station lets it reach. */
|
|
196
|
+
domains: z.array(domain).optional(),
|
|
197
|
+
/** The channels it joins. */
|
|
198
|
+
channels: z.array(channel).optional(),
|
|
199
|
+
/** What rixse's gateway lets its calls do: rules per host, method and path
|
|
200
|
+
* (`apply`, `request` held for a person, `forbidden`), and per host the
|
|
201
|
+
* secret its calls use, with an optional separate one for approved sends.
|
|
202
|
+
* In force only as the person approved it (mirab manifest approve). */
|
|
203
|
+
gateway: z
|
|
204
|
+
.strictObject({
|
|
205
|
+
rules: z.array(gatewayRule).optional(),
|
|
206
|
+
secrets: z
|
|
207
|
+
.record(
|
|
208
|
+
domain,
|
|
209
|
+
z.strictObject({
|
|
210
|
+
agent: secretPath,
|
|
211
|
+
approved: secretPath.optional(),
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
.optional(),
|
|
215
|
+
})
|
|
216
|
+
.optional(),
|
|
217
|
+
})
|
|
218
|
+
export type Agent = z.infer<typeof agentSchema>
|
|
219
|
+
|
|
220
|
+
export function defineAgent(agent: Agent): Agent {
|
|
221
|
+
return agentSchema.parse(agent)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface Tool<Input extends z.ZodType> {
|
|
225
|
+
description: string
|
|
226
|
+
inputSchema: Input
|
|
227
|
+
execute(input: z.infer<Input>): unknown | Promise<unknown>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function defineTool<Input extends z.ZodType>(
|
|
231
|
+
tool: Tool<Input>,
|
|
232
|
+
): Tool<Input> {
|
|
233
|
+
if (!tool.description.trim()) throw new Error('a tool needs a description')
|
|
234
|
+
return tool
|
|
235
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The agent loop as it runs in the VM: `bun loop.js <project>` on the guest
|
|
2
|
+
// pty, speaking the channel `mirab dev` opens between the person and the
|
|
3
|
+
// agent (src/channel). The model is the station's route on the guest's
|
|
4
|
+
// loopback; the key is a placeholder the station replaces on the host, so
|
|
5
|
+
// nothing in the VM ever holds it.
|
|
6
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { createInterface } from 'node:readline'
|
|
9
|
+
import type { ModelMessage } from 'ai'
|
|
10
|
+
import {
|
|
11
|
+
decodeToAgent,
|
|
12
|
+
encodeFromAgent,
|
|
13
|
+
fitted,
|
|
14
|
+
type FromAgent,
|
|
15
|
+
} from '../channel/wire'
|
|
16
|
+
import { loadManifest } from './manifest'
|
|
17
|
+
import { loadAgent, modelFor, runTurn, type ModelEndpoint } from './runtime'
|
|
18
|
+
|
|
19
|
+
/** When anything under the agent's folder last changed. */
|
|
20
|
+
export function agentStamp(project: string): string {
|
|
21
|
+
const dir = join(project, 'agent')
|
|
22
|
+
const parts: string[] = []
|
|
23
|
+
const walk = (path: string) => {
|
|
24
|
+
for (const name of readdirSync(path).sort()) {
|
|
25
|
+
const full = join(path, name)
|
|
26
|
+
// Links are part of the folder as they are; never followed.
|
|
27
|
+
const st = lstatSync(full)
|
|
28
|
+
parts.push(`${full}:${st.mtimeMs}:${st.size}`)
|
|
29
|
+
if (st.isDirectory()) walk(full)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
walk(dir)
|
|
34
|
+
} catch {
|
|
35
|
+
return ''
|
|
36
|
+
}
|
|
37
|
+
return parts.join('|')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The agent's side of the channel its station hosts (src/channel): a message
|
|
42
|
+
* in on `input`, one JSON line each; what the agent says out on `output`.
|
|
43
|
+
* Every message gets exactly one answer, its reply or why there is none. A
|
|
44
|
+
* message is untrusted input, and only ever the next thing said to the agent.
|
|
45
|
+
*/
|
|
46
|
+
export async function serve(
|
|
47
|
+
project: string,
|
|
48
|
+
input: NodeJS.ReadableStream,
|
|
49
|
+
output: NodeJS.WritableStream,
|
|
50
|
+
endpoint: ModelEndpoint,
|
|
51
|
+
) {
|
|
52
|
+
// On the guest pty: no echo, no line editing, the bytes as the host sent them.
|
|
53
|
+
const tty = input as NodeJS.ReadableStream & {
|
|
54
|
+
isTTY?: boolean
|
|
55
|
+
setRawMode?: (raw: boolean) => void
|
|
56
|
+
}
|
|
57
|
+
if (tty.isTTY) tty.setRawMode?.(true)
|
|
58
|
+
const say = (m: FromAgent) => output.write(encodeFromAgent(m))
|
|
59
|
+
let loaded = await loadAgent(project)
|
|
60
|
+
let model = modelFor(loaded.agent, endpoint)
|
|
61
|
+
let stamp = agentStamp(project)
|
|
62
|
+
say({ text: `${loaded.agent.name} is here.`, note: true })
|
|
63
|
+
let history: ModelMessage[] = []
|
|
64
|
+
for await (const line of createInterface({ input, crlfDelay: Infinity })) {
|
|
65
|
+
const message = decodeToAgent(line)
|
|
66
|
+
if (!message) continue
|
|
67
|
+
// The person's edits reach the VM as they save (live sync): pick up
|
|
68
|
+
// agent.ts, instructions and tools before the next turn, keeping the
|
|
69
|
+
// conversation.
|
|
70
|
+
const now = agentStamp(project)
|
|
71
|
+
if (now !== stamp) {
|
|
72
|
+
// Only a load that worked counts: a half-saved agent is tried again
|
|
73
|
+
// next turn, and the one that loaded answers meanwhile.
|
|
74
|
+
try {
|
|
75
|
+
const next = await loadAgent(project)
|
|
76
|
+
model = modelFor(next.agent, endpoint)
|
|
77
|
+
loaded = next
|
|
78
|
+
stamp = now
|
|
79
|
+
say({ text: 'the agent changed; reloaded', note: true })
|
|
80
|
+
} catch (e) {
|
|
81
|
+
say({
|
|
82
|
+
text: fitted(
|
|
83
|
+
`the agent changed but didn't load: ${(e as Error).message}`,
|
|
84
|
+
),
|
|
85
|
+
note: true,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const turn = await runTurn({
|
|
91
|
+
loaded,
|
|
92
|
+
model,
|
|
93
|
+
history,
|
|
94
|
+
message: message.text,
|
|
95
|
+
})
|
|
96
|
+
history = turn.messages
|
|
97
|
+
say({
|
|
98
|
+
text: fitted(
|
|
99
|
+
`${turn.text}${turn.truncated ? ' [stopped: too many steps]' : ''}`,
|
|
100
|
+
),
|
|
101
|
+
answers: message.id,
|
|
102
|
+
})
|
|
103
|
+
} catch (e) {
|
|
104
|
+
say({
|
|
105
|
+
text: fitted((e as Error).message),
|
|
106
|
+
note: true,
|
|
107
|
+
answers: message.id,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The line the guest prints the manifest on, for the host to read back. */
|
|
114
|
+
export const MANIFEST_LINE = 'mirab-manifest '
|
|
115
|
+
|
|
116
|
+
if (import.meta.main) {
|
|
117
|
+
const [first, second] = process.argv.slice(2)
|
|
118
|
+
// `--manifest <project>`: the agent's files run here, in the VM, never on
|
|
119
|
+
// the host, which only checks the JSON this prints.
|
|
120
|
+
if (first === '--manifest')
|
|
121
|
+
console.log(
|
|
122
|
+
MANIFEST_LINE +
|
|
123
|
+
JSON.stringify((await loadManifest(second ?? '/workspace')).manifest),
|
|
124
|
+
)
|
|
125
|
+
// `<project> <model URL> <placeholder key>`: the gateway's route, as the
|
|
126
|
+
// host (which runs the gateway) says it; the guest holds no key.
|
|
127
|
+
else {
|
|
128
|
+
const [, baseURL, key] = process.argv.slice(2)
|
|
129
|
+
if (!baseURL || !key)
|
|
130
|
+
throw new Error(
|
|
131
|
+
'usage: loop.js <project> <model base URL> <placeholder key>',
|
|
132
|
+
)
|
|
133
|
+
await serve(first ?? '/workspace', process.stdin, process.stdout, {
|
|
134
|
+
baseURL,
|
|
135
|
+
key,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|