feat(orcad): ship the mobile web page so phones paired to orcad get it (#29882)

* feat(orcad): ship the mobile web page so phones paired to orcad get it

orcad never carried out/mobile-web, so it did not advertise
mobileWeb.bundle.v1 and phones paired directly to an orcad server fell
back to the app's built-in native screens.

The built page now ships in every orcad package at out/mobile-web, the
path getBundledMobileWebBundleRoot already probes under the install
root. Its manifest is a fixed artifact; its content-addressed assets
join the hashed artifact list from that manifest, as one shared copy in
the template's common files. build-orcad fails when the page is missing
or stale, and CI lanes that build orcad now build the page first.

* ci(orcad): check out mobile/ for the Windows host template build

* refactor(orcad): copy the page tree whole and build it once per CI run

The guard already refuses unlisted files, so staging copies the
bundle directory as native-chat-visuals is copied. The shipped template
takes the page the qualification lanes tested, the e2e build artifact
carries it, and the PR smoke builds it through install-mobile-dependencies
behind its own scope step. Drops tests that duplicated the guard's own.

* fix(orcad): refuse a template page asset name that escapes the slot
This commit is contained in:
Jinwoo Hong
2026-10-16 19:29:41 -07:00
committed by GitHub
parent 0013011409
commit 776c791554
31 changed files with 505 additions and 72 deletions
@@ -0,0 +1,11 @@
name: Build the mobile web bundle
description: Builds out/mobile-web, the page phones load, which every orcad build ships and refuses to build without.
runs:
using: composite
steps:
# Why: the bundle build resolves React Native and Expo from mobile/node_modules.
- uses: ./.github/actions/install-mobile-dependencies
- name: Build the mobile web bundle
shell: bash
run: pnpm run build:mobile-web
+5
View File
@@ -134,6 +134,10 @@ jobs:
echo 'available=false' >> "$GITHUB_OUTPUT"
fi
# The orcad builds downstream refuse to run without the page; older refs predate the action.
- uses: ./.github/actions/build-mobile-web-bundle
if: hashFiles('.github/actions/build-mobile-web-bundle/action.yml') != ''
# Only the completed output upload overlaps the change from Node to Electron ABI.
- name: Upload E2E build output
id: e2e-build-upload
@@ -519,6 +523,7 @@ jobs:
- uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: x64
- uses: ./.github/actions/build-mobile-web-bundle
- name: Build the e2e app, CLI, orcad slot and template
shell: bash
env:
+46 -4
View File
@@ -191,6 +191,7 @@ jobs:
if: runner.os == 'Windows'
with:
arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }}
- uses: ./.github/actions/build-mobile-web-bundle
- run: pnpm build:orcad
env:
ORCA_REUSE_PREPARED_WINDOWS_SECURITY: ${{ github.event_name == 'pull_request' && runner.os == 'Windows' && steps.dependencies.outcome == 'success' }}
@@ -297,14 +298,40 @@ jobs:
# A rerun attempt re-uploads under the same name, which v4 otherwise refuses.
overwrite: true
# The container lanes take the page from here: it is platform-independent, and building it needs
# mobile/node_modules, which those images never install.
mobile_web_bundle:
needs: changes
if: >-
${{ !cancelled() && needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- uses: ./.github/actions/build-mobile-web-bundle
- uses: actions/upload-artifact@v7
with:
name: orcad-mobile-web-bundle
path: out/mobile-web/
if-no-files-found: error
retention-days: 1
overwrite: true
linux_glibc_floor:
needs: [changes, persistence]
needs: [changes, persistence, mobile_web_bundle]
concurrency:
group: node-server-linux_glibc_floor-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
# A failed smoke already blocks qualification; missing scope still selects every platform.
if: >-
${{ !cancelled() && needs.persistence.result == 'success' &&
needs.mobile_web_bundle.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
strategy:
@@ -353,6 +380,10 @@ jobs:
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds --smoke
- uses: actions/download-artifact@v8
with:
name: orcad-mobile-web-bundle
path: out/mobile-web
- run: pnpm build:orcad
- run: pnpm test:node-server --artifact
- name: Keep this runner's glibc 2.28 slot for the desktop template
@@ -423,13 +454,14 @@ jobs:
overwrite: true
linux_musl:
needs: [changes, persistence]
needs: [changes, persistence, mobile_web_bundle]
concurrency:
group: node-server-linux_musl-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
# A failed smoke already blocks qualification; missing scope still selects every platform.
if: >-
${{ !cancelled() && needs.persistence.result == 'success' &&
needs.mobile_web_bundle.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
strategy:
@@ -449,6 +481,10 @@ jobs:
id: pnpm-verification
with:
container-toolchain: 'true'
- uses: actions/download-artifact@v8
with:
name: orcad-mobile-web-bundle
path: out/mobile-web
- name: Verify native Alpine artifact and persistence
env:
VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }}
@@ -483,10 +519,11 @@ jobs:
# Design D2: the desktop ships every target's addons, merged from the lanes that qualified them.
desktop_template:
needs: [persistence, linux_glibc_floor, linux_glibc217_compat, linux_musl]
needs: [persistence, mobile_web_bundle, linux_glibc_floor, linux_glibc217_compat, linux_musl]
if: >-
${{ !cancelled() && inputs.build_template &&
needs.persistence.result == 'success' && needs.linux_glibc_floor.result == 'success' &&
needs.persistence.result == 'success' && needs.mobile_web_bundle.result == 'success' &&
needs.linux_glibc_floor.result == 'success' &&
needs.linux_glibc217_compat.result == 'success' && needs.linux_musl.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -523,6 +560,11 @@ jobs:
node config/scripts/merge-orcad-prebuilds.mjs "$RUNNER_TEMP"/orcad-prebuild-lanes/*
pnpm build:orcad-prebuilds --require-slots
pnpm build:orcad-prebuilds --require-slots linux-x64-glibc217
# The page the qualification lanes tested, not a rebuild.
- uses: actions/download-artifact@v8
with:
name: orcad-mobile-web-bundle
path: out/mobile-web
- run: pnpm build:orcad-template
- name: Report the template size
shell: bash
+17 -6
View File
@@ -412,24 +412,35 @@ jobs:
bun config/scripts/verify-localization-extraction.mjs
fi
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
- name: Decide whether the orcad terminal smoke runs
id: orcad-smoke-scope
if: needs.code_paths.outputs.static_analysis == 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
ORCA_BACKGROUND_LAUNCH: '1'
run: |
# Detection failures run the smoke; renames retain the removed input path.
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/orcad-smoke-changes" &&
scope="$(node config/scripts/orcad-terminal-smoke-change-scope.mjs "$RUNNER_TEMP/orcad-smoke-changes")" && [ "$scope" = false ]; then
echo "Orcad terminal smoke inputs are unchanged."
echo 'run=false' >> "$GITHUB_OUTPUT"
else
pnpm run smoke:orcad-terminal
echo 'run=true' >> "$GITHUB_OUTPUT"
fi
# orcad refuses to build without the page phones load.
- uses: ./.github/actions/build-mobile-web-bundle
if: steps.orcad-smoke-scope.outputs.run == 'true'
# Why both: the ratchet proves nothing reachable from the runtime imports electron,
# which is a property of the import graph. This proves the Node artifact it enables
# actually boots, pairs, creates a worktree and round-trips a real PTY.
- name: Boot orcad and round-trip a terminal
if: steps.orcad-smoke-scope.outputs.run == 'true'
env:
ORCA_BACKGROUND_LAUNCH: '1'
run: pnpm run smoke:orcad-terminal
- name: Verify the generated RPC params catalog
if: needs.code_paths.outputs.static_analysis == 'true'
run: node config/scripts/generate-rpc-params-catalog.mjs --check
+2
View File
@@ -194,6 +194,7 @@ jobs:
pnpm build:orcad-prebuilds --require-slots linux-x64-glibc,linux-x64-musl,linux-x64-glibc217
# Only the x64 Linux slots exist here, so the template is built for those two, and
# glibc217 is staged beside its base.
- uses: ./.github/actions/build-mobile-web-bundle
- name: Build the orcad template
run: node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc,linux-x64-musl
- name: Run the hostile-host matrix
@@ -228,6 +229,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: ./.github/actions/build-mobile-web-bundle
- name: Build and smoke this runner's slot and the orcad template
run: |
pnpm build:orcad-prebuilds
+3
View File
@@ -106,9 +106,11 @@ jobs:
persist-credentials: false
# Keep complete server/test trees; missing future imports fail the unchanged builds.
# All of src: the orcad-convert cell builds the full e2e app and the bundled CLI.
# mobile: the template ships the mobile web page, which builds from mobile/.
sparse-checkout: |
.github
config
mobile
native
resources
tests
@@ -159,6 +161,7 @@ jobs:
- uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: ${{ matrix.arch }}
- uses: ./.github/actions/build-mobile-web-bundle
- name: Build the win32 template
shell: bash
env:
@@ -98,6 +98,7 @@ jobs:
- uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: x64
- uses: ./.github/actions/build-mobile-web-bundle
- name: Build the win32-x64 orcad template
shell: bash
env:
+1
View File
@@ -50,6 +50,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: ./.github/actions/build-mobile-web-bundle
- name: Build the template
run: |
pnpm build:orcad-prebuilds
+5 -1
View File
@@ -37,12 +37,12 @@ import {
} from './orcad-prebuild-slot-contents.mjs'
import { runProcessSync } from '@orca/process-host'
import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs'
import { readOrcadMobileWebAssetFilenames } from './orcad-mobile-web-bundle.mjs'
import { assertNotLayoutOwnerBuild } from '../layout-owner-build.cjs'
const root = resolve(import.meta.dirname, '../..')
const outputDir = join(root, 'out', 'orcad-template')
const buildDir = join(root, 'out', '.orcad-template-build')
const commonArtifacts = orcadTemplateCommonFilenames()
function isExecutable(filename) {
return /(?:^|\/)(?:rg|spawn-helper)$/.test(filename)
@@ -190,6 +190,10 @@ async function main() {
rmSync(outputDir, { recursive: true, force: true })
mkdirSync(outputDir, { recursive: true })
const [firstTarget] = templateTargets
// Every package carries the same page: its manifest is a common file, checked byte for byte below.
const commonArtifacts = orcadTemplateCommonFilenames(
readOrcadMobileWebAssetFilenames(packages[firstTarget])
)
for (const filename of commonArtifacts) {
const source = join(packages[firstTarget], ...filename.split('/'))
// Why check every target: the template keeps one copy, so a per-target difference would ship wrong bytes.
+2
View File
@@ -35,6 +35,7 @@ import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
import { stageOrcadWindowsCliLauncher } from './orcad-windows-cli-launcher.mjs'
import { stageOrcadWindowsSecurity } from './orcad-windows-security.mjs'
import { stageOrcadLinearSdk } from './orcad-linear-sdk-package.mjs'
import { stageOrcadMobileWebBundle } from './orcad-mobile-web-bundle.mjs'
import {
ORCAD_EMOJI_SHORTCODE_DATASET,
ORCAD_CLI_ENTRY_FILENAME,
@@ -203,6 +204,7 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep',
cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-chat-visuals'), {
recursive: true
})
stageOrcadMobileWebBundle(OUT_DIR)
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
@@ -571,7 +571,10 @@ it('keeps every platform job and runs them when detection is skipped or fails',
expect(workflow.jobs.persistence.strategy.matrix.os).toContain('needs.changes.outputs.runners')
for (const jobName of ['linux_glibc_floor', 'linux_musl']) {
const job = workflow.jobs[jobName]
expect(job.needs).toEqual(['changes', 'persistence'])
expect(job.needs).toEqual(['changes', 'persistence', 'mobile_web_bundle'])
// The containers never install mobile/, and orcad refuses to build without the page.
expect(job.steps.some((step) => step.with?.name === 'orcad-mobile-web-bundle')).toBe(true)
expect(job.if).toContain("needs.mobile_web_bundle.result == 'success'")
expect(job.if).toContain("needs.persistence.result == 'success'")
expect(job.if).toContain("needs.changes.outputs.qualification != 'false'")
expect(job.if).toContain("needs.changes.outputs.should_run != 'false'")
@@ -328,7 +328,10 @@ it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (d
const compat = workflow.jobs.linux_glibc217_compat
expect(compat.needs).toEqual(['changes', 'persistence'])
expect(compat.if).toContain(floor.if.slice(0, -3))
// Only the floor builds orcad, so only it waits for the page.
expect(compat.if).toContain(
floor.if.slice(0, -3).replace(" && needs.mobile_web_bundle.result == 'success'", '')
)
expect(compat.if).toContain("needs.changes.outputs.native_compatibility != 'false'")
expect(compat.if).toContain(
"github.event_name != 'pull_request' || inputs.build_template || inputs.ref != ''"
+5 -1
View File
@@ -2,11 +2,15 @@ import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { ORCAD_VERSION, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts'
import { readOrcadMobileWebAssetFilenames } from './orcad-mobile-web-bundle.mjs'
/** The runtime enters through `.runtime-node`'s executableSha256 string, never its bytes (design D2). */
export function computeOrcadFullVersion(artifactDir, { target, agentBrowserFilename }) {
const hash = createHash('sha256')
for (const filename of orcadArtifactFilenames(target)) {
for (const filename of orcadArtifactFilenames(
target,
readOrcadMobileWebAssetFilenames(artifactDir)
)) {
const artifactPath = join(artifactDir, filename)
if (!existsSync(artifactPath)) {
throw new Error(
+16 -2
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_SERVER_TARGET_FILENAME,
orcadArtifactFilenames,
@@ -20,13 +21,16 @@ afterEach(() => {
}
})
const PAGE_ASSET = 'out/mobile-web/index.html'
const PAGE_MANIFEST = JSON.stringify({ assets: [{ path: 'index.html' }] })
function createArtifactDirectory(target = 'linux-x64-glibc') {
const directory = mkdtempSync(join(tmpdir(), 'orcad-version-'))
directories.push(directory)
for (const filename of orcadArtifactFilenames(target)) {
for (const filename of orcadArtifactFilenames(target, [PAGE_ASSET])) {
const path = join(directory, filename)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, filename)
writeFileSync(path, filename === ORCAD_MOBILE_WEB_MANIFEST ? PAGE_MANIFEST : filename)
}
writeFileSync(join(directory, ORCAD_SERVER_TARGET_FILENAME), `${target}\n`)
return directory
@@ -44,6 +48,16 @@ describe('standalone runtime version', () => {
expect(() => computeOrcadFullVersion(dir, { target })).toThrow(orcadRipgrepArtifact(target))
})
it('changes when a page asset changes and rejects a missing one', () => {
const target = 'linux-x64-glibc'
const dir = createArtifactDirectory(target)
const before = computeOrcadFullVersion(dir, { target })
writeFileSync(join(dir, PAGE_ASSET), 'updated page')
expect(computeOrcadFullVersion(dir, { target })).not.toBe(before)
rmSync(join(dir, PAGE_ASSET))
expect(() => computeOrcadFullVersion(dir, { target })).toThrow(PAGE_ASSET)
})
it('keys the version on the referenced runtime digest rather than its bytes', () => {
const target = 'darwin-arm64'
const dir = createArtifactDirectory(target)
@@ -0,0 +1,29 @@
import { cpSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import {
ORCAD_MOBILE_WEB_DIR,
ORCAD_MOBILE_WEB_MANIFEST,
orcadMobileWebAssetFilenames
} from '../../src/shared/orcad-artifacts.ts'
const { MOBILE_WEB_BUNDLE_DIR, assertMobileWebBundleBuilt } = createRequire(import.meta.url)(
'./verify-packaged-mobile-web-bundle.cjs'
)
/**
* The built page (pnpm build:mobile-web) into an orcad package. Fatal when it is missing or stale,
* as electron-builder's beforePack is: a server without it serves phones no page.
*/
export function stageOrcadMobileWebBundle(outputDir, bundleDir = MOBILE_WEB_BUNDLE_DIR) {
assertMobileWebBundleBuilt(bundleDir)
// The guard refuses unlisted files, so the tree is exactly the manifest and its assets.
cpSync(bundleDir, join(outputDir, ...ORCAD_MOBILE_WEB_DIR.split('/')), { recursive: true })
}
/** The asset half of a package's artifact list, read from the page it carries. */
export function readOrcadMobileWebAssetFilenames(packageDir) {
return orcadMobileWebAssetFilenames(
readFileSync(join(packageDir, ...ORCAD_MOBILE_WEB_MANIFEST.split('/')), 'utf8')
)
}
@@ -0,0 +1,59 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { parse } from 'yaml'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { ORCAD_MOBILE_WEB_MANIFEST } from '../../src/shared/orcad-artifacts.ts'
import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tree.mjs'
import {
readOrcadMobileWebAssetFilenames,
stageOrcadMobileWebBundle
} from './orcad-mobile-web-bundle.mjs'
let scratch
let bundleDir
let outputDir
beforeEach(() => {
scratch = mkdtempSync(join(tmpdir(), 'orcad-mobile-web-'))
bundleDir = join(scratch, 'mobile-web')
outputDir = join(scratch, 'orcad')
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(scratch, { recursive: true, force: true })
})
it('stages the manifest and every asset it lists, byte for byte', async () => {
const { manifest } = await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
stageOrcadMobileWebBundle(outputDir, bundleDir)
const staged = readOrcadMobileWebAssetFilenames(outputDir)
expect(staged).toHaveLength(manifest.assets.length)
for (const asset of manifest.assets) {
expect(readFileSync(join(outputDir, 'out', 'mobile-web', asset.path))).toEqual(
readFileSync(join(bundleDir, asset.path))
)
}
expect(readFileSync(join(outputDir, ORCAD_MOBILE_WEB_MANIFEST))).toEqual(
readFileSync(join(bundleDir, 'manifest.json'))
)
})
it('fails the build when the page was never built', () => {
expect(() => stageOrcadMobileWebBundle(outputDir, bundleDir)).toThrow('pnpm build:mobile-web')
expect(existsSync(join(outputDir, ORCAD_MOBILE_WEB_MANIFEST))).toBe(false)
})
it('ships the page the qualification lanes tested in the release template', () => {
const workflow = parse(
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
)
const template = workflow.jobs.desktop_template
expect(template.needs).toContain('mobile_web_bundle')
expect(template.if).toContain("needs.mobile_web_bundle.result == 'success'")
expect(template.steps.some((step) => step.with?.name === 'orcad-mobile-web-bundle')).toBe(true)
expect(template.steps.some((step) => step.uses?.includes('build-mobile-web-bundle'))).toBe(false)
})
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
@@ -34,10 +35,14 @@ function targetFileContents(target, filename) {
export async function writeOrcadTemplateTestFixture(resourcesDir, { compat = false } = {}) {
const templateDir = join(resourcesDir, 'orcad-template')
const commonSha256 = {}
for (const filename of orcadTemplateCommonFilenames()) {
for (const filename of orcadTemplateCommonFilenames(['out/mobile-web/index.html'])) {
commonSha256[filename] = await write(
join(templateDir, ...filename.split('/')),
Buffer.from(`common:${filename}`)
Buffer.from(
filename === ORCAD_MOBILE_WEB_MANIFEST
? JSON.stringify({ assets: [{ path: 'index.html' }] })
: `common:${filename}`
)
)
}
const targets = {}
@@ -92,18 +92,25 @@ it('only skips the unchanged smoke after a successful diff and dependency analys
const workflow = parse(
readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8')
)
const step = workflow.jobs.preflight.steps.find(
(candidate) => candidate.name === 'Boot orcad and round-trip a terminal'
)
expect(step.env).toEqual({
BASE_SHA: '${{ github.event.pull_request.base.sha }}',
ORCA_BACKGROUND_LAUNCH: '1'
})
const steps = workflow.jobs.preflight.steps
const scope = steps.find((candidate) => candidate.id === 'orcad-smoke-scope')
expect(scope.env).toEqual({ BASE_SHA: '${{ github.event.pull_request.base.sha }}' })
// See the localization gate: HEAD^1 removes the merge-base computation, so a shallow checkout
// is enough and the payload head SHA is unused.
expect(step.run).toContain('node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA"')
expect(step.run).toContain('git diff --name-only --no-renames -z "$DIFF_BASE" HEAD')
expect(step.run).not.toContain('--diff-filter')
expect(step.run).toContain('&& [ "$scope" = false ]; then')
expect(step.run).toMatch(/else\s+pnpm run smoke:orcad-terminal\s+fi/)
expect(scope.run).toContain('node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA"')
expect(scope.run).toContain('git diff --name-only --no-renames -z "$DIFF_BASE" HEAD')
expect(scope.run).not.toContain('--diff-filter')
expect(scope.run).toMatch(
/&& \[ "\$scope" = false \]; then[\s\S]*run=false[\s\S]*else\s+echo 'run=true'/
)
// orcad refuses to build without the page, so the page builds exactly when the smoke runs.
const gated = "steps.orcad-smoke-scope.outputs.run == 'true'"
const page = steps.find(
(candidate) => candidate.uses === './.github/actions/build-mobile-web-bundle'
)
const smoke = steps.find((candidate) => candidate.name === 'Boot orcad and round-trip a terminal')
expect(page.if).toBe(gated)
expect(smoke.if).toBe(gated)
expect(smoke.run).toBe('pnpm run smoke:orcad-terminal')
expect(steps.indexOf(page)).toBeLessThan(steps.indexOf(smoke))
})
+1 -1
View File
@@ -243,7 +243,7 @@ it('pins every foreground and background step to its selected phase', () => {
['Enforce focus-read ratchet', staticPhase],
['Enforce layout-writer ratchets', staticPhase],
['Check Node runtime pin', staticPhase],
['Boot orcad and round-trip a terminal', staticPhase],
['Decide whether the orcad terminal smoke runs', staticPhase],
['Verify the generated RPC params catalog', staticPhase],
['Verify the generated ACP protocol schema', staticPhase],
['Verify bundled skill guides', staticPhase],
@@ -21,6 +21,7 @@ const EXPECTED_MATRIX = {
'.github/workflows/node-server-tests.yml#linux_glibc217_compat': { contents: 'read' },
'.github/workflows/node-server-tests.yml#linux_glibc_floor': { contents: 'read' },
'.github/workflows/node-server-tests.yml#linux_musl': { contents: 'read' },
'.github/workflows/node-server-tests.yml#mobile_web_bundle': { contents: 'read' },
'.github/workflows/node-server-tests.yml#persistence': { contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { actions: 'read', contents: 'write' },
[`${RELEASE_WORKFLOW}#build`]: { actions: 'read', contents: 'write' },
@@ -57,6 +58,10 @@ const EXPECTED_MATRIX = {
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#linux_musl`]: {
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#mobile_web_bundle`]:
{
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#persistence`]: {
contents: 'read'
},
@@ -2,10 +2,12 @@ const { createHash } = require('node:crypto')
const { lstatSync, readFileSync, readdirSync } = require('node:fs')
const { basename, join, relative, sep } = require('node:path')
const {
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR,
orcadMobileWebAssetFilenames,
orcadTemplateCommonFilenames,
orcadTemplateTargetFilenames
} = require('../../src/shared/orcad-artifacts.ts')
@@ -152,7 +154,15 @@ function verifyPackagedOrcadTemplate(resourcesDir, targets = ORCAD_TEMPLATE_TARG
)
}
const commonSha256 = requireRecord(manifest.commonSha256, 'commonSha256')
const commonFilenames = orcadTemplateCommonFilenames()
const pageManifest = join(templateDir, ...ORCAD_MOBILE_WEB_MANIFEST.split('/'))
verifyFile(
pageManifest,
requireSha256(commonSha256[ORCAD_MOBILE_WEB_MANIFEST], `${ORCAD_MOBILE_WEB_MANIFEST} checksum`),
ORCAD_MOBILE_WEB_MANIFEST
)
const commonFilenames = orcadTemplateCommonFilenames(
orcadMobileWebAssetFilenames(readFileSync(pageManifest, 'utf8'))
)
requireExactNames(Object.keys(commonSha256), commonFilenames, 'common manifest inventory')
for (const filename of commonFilenames) {
verifyFile(
@@ -58,6 +58,25 @@ describe('verifyPackagedOrcadTemplate', () => {
)
})
it('rejects a page asset changed after manifest generation', async () => {
const fixture = await createFixture()
await writeFile(join(fixture.templateDir, 'out/mobile-web/index.html'), 'mutated')
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow(
'out/mobile-web/index.html checksum mismatch'
)
})
it('rejects a template whose inventory omits an asset the page lists', async () => {
const fixture = await createFixture()
const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME)
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
delete manifest.commonSha256['out/mobile-web/index.html']
await writeFile(manifestPath, JSON.stringify(manifest))
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow('common manifest inventory')
})
it('rejects target-native bytes changed after manifest generation', async () => {
const fixture = await createFixture()
await writeFile(
+7 -2
View File
@@ -4,9 +4,11 @@ import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { z } from 'zod'
import {
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_VERSION,
orcadArtifactFilenames
orcadArtifactFilenames,
orcadMobileWebAssetFilenames
} from '../../shared/orcad-artifacts'
import { COMPAT_SERVER_TARGETS, SERVER_TARGETS } from '../../shared/node-runtime-pin'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
@@ -26,7 +28,10 @@ export async function readOrcadArtifactIdentity(directory: string): Promise<stri
target.split('-')[1] ?? '',
target.endsWith('-musl') ? 'musl' : 'glibc'
)
const filenames = orcadArtifactFilenames(target)
const filenames = orcadArtifactFilenames(
target,
orcadMobileWebAssetFilenames(await readFile(join(directory, ORCAD_MOBILE_WEB_MANIFEST), 'utf8'))
)
if (existsSync(join(directory, browser))) {
filenames.push(browser)
}
@@ -25,6 +25,7 @@ import {
} from '../../shared/node-runtime-pin'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
@@ -51,6 +52,7 @@ import {
} from './orcad-artifact-materializer'
const TARGET = 'linux-x64-glibc' as const
const PAGE_ASSETS = ['out/mobile-web/index.html']
const fileReads = vi.hoisted(() => ({ count: 0 }))
// Counts whole-file hash reads without changing read behavior.
@@ -103,6 +105,13 @@ function targetContents(target: NodeRuntimeTarget, filename: string): string {
return `${target}:${filename}`
}
function commonContents(filename: string): string {
if (filename === ORCAD_MOBILE_WEB_MANIFEST) {
return JSON.stringify({ assets: [{ path: 'index.html' }] })
}
return filename === 'orcad.js' ? 'orcad-entry' : filename
}
function createTemplate(target: NodeRuntimeTarget = TARGET): {
templateDir: string
cacheRoot: string
@@ -112,8 +121,8 @@ function createTemplate(target: NodeRuntimeTarget = TARGET): {
const templateDir = join(root, 'template')
const cacheRoot = join(root, 'cache')
const commonSha256: Record<string, string> = {}
for (const filename of orcadTemplateCommonFilenames()) {
write(join(templateDir, filename), filename === 'orcad.js' ? 'orcad-entry' : filename)
for (const filename of orcadTemplateCommonFilenames(PAGE_ASSETS)) {
write(join(templateDir, filename), commonContents(filename))
commonSha256[filename] = sha256(join(templateDir, filename))
}
const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
@@ -247,7 +256,7 @@ describe('assembleOrcadArtifact', () => {
expect(readFileSync(join(artifactDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME), 'utf8').trim()).toBe(
NODE_RUNTIME_ASSETS[TARGET].executableSha256
)
for (const filename of orcadArtifactFilenames(TARGET)) {
for (const filename of orcadArtifactFilenames(TARGET, PAGE_ASSETS)) {
expect(readFileSync(join(artifactDir, filename)).byteLength).toBeGreaterThan(0)
}
// A Bun-era client's selector would exit 78 on this file without a Bun runtime (R5).
@@ -272,6 +281,33 @@ describe('assembleOrcadArtifact', () => {
)
})
it('copies the page phones load into the slot, verified like every other file', async () => {
const fixture = createTemplate()
const artifactDir = await assembleOrcadArtifact({ ...fixture, target: TARGET })
expect(readFileSync(join(artifactDir, 'out/mobile-web/index.html'), 'utf8')).toBe(
'out/mobile-web/index.html'
)
expect(existsSync(join(artifactDir, ORCAD_MOBILE_WEB_MANIFEST))).toBe(true)
const corrupted = createTemplate()
write(join(corrupted.templateDir, 'out/mobile-web/index.html'), 'corrupted')
await expect(assembleOrcadArtifact({ ...corrupted, target: TARGET })).rejects.toThrow(
'out/mobile-web/index.html checksum mismatch'
)
})
it('refuses a template page asset name that escapes the slot', async () => {
const fixture = createTemplate()
rewriteManifest(fixture.templateDir, (manifest) => {
manifest.commonSha256['out/mobile-web/../../escape.js'] = sha256(
join(fixture.templateDir, 'out/mobile-web/index.html')
)
})
await expect(assembleOrcadArtifact({ ...fixture, target: TARGET })).rejects.toThrow(
'unsafe page asset: out/mobile-web/../../escape.js'
)
})
it('rejects a self-consistent target marker for a different native slot', async () => {
const fixture = createTemplate()
const targetPath = join(
@@ -393,7 +429,9 @@ describe('assembleOrcadArtifact verification record', () => {
const before = fileReads.count
expect(await assembleOrcadArtifact({ ...fixture, target: TARGET, now: later })).toBe(slot)
// Template pass plus the slot's own check, exactly as with no record at all.
expect(fileReads.count - before).toBe((orcadArtifactFilenames(TARGET).length + 1) * 2)
expect(fileReads.count - before).toBe(
(orcadArtifactFilenames(TARGET, PAGE_ASSETS).length + 1) * 2
)
rmSync(recordPath(fixture.cacheRoot))
await assembleOrcadArtifact({ ...fixture, target: TARGET, now: later })
expect(existsSync(recordPath(fixture.cacheRoot))).toBe(false)
@@ -425,7 +463,7 @@ describe('assembleOrcadArtifact verification record', () => {
const fixture = createTemplate()
const before = fileReads.count
const slot = await assembleOrcadArtifact({ ...fixture, target: TARGET })
const sources = orcadArtifactFilenames(TARGET).length + 1
const sources = orcadArtifactFilenames(TARGET, PAGE_ASSETS).length + 1
// One template pass plus the copied slot's own check, where three template passes ran before.
expect(fileReads.count - before).toBe(sources * 2)
expect(slot).toContain(fixture.cacheRoot)
+32 -6
View File
@@ -6,11 +6,15 @@ import { z } from 'zod'
import { getAppEnvironment } from '../../shared/app-environment'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import {
ORCAD_MOBILE_WEB_DIR,
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR,
ORCAD_VERSION_FILENAME,
compareCodeUnits,
isOrcadMobileWebAssetPath,
orcadArtifactFilenames,
orcadTemplateCommonFilenames,
orcadTemplateTargetFilenames
@@ -104,8 +108,9 @@ export async function assembleOrcadArtifact(args: {
}): Promise<string> {
const trust = args.trustVerificationRecord === true
const manifest = args.manifest ?? (await readTemplateManifest(args.templateDir))
const sources = artifactSources(args.templateDir, args.target, manifest)
const expected = expectedTemplateSha256(args.target, manifest)
const mobileWebAssets = templateMobileWebAssets(manifest)
const sources = artifactSources(args.templateDir, args.target, manifest, mobileWebAssets)
const expected = expectedTemplateSha256(args.target, manifest, mobileWebAssets)
const targetRoot = join(args.cacheRoot, args.target)
const nowMs = (args.now ?? Date.now)()
const record = trust ? await readOrcadArtifactVerificationRecord(targetRoot) : null
@@ -177,7 +182,8 @@ function isExecutableArtifact(filename: string): boolean {
function artifactSources(
templateDir: string,
target: NodeRuntimeTarget,
manifest: z.infer<typeof TemplateManifestSchema>
manifest: z.infer<typeof TemplateManifestSchema>,
mobileWebAssets: readonly string[]
): ArtifactSource[] {
const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
const targetManifest = manifest.targets[target]
@@ -185,7 +191,7 @@ function artifactSources(
throw new OrcadHostUnsupportedError(`Packaged orcad template does not support ${target}`)
}
const targetFiles = new Set(orcadTemplateTargetFilenames(target))
const required = orcadArtifactFilenames(target).map((filename) => ({
const required = orcadArtifactFilenames(target, mobileWebAssets).map((filename) => ({
filename,
path: join(targetFiles.has(filename) ? targetDir : templateDir, ...filename.split('/')),
executable: isExecutableArtifact(filename)
@@ -206,14 +212,15 @@ function artifactSources(
/** The manifest digest each slot source must hash to. */
function expectedTemplateSha256(
target: NodeRuntimeTarget,
manifest: z.infer<typeof TemplateManifestSchema>
manifest: z.infer<typeof TemplateManifestSchema>,
mobileWebAssets: readonly string[]
): Map<string, ExpectedSha256> {
const targetManifest = manifest.targets[target]
if (!targetManifest) {
throw new OrcadHostUnsupportedError(`Packaged orcad template does not support ${target}`)
}
const expected = new Map<string, ExpectedSha256>()
for (const filename of orcadTemplateCommonFilenames()) {
for (const filename of orcadTemplateCommonFilenames(mobileWebAssets)) {
const sha256 = manifest.commonSha256[filename]
if (!sha256) {
throw new Error(`Packaged orcad template manifest omits ${filename}`)
@@ -240,6 +247,25 @@ function expectedTemplateSha256(
return expected
}
/**
* The page's asset names, from the template manifest (verify-packaged-orcad-template.cjs pins them
* to the page's own manifest), so a WSL wake that trusts its record reads no extra file.
*/
function templateMobileWebAssets(manifest: z.infer<typeof TemplateManifestSchema>): string[] {
const prefix = `${ORCAD_MOBILE_WEB_DIR}/`
const assets = Object.keys(manifest.commonSha256).filter(
(filename) => filename.startsWith(prefix) && filename !== ORCAD_MOBILE_WEB_MANIFEST
)
// Names become copy destinations under the slot, so one escaping it must never be joined.
const unsafe = assets.find(
(filename) => !isOrcadMobileWebAssetPath(filename.slice(prefix.length))
)
if (unsafe) {
throw new Error(`Packaged orcad template names an unsafe page asset: ${unsafe}`)
}
return assets.sort(compareCodeUnits)
}
async function readTemplateManifest(
templateDir: string
): Promise<z.infer<typeof TemplateManifestSchema>> {
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { orcadArtifactFilenames } from '../../shared/orcad-artifacts'
import { ORCAD_MOBILE_WEB_MANIFEST, orcadArtifactFilenames } from '../../shared/orcad-artifacts'
import { runOrcadProfilePreflight } from './orcad-profile-preflight'
import { readOrcadArtifactIdentity } from './orcad-artifact-identity'
import { resolveOrcadExitCode } from './orcad-exit-code'
@@ -25,12 +25,21 @@ vi.mock('./orcad-bundled-runtime', async (importOriginal) => ({
isRunningAsBundledOrcadRuntime: () => false
}))
function fixtureContents(filename: string): string {
if (filename === '.server-target') {
return 'linux-x64-glibc\n'
}
return filename === ORCAD_MOBILE_WEB_MANIFEST
? JSON.stringify({ assets: [{ path: 'index.html' }] })
: filename
}
beforeEach(async () => {
fixture.directory = await mkdtemp(join(tmpdir(), 'orcad-artifact-preflight-'))
for (const filename of orcadArtifactFilenames('linux-x64-glibc')) {
for (const filename of orcadArtifactFilenames('linux-x64-glibc', ['out/mobile-web/index.html'])) {
const path = join(fixture.directory, filename)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, filename === '.server-target' ? 'linux-x64-glibc\n' : filename)
await writeFile(path, fixtureContents(filename))
}
vi.spyOn(console, 'log').mockImplementation(() => {})
})
@@ -53,7 +62,8 @@ describe('installed artifact admission', () => {
'.server-target',
'node_modules/@parcel/watcher/watcher.node',
'.runtime-node',
'node_modules/node-pty/build/Release/pty.node'
'node_modules/node-pty/build/Release/pty.node',
'out/mobile-web/index.html'
])('refuses a missing %s as configuration before any profile probe', async (filename) => {
await rm(join(fixture.directory, filename))
const error = await runOrcadProfilePreflight(randomUUID()).catch((failure: unknown) => failure)
@@ -53,7 +53,7 @@ it('ships every worker and child entry orcad loads at runtime', () => {
const named = new Set(
buildOrcadBundles().flatMap((text) => text.match(RUNTIME_ENTRY_FILENAME) ?? [])
)
const shipped = new Set(orcadArtifactFilenames('linux-x64-glibc'))
const shipped = new Set(orcadArtifactFilenames('linux-x64-glibc', []))
const missing = [...named].filter(
(filename) => !shipped.has(filename) && !KNOWN_UNSHIPPED_ENTRIES.has(filename)
)
@@ -67,7 +67,7 @@ it('ships every worker and child entry orcad loads at runtime', () => {
it('ships every child entry build-orcad.mjs builds', async () => {
const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href
const entries = await import(builder)
const shipped = new Set(orcadArtifactFilenames('linux-x64-glibc'))
const shipped = new Set(orcadArtifactFilenames('linux-x64-glibc', []))
const built = Object.values<string>(entries.ORCAD_CHILD_ENTRY_POINTS).map((entry) =>
entries.orcadChildOutputFilename(entry)
)
@@ -12,6 +12,7 @@ import {
} from '../../../../shared/mobile-web-bundle/bundle-rpc-contract'
import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../runtime-rpc/runtime-rpc-mobile-method-allowlist'
import type { RpcRequest, RpcResponse } from '../core'
import { ORCAD_MOBILE_WEB_DIR, ORCAD_MOBILE_WEB_MANIFEST } from '../../../../shared/orcad-artifacts'
import type { RpcDispatcher } from '../dispatcher'
import {
@@ -342,6 +343,19 @@ describe('where the resolver probes', () => {
expect(getBundledMobileWebBundleRoot()).toBe(bundle.root)
})
// orcad's appPath is its slot root, and the slot ships the page at ORCAD_MOBILE_WEB_DIR.
it('finds the page an orcad slot ships, without a resolver of its own', () => {
const bundle = writeSyntheticMobileWebBundle(
join(scratch, ...ORCAD_MOBILE_WEB_DIR.split('/')),
2
)
expect(getBundledMobileWebBundleRoot()).toBe(bundle.root)
expect(join(bundle.root, 'manifest.json')).toBe(
join(scratch, ...ORCAD_MOBILE_WEB_MANIFEST.split('/'))
)
})
it('answers undefined when neither layout holds a manifest', () => {
expect(getBundledMobileWebBundleRoot()).toBeUndefined()
})
+3 -2
View File
@@ -57,8 +57,9 @@ export const ORCAD_INSTALL_MODEL: RemoteInstallModel = {
nativeDepsPackageName: 'orca-orcad',
versionFilename: ORCAD_VERSION_FILENAME,
installCompleteFilename: ORCAD_INSTALL_COMPLETE_FILENAME,
// libc changes no file name, so `<os>-<arch>` names every slot file.
requiredArtifacts: (host) => orcadArtifactFilenames(`${host.os}-${host.arch}`)
// libc changes no file name, so `<os>-<arch>` names every slot file. The page's hashed assets
// are named only by its manifest, required here; the sentinel follows the whole-tree upload.
requiredArtifacts: (host) => orcadArtifactFilenames(`${host.os}-${host.arch}`, [])
}
export const REMOTE_INSTALL_MODELS: readonly RemoteInstallModel[] = [
+58 -12
View File
@@ -6,6 +6,7 @@ import { SERVER_TARGETS } from './node-runtime-pin'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_FOREIGN_SQLITE_READER_ENTRY,
ORCAD_MOBILE_WEB_MANIFEST,
ORCAD_NODE_PTY_JS_ARTIFACTS,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_RIPGREP_ARTIFACTS,
@@ -14,6 +15,7 @@ import {
ORCAD_WINDOWS_SECURITY_FILENAME,
orcadArtifactFilenames,
orcadBunRuntimeFilename,
orcadMobileWebAssetFilenames,
orcadNodeRuntimeRelativePath,
orcadTemplateCommonFilenames,
orcadTemplateTargetFilenames
@@ -21,16 +23,16 @@ import {
describe('standalone runtime artifacts', () => {
it.each(SERVER_TARGETS)('ships the native CLI launcher only in Windows %s slots', (target) => {
expect(orcadArtifactFilenames(target).includes(ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME)).toBe(
expect(orcadArtifactFilenames(target, []).includes(ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME)).toBe(
target.startsWith('win32-')
)
expect(orcadTemplateCommonFilenames()).not.toContain(ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME)
expect(orcadTemplateCommonFilenames([])).not.toContain(ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME)
})
it.each(SERVER_TARGETS)(
'ships the foreign SQLite reader worker the %s runtime starts',
(target) => {
expect(orcadArtifactFilenames(target)).toContain(ORCAD_FOREIGN_SQLITE_READER_ENTRY)
expect(orcadArtifactFilenames(target, [])).toContain(ORCAD_FOREIGN_SQLITE_READER_ENTRY)
}
)
@@ -43,7 +45,7 @@ describe('standalone runtime artifacts', () => {
it.each(SERVER_TARGETS)('ships only the %s search binary in its install identity', (target) => {
const [platform, arch] = target.split('-')
const shipped = orcadArtifactFilenames(target).filter((file) =>
const shipped = orcadArtifactFilenames(target, []).filter((file) =>
/^ripgrep\/[^/]+\/rg/.test(file)
)
expect(shipped).toEqual([
@@ -56,13 +58,13 @@ describe('standalone runtime artifacts', () => {
expect(ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((path) => path.split('/').at(-1)).sort()).toEqual(
readdirSync(sourceDir).sort()
)
expect(orcadArtifactFilenames('linux-x64-glibc')).toEqual(
expect(orcadArtifactFilenames('linux-x64-glibc', [])).toEqual(
expect.arrayContaining([...ORCAD_RIPGREP_LICENSE_ARTIFACTS])
)
})
it.each(SERVER_TARGETS)('references the %s runtime instead of carrying a Bun one', (target) => {
const filenames = orcadArtifactFilenames(target)
const filenames = orcadArtifactFilenames(target, [])
expect(filenames).toContain(ORCAD_NODE_RUNTIME_MARKER_FILENAME)
expect(filenames).toEqual(expect.arrayContaining(ORCAD_NODE_PTY_JS_ARTIFACTS))
// A Bun-era selector exits 78 on `.build-target` without its Bun runtime (design D7.1 R5).
@@ -73,7 +75,7 @@ describe('standalone runtime artifacts', () => {
it('ships node-pty natives per target, with ConPTY and spawn-helper where they run', () => {
const natives = (target: string): string[] =>
orcadArtifactFilenames(target)
orcadArtifactFilenames(target, [])
.filter((file) => file.startsWith('node_modules/node-pty/build/Release/'))
.map((file) => file.slice('node_modules/node-pty/build/Release/'.length))
expect(natives('linux-arm64-musl')).toEqual(['pty.node'])
@@ -89,11 +91,15 @@ describe('standalone runtime artifacts', () => {
// Without it orcad's data-root privacy check fails closed and a Windows slot never starts.
it('requires the Windows security addon in every Windows slot and nowhere else', () => {
for (const target of ['win32-x64', 'win32-arm64']) {
expect(orcadArtifactFilenames(target)).toContain(ORCAD_WINDOWS_SECURITY_FILENAME)
expect(orcadArtifactFilenames(target, [])).toContain(ORCAD_WINDOWS_SECURITY_FILENAME)
expect(orcadTemplateTargetFilenames(target)).toContain(ORCAD_WINDOWS_SECURITY_FILENAME)
}
expect(orcadArtifactFilenames('linux-x64-glibc')).not.toContain(ORCAD_WINDOWS_SECURITY_FILENAME)
expect(orcadArtifactFilenames('darwin-arm64')).not.toContain(ORCAD_WINDOWS_SECURITY_FILENAME)
expect(orcadArtifactFilenames('linux-x64-glibc', [])).not.toContain(
ORCAD_WINDOWS_SECURITY_FILENAME
)
expect(orcadArtifactFilenames('darwin-arm64', [])).not.toContain(
ORCAD_WINDOWS_SECURITY_FILENAME
)
})
it('keeps the runtime executable under its upstream name in the shared store', () => {
@@ -108,9 +114,49 @@ describe('standalone runtime artifacts', () => {
})
it('keeps target-specific files out of the shared template set', () => {
const common = orcadTemplateCommonFilenames()
const common = orcadTemplateCommonFilenames([])
for (const target of SERVER_TARGETS) {
expect(common.every((file) => orcadArtifactFilenames(target).includes(file))).toBe(true)
expect(common.every((file) => orcadArtifactFilenames(target, []).includes(file))).toBe(true)
}
})
// Phones paired to orcad get the page only when the slot carries it (mobileWeb.bundle.v1).
it.each(SERVER_TARGETS)(
'requires the mobile web page in every %s slot, shared by all',
(target) => {
const assets = ['out/mobile-web/assets/app-1.js', 'out/mobile-web/index.html']
expect(orcadArtifactFilenames(target, assets)).toEqual(
expect.arrayContaining([ORCAD_MOBILE_WEB_MANIFEST, ...assets])
)
expect(orcadTemplateCommonFilenames(assets)).toEqual(
expect.arrayContaining([ORCAD_MOBILE_WEB_MANIFEST, ...assets])
)
expect(
orcadTemplateTargetFilenames(target).some((file) => file.startsWith('out/mobile-web'))
).toBe(false)
}
)
it('names the page assets its manifest lists, in a locale-free order', () => {
const manifest = JSON.stringify({
assets: [{ path: 'index.html' }, { path: 'assets/b.js' }, { path: 'assets/B.css' }]
})
expect(orcadMobileWebAssetFilenames(manifest)).toEqual([
'out/mobile-web/assets/B.css',
'out/mobile-web/assets/b.js',
'out/mobile-web/index.html'
])
})
it.each([
['no assets', { assets: [] }],
['a traversal path', { assets: [{ path: '../orcad.js' }] }],
['an absolute path', { assets: [{ path: '/etc/passwd' }] }],
['a backslash path', { assets: [{ path: 'a\\b.js' }] }],
['a missing path', { assets: [{}] }]
])('refuses a page manifest with %s', (_label, manifest) => {
expect(() => orcadMobileWebAssetFilenames(JSON.stringify(manifest))).toThrow(
ORCAD_MOBILE_WEB_MANIFEST
)
})
})
+60 -7
View File
@@ -157,6 +157,47 @@ export const ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS = [
'native-chat-visuals/skills/orca-chat-visuals/SKILL.md'
] as const
/**
* The page phones load over mobileWeb.bundle.*, at the path getBundledMobileWebBundleRoot probes
* under the install root. Its assets are content-addressed, so only its manifest names them.
*/
export const ORCAD_MOBILE_WEB_DIR = 'out/mobile-web'
export const ORCAD_MOBILE_WEB_MANIFEST = `${ORCAD_MOBILE_WEB_DIR}/manifest.json`
// Mirrors manifest-contract ASSET_PATH_PATTERN; cannot import under type stripping.
const MOBILE_WEB_ASSET_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/
/** A bundle-relative asset path that stays inside the page directory. */
export function isOrcadMobileWebAssetPath(path: string): boolean {
return (
MOBILE_WEB_ASSET_PATH.test(path) &&
!path.split('/').some((segment) => segment === '.' || segment === '..')
)
}
/** Slot paths of every asset `manifestText` lists, sorted; throws rather than ship a partial page. */
export function orcadMobileWebAssetFilenames(manifestText: string): string[] {
const manifest: unknown = JSON.parse(manifestText)
const assets =
manifest && typeof manifest === 'object' && 'assets' in manifest ? manifest.assets : undefined
if (!Array.isArray(assets) || assets.length === 0) {
throw new Error(`${ORCAD_MOBILE_WEB_MANIFEST} lists no assets`)
}
const filenames = assets.map((asset: unknown) => {
const path = asset && typeof asset === 'object' && 'path' in asset ? asset.path : undefined
if (typeof path !== 'string' || !isOrcadMobileWebAssetPath(path)) {
throw new Error(`${ORCAD_MOBILE_WEB_MANIFEST} names an unsafe asset path: ${String(path)}`)
}
return `${ORCAD_MOBILE_WEB_DIR}/${path}`
})
return filenames.sort(compareCodeUnits)
}
/** Code-unit order: page asset lists feed the install hash, so they must not depend on locale. */
export function compareCodeUnits(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
export type OrcadArtifact = {
filename: string
/**
@@ -196,7 +237,9 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [
{ filename: ORCAD_LINEAR_SDK_ENTRY },
...ORCAD_NODE_PTY_JS_ARTIFACTS.map((filename) => ({ filename })),
...ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((filename) => ({ filename })),
...ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS.map((filename) => ({ filename }))
...ORCAD_NATIVE_CHAT_VISUALS_ARTIFACTS.map((filename) => ({ filename })),
// Without it phones paired to this server get no page (mobileWeb.bundle.v1 is not advertised).
{ filename: ORCAD_MOBILE_WEB_MANIFEST }
]
/** Written after the artifacts, so it is never an input to its own hash. */
@@ -207,8 +250,14 @@ export const ORCAD_TEMPLATE_TARGETS_DIR = 'targets'
/** Written last by the installer; its absence means a torn install. */
export const ORCAD_INSTALL_COMPLETE_FILENAME = '.install-complete'
/** Every file a `target` slot ships, in hash order; `target` is `<os>-<arch>[-<libc>]`. */
export function orcadArtifactFilenames(target: string): string[] {
/**
* Every file a `target` slot ships, in hash order; `target` is `<os>-<arch>[-<libc>]`.
* `mobileWebAssets` is orcadMobileWebAssetFilenames of the slot's own page manifest.
*/
export function orcadArtifactFilenames(
target: string,
mobileWebAssets: readonly string[]
): string[] {
const filenames = ORCAD_ARTIFACTS.filter((artifact) => !artifact.optional).map(
(artifact) => artifact.filename
)
@@ -220,22 +269,26 @@ export function orcadArtifactFilenames(target: string): string[] {
ORCAD_WINDOWS_CLI_LAUNCHER_FILENAME
)
}
filenames.push(...mobileWebAssets)
return filenames
}
/** Files shared by every template target; the rest live under `targets/<target>/`. */
export function orcadTemplateCommonFilenames(): string[] {
return ORCAD_ARTIFACTS.filter(
export function orcadTemplateCommonFilenames(mobileWebAssets: readonly string[]): string[] {
const filenames = ORCAD_ARTIFACTS.filter(
(artifact) =>
!artifact.optional &&
artifact.filename !== ORCAD_SERVER_TARGET_FILENAME &&
artifact.filename !== ORCAD_NODE_RUNTIME_MARKER_FILENAME &&
artifact.filename !== ORCAD_PARCEL_WATCHER_NATIVE
).map((artifact) => artifact.filename)
// The page is platform-independent, so the template keeps one copy for every target.
filenames.push(...mobileWebAssets)
return filenames
}
/** Files stored under the template's `targets/<target>/`, at their slot-relative paths. */
export function orcadTemplateTargetFilenames(target: string): string[] {
const common = new Set(orcadTemplateCommonFilenames())
return orcadArtifactFilenames(target).filter((filename) => !common.has(filename))
const common = new Set(orcadTemplateCommonFilenames([]))
return orcadArtifactFilenames(target, []).filter((filename) => !common.has(filename))
}