mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* feat(mobile-web-bundle): serve the bundle manifest and chunks over RPC Two paired-runtime methods on the already-authenticated connection: `mobileWeb.bundle.manifest` returns this install's manifest plus the chunk size it advertises, and `mobileWeb.bundle.chunk` returns one aligned range of one asset with the whole asset's length and hash, so a single chunk describes what it belongs to. `path` is accepted only by exact match against a manifest member, so traversal is unreachable rather than mitigated. Each asset's on-disk sha256 is verified once and the verdict remembered, concurrent first readers sharing one hash. Reads are capped at four in flight per connection, and a disconnected client stops costing reads at the next checkpoint. No SSH or relay proxying: a runtime answers only out of its own install. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the three buildId serializers against each other The canonical serialization exists in the builder, the packaging guard, and the shared contract, because the two packaging scripts run on bare node before any build output exists and cannot import TypeScript. A divergence in any one would reject every honest bundle at packaging, or ship a bundle whose id the phone recomputes differently and re-downloads forever. Proved red by swapping the guard's code-unit sort for localeCompare: five of six cases fail. Exports the guard's serializer for the test; no packaging behaviour changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover every error code and a multi-chunk paging round trip Against a synthetic bundle in a temp dir, because the real builder's largest asset is under one chunk and CI unit jobs never build out/mobile-web. The fixture's script spans three chunks, its stylesheet is exactly one, and one asset is empty, so paging, the eof boundary, and the zero-byte case are exercised rather than assumed. Reads in flight are held by latching `open`, so the four-per-connection cap and an abort arriving mid-read are deterministic rather than a race with a stopwatch. Both were proved red: dropping the abort check after verification fails the abort case, and keying the cap on connectionId alone fails the device-token case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): resolve the bundle root through the AppEnvironment port check:runtime-electron-ratchet caught this: the resolver sat beside getBundledWebClientRoot in src/main/startup and imported electron, and importing it from an RPC method pulled the first electron edge into a runtime graph whose baseline is zero. The runtime has to stay bootable on plain Node. So it reads app.getAppPath() through the port every other runtime module already uses, and moves next to its two callers under src/main/runtime. A host with no environment installed has no install root, which is the same answer as having no bundle. orcad answers getAppPath from its own install root, so a headless runtime that carries the artifact serves it with no special case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover the resolver's two probe layouts directly Also stops exporting the manifest filename, which nothing outside the resolver needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin both methods on the mobile allowlist The scanner only checks mobile-used ⊆ allowlist, and no mobile source calls these until A5, so deleting both entries left every test green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): keep filesystem failures inside the six error codes An asset unlinked or truncated after its verdict was cached reached the client as runtime_error carrying the desktop's absolute install path. Both now answer mobile_web_bundle_asset_changed, with the cause warned host-side only. A short positional read is the truncation case, so it throws instead of paging the client past the end. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): drop the unreachable release-idempotence guard The one caller releases exactly once in a finally; removing the flag left every test green, so it was defensiveness against a caller that does not exist. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): prove a failed verify is not cached as a verdict The verdict cache never invalidates, so a transient read failure remembered as a verdict would poison the asset for the life of the process. Removing the delete left every test green until now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): delete the unsatisfiable manifest params schema The dispatcher substitutes `{}` for absent params, so `z.null()` could never parse; the method declares `params: null` instead. A comment on the method name records why there is no schema. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): fill the read window instead of failing a partial read fs.read may answer short of what it was asked for before EOF, so the previous check turned a legitimate partial read into a spurious asset_changed. The loop mirrors the relay's readFullStreamChunk, which is not imported because it sits behind the relay dispatcher's module graph; only a read returning nothing is treated as truncation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): read the disconnect idiom with the shared predicate isClientDisconnectedError already exports exactly the check the catch needed, so the local error class goes away and the throw returns to the repo-wide idiom. The module doc now says asContractError is a total catch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the four branches no test was holding Each one survived a mutation: the abort check before verification, the per-process manifest cache, the buildId component of the verdict key, and delete-at-zero in the admission map. The last two matter beyond hygiene — a verdict keyed by path alone carries a failed verdict onto the next build of index.html, and a map that never drops a key retains one pairing token per socket. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
194 lines
7.4 KiB
JavaScript
194 lines
7.4 KiB
JavaScript
const { createHash } = require('node:crypto')
|
|
const { readFileSync, readdirSync, statSync } = require('node:fs')
|
|
const { join, resolve } = require('node:path')
|
|
|
|
const projectDir = resolve(__dirname, '..', '..')
|
|
const MOBILE_WEB_BUNDLE_DIR = join(projectDir, 'out', 'mobile-web')
|
|
const REMEDY = 'Run pnpm build:mobile-web (build:desktop and build:release already do).'
|
|
const ENTRYPOINT = 'index.html'
|
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/
|
|
|
|
function failure(message) {
|
|
return new Error(`[verify-packaged-mobile-web-bundle] ${message}`)
|
|
}
|
|
|
|
function assertSafeRelativePath(path) {
|
|
if (typeof path !== 'string' || path.length === 0) {
|
|
throw failure('manifest asset has a missing or empty path')
|
|
}
|
|
const segments = path.split('/')
|
|
if (
|
|
path.includes('\\') ||
|
|
path.startsWith('/') ||
|
|
/^[a-zA-Z]:/.test(path) ||
|
|
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
) {
|
|
throw failure(`manifest asset path is not a safe relative path: ${path}`)
|
|
}
|
|
}
|
|
|
|
function assertInteger(value, field) {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw failure(`manifest field ${field} is not a non-negative integer: ${String(value)}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Canonical serialization of the asset list. Must stay byte-identical to
|
|
* serializeMobileWebBundleAssets in config/scripts/build-mobile-web-bundle.mjs; a divergence here
|
|
* would reject every honest bundle, so the two move together.
|
|
*/
|
|
function serializeAssets(assets) {
|
|
return JSON.stringify(
|
|
[...assets]
|
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
.map(({ path, sha256, byteLength, contentType }) => ({
|
|
path,
|
|
sha256,
|
|
byteLength,
|
|
contentType
|
|
}))
|
|
)
|
|
}
|
|
|
|
function parseManifest(bundleDir) {
|
|
const manifestPath = join(bundleDir, 'manifest.json')
|
|
let raw
|
|
try {
|
|
raw = readFileSync(manifestPath, 'utf8')
|
|
} catch (error) {
|
|
throw failure(
|
|
`no bundle manifest at ${manifestPath} (${error.code ?? error.message}). ${REMEDY}`
|
|
)
|
|
}
|
|
let manifest
|
|
try {
|
|
manifest = JSON.parse(raw)
|
|
} catch (error) {
|
|
throw failure(`${manifestPath} is not valid JSON: ${error.message}. ${REMEDY}`)
|
|
}
|
|
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
|
throw failure(`${manifestPath} is not a JSON object. ${REMEDY}`)
|
|
}
|
|
if (manifest.schemaVersion !== 1) {
|
|
throw failure(`unsupported manifest schemaVersion: ${String(manifest.schemaVersion)}`)
|
|
}
|
|
if (typeof manifest.buildId !== 'string' || !SHA256_PATTERN.test(manifest.buildId)) {
|
|
throw failure(`manifest buildId is not a sha256 digest: ${String(manifest.buildId)}`)
|
|
}
|
|
if (typeof manifest.desktopVersion !== 'string' || manifest.desktopVersion.length === 0) {
|
|
throw failure('manifest desktopVersion is missing')
|
|
}
|
|
assertInteger(manifest.minCompatibleRuntimeProtocolVersion, 'minCompatibleRuntimeProtocolVersion')
|
|
assertInteger(manifest.runtimeProtocolVersion, 'runtimeProtocolVersion')
|
|
assertInteger(manifest.totalBytes, 'totalBytes')
|
|
if (manifest.entrypoint !== ENTRYPOINT) {
|
|
throw failure(`manifest entrypoint must be ${ENTRYPOINT}, got ${String(manifest.entrypoint)}`)
|
|
}
|
|
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
|
throw failure('manifest lists no assets')
|
|
}
|
|
for (const asset of manifest.assets) {
|
|
if (typeof asset !== 'object' || asset === null) {
|
|
throw failure('manifest asset entry is not an object')
|
|
}
|
|
assertSafeRelativePath(asset.path)
|
|
if (typeof asset.sha256 !== 'string' || !SHA256_PATTERN.test(asset.sha256)) {
|
|
throw failure(`manifest asset ${asset.path} has no sha256 digest`)
|
|
}
|
|
assertInteger(asset.byteLength, `assets[${asset.path}].byteLength`)
|
|
if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) {
|
|
throw failure(`manifest asset ${asset.path} has no contentType`)
|
|
}
|
|
}
|
|
if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) {
|
|
throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`)
|
|
}
|
|
const declaredTotal = manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
|
|
if (declaredTotal !== manifest.totalBytes) {
|
|
throw failure(
|
|
`manifest totalBytes is ${String(manifest.totalBytes)}, its assets sum to ${String(declaredTotal)}`
|
|
)
|
|
}
|
|
const recomputed = createHash('sha256')
|
|
.update(serializeAssets(manifest.assets), 'utf8')
|
|
.digest('hex')
|
|
if (recomputed !== manifest.buildId) {
|
|
throw failure(
|
|
`manifest buildId ${manifest.buildId} does not match its asset list (expected ${recomputed}). ${REMEDY}`
|
|
)
|
|
}
|
|
return manifest
|
|
}
|
|
|
|
/** Every file under the bundle directory, as a manifest-shaped relative path. */
|
|
function listBundleFiles(directory, prefix = '') {
|
|
const found = []
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const relativePath = prefix === '' ? entry.name : `${prefix}/${entry.name}`
|
|
if (entry.isDirectory()) {
|
|
found.push(...listBundleFiles(join(directory, entry.name), relativePath))
|
|
} else {
|
|
found.push(relativePath)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
/**
|
|
* Nothing in the bundle directory may be unaccounted for. An asset dropped from the manifest but
|
|
* left on disk by an interrupted build ships inside asar, unreachable and unverified, and grows
|
|
* the installer; content-addressed names mean stale copies never get overwritten.
|
|
*/
|
|
function assertNoUnlistedFiles(bundleDir, manifest) {
|
|
const listed = new Set(['manifest.json', ...manifest.assets.map((asset) => asset.path)])
|
|
const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path))
|
|
if (strays.length > 0) {
|
|
throw failure(
|
|
`${bundleDir} holds ${String(strays.length)} file(s) the manifest does not list: ` +
|
|
`${strays.sort().join(', ')}. ${REMEDY}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Packaging guard: electron-builder only warns about a missing input, so without this a release
|
|
* would ship an app that advertises the bundle capability and then errors on every request. The
|
|
* hash check, not the existence check, is what catches a half-written or stale out/.
|
|
*/
|
|
function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) {
|
|
const manifest = parseManifest(bundleDir)
|
|
assertNoUnlistedFiles(bundleDir, manifest)
|
|
for (const asset of manifest.assets) {
|
|
const assetPath = join(bundleDir, asset.path)
|
|
let size
|
|
try {
|
|
size = statSync(assetPath).size
|
|
} catch (error) {
|
|
throw failure(
|
|
`manifest lists ${asset.path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}`
|
|
)
|
|
}
|
|
if (size !== asset.byteLength) {
|
|
throw failure(
|
|
`${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}`
|
|
)
|
|
}
|
|
const sha256 = createHash('sha256').update(readFileSync(assetPath)).digest('hex')
|
|
if (sha256 !== asset.sha256) {
|
|
throw failure(
|
|
`${asset.path} hashes to ${sha256} on disk, manifest says ${asset.sha256}. ${REMEDY}`
|
|
)
|
|
}
|
|
}
|
|
console.log(
|
|
`[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` +
|
|
`${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes`
|
|
)
|
|
return manifest
|
|
}
|
|
|
|
// serializeAssets is exported for the parity test that pins it against the builder's and the
|
|
// contract's serializers; nothing in packaging calls it from outside this module.
|
|
module.exports = { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt, serializeAssets }
|