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,86 @@
|
|
|
1
|
+
// Isolation policy: which level a run gets, and when it is refused.
|
|
2
|
+
//
|
|
3
|
+
// Levels, strongest first. `none` is never picked automatically: it needs an
|
|
4
|
+
// explicit request and is labelled unisolated everywhere it is shown.
|
|
5
|
+
export const LEVELS = ['vm', 'container', 'host', 'none'] as const
|
|
6
|
+
export type Level = (typeof LEVELS)[number]
|
|
7
|
+
|
|
8
|
+
export const strength = (level: Level) => LEVELS.length - LEVELS.indexOf(level)
|
|
9
|
+
|
|
10
|
+
export type RequestSource = 'flag' | 'job' | 'profile' | 'host-default' | 'auto'
|
|
11
|
+
|
|
12
|
+
export interface PolicyInput {
|
|
13
|
+
/** `--isolation` on this run. Most specific, wins over everything below. */
|
|
14
|
+
flag?: Level
|
|
15
|
+
/** `isolation:` on the workflow job. */
|
|
16
|
+
job?: Level
|
|
17
|
+
/** `isolation` in the agent profile. */
|
|
18
|
+
profile?: Level
|
|
19
|
+
/** The host default the owner set once in the host config. */
|
|
20
|
+
hostDefault?: Level
|
|
21
|
+
/** Owner floor: levels weaker than this are refused, never upgraded. */
|
|
22
|
+
floor?: Level
|
|
23
|
+
/** Backends usable on this machine. */
|
|
24
|
+
available: readonly Level[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Resolved {
|
|
28
|
+
level: Level
|
|
29
|
+
source: RequestSource
|
|
30
|
+
/** Set when the requested level could not be had and a weaker tier took the run. */
|
|
31
|
+
fallback?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class IsolationRefused extends Error {}
|
|
35
|
+
|
|
36
|
+
export function parseLevel(
|
|
37
|
+
text: string | undefined,
|
|
38
|
+
where: string,
|
|
39
|
+
): Level | undefined {
|
|
40
|
+
if (text === undefined) return undefined
|
|
41
|
+
if ((LEVELS as readonly string[]).includes(text)) return text as Level
|
|
42
|
+
throw new IsolationRefused(
|
|
43
|
+
`${where}: unknown isolation "${text}" (use ${LEVELS.join(', ')})`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveIsolation(input: PolicyInput): Resolved {
|
|
48
|
+
const asked: [Level | undefined, RequestSource][] = [
|
|
49
|
+
[input.flag, 'flag'],
|
|
50
|
+
[input.job, 'job'],
|
|
51
|
+
[input.profile, 'profile'],
|
|
52
|
+
[input.hostDefault, 'host-default'],
|
|
53
|
+
]
|
|
54
|
+
const hit = asked.find(([level]) => level !== undefined)
|
|
55
|
+
const floor = input.floor
|
|
56
|
+
let chosen: Resolved
|
|
57
|
+
if (hit) {
|
|
58
|
+
chosen = { level: hit[0]!, source: hit[1] }
|
|
59
|
+
} else {
|
|
60
|
+
// Nothing asked: strongest available level that satisfies the floor.
|
|
61
|
+
const auto = LEVELS.filter((l) => l !== 'none').find((l) =>
|
|
62
|
+
input.available.includes(l),
|
|
63
|
+
)
|
|
64
|
+
if (auto && (!floor || strength(auto) >= strength(floor)))
|
|
65
|
+
return { level: auto, source: 'auto' }
|
|
66
|
+
throw new IsolationRefused(
|
|
67
|
+
floor
|
|
68
|
+
? `this host requires isolation "${floor}" or stronger and none is available (available: ${input.available.join(', ') || 'none'})`
|
|
69
|
+
: `no isolation backend is available on this host (tried vm, container, host)`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
if (floor && strength(chosen.level) < strength(floor))
|
|
73
|
+
throw new IsolationRefused(
|
|
74
|
+
`isolation "${chosen.level}" (from ${chosen.source}) is below this host's floor "${floor}"`,
|
|
75
|
+
)
|
|
76
|
+
if (!input.available.includes(chosen.level))
|
|
77
|
+
throw new IsolationRefused(
|
|
78
|
+
`isolation "${chosen.level}" (from ${chosen.source}) is not available on this host (available: ${input.available.join(', ') || 'none'})`,
|
|
79
|
+
)
|
|
80
|
+
return chosen
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const labelFor = (level: Level, backend: string) =>
|
|
84
|
+
level === 'none'
|
|
85
|
+
? 'unisolated (runs as the runner user)'
|
|
86
|
+
: `${level} (${backend})`
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// One exec contract for every isolation backend (see guest-agent/src/main.rs).
|
|
2
|
+
// A backend only has to open a byte channel to the guest agent; the runner and
|
|
3
|
+
// spawn code talk to `Isolation` and never branch on the backend.
|
|
4
|
+
import type { Level } from './policy'
|
|
5
|
+
import shared from './guest-protocol.json'
|
|
6
|
+
|
|
7
|
+
/** The guest-agent wire protocol this CLI speaks; guest-agent/src/main.rs reads the same file. */
|
|
8
|
+
export const GUEST_PROTOCOL: number = shared.protocol
|
|
9
|
+
/** Where the guest image is rebuilt from; printed with every mismatch. */
|
|
10
|
+
export const REBUILD_HINT =
|
|
11
|
+
process.platform === 'darwin'
|
|
12
|
+
? 'Rebuild the guest image from a mirab checkout at this version: `scripts/build-vm-guest.sh <dir>` (docs/VM.md)'
|
|
13
|
+
: 'Rebuild the guest image from a mirab checkout at this version: `bun run vm:image` (docs/VM.md)'
|
|
14
|
+
|
|
15
|
+
export interface Channel {
|
|
16
|
+
write(data: Uint8Array): void
|
|
17
|
+
onData(cb: (chunk: Buffer) => void): void
|
|
18
|
+
onClose(cb: (error?: Error) => void): void
|
|
19
|
+
close(): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecOptions {
|
|
23
|
+
cwd: string
|
|
24
|
+
env: Record<string, string>
|
|
25
|
+
timeoutMs: number
|
|
26
|
+
signal: AbortSignal
|
|
27
|
+
/** One call per output line (stdout and stderr), already de-framed. */
|
|
28
|
+
onLine: (line: string) => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Isolation {
|
|
32
|
+
readonly level: Level
|
|
33
|
+
readonly backend: string
|
|
34
|
+
/** What `mirab status` and job logs print. */
|
|
35
|
+
readonly label: string
|
|
36
|
+
/** Host path of the job work directory -> where the guest sees it. */
|
|
37
|
+
guestPath(hostPath: string): string
|
|
38
|
+
put(
|
|
39
|
+
hostPath: string,
|
|
40
|
+
content: string | Uint8Array,
|
|
41
|
+
mode?: number,
|
|
42
|
+
): Promise<void>
|
|
43
|
+
/** Run a step; resolves with its exit code (124 timeout, 130 cancelled). */
|
|
44
|
+
exec(argv: string[], options: ExecOptions): Promise<number>
|
|
45
|
+
/** Interactive program on a guest pty (spawn --vm). */
|
|
46
|
+
pty?(request: PtyRequest): Promise<PtyHandle>
|
|
47
|
+
/** Read the guest file tree (working-directory sync); backends without a copy omit it. */
|
|
48
|
+
files?: GuestFiles
|
|
49
|
+
/** Messages the guest initiates toward the host on a vsock port (hooks). */
|
|
50
|
+
onGuestMessage?(port: number, handler: (message: any) => void): void
|
|
51
|
+
/** Serve the VM's guest-to-host `port` (e.g. the station's egress): `serve`
|
|
52
|
+
* listens on the socket path given, which belongs to this VM alone. */
|
|
53
|
+
onGuestConnect?(
|
|
54
|
+
port: number,
|
|
55
|
+
serve: (socketPath: string) => Promise<import('node:net').Server>,
|
|
56
|
+
): Promise<void>
|
|
57
|
+
/** Stop the backend; preserve its workspace for recovery after incomplete sync. */
|
|
58
|
+
close(options?: {
|
|
59
|
+
preserveWorkspace?: boolean
|
|
60
|
+
/** Keep this VM's root disk (its own copy) at this path once it has
|
|
61
|
+
* stopped: how an agent's image is built and updated. */
|
|
62
|
+
keepRoot?: string
|
|
63
|
+
}): Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One entry under a sync root, the same shape the guest agent's `list` op returns. */
|
|
67
|
+
export interface Entry {
|
|
68
|
+
/** Path relative to the root, `/`-separated. */
|
|
69
|
+
p: string
|
|
70
|
+
/** `f` regular file, `d` directory, `l` symlink. */
|
|
71
|
+
k: 'f' | 'd' | 'l'
|
|
72
|
+
/** Permission bits (0 for symlinks). */
|
|
73
|
+
m: number
|
|
74
|
+
s?: number
|
|
75
|
+
/** sha256 hex of the content (files). */
|
|
76
|
+
h?: string
|
|
77
|
+
/** Link target (symlinks). */
|
|
78
|
+
t?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GuestFiles {
|
|
82
|
+
list(root: string): Promise<Entry[]>
|
|
83
|
+
read(path: string): Promise<Buffer>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PtyRequest {
|
|
87
|
+
argv: string[]
|
|
88
|
+
env: Record<string, string>
|
|
89
|
+
cwd: string
|
|
90
|
+
cols: number
|
|
91
|
+
rows: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PtyHandle {
|
|
95
|
+
write(data: Uint8Array | string): void
|
|
96
|
+
resize(cols: number, rows: number): void
|
|
97
|
+
onData(cb: (data: Buffer) => void): void
|
|
98
|
+
onExit(cb: (code: number) => void): void
|
|
99
|
+
/** Drop the connection: the guest hangs up the session. */
|
|
100
|
+
close(): void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type Opener = () => Promise<Channel>
|
|
104
|
+
|
|
105
|
+
const MAX_LINE = 32768
|
|
106
|
+
|
|
107
|
+
function frame(value: unknown): Buffer {
|
|
108
|
+
const body = Buffer.from(JSON.stringify(value))
|
|
109
|
+
const head = Buffer.alloc(4)
|
|
110
|
+
head.writeUInt32BE(body.length)
|
|
111
|
+
return Buffer.concat([head, body])
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type Reply =
|
|
115
|
+
| { t: 'out' | 'err'; d: string }
|
|
116
|
+
| { t: 'entries'; e: Entry[] }
|
|
117
|
+
| { t: 'exit'; code: number; timed_out: boolean }
|
|
118
|
+
| { t: 'ok'; protocol?: number; agent?: string }
|
|
119
|
+
| { t: 'error'; message: string }
|
|
120
|
+
|
|
121
|
+
/** Send one request and stream replies until `done` returns a value. */
|
|
122
|
+
function converse<T>(
|
|
123
|
+
open: Opener,
|
|
124
|
+
request: unknown,
|
|
125
|
+
signal: AbortSignal | undefined,
|
|
126
|
+
onReply: (r: Reply) => T | undefined | Promise<T | undefined>,
|
|
127
|
+
): Promise<T> {
|
|
128
|
+
return new Promise<T>(async (resolve, reject) => {
|
|
129
|
+
let ch: Channel
|
|
130
|
+
try {
|
|
131
|
+
ch = await open()
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return reject(e)
|
|
134
|
+
}
|
|
135
|
+
let buf = Buffer.alloc(0),
|
|
136
|
+
finished = false,
|
|
137
|
+
chain = Promise.resolve()
|
|
138
|
+
const end = (fn: () => void) => {
|
|
139
|
+
if (finished) return
|
|
140
|
+
finished = true
|
|
141
|
+
signal?.removeEventListener('abort', abort)
|
|
142
|
+
fn()
|
|
143
|
+
ch.close()
|
|
144
|
+
}
|
|
145
|
+
const abort = () => ch.close() // guest kills the step when the connection drops
|
|
146
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
147
|
+
ch.onData((chunk) => {
|
|
148
|
+
buf = Buffer.concat([buf, chunk])
|
|
149
|
+
while (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0)) {
|
|
150
|
+
const n = buf.readUInt32BE(0)
|
|
151
|
+
const reply = JSON.parse(buf.subarray(4, 4 + n).toString()) as Reply
|
|
152
|
+
buf = buf.subarray(4 + n)
|
|
153
|
+
chain = chain.then(async () => {
|
|
154
|
+
if (finished) return
|
|
155
|
+
try {
|
|
156
|
+
const out = await onReply(reply)
|
|
157
|
+
if (out !== undefined) end(() => resolve(out))
|
|
158
|
+
} catch (e) {
|
|
159
|
+
end(() => reject(e))
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
ch.onClose((error) => {
|
|
165
|
+
chain = chain.then(() =>
|
|
166
|
+
end(() => reject(error ?? new Error('guest connection closed early'))),
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
ch.write(frame(request))
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface GuestHello {
|
|
174
|
+
/** 1 for agents that predate the handshake (their ping reply has no number). */
|
|
175
|
+
protocol: number
|
|
176
|
+
agent?: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function guestPing(open: Opener): Promise<GuestHello> {
|
|
180
|
+
return converse<GuestHello>(open, { op: 'ping' }, undefined, (r) =>
|
|
181
|
+
r.t === 'ok'
|
|
182
|
+
? {
|
|
183
|
+
protocol: typeof r.protocol === 'number' ? r.protocol : 1,
|
|
184
|
+
agent: r.agent,
|
|
185
|
+
}
|
|
186
|
+
: undefined,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class GuestProtocolError extends Error {}
|
|
191
|
+
|
|
192
|
+
/** Refuse a guest agent that speaks another protocol, at connect time and
|
|
193
|
+
* with the fix, instead of failing mid-session on an op it lacks. */
|
|
194
|
+
export function checkGuestProtocol(hello: GuestHello, image: string) {
|
|
195
|
+
if (hello.protocol === GUEST_PROTOCOL) return
|
|
196
|
+
const agent = `mirab-guest-agent ${hello.agent ?? '(no version, before 0.2.0)'}`
|
|
197
|
+
throw new GuestProtocolError(
|
|
198
|
+
hello.protocol < GUEST_PROTOCOL
|
|
199
|
+
? `The guest image ${image} is outdated: its ${agent} speaks protocol ${hello.protocol}, this mirab needs ${GUEST_PROTOCOL}. ${REBUILD_HINT}.`
|
|
200
|
+
: `The guest image ${image} is newer than this mirab: its ${agent} speaks protocol ${hello.protocol}, this mirab speaks ${GUEST_PROTOCOL}. Update mirab, or ${REBUILD_HINT.charAt(0).toLowerCase() + REBUILD_HINT.slice(1)}.`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function guestPut(
|
|
205
|
+
open: Opener,
|
|
206
|
+
path: string,
|
|
207
|
+
content: string | Uint8Array,
|
|
208
|
+
mode = 0o600,
|
|
209
|
+
) {
|
|
210
|
+
await converse<true>(
|
|
211
|
+
open,
|
|
212
|
+
{ op: 'put', path, mode, content: Buffer.from(content).toString('base64') },
|
|
213
|
+
undefined,
|
|
214
|
+
(r) => {
|
|
215
|
+
if (r.t === 'error') throw new Error(r.message)
|
|
216
|
+
return r.t === 'ok' ? true : undefined
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function guestFiles(open: Opener): GuestFiles {
|
|
222
|
+
return {
|
|
223
|
+
async list(root) {
|
|
224
|
+
const all: Entry[] = []
|
|
225
|
+
await converse<true>(open, { op: 'list', root }, undefined, (r) => {
|
|
226
|
+
if (r.t === 'error') throw new Error(r.message)
|
|
227
|
+
if (r.t === 'entries') all.push(...r.e)
|
|
228
|
+
return r.t === 'ok' ? true : undefined
|
|
229
|
+
})
|
|
230
|
+
return all
|
|
231
|
+
},
|
|
232
|
+
async read(path) {
|
|
233
|
+
const parts: Buffer[] = []
|
|
234
|
+
await converse<true>(open, { op: 'get', path }, undefined, (r) => {
|
|
235
|
+
if (r.t === 'error') throw new Error(r.message)
|
|
236
|
+
if (r.t === 'out') parts.push(Buffer.from(r.d, 'base64'))
|
|
237
|
+
return r.t === 'ok' ? true : undefined
|
|
238
|
+
})
|
|
239
|
+
return Buffer.concat(parts)
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function guestExec(
|
|
245
|
+
open: Opener,
|
|
246
|
+
argv: string[],
|
|
247
|
+
o: ExecOptions,
|
|
248
|
+
): Promise<number> {
|
|
249
|
+
if (o.signal.aborted) return 130
|
|
250
|
+
const pending = { out: '', err: '' },
|
|
251
|
+
decoders = { out: new TextDecoder(), err: new TextDecoder() }
|
|
252
|
+
let failure: Error | undefined
|
|
253
|
+
const feed = async (which: 'out' | 'err', data: Buffer) => {
|
|
254
|
+
pending[which] += decoders[which].decode(data, { stream: true })
|
|
255
|
+
let at: number
|
|
256
|
+
while ((at = pending[which].indexOf('\n')) >= 0) {
|
|
257
|
+
if (at > MAX_LINE) return tooLong(which)
|
|
258
|
+
await o.onLine(pending[which].slice(0, at).replace(/\r$/, ''))
|
|
259
|
+
pending[which] = pending[which].slice(at + 1)
|
|
260
|
+
}
|
|
261
|
+
if (pending[which].length > MAX_LINE) tooLong(which)
|
|
262
|
+
}
|
|
263
|
+
// Same rule as host steps: never emit the tail of an oversized line, it could
|
|
264
|
+
// be half of a secret split across chunks.
|
|
265
|
+
const tooLong = (which: 'out' | 'err') => {
|
|
266
|
+
failure = new Error('output line exceeds 32 KiB')
|
|
267
|
+
pending[which] = ''
|
|
268
|
+
ctl.abort()
|
|
269
|
+
}
|
|
270
|
+
const ctl = new AbortController(),
|
|
271
|
+
relay = () => ctl.abort()
|
|
272
|
+
o.signal.addEventListener('abort', relay, { once: true })
|
|
273
|
+
try {
|
|
274
|
+
const code = await converse<number>(
|
|
275
|
+
open,
|
|
276
|
+
{ op: 'exec', argv, env: o.env, cwd: o.cwd, timeout_ms: o.timeoutMs },
|
|
277
|
+
ctl.signal,
|
|
278
|
+
async (r) => {
|
|
279
|
+
if (failure) return undefined
|
|
280
|
+
if (r.t === 'out' || r.t === 'err') {
|
|
281
|
+
await feed(r.t, Buffer.from(r.d, 'base64'))
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
if (r.t === 'error') throw new Error(r.message)
|
|
285
|
+
if (r.t === 'exit') {
|
|
286
|
+
for (const w of ['out', 'err'] as const) {
|
|
287
|
+
pending[w] += decoders[w].decode()
|
|
288
|
+
if (pending[w]) await o.onLine(pending[w])
|
|
289
|
+
}
|
|
290
|
+
if (r.timed_out)
|
|
291
|
+
await o.onLine(
|
|
292
|
+
`[mirab: step timeout after ${o.timeoutMs / 60000} minutes]`,
|
|
293
|
+
)
|
|
294
|
+
return r.code
|
|
295
|
+
}
|
|
296
|
+
return undefined
|
|
297
|
+
},
|
|
298
|
+
).catch((e) => {
|
|
299
|
+
if (failure) throw failure
|
|
300
|
+
if (o.signal.aborted) return 130
|
|
301
|
+
throw e
|
|
302
|
+
})
|
|
303
|
+
if (failure) throw failure
|
|
304
|
+
return code
|
|
305
|
+
} finally {
|
|
306
|
+
o.signal.removeEventListener('abort', relay)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function guestPty(
|
|
311
|
+
open: Opener,
|
|
312
|
+
request: PtyRequest,
|
|
313
|
+
): Promise<PtyHandle> {
|
|
314
|
+
const ch = await open()
|
|
315
|
+
// Every onData listener sees every byte (a detached session has two: the
|
|
316
|
+
// output log and the quiet detector). Output that arrives before the first
|
|
317
|
+
// listener is held for it, and an exit that comes before onExit is replayed.
|
|
318
|
+
let buf = Buffer.alloc(0),
|
|
319
|
+
exitCode: number | undefined,
|
|
320
|
+
pending: Buffer[] | undefined = []
|
|
321
|
+
const dataCbs: ((d: Buffer) => void)[] = [],
|
|
322
|
+
exitCbs: ((code: number) => void)[] = []
|
|
323
|
+
const emit = (d: Buffer) => {
|
|
324
|
+
if (pending) pending.push(d)
|
|
325
|
+
else for (const cb of dataCbs) cb(d)
|
|
326
|
+
}
|
|
327
|
+
const done = (code: number) => {
|
|
328
|
+
if (exitCode !== undefined) return
|
|
329
|
+
exitCode = code
|
|
330
|
+
for (const cb of exitCbs) cb(code)
|
|
331
|
+
}
|
|
332
|
+
ch.onData((chunk) => {
|
|
333
|
+
buf = Buffer.concat([buf, chunk])
|
|
334
|
+
while (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0)) {
|
|
335
|
+
const n = buf.readUInt32BE(0)
|
|
336
|
+
const r = JSON.parse(buf.subarray(4, 4 + n).toString()) as Reply
|
|
337
|
+
buf = buf.subarray(4 + n)
|
|
338
|
+
if (r.t === 'out') emit(Buffer.from(r.d, 'base64'))
|
|
339
|
+
else if (r.t === 'exit') done(r.code)
|
|
340
|
+
else if (r.t === 'error') {
|
|
341
|
+
emit(Buffer.from(`mirab: ${r.message}\r\n`))
|
|
342
|
+
done(127)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
ch.onClose(() => done(129))
|
|
347
|
+
ch.write(frame({ op: 'pty', ...request }))
|
|
348
|
+
return {
|
|
349
|
+
write: (d) =>
|
|
350
|
+
ch.write(frame({ t: 'in', d: Buffer.from(d).toString('base64') })),
|
|
351
|
+
resize: (cols, rows) => ch.write(frame({ t: 'resize', cols, rows })),
|
|
352
|
+
onData: (cb) => {
|
|
353
|
+
dataCbs.push(cb)
|
|
354
|
+
if (!pending) return
|
|
355
|
+
const held = pending
|
|
356
|
+
pending = undefined
|
|
357
|
+
for (const d of held) cb(d)
|
|
358
|
+
},
|
|
359
|
+
onExit: (cb) => {
|
|
360
|
+
exitCbs.push(cb)
|
|
361
|
+
if (exitCode !== undefined) cb(exitCode)
|
|
362
|
+
},
|
|
363
|
+
close: () => ch.close(),
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Read one length-prefixed JSON message from a connection the guest opened. */
|
|
368
|
+
export function readOneMessage(stream: {
|
|
369
|
+
on(event: 'data', cb: (c: Buffer) => void): unknown
|
|
370
|
+
}): Promise<any> {
|
|
371
|
+
return new Promise((resolve, reject) => {
|
|
372
|
+
let buf = Buffer.alloc(0)
|
|
373
|
+
stream.on('data', (chunk) => {
|
|
374
|
+
buf = Buffer.concat([buf, chunk])
|
|
375
|
+
if (buf.length >= 4 && buf.length >= 4 + buf.readUInt32BE(0))
|
|
376
|
+
try {
|
|
377
|
+
resolve(
|
|
378
|
+
JSON.parse(buf.subarray(4, 4 + buf.readUInt32BE(0)).toString()),
|
|
379
|
+
)
|
|
380
|
+
} catch (e) {
|
|
381
|
+
reject(e)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
}
|