dshm-compat 0.1.2 → 0.1.4

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/lib/release.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Generic GitHub Release worker acquisition.
3
+ *
4
+ * Some native plugins do not ship a runtime in their npm tarball but publish
5
+ * per-platform workers as release assets. This module finds a Linux asset,
6
+ * extracts it, and returns the first AArch64/x86_64 ELF. It is repository
7
+ * agnostic; no package name is special-cased.
8
+ *
9
+ * @module dshm-compat/release
10
+ */
11
+ import { basename } from 'node:path'
12
+ import { elfArch } from './elf.js'
13
+ import { downloadBuffer } from './fetch.js'
14
+ import { extractTar } from './tar.js'
15
+ import { extractZip } from './zip.js'
16
+
17
+ const API = 'https://api.github.com'
18
+
19
+ /** @param {any} report */
20
+ export function githubRepo(report) {
21
+ if (report?.resolved?.kind === 'github' && typeof report.resolved.name === 'string') {
22
+ const [owner, repo] = report.resolved.name.split('/')
23
+ if (owner && repo) return { owner, repo }
24
+ }
25
+ const repository = report?.package?.repository
26
+ const url = typeof repository === 'string' ? repository : repository?.url
27
+ if (typeof url === 'string') {
28
+ const match = url.match(/github\.com[/:]([^/]+)\/([^/#?]+)/i)
29
+ if (match) return { owner: match[1], repo: match[2].replace(/\.git$/i, '') }
30
+ }
31
+ const tarball = report?.resolved?.tarballUrl
32
+ if (typeof tarball === 'string') {
33
+ const match = tarball.match(/api\.github\.com\/repos\/([^/]+)\/([^/]+)/i)
34
+ if (match) return { owner: match[1], repo: match[2] }
35
+ }
36
+ return undefined
37
+ }
38
+
39
+ /** @param {string} url @param {AbortSignal} [signal] */
40
+ async function fetchJson(url, signal) {
41
+ const timeout = AbortSignal.timeout(20000)
42
+ const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
43
+ const response = await fetch(url, {
44
+ signal: combined,
45
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'dshm-compat' },
46
+ })
47
+ if (!response.ok) throw new Error(`GitHub API ${response.status} for ${url}`)
48
+ return await response.json()
49
+ }
50
+
51
+ /** @param {string} owner @param {string} repo @param {AbortSignal} [signal] */
52
+ async function releaseAssets(owner, repo, signal) {
53
+ try {
54
+ const latest = await fetchJson(`${API}/repos/${owner}/${repo}/releases/latest`, signal)
55
+ if (Array.isArray(latest?.assets)) return latest.assets
56
+ } catch { /* fall through to the list endpoint */ }
57
+ const releases = await fetchJson(`${API}/repos/${owner}/${repo}/releases?per_page=5`, signal)
58
+ /** @type {any[]} */
59
+ const assets = []
60
+ for (const release of Array.isArray(releases) ? releases : []) {
61
+ if (Array.isArray(release?.assets)) assets.push(...release.assets)
62
+ }
63
+ return assets
64
+ }
65
+
66
+ /** @param {any} asset */
67
+ function assetRank(asset) {
68
+ const name = String(asset?.name ?? '').toLowerCase()
69
+ if (!/\.(?:zip|tgz|tar\.gz)$/.test(name)) return undefined
70
+ if (/win32|windows|darwin|macos|source|checksum|sha256/.test(name)) return undefined
71
+ if (!/linux|musl/.test(name)) return undefined
72
+ if (/aarch64|arm64/.test(name)) return 0
73
+ if (/x86_64|amd64|x64/.test(name)) return 1
74
+ return undefined
75
+ }
76
+
77
+ /** @param {Buffer} archive @param {string} name */
78
+ function archiveEntries(archive, name) {
79
+ if (/\.zip$/i.test(name)) return extractZip(archive)
80
+ return extractTar(archive).entries
81
+ .filter(entry => entry.type === 'file')
82
+ .map(entry => ({ path: entry.path, data: entry.data }))
83
+ }
84
+
85
+ /**
86
+ * @param {any} report
87
+ * @param {{ signal?: AbortSignal }} [options]
88
+ * @returns {Promise<{ buffer: Buffer, name: string, arch: 'arm64'|'x64', asset: string } | undefined>}
89
+ */
90
+ export async function findReleaseWorker(report, options = {}) {
91
+ const repo = githubRepo(report)
92
+ if (repo === undefined) return undefined
93
+ let assets
94
+ try { assets = await releaseAssets(repo.owner, repo.repo, options.signal) } catch { return undefined }
95
+ const ranked = assets
96
+ .map(asset => ({ asset, rank: assetRank(asset) }))
97
+ .filter(item => item.rank !== undefined)
98
+ .sort((a, b) => a.rank - b.rank)
99
+ for (const { asset } of ranked) {
100
+ if (typeof asset.browser_download_url !== 'string' || Number(asset.size ?? 0) > 64 * 1024 * 1024) continue
101
+ let archive
102
+ try { archive = await downloadBuffer(asset.browser_download_url, { maxBytes: 64 * 1024 * 1024, signal: options.signal }) } catch { continue }
103
+ let entries
104
+ try { entries = archiveEntries(archive, asset.name) } catch { continue }
105
+ const candidates = entries
106
+ .filter(entry => elfArch(entry.data) !== undefined)
107
+ .sort((a, b) => b.data.length - a.data.length)
108
+ if (candidates.length === 0) continue
109
+ const worker = candidates[0]
110
+ return {
111
+ buffer: worker.data,
112
+ name: basename(worker.path),
113
+ arch: elfArch(worker.data),
114
+ asset: asset.name,
115
+ }
116
+ }
117
+ return undefined
118
+ }
package/lib/rules.js CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Compatibility rules for known packages.
2
+ * Custom compatibility rules.
3
3
  *
4
- * Heuristics classify unknown packages. A rule is for a package whose exact
5
- * blocker and conversion recipe are already known; it wins over the heuristic
6
- * and carries the build/run instructions the converter cannot infer.
4
+ * Heuristics classify unknown packages. A rule is an operator-supplied hint for
5
+ * a package whose exact blocker and conversion recipe are already known; it wins
6
+ * over the heuristic and carries build/run instructions the converter cannot
7
+ * infer. Built-in per-package rules are intentionally absent: adaptation lives
8
+ * in the generic engines, not in a package-name branch.
7
9
  *
8
10
  * A deployment can add rules in
9
11
  * `$DSH_HOME/dshm-compat/registry.json`:
@@ -28,24 +30,7 @@ import { compatDir } from './state.js'
28
30
  */
29
31
 
30
32
  /** @type {Record<string, PackageRule>} */
31
- export const PACKAGE_RULES = {
32
- 'dsh-ops': {
33
- strategy: 'S2',
34
- reason: 'the plugin is a Rust worker executable; the published package carries only win32-x64, linux-x64 and darwin-arm64 payloads',
35
- next: [
36
- 'cross-build native/ for aarch64-unknown-linux-musl',
37
- 'run dsh-ops-worker in the Alpine guest and proxy the ops_* tools over RPC',
38
- 'rewrite PLATFORMS, package.json os/cpu and dsh-plugin.json targets for linux-arm64',
39
- ],
40
- nativeBuild: {
41
- language: 'rust',
42
- target: 'aarch64-unknown-linux-musl',
43
- crate: 'native/Cargo.toml',
44
- binary: 'dsh-ops-worker',
45
- notes: 'the crate is portable; windows/darwin metadata crates are target-gated',
46
- },
47
- },
48
- }
33
+ export const PACKAGE_RULES = {}
49
34
 
50
35
  const STRATEGIES = new Set(['S0', 'S1', 'S2', 'S3', 'S4'])
51
36
 
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Generic S2 native adapter (dsh-ops is the first fixture).
3
+ *
4
+ * A native plugin usually ships a worker plus JS that (a) gates a PLATFORMS
5
+ * matrix, (b) interpolates `${platform}-${arch}` into runtime paths, and
6
+ * (c) spawns a worker argv array. This adapter normalizes those three patterns
7
+ * and stages an externally built AArch64 musl worker. It is package-name
8
+ * agnostic; dsh-ops remains the reference sample and test fixture.
9
+ *
10
+ * @module dshm-compat/s2-native
11
+ */
12
+ import { createHash } from 'node:crypto'
13
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { basename, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { analyze } from './analyze.js'
17
+ import { elfArch } from './elf.js'
18
+ import { loadArchive, resolveSpec } from './fetch.js'
19
+ import { packTarGz } from './pack.js'
20
+ import { findReleaseWorker } from './release.js'
21
+ import { compatDir } from './state.js'
22
+ import { extractTar, findRootPrefix } from './tar.js'
23
+ import { validateTarball } from './validate.js'
24
+
25
+ const BUILD_COMMAND = 'cargo build --release --locked --manifest-path native/Cargo.toml --target aarch64-unknown-linux-musl'
26
+ const TARGET = 'aarch64-unknown-linux-musl'
27
+ const QEMU_ROOT = fileURLToPath(new URL('../assets/qemu-x86_64/', import.meta.url))
28
+
29
+ /** @param {import('../tar.js').TarEntry[]} entries */
30
+ function selectRoot(entries) {
31
+ const candidates = entries
32
+ .filter(entry => entry.type === 'file' && (entry.path === 'package.json' || entry.path.endsWith('/package.json')))
33
+ .map(entry => ({ dir: entry.path === 'package.json' ? '' : entry.path.slice(0, -'/package.json'.length), depth: entry.path.split('/').length }))
34
+ .sort((a, b) => a.depth - b.depth)
35
+ return candidates.length === 0 ? '' : (candidates[0].dir === '' ? '' : `${candidates[0].dir}/`)
36
+ }
37
+
38
+ /**
39
+ * @param {string} spec
40
+ * @param {{ report?: any, workerPath?: string, workerBinary?: Buffer, signal?: AbortSignal }} [options]
41
+ */
42
+ export async function convertS2Native(spec, options = {}) {
43
+ const report = options.report ?? await analyze(spec, { textScan: false, signal: options.signal })
44
+ const packageName = report.package?.name ?? report.resolved?.name ?? 'native-plugin'
45
+ const packageVersion = report.package?.version ?? report.resolved?.version ?? '0.0.0'
46
+ const resolved = await resolveSpec(spec, { signal: options.signal })
47
+ const archive = await loadArchive(resolved, { signal: options.signal })
48
+ const { entries } = extractTar(archive)
49
+ const prefix = findRootPrefix(entries)
50
+ const top = entries
51
+ .filter(entry => entry.type === 'file')
52
+ .map(entry => ({ ...entry, path: prefix !== '' && entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path }))
53
+ const root = selectRoot(top)
54
+ const files = top
55
+ .filter(entry => entry.path.startsWith(root))
56
+ .map(entry => ({ ...entry, path: entry.path.slice(root.length) }))
57
+ const byPath = new Map(files.map(file => [file.path, file]))
58
+ const getText = path => byPath.get(path)?.data?.toString('utf8')
59
+
60
+ let workerBinary = options.workerBinary
61
+ let workerName = options.workerName
62
+ if (workerBinary === undefined && typeof options.workerPath === 'string') {
63
+ try {
64
+ workerBinary = readFileSync(options.workerPath)
65
+ workerName ??= basename(options.workerPath)
66
+ } catch (error) {
67
+ return { status: 'needs-native-build', target: TARGET, command: BUILD_COMMAND, message: `cannot read worker: ${String(error?.message ?? error)}` }
68
+ }
69
+ }
70
+ if (workerBinary === undefined) {
71
+ const candidate = files.find(file => file.type === 'file'
72
+ && /^runtime\/linux-(?:arm64|x64)\//.test(file.path)
73
+ && !file.path.endsWith('/provenance.json')
74
+ && elfArch(file.data) !== undefined)
75
+ if (candidate !== undefined) {
76
+ workerBinary = candidate.data
77
+ workerName = basename(candidate.path)
78
+ }
79
+ }
80
+ let acquiredAsset
81
+ if (workerBinary === undefined) {
82
+ const releaseWorker = await findReleaseWorker(report, { signal: options.signal })
83
+ if (releaseWorker !== undefined) {
84
+ workerBinary = releaseWorker.buffer
85
+ workerName = releaseWorker.name
86
+ acquiredAsset = releaseWorker.asset
87
+ }
88
+ }
89
+ if (workerBinary === undefined) {
90
+ return {
91
+ status: 'needs-native-build',
92
+ target: TARGET,
93
+ command: BUILD_COMMAND,
94
+ message: `${packageName} needs a Linux arm64 or x64 worker; build one or pass workerPath to compat_convert`,
95
+ notes: [
96
+ 'install a Rust toolchain and a musl aarch64 cross linker, or use cross/cargo-zigbuild',
97
+ 'stage the result at runtime/linux-arm64/<worker> or runtime/linux-x64/<worker>, or pass it as workerPath',
98
+ ],
99
+ }
100
+ }
101
+ const workerArch = elfArch(workerBinary)
102
+ if (workerArch === undefined) {
103
+ return { status: 'invalid-worker', message: 'worker is not an AArch64 or x86_64 ELF image' }
104
+ }
105
+ const runtimeTarget = workerArch === 'arm64' ? 'linux-arm64' : 'linux-x64'
106
+ workerName ??= 'worker'
107
+
108
+ const runtimeEntry = files.find(file => file.type === 'file'
109
+ && file.path.endsWith('.js')
110
+ && file.data?.toString('utf8').includes('export const PLATFORMS = Object.freeze(['))
111
+ if (runtimeEntry === undefined) {
112
+ return { status: 'recipe-mismatch', message: `${packageName} has no JS file exposing the PLATFORMS matrix` }
113
+ }
114
+ const runtime = runtimeEntry.data.toString('utf8')
115
+ const platformsMatch = runtime.match(/export const PLATFORMS = Object\.freeze\(\[([^\]]*)\]\)/)
116
+ const platforms = platformsMatch[1]
117
+ .split(',')
118
+ .map(item => item.trim().replace(/^['"]|['"]$/g, ''))
119
+ .filter(item => item !== '')
120
+ if (!platforms.includes(runtimeTarget)) platforms.push(runtimeTarget)
121
+ const platformLine = `export const PLATFORMS = Object.freeze([${platforms.map(item => `'${item}'`).join(', ')}])`
122
+ const targetHelper = "\nconst resolveTarget = (platform, arch) => (platform === 'openharmony' || (platform === 'linux' && arch === 'arm64')) ? '" + runtimeTarget + "' : `${platform}-${arch}`\n"
123
+ let patchedRuntime = runtime.replaceAll('${platform}-${arch}', '${resolveTarget(platform, arch)}')
124
+ patchedRuntime = patchedRuntime.replace(platformsMatch[0], `${platformLine}${targetHelper}`)
125
+ const signature = 'export function resolveWorker(config = {}, { packageRoot = PACKAGE_ROOT, env = process.env, platform = process.platform, arch = process.arch } = {}) {'
126
+ if (!patchedRuntime.includes(signature)) return { status: 'recipe-mismatch', message: 'resolveWorker signature changed' }
127
+
128
+ const patchedSources = new Map()
129
+ for (const file of files) {
130
+ if (file.type !== 'file' || !file.path.endsWith('.js') || file.data === undefined) continue
131
+ let text = file.data.toString('utf8')
132
+ const before = text
133
+ text = text
134
+ .replaceAll('const argv = [file]', "const argv = ['bash', '-c', 'exec \"$0\" \"$@\"', file]")
135
+ .replaceAll('let argv = [file]', "let argv = ['bash', '-c', 'exec \"$0\" \"$@\"', file]")
136
+ if (text !== before) patchedSources.set(file.path, text)
137
+ }
138
+ if (patchedSources.size === 0) {
139
+ return { status: 'recipe-mismatch', message: `${packageName} has no standard native worker spawn (const/let argv = [file])` }
140
+ }
141
+
142
+ const pkgFile = byPath.get('package.json')
143
+ if (pkgFile?.data === undefined) return { status: 'invalid-package', message: 'package.json is missing' }
144
+ const pkg = JSON.parse(pkgFile.data.toString('utf8'))
145
+ delete pkg.os
146
+ delete pkg.cpu
147
+ // DSHM core is a prerelease (e.g. 0.2.1-alpha.4+dshm.56). The upstream
148
+ // ^0.2.0-rc.2 range does not match 0.2.1-*, and the bundle loader would throw
149
+ // on the next boot. "workspace:*" follows the running core instead.
150
+ if (pkg.peerDependencies !== null && typeof pkg.peerDependencies === 'object' && !Array.isArray(pkg.peerDependencies)) {
151
+ if (typeof pkg.peerDependencies['@deepseek-ai/dsh'] === 'string') {
152
+ pkg.peerDependencies['@deepseek-ai/dsh'] = 'workspace:*'
153
+ }
154
+ }
155
+ if (pkg.optionalDependencies !== null && typeof pkg.optionalDependencies === 'object') {
156
+ for (const name of Object.keys(pkg.optionalDependencies)) {
157
+ if (/(?:^|[-/])(?:win32|darwin)(?:[-/]|$)/i.test(name)) delete pkg.optionalDependencies[name]
158
+ }
159
+ }
160
+
161
+ const pluginFile = byPath.get('dsh-plugin.json')
162
+ if (pluginFile?.data !== undefined) {
163
+ const manifest = JSON.parse(pluginFile.data.toString('utf8'))
164
+ const components = manifest?.contributes?.['x-components']
165
+ if (Array.isArray(components)) {
166
+ for (const component of components) {
167
+ if (component === null || typeof component !== 'object') continue
168
+ if (Array.isArray(component.platforms) && !component.platforms.includes('openharmony')) component.platforms.push('openharmony')
169
+ if (Array.isArray(component.targets) && !component.targets.includes(runtimeTarget)) component.targets.push(runtimeTarget)
170
+ if (Array.isArray(component.architectures) && !component.architectures.includes('arm64')) component.architectures.push('arm64')
171
+ }
172
+ }
173
+ pluginFile.data = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
174
+ }
175
+
176
+ const runtimeDir = `runtime/${runtimeTarget}`
177
+ const workerPath = `${runtimeDir}/${workerName}`
178
+ const provenancePath = `${runtimeDir}/provenance.json`
179
+ /** @type {{ path: string, data: Buffer, mode?: number }[]} */
180
+ const extraEntries = []
181
+ let stagedWorker = workerBinary
182
+ let workerSource = 'external aarch64-unknown-linux-musl build supplied by the caller'
183
+ if (workerArch === 'x64') {
184
+ try {
185
+ const wrapper = Buffer.from([
186
+ '#!/bin/sh',
187
+ 'set -e',
188
+ 'DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
189
+ `exec "$DIR/qemu-x86_64" -L "$DIR/rootfs" "$DIR/${workerName}-x64" "$@"`,
190
+ '',
191
+ ].join('\n'))
192
+ stagedWorker = wrapper
193
+ workerSource = 'x86_64 worker executed through the bundled qemu-x86_64 and glibc rootfs'
194
+ extraEntries.push({ path: `${runtimeDir}/${workerName}-x64`, data: workerBinary, mode: 0o755 })
195
+ extraEntries.push({ path: `${runtimeDir}/qemu-x86_64`, data: readFileSync(join(QEMU_ROOT, 'qemu-x86_64')), mode: 0o755 })
196
+ extraEntries.push({ path: `${runtimeDir}/rootfs/lib64/ld-linux-x86-64.so.2`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib64', 'ld-linux-x86-64.so.2')), mode: 0o644 })
197
+ extraEntries.push({ path: `${runtimeDir}/rootfs/lib/x86_64-linux-gnu/libc.so.6`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib', 'x86_64-linux-gnu', 'libc.so.6')), mode: 0o644 })
198
+ extraEntries.push({ path: `${runtimeDir}/rootfs/lib/x86_64-linux-gnu/libgcc_s.so.1`, data: readFileSync(join(QEMU_ROOT, 'rootfs', 'lib', 'x86_64-linux-gnu', 'libgcc_s.so.1')), mode: 0o644 })
199
+ } catch (error) {
200
+ return { status: 'invalid-output', error: `cannot stage the bundled qemu runtime: ${String(error?.message ?? error)}` }
201
+ }
202
+ }
203
+ const sha256 = createHash('sha256').update(stagedWorker).digest('hex')
204
+ const provenance = {
205
+ version: typeof pkg.version === 'string' ? pkg.version : '0.0.0',
206
+ target: runtimeTarget,
207
+ executable: workerName,
208
+ sha256,
209
+ source: workerSource,
210
+ }
211
+
212
+ /** @type {string[]} */
213
+ const changed = [
214
+ `added ${runtimeTarget} to PLATFORMS and mapped openharmony/arm64 to it`,
215
+ `wrapped ${patchedSources.size} native worker spawn(s) in bash -c so dshm-vm-bash routes them to the guest`,
216
+ 'removed package.json os/cpu gates and non-Linux optional payloads',
217
+ 'relaxed @deepseek-ai/dsh peer range to workspace:* for the DSHM core',
218
+ `added dsh-plugin.json openharmony/${runtimeTarget} targets`,
219
+ ...(acquiredAsset === undefined ? [] : [`acquired ${workerName} from GitHub release asset ${acquiredAsset}`]),
220
+ workerArch === 'x64'
221
+ ? `staged ${workerName} as a shell wrapper over qemu-x86_64 + glibc rootfs`
222
+ : `staged ${workerPath}`,
223
+ ]
224
+
225
+ const output = files.filter(file => file.path !== workerPath).map(file => {
226
+ if (file.path === runtimeEntry.path) return { path: file.path, data: Buffer.from(patchedRuntime, 'utf8'), mode: file.mode }
227
+ if (patchedSources.has(file.path)) return { path: file.path, data: Buffer.from(patchedSources.get(file.path), 'utf8'), mode: file.mode }
228
+ if (file.path === 'package.json') return { path: file.path, data: Buffer.from(`${JSON.stringify(pkg, null, 2)}\n`, 'utf8'), mode: file.mode }
229
+ return { path: file.path, data: file.data, mode: file.mode }
230
+ })
231
+ output.push({ path: workerPath, data: stagedWorker, mode: 0o755 })
232
+ output.push(...extraEntries)
233
+ output.push({ path: provenancePath, data: Buffer.from(`${JSON.stringify(provenance, null, 2)}\n`, 'utf8'), mode: 0o644 })
234
+
235
+ const packed = packTarGz(output)
236
+ const outputDir = join(compatDir(process.env), 'builds')
237
+ mkdirSync(outputDir, { recursive: true })
238
+ const safeName = String(packageName).replace(/^@/, '').replace(/[\\/]/g, '-').replace(/[^A-Za-z0-9._-]/g, '_')
239
+ const tarball = join(outputDir, `${safeName}-${pkg.version ?? '0.0.0'}-dshm.tgz`)
240
+ writeFileSync(tarball, packed)
241
+ const validation = validateTarball(tarball)
242
+ if (!validation.ok) return { status: 'invalid-output', tarball, errors: validation.errors }
243
+ return {
244
+ status: 'ok',
245
+ tarball,
246
+ bytes: packed.length,
247
+ sha256: createHash('sha256').update(packed).digest('hex'),
248
+ changed,
249
+ warnings: validation.warnings,
250
+ validation,
251
+ worker: { target: workerArch === 'arm64' ? TARGET : 'x86_64-linux-gnu', arch: workerArch, sha256 },
252
+ }
253
+ }
254
+
package/lib/sidecar.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Generic guest sidecar staging and JSON-stdio calls.
3
+ *
4
+ * An S2 plugin's native worker is a Linux/AArch64 executable. This module
5
+ * stages such a worker from a converted tarball into
6
+ * `$DSH_HOME/dshm-compat/sidecars/<id>/` and invokes it inside the Alpine guest
7
+ * through the public subprocess service (which `dshm-vm-bash` routes).
8
+ *
9
+ * The worker protocol assumed here is one JSON request on stdin and one JSON
10
+ * value on stdout per process. That is the protocol most native DSH plugins use
11
+ * and the one the generic S2 adapter's `bash -c` wrapper preserves.
12
+ *
13
+ * @module dshm-compat/sidecar
14
+ */
15
+ import { createHash } from 'node:crypto'
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
17
+ import { join, posix } from 'node:path'
18
+ import { elfSummary } from './elf.js'
19
+ import { runGuestFile } from './guest.js'
20
+ import { compatDir } from './state.js'
21
+ import { extractTar } from './tar.js'
22
+
23
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i
24
+
25
+ /** @param {NodeJS.ProcessEnv} env */
26
+ export function sidecarRoot(env = process.env) {
27
+ return join(compatDir(env), 'sidecars')
28
+ }
29
+
30
+ /** @param {string} id */
31
+ export function validSidecarId(id) {
32
+ return typeof id === 'string' && ID_PATTERN.test(id)
33
+ }
34
+
35
+ /** @param {string} path */
36
+ function safeRelative(path) {
37
+ if (typeof path !== 'string' || path === '' || path.startsWith('/') || path.includes('\\')) return undefined
38
+ const normalized = posix.normalize(path).replace(/^\.\//, '')
39
+ if (normalized.startsWith('../') || normalized === '..') return undefined
40
+ return normalized
41
+ }
42
+
43
+ /** @param {NodeJS.ProcessEnv} env @param {string} id */
44
+ export function sidecarDir(id, env = process.env) {
45
+ if (!validSidecarId(id)) throw Object.assign(new Error('invalid sidecar id'), { code: 'INVALID_ID' })
46
+ return join(sidecarRoot(env), id)
47
+ }
48
+
49
+ /** @param {NodeJS.ProcessEnv} env @param {string} id */
50
+ export function readSidecar(id, env = process.env) {
51
+ try {
52
+ const meta = JSON.parse(readFileSync(join(sidecarDir(id, env), 'meta.json'), 'utf8'))
53
+ return meta !== null && typeof meta === 'object' ? meta : undefined
54
+ } catch { return undefined }
55
+ }
56
+
57
+ /** @param {NodeJS.ProcessEnv} env */
58
+ export function listSidecars(env = process.env) {
59
+ const root = sidecarRoot(env)
60
+ let entries
61
+ try { entries = readdirSync(root, { withFileTypes: true }) } catch { return [] }
62
+ const result = []
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory()) continue
65
+ const meta = readSidecar(entry.name, env)
66
+ if (meta !== undefined) result.push(meta)
67
+ }
68
+ return result.sort((a, b) => String(a.id).localeCompare(String(b.id)))
69
+ }
70
+
71
+ /**
72
+ * Stage a worker file from a tarball.
73
+ *
74
+ * @param {string} tarball
75
+ * @param {string} workerPath - path inside the package, e.g. `runtime/linux-arm64/<worker>`.
76
+ * @param {{ id?: string, env?: NodeJS.ProcessEnv }} [options]
77
+ */
78
+ export function stageSidecar(tarball, workerPath, options = {}) {
79
+ const env = options.env ?? process.env
80
+ const relative = safeRelative(workerPath)
81
+ if (relative === undefined) return { ok: false, error: 'workerPath must be a relative path inside the package' }
82
+ if (typeof tarball !== 'string' || !existsSync(tarball)) return { ok: false, error: `tarball not found: ${String(tarball)}` }
83
+
84
+ let entries
85
+ try { entries = extractTar(readFileSync(tarball)).entries } catch (error) {
86
+ return { ok: false, error: `cannot read tarball: ${String(error?.message ?? error)}` }
87
+ }
88
+ const entry = entries.find(item => item.type === 'file' && item.path === `package/${relative}`)
89
+ if (entry?.data === undefined) return { ok: false, error: `worker not found in tarball: package/${relative}` }
90
+ const elf = elfSummary(entry.data)
91
+ if (elf === undefined) return { ok: false, error: 'worker is not an AArch64 ELF image' }
92
+
93
+ const id = options.id ?? `worker-${elf.type.toLowerCase()}`
94
+ if (!validSidecarId(id)) return { ok: false, error: `invalid sidecar id: ${String(id)}` }
95
+ const target = sidecarDir(id, env)
96
+ const staging = `${target}.staging-${process.pid}-${Date.now()}`
97
+ try {
98
+ rmSync(staging, { recursive: true, force: true })
99
+ mkdirSync(staging, { recursive: true })
100
+ writeFileSync(join(staging, 'worker'), entry.data, { mode: 0o755 })
101
+ const sha256 = createHash('sha256').update(entry.data).digest('hex')
102
+ const meta = {
103
+ id,
104
+ worker: join(target, 'worker'),
105
+ workerPath: relative,
106
+ sha256,
107
+ bytes: entry.data.length,
108
+ elf,
109
+ source: tarball,
110
+ stagedAt: new Date().toISOString(),
111
+ }
112
+ writeFileSync(join(staging, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
113
+ rmSync(target, { recursive: true, force: true })
114
+ renameSync(staging, target)
115
+ return { ok: true, ...meta }
116
+ } catch (error) {
117
+ try { rmSync(staging, { recursive: true, force: true }) } catch { /* best effort */ }
118
+ return { ok: false, error: `cannot stage sidecar: ${String(error?.message ?? error)}` }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Call a staged worker with one JSON request.
124
+ *
125
+ * @param {import('@deepseek-ai/cordis').Context} ctx
126
+ * @param {string} id
127
+ * @param {unknown} input
128
+ * @param {{ timeoutMs?: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options]
129
+ */
130
+ export async function callSidecar(ctx, id, input, options = {}) {
131
+ const env = options.env ?? process.env
132
+ const meta = readSidecar(id, env)
133
+ if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
134
+ return { ok: false, error: `sidecar not staged: ${String(id)}` }
135
+ }
136
+ const result = await runGuestFile(ctx, meta.worker, [], JSON.stringify(input ?? {}), {
137
+ timeoutMs: options.timeoutMs ?? 120000,
138
+ signal: options.signal,
139
+ })
140
+ if (result.status !== 'ok') {
141
+ return { ok: false, status: result.status, error: result.reason ?? `worker exited ${result.exitCode}`, stdout: result.stdout, stderr: result.stderr }
142
+ }
143
+ try {
144
+ return { ok: true, value: JSON.parse(String(result.stdout).trim()), stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
145
+ } catch {
146
+ return { ok: false, error: 'worker returned invalid JSON', stdout: result.stdout, stderr: result.stderr }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Ask a staged worker for its version.
152
+ * @param {import('@deepseek-ai/cordis').Context} ctx
153
+ * @param {string} id
154
+ * @param {{ timeoutMs?: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options]
155
+ */
156
+ export async function probeSidecar(ctx, id, options = {}) {
157
+ const env = options.env ?? process.env
158
+ const meta = readSidecar(id, env)
159
+ if (meta === undefined || typeof meta.worker !== 'string' || !existsSync(meta.worker)) {
160
+ return { ok: false, error: `sidecar not staged: ${String(id)}` }
161
+ }
162
+ const result = await runGuestFile(ctx, meta.worker, ['--version'], '', {
163
+ timeoutMs: options.timeoutMs ?? 30000,
164
+ signal: options.signal,
165
+ })
166
+ return {
167
+ ok: result.status === 'ok',
168
+ status: result.status,
169
+ exitCode: result.exitCode,
170
+ stdout: result.stdout,
171
+ stderr: result.stderr,
172
+ error: result.reason,
173
+ }
174
+ }
package/lib/zip.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal ZIP reader for GitHub release assets.
3
+ *
4
+ * Supports stored (0) and deflate (8) entries and the classic EOCD layout.
5
+ * It intentionally rejects ZIP64 and encrypted archives: release worker
6
+ * payloads are small, and a missing worker is better than guessing.
7
+ *
8
+ * @module dshm-compat/zip
9
+ */
10
+ import { inflateRawSync } from 'node:zlib'
11
+
12
+ const EOCD = 0x06054b50
13
+ const CENTRAL = 0x02014b50
14
+ const LOCAL = 0x04034b50
15
+
16
+ /** @param {Buffer} buffer */
17
+ export function extractZip(buffer) {
18
+ if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('not a ZIP archive')
19
+ let eocd = -1
20
+ const start = Math.max(0, buffer.length - 65557)
21
+ for (let i = buffer.length - 22; i >= start; i--) {
22
+ if (buffer.readUInt32LE(i) === EOCD) { eocd = i; break }
23
+ }
24
+ if (eocd < 0) throw new Error('ZIP end-of-central-directory not found')
25
+ const count = buffer.readUInt16LE(eocd + 10)
26
+ const centralOffset = buffer.readUInt32LE(eocd + 16)
27
+ if (centralOffset === 0xffffffff || count === 0xffff) throw new Error('ZIP64 is not supported')
28
+
29
+ /** @type {{ path: string, data: Buffer }[]} */
30
+ const entries = []
31
+ let offset = centralOffset
32
+ for (let index = 0; index < count; index++) {
33
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL) throw new Error('invalid ZIP central directory')
34
+ const method = buffer.readUInt16LE(offset + 10)
35
+ const compressedSize = buffer.readUInt32LE(offset + 20)
36
+ const uncompressedSize = buffer.readUInt32LE(offset + 24)
37
+ const nameLength = buffer.readUInt16LE(offset + 28)
38
+ const extraLength = buffer.readUInt16LE(offset + 30)
39
+ const commentLength = buffer.readUInt16LE(offset + 32)
40
+ const localOffset = buffer.readUInt32LE(offset + 42)
41
+ const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
42
+ offset += 46 + nameLength + extraLength + commentLength
43
+ if (name.endsWith('/')) continue
44
+ const normalized = name.replace(/\\/g, '/').replace(/^\.\//, '')
45
+ if (normalized.startsWith('/') || normalized.split('/').includes('..')) throw new Error(`unsafe ZIP path: ${name}`)
46
+ if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== LOCAL) throw new Error(`invalid ZIP local header: ${name}`)
47
+ const localNameLength = buffer.readUInt16LE(localOffset + 26)
48
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28)
49
+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength
50
+ const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize)
51
+ let data
52
+ if (method === 0) data = compressed
53
+ else if (method === 8) data = inflateRawSync(compressed)
54
+ else throw new Error(`unsupported ZIP compression method ${method} for ${name}`)
55
+ if (data.length !== uncompressedSize) throw new Error(`ZIP size mismatch for ${name}`)
56
+ entries.push({ path: normalized, data })
57
+ }
58
+ return entries
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshm-compat",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Analyze and convert DSH plugins that cannot be installed or run on HarmonyOS DSHM.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -24,12 +24,14 @@
24
24
  "index.js",
25
25
  "bin",
26
26
  "lib",
27
+ "assets",
27
28
  "cordis.patch.yml",
28
29
  "locale/*.json",
29
30
  "icon.svg",
30
31
  "README.md",
31
32
  "INSTALL.md",
32
33
  "CHANGELOG.md",
34
+ "AGENTS.md",
33
35
  "docs/*.md"
34
36
  ],
35
37
  "engines": {