mirab 0.0.0-stage → 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 +226 -2
- package/package.json +58 -4
- 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
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// The published microVM guest image: built by the release workflow for each
|
|
2
|
+
// VM platform, published next to mirab's own release, and fetched here on
|
|
3
|
+
// first VM use into <config dir>/vm/, so `mirab spawn --vm` works without
|
|
4
|
+
// building anything.
|
|
5
|
+
//
|
|
6
|
+
// Layout of one release on mirab-releases.addis.gg (R2 mirab-releases; directories are
|
|
7
|
+
// the release's full version with no "v", scripts/vm-release.ts writes vm/):
|
|
8
|
+
// <release>/vm/manifest.json VmManifest (sha256 of every file)
|
|
9
|
+
// <release>/vm/<platform>/<name>.gz one gzip per role
|
|
10
|
+
// Every file is checked against the manifest's sha256 (compressed and
|
|
11
|
+
// unpacked) before it replaces anything; the manifest itself must name this
|
|
12
|
+
// release and this CLI's guest protocol. At boot the guest agent handshake
|
|
13
|
+
// (session.ts checkGuestProtocol) checks the protocol once more.
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { createWriteStream, existsSync, readFileSync, statSync } from 'node:fs'
|
|
16
|
+
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import { Readable, Transform } from 'node:stream'
|
|
19
|
+
import { pipeline } from 'node:stream/promises'
|
|
20
|
+
import { createGunzip } from 'node:zlib'
|
|
21
|
+
import { z } from 'zod'
|
|
22
|
+
import pkg from '../../package.json'
|
|
23
|
+
import { GUEST_PROTOCOL } from './session'
|
|
24
|
+
|
|
25
|
+
/** Where mirab's releases and guest images live. */
|
|
26
|
+
export const RELEASES = 'https://mirab-releases.addis.gg'
|
|
27
|
+
export const MIRAB_VERSION: string = pkg.version
|
|
28
|
+
|
|
29
|
+
/** The release this copy of mirab was installed from (`0.1.0-canary-abc1234`):
|
|
30
|
+
* the release workflow stamps release.json into what it ships. From a
|
|
31
|
+
* checkout there is none, and the package version stands in. */
|
|
32
|
+
export const MIRAB_RELEASE: string = (() => {
|
|
33
|
+
try {
|
|
34
|
+
const stamped = JSON.parse(
|
|
35
|
+
readFileSync(join(import.meta.dir, '..', '..', 'release.json'), 'utf8'),
|
|
36
|
+
)
|
|
37
|
+
return typeof stamped.version === 'string' ? stamped.version : pkg.version
|
|
38
|
+
} catch {
|
|
39
|
+
return pkg.version
|
|
40
|
+
}
|
|
41
|
+
})()
|
|
42
|
+
|
|
43
|
+
/** Where the guest kernel, rootfs and (macOS) vz helper live, published or
|
|
44
|
+
* built by `bun run vm:image`; `vm` uses them when isolation.json names no
|
|
45
|
+
* other files. */
|
|
46
|
+
export function vmImagePaths(configDir: string) {
|
|
47
|
+
const dir = join(configDir, 'vm')
|
|
48
|
+
return {
|
|
49
|
+
dir,
|
|
50
|
+
kernel: join(dir, 'vmlinux'),
|
|
51
|
+
rootfs: join(dir, 'rootfs.ext4'),
|
|
52
|
+
vz: join(dir, 'mirab-vz'),
|
|
53
|
+
record: join(dir, 'image.json'),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The VM platforms a release publishes, and the file each role is built as. */
|
|
58
|
+
export const VM_PLATFORMS = {
|
|
59
|
+
'linux-x64': { kernel: 'vmlinux', rootfs: 'rootfs.ext4' },
|
|
60
|
+
'darwin-x64': { kernel: 'bzImage', rootfs: 'rootfs.ext4', vz: 'mirab-vz' },
|
|
61
|
+
'darwin-arm64': { kernel: 'Image', rootfs: 'rootfs.ext4', vz: 'mirab-vz' },
|
|
62
|
+
} as const
|
|
63
|
+
export type VmPlatform = keyof typeof VM_PLATFORMS
|
|
64
|
+
export type VmRole = 'kernel' | 'rootfs' | 'vz'
|
|
65
|
+
|
|
66
|
+
export const vmPlatform = (
|
|
67
|
+
platform: string = process.platform,
|
|
68
|
+
arch: string = process.arch,
|
|
69
|
+
): VmPlatform | undefined =>
|
|
70
|
+
`${platform}-${arch}` in VM_PLATFORMS
|
|
71
|
+
? (`${platform}-${arch}` as VmPlatform)
|
|
72
|
+
: undefined
|
|
73
|
+
|
|
74
|
+
const hex = z.string().regex(/^[0-9a-f]{64}$/)
|
|
75
|
+
const size = z.number().int().nonnegative()
|
|
76
|
+
const entry = z.object({
|
|
77
|
+
/** Relative to the manifest: `<platform>/<name>.gz`. */
|
|
78
|
+
file: z
|
|
79
|
+
.string()
|
|
80
|
+
.regex(/^(linux-x64|darwin-x64|darwin-arm64)\/[A-Za-z0-9._-]+\.gz$/),
|
|
81
|
+
sha256: hex,
|
|
82
|
+
size,
|
|
83
|
+
unpacked: z.object({ sha256: hex, size }),
|
|
84
|
+
})
|
|
85
|
+
export type VmFileEntry = z.infer<typeof entry>
|
|
86
|
+
export const vmManifestSchema = z.object({
|
|
87
|
+
version: z.string(),
|
|
88
|
+
protocol: z.number().int(),
|
|
89
|
+
commit: z.string(),
|
|
90
|
+
platforms: z.object({
|
|
91
|
+
'linux-x64': z.object({ kernel: entry, rootfs: entry }).optional(),
|
|
92
|
+
'darwin-x64': z
|
|
93
|
+
.object({ kernel: entry, rootfs: entry, vz: entry })
|
|
94
|
+
.optional(),
|
|
95
|
+
'darwin-arm64': z
|
|
96
|
+
.object({ kernel: entry, rootfs: entry, vz: entry })
|
|
97
|
+
.optional(),
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
export type VmManifest = z.infer<typeof vmManifestSchema>
|
|
101
|
+
|
|
102
|
+
/** <config dir>/vm/image.json for a published image (`bun run vm:image` writes its own shape). */
|
|
103
|
+
const recordSchema = z.object({
|
|
104
|
+
source: z.literal('release'),
|
|
105
|
+
version: z.string(),
|
|
106
|
+
platform: z.string(),
|
|
107
|
+
protocol: z.number().int(),
|
|
108
|
+
commit: z.string(),
|
|
109
|
+
files: z.record(z.string(), z.object({ sha256: hex, size })),
|
|
110
|
+
})
|
|
111
|
+
type ImageRecord = z.infer<typeof recordSchema>
|
|
112
|
+
|
|
113
|
+
export class VmImageError extends Error {}
|
|
114
|
+
|
|
115
|
+
/** What the next tier hint says when no image could be fetched. */
|
|
116
|
+
export const VM_IMAGE_FALLBACK_HINT =
|
|
117
|
+
'connect and retry, build the image yourself (docs/VM.md), or use --isolation container|host'
|
|
118
|
+
|
|
119
|
+
function readRecord(configDir: string): ImageRecord | 'local' | undefined {
|
|
120
|
+
const path = vmImagePaths(configDir).record
|
|
121
|
+
if (!existsSync(path)) return undefined
|
|
122
|
+
try {
|
|
123
|
+
const parsed = recordSchema.safeParse(
|
|
124
|
+
JSON.parse(readFileSync(path, 'utf8')),
|
|
125
|
+
)
|
|
126
|
+
return parsed.success ? parsed.data : 'local'
|
|
127
|
+
} catch {
|
|
128
|
+
return 'local'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const rolesOf = (platform: VmPlatform) =>
|
|
133
|
+
Object.keys(VM_PLATFORMS[platform]) as VmRole[]
|
|
134
|
+
|
|
135
|
+
/** The published image for this CLI version and platform is installed and intact (cheap: sizes). */
|
|
136
|
+
export function releaseImageInstalled(
|
|
137
|
+
configDir: string,
|
|
138
|
+
version = MIRAB_RELEASE,
|
|
139
|
+
platform = vmPlatform(),
|
|
140
|
+
): boolean {
|
|
141
|
+
const record = readRecord(configDir)
|
|
142
|
+
if (!platform || !record || record === 'local') return false
|
|
143
|
+
if (
|
|
144
|
+
record.version !== version ||
|
|
145
|
+
record.platform !== platform ||
|
|
146
|
+
record.protocol !== GUEST_PROTOCOL
|
|
147
|
+
)
|
|
148
|
+
return false
|
|
149
|
+
const paths = vmImagePaths(configDir)
|
|
150
|
+
return rolesOf(platform).every((role) => {
|
|
151
|
+
try {
|
|
152
|
+
return statSync(paths[role]).size === record.files[role]?.size
|
|
153
|
+
} catch {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `bun run vm:image` (or another hand build) owns <config dir>/vm: never overwrite it. */
|
|
160
|
+
export const localImageInstalled = (configDir: string) =>
|
|
161
|
+
readRecord(configDir) === 'local'
|
|
162
|
+
|
|
163
|
+
export interface EnsureOptions {
|
|
164
|
+
configDir: string
|
|
165
|
+
version?: string
|
|
166
|
+
platform?: VmPlatform
|
|
167
|
+
/** The release root (cli/ on the bucket); tests point it at a fake. */
|
|
168
|
+
base?: string
|
|
169
|
+
log?: (line: string) => void
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Make sure <config dir>/vm holds the published guest image for this mirab
|
|
174
|
+
* version and platform: nothing to do when it is already there (offline is
|
|
175
|
+
* fine), otherwise download, verify and install it. Throws VmImageError with
|
|
176
|
+
* the reason and the fallback when it cannot.
|
|
177
|
+
*/
|
|
178
|
+
export async function ensureVmImage(
|
|
179
|
+
o: EnsureOptions,
|
|
180
|
+
): Promise<'installed' | 'downloaded' | 'local'> {
|
|
181
|
+
const version = o.version ?? MIRAB_RELEASE,
|
|
182
|
+
platform = o.platform ?? vmPlatform(),
|
|
183
|
+
base = o.base ?? RELEASES,
|
|
184
|
+
log = o.log ?? (() => {})
|
|
185
|
+
if (!platform)
|
|
186
|
+
throw new VmImageError(
|
|
187
|
+
`no VM image is published for ${process.platform}-${process.arch} (published: ${Object.keys(VM_PLATFORMS).join(', ')})`,
|
|
188
|
+
)
|
|
189
|
+
if (localImageInstalled(o.configDir)) return 'local'
|
|
190
|
+
if (releaseImageInstalled(o.configDir, version, platform)) return 'installed'
|
|
191
|
+
|
|
192
|
+
const url = `${base}/${version}/vm/manifest.json`
|
|
193
|
+
const offline = (why: string) =>
|
|
194
|
+
new VmImageError(
|
|
195
|
+
`could not fetch the VM image for mirab ${version} (${platform}) from ${url}: ${why}; ${VM_IMAGE_FALLBACK_HINT}`,
|
|
196
|
+
)
|
|
197
|
+
let res: Response
|
|
198
|
+
try {
|
|
199
|
+
res = await fetch(url)
|
|
200
|
+
} catch (e) {
|
|
201
|
+
throw offline((e as Error).message)
|
|
202
|
+
}
|
|
203
|
+
if (!res.ok)
|
|
204
|
+
throw offline(
|
|
205
|
+
res.status === 404
|
|
206
|
+
? `HTTP 404, no image was published for this version`
|
|
207
|
+
: `HTTP ${res.status}`,
|
|
208
|
+
)
|
|
209
|
+
let manifest: VmManifest
|
|
210
|
+
try {
|
|
211
|
+
manifest = vmManifestSchema.parse(await res.json())
|
|
212
|
+
} catch (e) {
|
|
213
|
+
throw new VmImageError(
|
|
214
|
+
`the VM image manifest ${url} is malformed: ${(e as Error).message}`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
if (manifest.version !== version)
|
|
218
|
+
throw new VmImageError(
|
|
219
|
+
`refusing the VM image manifest ${url}: it is for mirab ${manifest.version}, this is mirab ${version}`,
|
|
220
|
+
)
|
|
221
|
+
if (manifest.protocol !== GUEST_PROTOCOL)
|
|
222
|
+
throw new VmImageError(
|
|
223
|
+
`refusing the VM image manifest ${url}: its guest agent speaks protocol ${manifest.protocol}, this mirab speaks ${GUEST_PROTOCOL}`,
|
|
224
|
+
)
|
|
225
|
+
const files = manifest.platforms[platform] as
|
|
226
|
+
Partial<Record<VmRole, VmFileEntry>> | undefined
|
|
227
|
+
const roles = rolesOf(platform)
|
|
228
|
+
if (!files || roles.some((r) => !files[r]))
|
|
229
|
+
throw new VmImageError(
|
|
230
|
+
`the VM image manifest ${url} has no complete ${platform} image`,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
const paths = vmImagePaths(o.configDir)
|
|
234
|
+
await mkdir(paths.dir, { recursive: true, mode: 0o700 })
|
|
235
|
+
const stage = await mkdtemp(join(paths.dir, '.download-'))
|
|
236
|
+
try {
|
|
237
|
+
const total = roles.reduce((n, r) => n + files[r]!.size, 0)
|
|
238
|
+
log(
|
|
239
|
+
`downloading the VM image for mirab ${version} (${platform}, ${mb(total)}) into ${paths.dir}`,
|
|
240
|
+
)
|
|
241
|
+
for (const role of roles)
|
|
242
|
+
await download(
|
|
243
|
+
`${base}/${version}/vm/${files[role]!.file}`,
|
|
244
|
+
files[role]!,
|
|
245
|
+
join(stage, role),
|
|
246
|
+
role,
|
|
247
|
+
log,
|
|
248
|
+
)
|
|
249
|
+
for (const role of roles) {
|
|
250
|
+
await chmod(join(stage, role), role === 'vz' ? 0o755 : 0o644)
|
|
251
|
+
await rename(join(stage, role), paths[role])
|
|
252
|
+
}
|
|
253
|
+
const record: ImageRecord = {
|
|
254
|
+
source: 'release',
|
|
255
|
+
version,
|
|
256
|
+
platform,
|
|
257
|
+
protocol: manifest.protocol,
|
|
258
|
+
commit: manifest.commit,
|
|
259
|
+
files: Object.fromEntries(roles.map((r) => [r, files[r]!.unpacked])),
|
|
260
|
+
}
|
|
261
|
+
// Written last: an interrupted download leaves no record, so it is retried.
|
|
262
|
+
await writeFile(paths.record, JSON.stringify(record, null, 2) + '\n')
|
|
263
|
+
log(`VM image for mirab ${version} verified and installed`)
|
|
264
|
+
return 'downloaded'
|
|
265
|
+
} finally {
|
|
266
|
+
await rm(stage, { recursive: true, force: true })
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const mb = (n: number) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`
|
|
271
|
+
|
|
272
|
+
function hashing(onBytes?: (n: number) => void) {
|
|
273
|
+
const hash = createHash('sha256')
|
|
274
|
+
let bytes = 0
|
|
275
|
+
const tap = new Transform({
|
|
276
|
+
transform(chunk: Buffer, _enc, done) {
|
|
277
|
+
hash.update(chunk)
|
|
278
|
+
bytes += chunk.length
|
|
279
|
+
onBytes?.(bytes)
|
|
280
|
+
done(null, chunk)
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
return { tap, digest: () => hash.digest('hex'), bytes: () => bytes }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function download(
|
|
287
|
+
url: string,
|
|
288
|
+
want: VmFileEntry,
|
|
289
|
+
to: string,
|
|
290
|
+
role: VmRole,
|
|
291
|
+
log: (line: string) => void,
|
|
292
|
+
) {
|
|
293
|
+
let res: Response
|
|
294
|
+
try {
|
|
295
|
+
res = await fetch(url)
|
|
296
|
+
} catch (e) {
|
|
297
|
+
throw new VmImageError(
|
|
298
|
+
`could not fetch ${url}: ${(e as Error).message}; ${VM_IMAGE_FALLBACK_HINT}`,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
if (!res.ok || !res.body)
|
|
302
|
+
throw new VmImageError(`could not fetch ${url}: HTTP ${res.status}`)
|
|
303
|
+
let shown = -1
|
|
304
|
+
const packed = hashing((n) => {
|
|
305
|
+
const pct = Math.floor((n / Math.max(want.size, 1)) * 10) * 10
|
|
306
|
+
if (pct > shown && want.size > 0) {
|
|
307
|
+
shown = pct
|
|
308
|
+
log(` ${role}: ${mb(n)} / ${mb(want.size)} (${Math.min(pct, 100)}%)`)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
const unpacked = hashing()
|
|
312
|
+
try {
|
|
313
|
+
await pipeline(
|
|
314
|
+
Readable.fromWeb(res.body as never),
|
|
315
|
+
packed.tap,
|
|
316
|
+
createGunzip(),
|
|
317
|
+
unpacked.tap,
|
|
318
|
+
createWriteStream(to, { mode: 0o600 }),
|
|
319
|
+
)
|
|
320
|
+
} catch (e) {
|
|
321
|
+
throw new VmImageError(`${url}: ${(e as Error).message}`)
|
|
322
|
+
}
|
|
323
|
+
const got = packed.digest()
|
|
324
|
+
if (got !== want.sha256 || packed.bytes() !== want.size)
|
|
325
|
+
throw new VmImageError(
|
|
326
|
+
`refusing ${url}: sha256 ${got} (${packed.bytes()} bytes) is not the manifest's ${want.sha256} (${want.size} bytes)`,
|
|
327
|
+
)
|
|
328
|
+
const gotUnpacked = unpacked.digest()
|
|
329
|
+
if (
|
|
330
|
+
gotUnpacked !== want.unpacked.sha256 ||
|
|
331
|
+
unpacked.bytes() !== want.unpacked.size
|
|
332
|
+
)
|
|
333
|
+
throw new VmImageError(
|
|
334
|
+
`refusing ${url}: unpacked sha256 ${gotUnpacked} is not the manifest's ${want.unpacked.sha256}`,
|
|
335
|
+
)
|
|
336
|
+
}
|