From 776c791554bf4a4dfd4298cb57cddf60316c9d2c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:29:41 -0700 Subject: [PATCH] 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 --- .../build-mobile-web-bundle/action.yml | 11 +++ .github/workflows/e2e.yml | 5 ++ .github/workflows/node-server-tests.yml | 50 +++++++++++-- .github/workflows/pr.yml | 23 ++++-- .github/workflows/ssh-hostile-hosts.yml | 2 + .github/workflows/ssh-windows-hosts.yml | 3 + .../workflows/win-orcad-serve-switch-e2e.yml | 1 + .github/workflows/wsl-orcad-e2e.yml | 1 + config/scripts/build-orcad-template.mjs | 6 +- config/scripts/build-orcad.mjs | 2 + .../scripts/node-server-change-scope.test.mjs | 5 +- .../node-server-native-compat-scope.test.mjs | 5 +- config/scripts/orcad-artifact-version.mjs | 6 +- .../scripts/orcad-artifact-version.test.mjs | 18 ++++- config/scripts/orcad-mobile-web-bundle.mjs | 29 ++++++++ .../scripts/orcad-mobile-web-bundle.test.mjs | 59 ++++++++++++++++ .../scripts/orcad-template-test-fixture.mjs | 9 ++- ...orcad-terminal-smoke-change-scope.test.mjs | 31 ++++---- config/scripts/pr-preflight-gates.test.mjs | 2 +- .../release-cut-token-permissions.test.mjs | 5 ++ .../verify-packaged-orcad-template.cjs | 12 +++- .../verify-packaged-orcad-template.test.mjs | 19 +++++ src/main/orcad/orcad-artifact-identity.ts | 9 ++- .../orcad/orcad-artifact-materializer.test.ts | 48 +++++++++++-- src/main/orcad/orcad-artifact-materializer.ts | 38 ++++++++-- .../orcad/orcad-artifact-preflight.test.ts | 18 +++-- .../orcad-runtime-entry-artifacts.test.ts | 4 +- .../rpc/methods/mobile-web-bundle.test.ts | 14 ++++ src/main/ssh/remote-install-model.ts | 5 +- src/shared/orcad-artifacts.test.ts | 70 +++++++++++++++---- src/shared/orcad-artifacts.ts | 67 ++++++++++++++++-- 31 files changed, 505 insertions(+), 72 deletions(-) create mode 100644 .github/actions/build-mobile-web-bundle/action.yml create mode 100644 config/scripts/orcad-mobile-web-bundle.mjs create mode 100644 config/scripts/orcad-mobile-web-bundle.test.mjs diff --git a/.github/actions/build-mobile-web-bundle/action.yml b/.github/actions/build-mobile-web-bundle/action.yml new file mode 100644 index 00000000000..f37e23a6ac5 --- /dev/null +++ b/.github/actions/build-mobile-web-bundle/action.yml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fcf4810f254..a926dc0fa11 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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: diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 6d3552fca7a..0c0aba24d6e 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2a612c5b922..98852288786 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index a425906dabe..1b7176da38f 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -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 diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index fd5e24dd33e..8fd8acb63a0 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -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: diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml index 0f9aeee5aa1..f7227d7ff19 100644 --- a/.github/workflows/win-orcad-serve-switch-e2e.yml +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -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: diff --git a/.github/workflows/wsl-orcad-e2e.yml b/.github/workflows/wsl-orcad-e2e.yml index a92837c9636..ad329b93bc6 100644 --- a/.github/workflows/wsl-orcad-e2e.yml +++ b/.github/workflows/wsl-orcad-e2e.yml @@ -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 diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index a69e023dcc9..4a2582798db 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -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. diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index e5a8083426b..17d25f1c100 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -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 diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 73f0c488fb4..00f2abcd414 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -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'") diff --git a/config/scripts/node-server-native-compat-scope.test.mjs b/config/scripts/node-server-native-compat-scope.test.mjs index f338f53d030..d2fa1111811 100644 --- a/config/scripts/node-server-native-compat-scope.test.mjs +++ b/config/scripts/node-server-native-compat-scope.test.mjs @@ -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 != ''" diff --git a/config/scripts/orcad-artifact-version.mjs b/config/scripts/orcad-artifact-version.mjs index 7eb20ef2452..91e32c3c5a3 100644 --- a/config/scripts/orcad-artifact-version.mjs +++ b/config/scripts/orcad-artifact-version.mjs @@ -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( diff --git a/config/scripts/orcad-artifact-version.test.mjs b/config/scripts/orcad-artifact-version.test.mjs index 18848568ebd..88a187e80ba 100644 --- a/config/scripts/orcad-artifact-version.test.mjs +++ b/config/scripts/orcad-artifact-version.test.mjs @@ -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) diff --git a/config/scripts/orcad-mobile-web-bundle.mjs b/config/scripts/orcad-mobile-web-bundle.mjs new file mode 100644 index 00000000000..2aad10ad967 --- /dev/null +++ b/config/scripts/orcad-mobile-web-bundle.mjs @@ -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') + ) +} diff --git a/config/scripts/orcad-mobile-web-bundle.test.mjs b/config/scripts/orcad-mobile-web-bundle.test.mjs new file mode 100644 index 00000000000..27079a8c0ef --- /dev/null +++ b/config/scripts/orcad-mobile-web-bundle.test.mjs @@ -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) +}) diff --git a/config/scripts/orcad-template-test-fixture.mjs b/config/scripts/orcad-template-test-fixture.mjs index 85f03a816f1..f034cae04c8 100644 --- a/config/scripts/orcad-template-test-fixture.mjs +++ b/config/scripts/orcad-template-test-fixture.mjs @@ -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 = {} diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index e3108d782be..ffd9402c3e8 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -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)) }) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 7396e6d5235..248d4d6714a 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -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], diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 936d09076a7..a2c616dd123 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -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' }, diff --git a/config/scripts/verify-packaged-orcad-template.cjs b/config/scripts/verify-packaged-orcad-template.cjs index ec6ab15353c..8e5a0e6752b 100644 --- a/config/scripts/verify-packaged-orcad-template.cjs +++ b/config/scripts/verify-packaged-orcad-template.cjs @@ -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( diff --git a/config/scripts/verify-packaged-orcad-template.test.mjs b/config/scripts/verify-packaged-orcad-template.test.mjs index 466ff1a473b..3aeec7b1021 100644 --- a/config/scripts/verify-packaged-orcad-template.test.mjs +++ b/config/scripts/verify-packaged-orcad-template.test.mjs @@ -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( diff --git a/src/main/orcad/orcad-artifact-identity.ts b/src/main/orcad/orcad-artifact-identity.ts index 27b7c905daa..d79c5e8d79e 100644 --- a/src/main/orcad/orcad-artifact-identity.ts +++ b/src/main/orcad/orcad-artifact-identity.ts @@ -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 ({ 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 = {} - 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) diff --git a/src/main/orcad/orcad-artifact-materializer.ts b/src/main/orcad/orcad-artifact-materializer.ts index 6f39ee4fa17..3954abe26bf 100644 --- a/src/main/orcad/orcad-artifact-materializer.ts +++ b/src/main/orcad/orcad-artifact-materializer.ts @@ -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 { 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 + manifest: z.infer, + 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 + manifest: z.infer, + mobileWebAssets: readonly string[] ): Map { const targetManifest = manifest.targets[target] if (!targetManifest) { throw new OrcadHostUnsupportedError(`Packaged orcad template does not support ${target}`) } const expected = new Map() - 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): 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> { diff --git a/src/main/orcad/orcad-artifact-preflight.test.ts b/src/main/orcad/orcad-artifact-preflight.test.ts index 61077a94bd2..a36f50e2bae 100644 --- a/src/main/orcad/orcad-artifact-preflight.test.ts +++ b/src/main/orcad/orcad-artifact-preflight.test.ts @@ -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) diff --git a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts index bd7322040f7..9e20ff8f6aa 100644 --- a/src/main/orcad/orcad-runtime-entry-artifacts.test.ts +++ b/src/main/orcad/orcad-runtime-entry-artifacts.test.ts @@ -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(entries.ORCAD_CHILD_ENTRY_POINTS).map((entry) => entries.orcadChildOutputFilename(entry) ) diff --git a/src/main/runtime/rpc/methods/mobile-web-bundle.test.ts b/src/main/runtime/rpc/methods/mobile-web-bundle.test.ts index 377b1de04ba..982bffa8e18 100644 --- a/src/main/runtime/rpc/methods/mobile-web-bundle.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-bundle.test.ts @@ -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() }) diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts index 2eb13b02104..e53139babed 100644 --- a/src/main/ssh/remote-install-model.ts +++ b/src/main/ssh/remote-install-model.ts @@ -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 `-` names every slot file. - requiredArtifacts: (host) => orcadArtifactFilenames(`${host.os}-${host.arch}`) + // libc changes no file name, so `-` 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[] = [ diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 5c7eaafb04f..8d75c683fba 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -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 + ) + }) }) diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index 8d427b73495..3ad0b9c0e19 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -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 `-[-]`. */ -export function orcadArtifactFilenames(target: string): string[] { +/** + * Every file a `target` slot ships, in hash order; `target` is `-[-]`. + * `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//`. */ -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//`, 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)) }