mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195807643f | ||
|
|
21256e6d55 | ||
|
|
705fa875d3 | ||
|
|
498384357e | ||
|
|
53938e4cf8 | ||
|
|
e18ffcd48e | ||
|
|
df12f86da9 | ||
|
|
3dba7f2bf7 | ||
|
|
e7efe14336 | ||
|
|
78f2103bcb | ||
|
|
565b0ec425 | ||
|
|
253d72bb4d | ||
|
|
35522e9b28 | ||
|
|
4aa9522825 | ||
|
|
a92b0b0ad5 | ||
|
|
66cdccb0e7 | ||
|
|
2eefa44f8f | ||
|
|
55302dd830 | ||
|
|
924eb87157 | ||
|
|
f546bf3352 | ||
|
|
97553334d4 | ||
|
|
31d12b9888 | ||
|
|
dc2658f63b | ||
|
|
b8b9de78fc | ||
|
|
8ff599b1f2 | ||
|
|
e6f02af973 | ||
|
|
a163b5a76d | ||
|
|
cc37c1c360 | ||
|
|
e78771e0f4 | ||
|
|
288bc44dfc | ||
|
|
d3db3926e3 | ||
|
|
5b87ba0adb | ||
|
|
0b2cea7bb7 | ||
|
|
eab4fc3cd5 | ||
|
|
e2e8cd245c | ||
|
|
80d46f0783 | ||
|
|
3b6b63a273 | ||
|
|
8373ae353c | ||
|
|
994794dbad | ||
|
|
d103da3bc5 | ||
|
|
ee3c62683b | ||
|
|
8ac9d5f101 | ||
|
|
1eaa799b9f | ||
|
|
bb0f2dfb21 | ||
|
|
5409c03d58 | ||
|
|
a941930ea7 | ||
|
|
74572101ac | ||
|
|
7b050cae3f | ||
|
|
d193860668 | ||
|
|
1da0be8529 | ||
|
|
08b4346620 | ||
|
|
4d6d9b1fc4 | ||
|
|
59f9add53c | ||
|
|
1946562247 | ||
|
|
24a65ad80f | ||
|
|
37ea60c20c | ||
|
|
99cbcae249 | ||
|
|
a426042784 | ||
|
|
1cb06a6cb2 | ||
|
|
5236871b0b | ||
|
|
ceb20c9dd6 | ||
|
|
83fa42c179 | ||
|
|
7727b9c347 | ||
|
|
b015c0b226 | ||
|
|
882687818f | ||
|
|
37f5163872 | ||
|
|
df2c81d131 | ||
|
|
236398a823 | ||
|
|
eebe02eef2 | ||
|
|
4b1e5a76e2 | ||
|
|
74292d2ace | ||
|
|
b0b7cad316 | ||
|
|
926887d14e | ||
|
|
2e6bb2d276 | ||
|
|
30656509ac | ||
|
|
66e3f1ad7b | ||
|
|
9aadf2d467 | ||
|
|
ba80e57753 | ||
|
|
50c76f41e3 | ||
|
|
0658d58efd | ||
|
|
76a700c45c | ||
|
|
5ded193ccf | ||
|
|
93420d1fa0 | ||
|
|
add787f625 | ||
|
|
ec2c2aca5e | ||
|
|
1e86c48f48 | ||
|
|
545c2dbf9b | ||
|
|
3cb029147e | ||
|
|
d48cfbba69 | ||
|
|
07b5c0daa3 | ||
|
|
b78c84af2c | ||
|
|
dfaa2df90d | ||
|
|
2e2aaa4ac9 | ||
|
|
3ef0f74110 | ||
|
|
51ba7198d7 | ||
|
|
e10e6a2235 | ||
|
|
17f84cf7d6 | ||
|
|
f7d9b7b297 | ||
|
|
b287d7a4e0 | ||
|
|
d9d1b91fa5 |
No files matched your search
@@ -31,7 +31,9 @@ jobs:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: always()
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
refresh-message-position: true
|
||||
|
||||
@@ -37,20 +37,6 @@ jobs:
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
- name: Verify eas-cli was published as latest on npm
|
||||
run: |
|
||||
expected="$(jq -r .version lerna.json)"
|
||||
for attempt in $(seq 1 10); do
|
||||
published="$(npm view eas-cli@latest version 2>/dev/null || true)"
|
||||
if [ "$published" = "$expected" ]; then
|
||||
echo "Verified eas-cli@$expected is latest on npm."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: npm latest is '$published', expected '$expected'. Retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "eas-cli@$expected was not published as latest on npm; the publish step failed." >&2
|
||||
exit 1
|
||||
release-changelog:
|
||||
name: Update changelog and publish release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -67,6 +53,10 @@ jobs:
|
||||
run: yarn install --immutable
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
@@ -74,17 +64,52 @@ jobs:
|
||||
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push
|
||||
- name: Commit and push changelog
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
git add CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git push
|
||||
- name: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_deadline_epoch=$(( $(date +%s) + 600 ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
if [ "$published" = "$EAS_CLI_VERSION" ]; then
|
||||
echo "Verified eas-cli@$EAS_CLI_VERSION is available on npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_epoch=$(date +%s)
|
||||
if [ "$current_epoch" -ge "$availability_deadline_epoch" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
retry_delay=$((availability_deadline_epoch - current_epoch))
|
||||
if [ "$retry_delay" -gt 10 ]; then
|
||||
retry_delay=10
|
||||
fi
|
||||
echo "eas-cli@$EAS_CLI_VERSION is not available on npm yet. Retrying in ${retry_delay}s."
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
git pull --rebase --autostash origin main
|
||||
jq --arg version "$EAS_CLI_VERSION" '.STAGING = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
git add cli-versions.json CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md and bump STAGING in cli-versions.json"
|
||||
jq --arg version "$EAS_CLI_VERSION" '.PRODUCTION = $version' \
|
||||
cli-versions.json > cli-versions.json.tmp
|
||||
mv cli-versions.json.tmp cli-versions.json
|
||||
- name: Commit and push version update
|
||||
run: |
|
||||
git add cli-versions.json
|
||||
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git push
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: test-egress-guard
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- packages/build-tools/resources/egress-guard/**
|
||||
- packages/build-tools/src/steps/utils/localEgressGuard.ts
|
||||
- packages/build-tools/src/steps/utils/__tests__/localEgressGuard*.ts
|
||||
- packages/build-tools/src/steps/functions/startIosSimulator.ts
|
||||
- packages/worker/.eas/workflows/test-egress-guard.yml
|
||||
- .github/workflows/test-egress-guard.yml
|
||||
|
||||
jobs:
|
||||
test-egress-guard:
|
||||
# The simulator tests need macOS with Xcode, which the EAS macOS workers have.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
- name: Run the test-egress-guard EAS workflow on macOS
|
||||
working-directory: packages/worker
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
eas workflow:run test-egress-guard.yml \
|
||||
--ref ${{ github.event.pull_request.head.sha || github.sha }} \
|
||||
--non-interactive \
|
||||
--wait
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22', '24']
|
||||
node: ['20.18.3', '22', '24']
|
||||
include:
|
||||
- node: '20'
|
||||
- node: '20.18.3'
|
||||
coverage: true
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
|
||||
+157
@@ -10,8 +10,162 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.2.1`. ([#4404](https://github.com/expo/eas-cli/pull/4404) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.4.0](https://github.com/expo/eas-cli/releases/tag/v24.4.0) - 2026-09-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Require Node `^20.18.3 || >=22.0.0`. ([#4279](https://github.com/expo/eas-cli/pull/4279) by [@ramonclaudio](https://github.com/ramonclaudio))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Inject a guard into every iOS Simulator process during `--egress local` sessions that refuses connections which bypass the system proxy and reports them, with the calling frameworks, in the session log. ([#4393](https://github.com/expo/eas-cli/pull/4393) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Set proxy environment variables inside the iOS Simulator for `--egress local` sessions, so clients that read them (gRPC, libcurl) also exit from the egress client. ([#4390](https://github.com/expo/eas-cli/pull/4390) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.3.0](https://github.com/expo/eas-cli/releases/tag/v24.3.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add a shared protocol for sandbox daemon commands. ([#4346](https://github.com/expo/eas-cli/pull/4346) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.2.0](https://github.com/expo/eas-cli/releases/tag/v24.2.0) - 2026-09-11
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Remove repository URLs from Git job inputs and fetch them when resolving project sources. ([#4360](https://github.com/expo/eas-cli/pull/4360) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Add `eas workflow:insights` to display Workflows Insights for a time range: run counts and success rate compared with the previous period, runs over time, and a per-workflow breakdown, with `--json` output for scripts. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Add `eas workflow:insights:maestro` to display Maestro test insights for a time range: pass and flake rates, a sortable per-flow table, and a single flow's history with error patterns and recent runs, with `--json` output for scripts. ([#4370](https://github.com/expo/eas-cli/pull/4370) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Reject `--end` without `--start` in the `observe:*`, `update:insights`, `channel:insights`, and `update:view --insights` commands instead of silently falling back to the default time range. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject a time range that does not run forwards in the `observe:*`, `update:insights`, `channel:insights`, `update:view --insights`, and `workflow:insights` commands instead of sending it to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Reject non-integer values for `--limit` and `--offset` instead of sending them to the server. ([#4367](https://github.com/expo/eas-cli/pull/4367) by [@hSATAC](https://github.com/hSATAC))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [build-tools] Require PATH project sources for local builds. ([#4362](https://github.com/expo/eas-cli/pull/4362) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.1.2](https://github.com/expo/eas-cli/releases/tag/v24.1.2) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Shorten the local egress banner printed by `eas simulator:start` and stop mentioning `eas simulator:egress` in interactive mode, where the client runs in the same terminal. ([#4383](https://github.com/expo/eas-cli/pull/4383) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.1](https://github.com/expo/eas-cli/releases/tag/v24.1.1) - 2026-09-11
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop telling interactive `eas simulator:start --egress local` users to run `eas simulator:egress` when the egress client already runs in the same terminal. ([#4382](https://github.com/expo/eas-cli/pull/4382) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [24.1.0](https://github.com/expo/eas-cli/releases/tag/v24.1.0) - 2026-09-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--egress-allow <host:port>` to `eas simulator:start` so local egress sessions can reach dev servers on this machine or its network. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][build-tools] Forward `--egress-allow localhost:<port>` and `127.0.0.1:<port>` entries from the simulator host's loopback interface to this machine (like `adb reverse`), so dev server URLs that use `127.0.0.1` work through local egress. ([#4369](https://github.com/expo/eas-cli/pull/4369) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Add the `eas/start_local_egress` step for iOS simulator sessions with local egress: the device host's system proxy points at a reverse tunnel to the EAS CLI machine, so HTTP(S) requests that honor it exit from that machine and third parties see its IP. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--egress local` to `eas simulator:start` and the `eas simulator:egress` command, which run the local egress client for the session. ([#4364](https://github.com/expo/eas-cli/pull/4364) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Require the serve-sim session token for device run session previews, so the tunnel no longer exposes the shell-exec route. ([#4306](https://github.com/expo/eas-cli/pull/4306) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Align the `eas observe:*` commands with the renamed Observe GraphQL schema. `--json` output now uses `name`/`value` instead of `metricName`/`metricValue` (metrics) and `name` instead of `eventName` (log events and event-name summaries), and `eas observe:events` now lists user-defined events only — exceptions moved to the new `eas observe:errors`. ([#4338](https://github.com/expo/eas-cli/pull/4338) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `lcd_width`, `lcd_height`, and `lcd_density` inputs to configure the Android emulator display. ([#4349](https://github.com/expo/eas-cli/pull/4349) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Add `eas channel:protect` and `eas channel:unprotect` to manage EAS Update channel protection, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Add `eas observe:errors` to display error and exception issue groups (grouped by fingerprint), with `--fingerprint <fingerprint>` to drill into a group's individual occurrences and stack traces. ([#4339](https://github.com/expo/eas-cli/pull/4339) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `eas observe:event` to display a single Observe event (metric, log, or error) by its ID. ([#4252](https://github.com/expo/eas-cli/pull/4252) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Start Argent remote sessions with `bun x` so they work when the `bunx` symlink is missing. ([491b372a](https://github.com/expo/eas-cli/commit/491b372a) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Reduce `expo-device-hub` preview resolution from 1280 px to 960 px to match `serve-sim` and lower streaming bandwidth. ([#4326](https://github.com/expo/eas-cli/pull/4326) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [build-tools] Install FFmpeg before launching `expo-device-hub` in Android device sessions. ([#4332](https://github.com/expo/eas-cli/pull/4332) by [@krystofwoldrich-agent](https://github.com/krystofwoldrich-agent))
|
||||
- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Make ASC API key issuer identifier optional in GraphQL and credentials types. ([#4248](https://github.com/expo/eas-cli/pull/4248) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--tag` to `eas simulator` and `eas simulator:list` to group and filter simulator sessions by app variant. ([#4318](https://github.com/expo/eas-cli/pull/4318) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Update workflow run logs in real time, instead of every 10 seconds. ([#4228](https://github.com/expo/eas-cli/pull/4228) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [build-tools] Add `expo-device-hub` web previews to Android web-preview-only, Agent Device, Argent, and Appium remote sessions. ([#4304](https://github.com/expo/eas-cli/pull/4304) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [eas-cli] Add `eas workflow:ssh <workflow-job-id> [command...]` to open an ssh session on the worker running a workflow job. ([#4032](https://github.com/expo/eas-cli/pull/4032) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--ssh` (and `--ssh-idle-timeout`) to `eas workflow:run` to enable ssh on the run's VM jobs. ([#4033](https://github.com/expo/eas-cli/pull/4033) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Warn when starting Android EAS Simulator sessions that support is still in development and iOS parity is coming soon. ([#4320](https://github.com/expo/eas-cli/pull/4320) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [23.1.0](https://github.com/expo/eas-cli/releases/tag/v23.1.0) - 2026-08-31
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Prompt for the Expo SDK version in `eas new` and add a `--sdk-version` flag. The template now downloads from npm instead of GitHub to support SDK-specific versions. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Clean up template metadata in projects created with `eas new`: remove the template LICENSE file and reset the package.json name, version, and license fields. ([#4301](https://github.com/expo/eas-cli/pull/4301) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `EAS_PNPM_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `pnpm install --filter`. ([#4302](https://github.com/expo/eas-cli/pull/4302) by [@robingullo](https://github.com/robingullo))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Add regression coverage for the bundled iOS entitlement introspection fallback. ([#4285](https://github.com/expo/eas-cli/pull/4285) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [local-build-plugin] Replace `bunyan` with `@expo/bunyan` while preserving the Steps formatter binary. ([#4284](https://github.com/expo/eas-cli/pull/4284) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [steps] Replace deprecated `lodash.get` with a local helper. ([#4283](https://github.com/expo/eas-cli/pull/4283) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli][steps] Bump `uuid` to v11 and remove the redundant `@types/uuid` dependency. ([#4282](https://github.com/expo/eas-cli/pull/4282) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Bump `minizlib` to 3.1.0 to drop its deprecated `rimraf@5` transitive dependency. ([#4281](https://github.com/expo/eas-cli/pull/4281) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [23.0.0](https://github.com/expo/eas-cli/releases/tag/v23.0.0) - 2026-08-28
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename the EAS Simulator preview-only session type from `--type serve-sim` to `--type web-preview-only`. ([#4298](https://github.com/expo/eas-cli/pull/4298) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [build-tools] Support `EAS_BUN_FILTER_WORKSPACE` to install only the selected workspace's dependencies with `bun install --filter`. ([#4292](https://github.com/expo/eas-cli/pull/4292) by [@konrad-armatys](https://github.com/konrad-armatys))
|
||||
- [eas-cli] Add `eas project:status` (alias `eas status`) command that prints a snapshot of the project's recent builds, development builds, workflow runs, submissions, and updates, with agent-friendly JSON metadata, activity links, structured errors, submission-to-build correlation, and `--non-interactive` support. ([#4006](https://github.com/expo/eas-cli/pull/4006) by [@jonsamp](https://github.com/jonsamp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Prevent user npm minimum release age settings from applying to internal EAS CLI commands. ([#4296](https://github.com/expo/eas-cli/pull/4296) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Reduce serve-sim preview resolution from 1280 px to 960 px to lower streaming bandwidth. ([#4297](https://github.com/expo/eas-cli/pull/4297) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Use turtle cache upload and download URLs for eas.json build cache restore and save. ([#3860](https://github.com/expo/eas-cli/pull/3860) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [ci] Bump both STAGING and PRODUCTION cli-versions in release workflow. ([#4290](https://github.com/expo/eas-cli/pull/4290) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [22.6.0](https://github.com/expo/eas-cli/releases/tag/v22.6.0) - 2026-08-26
|
||||
|
||||
### 🎉 New features
|
||||
@@ -46,6 +200,9 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [build-tools] Support an optional `package_version` input on `eas/start_serve_sim_remote_session`, so a simulator session can pin the `@expo/serve-sim` version instead of always running `latest`. ([#4253](https://github.com/expo/eas-cli/pull/4253) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add composable custom build functions for downloading, installing, and launching application archives in simulator sessions. ([#4222](https://github.com/expo/eas-cli/pull/4222) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--build-id`, `--application-archive-url`, and `--expo-go` to `eas simulator` to install and launch an application before the session is ready. ([#4223](https://github.com/expo/eas-cli/pull/4223) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add `--environment` flag to the `eas observe:*` commands. ([#4275](https://github.com/expo/eas-cli/pull/4275) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `apple` value to the `--platform` flag of the `eas observe:*` commands. ([#4276](https://github.com/expo/eas-cli/pull/4276) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--build-number` flag to `eas observe:metrics` and `eas observe:events`. ([#4278](https://github.com/expo/eas-cli/pull/4278) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "22.6.0",
|
||||
"PRODUCTION": "22.4.0"
|
||||
"STAGING": "24.4.1",
|
||||
"PRODUCTION": "24.4.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "22.6.0",
|
||||
"version": "24.4.2",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -8,3 +8,5 @@ bin/record-sim
|
||||
|
||||
src/graphql-env.d.ts
|
||||
schema.graphql
|
||||
bin/egress-guard.dylib
|
||||
bin/egress-guard-check
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "22.6.0",
|
||||
"version": "24.4.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,6 +24,8 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected] -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
@@ -39,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "22.4.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/steps": "24.3.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -75,6 +77,7 @@
|
||||
"semver": "^7.6.2",
|
||||
"tar": "7.5.19",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.21.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
@@ -92,7 +95,7 @@
|
||||
"@types/retry": "^0.12.5",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"bunyan": "^1.8.15",
|
||||
"@types/ws": "8.5.10",
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# egress-guard
|
||||
|
||||
A small dylib injected into every process the iOS Simulator launches during a
|
||||
`--egress local` session. It interposes the socket calls that open outbound
|
||||
traffic (`connect`, `connectx`, `sendto`, `sendmsg`, and the `$NOCANCEL`
|
||||
variants of the last three that libsystem exports alongside them) and refuses
|
||||
any destination that is not loopback. A sockaddr whose family was left
|
||||
`AF_UNSPEC` is classified as the family its length implies, which is how the
|
||||
kernel treats it for TCP `connect` and IPv4 sends; on a datagram socket
|
||||
`connect(AF_UNSPEC)` is left to the kernel, which dissolves the association. The system proxy and the `--egress-allow`
|
||||
forwards live on loopback, so everything that honors the proxy keeps working
|
||||
and everything that bypasses it fails with `ECONNREFUSED` in the process that
|
||||
tried, with the calling frameworks recorded.
|
||||
|
||||
Why interposing: simulator processes share the worker's uid, so a packet
|
||||
filter cannot tell them apart from the worker's own traffic. dyld interposing
|
||||
from an inserted library reaches calls made inside Apple's own frameworks
|
||||
(CFNetwork, Network.framework), and `launchctl setenv DYLD_INSERT_LIBRARIES`
|
||||
inside the simulator makes launchd hand the library to every process it
|
||||
spawns. The simulator does not enforce library validation, which is what makes
|
||||
this possible there and nowhere else.
|
||||
|
||||
Configuration comes from the environment the simulator's launchd provides:
|
||||
|
||||
- `EAS_EGRESS_GUARD_LOG`: file the guard appends events to (host path).
|
||||
- `EAS_EGRESS_GUARD_MODE`: `block` (default, and what any unknown value means)
|
||||
or `log` (observe only).
|
||||
|
||||
Each event is one tab-separated line:
|
||||
|
||||
```
|
||||
eas-egress-guard\t<process>\t<pid>\t<function>\t<blocked|logged>\t<peer>\t<caller>,<caller>...
|
||||
```
|
||||
|
||||
A process lists each `(function, peer)` once, up to 128 of them. When that
|
||||
table fills it writes one more line with function `overflow` and the limit as
|
||||
the peer; further distinct destinations are still refused but not listed.
|
||||
|
||||
Layout:
|
||||
|
||||
- `policy.c` / `policy.h`: classification, mode, formatting, per-process
|
||||
dedupe. Pure C, host-testable.
|
||||
- `guard.c`: the interposers and the constructor that reads the environment.
|
||||
- `check.c`: `egress-guard-check`, run inside the simulator right after the
|
||||
guard is installed. Exits non-zero unless the library is loaded in a fresh
|
||||
process and behaves as the mode says on `connect`, `connect$NOCANCEL` and a
|
||||
`connect` with an `AF_UNSPEC` sockaddr; the worker fails the session on that.
|
||||
- `tests/policy_test.c`: host unit tests, `tests/run-policy-tests.sh`.
|
||||
- `tests/guard_test.c`, `tests/guard_insert_test.c`: host tests of the
|
||||
interposers themselves, `tests/run-guard-tests.sh`. The first compiles
|
||||
`guard.c` in and forces the lock schedules (a signal handler making a socket
|
||||
call inside the critical section, a fork while another thread holds the lock,
|
||||
contention); the second inserts the built library like the simulator does and
|
||||
checks descriptor reuse, the `$NOCANCEL` symbols, `AF_UNSPEC` shapes and the
|
||||
UDP disconnect. Nothing in them opens a socket to a non-loopback address.
|
||||
- `tests/nettest.swift`: probe binary the simulator end-to-end test runs inside
|
||||
a device; exercises URLSession, Network.framework, BSD TCP and UDP, DNS.
|
||||
- `build.sh`: universal simulator dylib into `packages/build-tools/bin/`.
|
||||
|
||||
Installation order matters. `simctl` forwards every `SIMCTL_CHILD_`-prefixed
|
||||
variable of its own environment to the process it starts, and for `simctl
|
||||
boot` that process is the simulator's launchd, so the worker boots with the
|
||||
guard and proxy variables in that form and every process of the boot inherits
|
||||
them (measured: 176 of 176). `launchctl setenv` after boot is kept for a
|
||||
device that was already booted, but by then the boot's own processes have
|
||||
started without it. It also cannot change a variable the boot already carried:
|
||||
a device booted with one guard configuration keeps it until it is shut down,
|
||||
which is why the worker boots devices itself and every configuration in the
|
||||
end-to-end test gets its own boot. The self-check runs once boot completes and is followed
|
||||
by a coverage report from `lsof`, listing any process without the library. Both the dylib and the check binary are built by
|
||||
`packages/worker/package.sh` for the iOS worker tarball and by the
|
||||
`test-egress-guard` EAS workflow, not committed.
|
||||
|
||||
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
|
||||
self-check fails the session, since a `--egress local` session without the
|
||||
guard would silently leak. Only an unwritable event log is a warning, because
|
||||
refusals still happen and only the reporting is lost. Recording is best-effort
|
||||
in the process too: the event log is opened per event rather than held open
|
||||
(a process that closes descriptors it does not own, as `launchd_sim` does,
|
||||
would otherwise make the guard write into whatever reused the number), and a
|
||||
dedupe slot that cannot be taken within a bounded number of attempts (a signal
|
||||
handler re-entering the guard, a fork child inheriting a held lock) skips the
|
||||
event rather than blocking or aborting the process.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard as a universal (arm64 + x86_64) iOS Simulator dylib into
|
||||
# packages/build-tools/bin/egress-guard.dylib. Requires Xcode.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
# Output directory; defaults to packages/build-tools/bin.
|
||||
bin_dir="${1:-../../bin}"
|
||||
mkdir -p "$bin_dir"
|
||||
out=$(mktemp -d)
|
||||
sdk=$(xcrun --sdk iphonesimulator --show-sdk-path)
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-dynamiclib -install_name @rpath/egress-guard.dylib \
|
||||
-o "$out/egress-guard-$arch.dylib" policy.c guard.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard.dylib" "$out/egress-guard-arm64.dylib" "$out/egress-guard-x86_64.dylib"
|
||||
lipo -info "$bin_dir/egress-guard.dylib"
|
||||
|
||||
# The self-check the worker runs inside the simulator after installing the guard.
|
||||
for arch in arm64 x86_64; do
|
||||
xcrun --sdk iphonesimulator clang \
|
||||
-std=c11 -Wall -Wextra -Werror -O2 \
|
||||
-target "$arch-apple-ios15.0-simulator" -isysroot "$sdk" \
|
||||
-o "$out/egress-guard-check-$arch" check.c
|
||||
done
|
||||
lipo -create -output "$bin_dir/egress-guard-check" "$out/egress-guard-check-arm64" "$out/egress-guard-check-x86_64"
|
||||
chmod +x "$bin_dir/egress-guard-check"
|
||||
lipo -info "$bin_dir/egress-guard-check"
|
||||
@@ -0,0 +1,112 @@
|
||||
// Self-check run inside a simulator right after the guard is installed:
|
||||
// proves the guard library is loaded in a freshly spawned process and that it
|
||||
// behaves as the requested mode says, on every connect entry point the guard
|
||||
// covers. Exit 0 on success; 2 when the library is not loaded; 3 when it is
|
||||
// loaded but did not behave; 1 on usage errors.
|
||||
//
|
||||
// egress-guard-check --mode block|log
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// TEST-NET-1 (RFC 5737): never routed, so without the guard a non-blocking
|
||||
// connect reports EINPROGRESS and nothing ever answers.
|
||||
#define PROBE_ADDRESS "192.0.2.1"
|
||||
#define PROBE_PORT 9
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
|
||||
static int guard_loaded(void) {
|
||||
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
|
||||
const char *name = _dyld_get_image_name(i);
|
||||
if (name != NULL && strstr(name, "egress-guard.dylib") != NULL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One non-blocking connect to the probe address through `entry`, with the
|
||||
// sockaddr family the caller wants. Returns the connect result; errno is
|
||||
// stored in *err.
|
||||
static int probe(connect_fn entry, sa_family_t family, int *err) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
*err = errno;
|
||||
return -2;
|
||||
}
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||
struct sockaddr_in to;
|
||||
memset(&to, 0, sizeof to);
|
||||
to.sin_family = family;
|
||||
to.sin_len = sizeof to;
|
||||
to.sin_port = htons(PROBE_PORT);
|
||||
inet_pton(AF_INET, PROBE_ADDRESS, &to.sin_addr);
|
||||
int rc = entry(fd, (struct sockaddr *)&to, sizeof to);
|
||||
*err = errno;
|
||||
close(fd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *mode = "block";
|
||||
if (argc == 3 && strcmp(argv[1], "--mode") == 0) {
|
||||
mode = argv[2];
|
||||
} else if (argc != 1) {
|
||||
fprintf(stderr, "usage: egress-guard-check [--mode block|log]\n");
|
||||
return 1;
|
||||
}
|
||||
if (!guard_loaded()) {
|
||||
printf("egress-guard-check: the guard library is not loaded in this process\n");
|
||||
return 2;
|
||||
}
|
||||
connect_fn connect_nocancel = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
if (connect_nocancel == NULL) {
|
||||
printf("egress-guard-check: connect$NOCANCEL is not exported in this process\n");
|
||||
return 3;
|
||||
}
|
||||
|
||||
// The plain call, the non-cancelable variant, and a sockaddr whose family
|
||||
// was left AF_UNSPEC, which the kernel connects as IPv4.
|
||||
const struct {
|
||||
const char *name;
|
||||
connect_fn entry;
|
||||
sa_family_t family;
|
||||
} probes[] = {
|
||||
{"connect", connect, AF_INET},
|
||||
{"connect$NOCANCEL", connect_nocancel, AF_INET},
|
||||
{"connect with AF_UNSPEC", connect, AF_UNSPEC},
|
||||
};
|
||||
int block = strcmp(mode, "block") == 0;
|
||||
for (size_t i = 0; i < sizeof probes / sizeof probes[0]; i++) {
|
||||
int err = 0;
|
||||
int rc = probe(probes[i].entry, probes[i].family, &err);
|
||||
if (rc == -2) {
|
||||
printf("egress-guard-check: socket() failed: %s\n", strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (block && !(rc == -1 && err == ECONNREFUSED)) {
|
||||
printf("egress-guard-check: guard loaded but a non-loopback %s was not refused (rc=%d, errno=%d %s)\n",
|
||||
probes[i].name, rc, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
if (!block && !(rc == 0 || (rc == -1 && err == EINPROGRESS))) {
|
||||
printf("egress-guard-check: guard loaded in log mode but %s failed (errno=%d %s)\n",
|
||||
probes[i].name, err, strerror(err));
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
if (block) {
|
||||
printf("egress-guard-check: guard loaded; non-loopback connections are refused\n");
|
||||
} else {
|
||||
printf("egress-guard-check: guard loaded in log mode; connections pass through\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// The local egress guard: interposes the calls that open outbound traffic in
|
||||
// every simulator process it is inserted into, refuses non-loopback
|
||||
// destinations in block mode, and records one event per destination per
|
||||
// process. See README.md and policy.h.
|
||||
#include "policy.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <execinfo.h>
|
||||
#include <fcntl.h>
|
||||
#include <os/lock.h>
|
||||
#include <sched.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define EG_LOG_ENV "EAS_EGRESS_GUARD_LOG"
|
||||
#define EG_MODE_ENV "EAS_EGRESS_GUARD_MODE"
|
||||
#define EG_MAX_CALLERS 6
|
||||
#define EG_CALLER_LENGTH 64
|
||||
#define EG_LOCK_ATTEMPTS 64
|
||||
|
||||
static int eg_initialized = 0;
|
||||
static void *eg_own_image = NULL;
|
||||
static char eg_log_path[1024];
|
||||
static eg_mode_t eg_mode = EG_MODE_BLOCK;
|
||||
static eg_seen_t eg_seen;
|
||||
static int eg_overflow_reported = 0;
|
||||
static os_unfair_lock eg_lock = OS_UNFAIR_LOCK_INIT;
|
||||
|
||||
static void eg_init(void) {
|
||||
if (eg_initialized) {
|
||||
return;
|
||||
}
|
||||
eg_mode = eg_parse_mode(getenv(EG_MODE_ENV));
|
||||
Dl_info self;
|
||||
if (dladdr((const void *)eg_init, &self)) {
|
||||
eg_own_image = self.dli_fbase;
|
||||
}
|
||||
const char *log_path = getenv(EG_LOG_ENV);
|
||||
// The path is kept rather than an open descriptor: a process that closes
|
||||
// descriptors it does not know about (launchd_sim does) would otherwise
|
||||
// leave the guard writing into whatever file reused the number.
|
||||
if (log_path != NULL && log_path[0] != '\0' && strlen(log_path) < sizeof eg_log_path) {
|
||||
strcpy(eg_log_path, log_path);
|
||||
}
|
||||
eg_initialized = 1;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void eg_constructor(void) { eg_init(); }
|
||||
|
||||
// Image names of the frames above the guard's own, innermost first, with
|
||||
// consecutive repeats collapsed: "Network,CFNetwork,MyApp".
|
||||
__attribute__((noinline)) static int eg_collect_callers(char names[EG_MAX_CALLERS][EG_CALLER_LENGTH]) {
|
||||
void *frames[EG_MAX_CALLERS + 6];
|
||||
int frame_count = backtrace(frames, EG_MAX_CALLERS + 6);
|
||||
int count = 0;
|
||||
for (int i = 1; i < frame_count && count < EG_MAX_CALLERS; i++) {
|
||||
Dl_info info;
|
||||
const char *image = "?";
|
||||
if (dladdr(frames[i], &info)) {
|
||||
if (info.dli_fbase == eg_own_image) {
|
||||
continue;
|
||||
}
|
||||
if (info.dli_fname != NULL) {
|
||||
const char *slash = strrchr(info.dli_fname, '/');
|
||||
image = slash ? slash + 1 : info.dli_fname;
|
||||
}
|
||||
}
|
||||
if (count > 0 && strcmp(names[count - 1], image) == 0) {
|
||||
continue;
|
||||
}
|
||||
strncpy(names[count], image, EG_CALLER_LENGTH - 1);
|
||||
names[count][EG_CALLER_LENGTH - 1] = '\0';
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// A lock held by this same thread (a signal handler making a socket call
|
||||
// while the interrupted call held it) or by a thread that did not survive
|
||||
// fork() never becomes available and os_unfair_lock_lock aborts the process
|
||||
// in both cases. Give up on recording the event instead; refusal does not
|
||||
// depend on it. Ordinary contention over the microsecond-long critical
|
||||
// section resolves within the first attempts.
|
||||
static int eg_lock_bounded(void) {
|
||||
for (int i = 0; i < EG_LOCK_ATTEMPTS; i++) {
|
||||
if (os_unfair_lock_trylock(&eg_lock)) {
|
||||
return 1;
|
||||
}
|
||||
sched_yield();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One line in the event log. O_APPEND keeps whole lines intact across
|
||||
// processes writing concurrently; the log is opened per event, at most
|
||||
// EG_SEEN_CAPACITY + 1 times per process.
|
||||
__attribute__((noinline)) static void eg_write_event(const char *function, const char *action,
|
||||
const char *peer, int with_callers) {
|
||||
if (eg_log_path[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
char names[EG_MAX_CALLERS][EG_CALLER_LENGTH];
|
||||
const char *callers[EG_MAX_CALLERS];
|
||||
int caller_count = with_callers ? eg_collect_callers(names) : 0;
|
||||
for (int i = 0; i < caller_count; i++) {
|
||||
callers[i] = names[i];
|
||||
}
|
||||
char line[1024];
|
||||
int n = eg_format_event(line, sizeof line, getprogname(), getpid(), function, action, peer,
|
||||
callers, caller_count);
|
||||
if (n <= 0) {
|
||||
return;
|
||||
}
|
||||
int fd = open(eg_log_path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);
|
||||
if (fd >= 0) {
|
||||
(void)write(fd, line, (size_t)n);
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns 1 when the call must be refused.
|
||||
__attribute__((noinline)) static int eg_handle(const char *function, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
eg_class_t cls = eg_classify(sa, len);
|
||||
if (cls != EG_REMOTE) {
|
||||
return 0;
|
||||
}
|
||||
eg_init();
|
||||
int deny = eg_should_deny(eg_mode, cls);
|
||||
|
||||
char peer[96];
|
||||
if (eg_format_peer(sa, len, peer, sizeof peer) != 0) {
|
||||
strncpy(peer, "?", sizeof peer);
|
||||
}
|
||||
char key[EG_SEEN_KEY_LENGTH];
|
||||
snprintf(key, sizeof key, "%s %s", function, peer);
|
||||
int fresh = 0;
|
||||
int overflowed = 0;
|
||||
if (eg_lock_bounded()) {
|
||||
fresh = eg_seen_insert(&eg_seen, key);
|
||||
if (!fresh && eg_seen.overflow > 0 && !eg_overflow_reported) {
|
||||
eg_overflow_reported = 1;
|
||||
overflowed = 1;
|
||||
}
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
}
|
||||
|
||||
const char *action = deny ? "blocked" : "logged";
|
||||
if (fresh) {
|
||||
eg_write_event(function, action, peer, 1);
|
||||
} else if (overflowed) {
|
||||
// Once per process: the table is full, so further distinct destinations
|
||||
// are still refused but no longer listed.
|
||||
char limit[64];
|
||||
snprintf(limit, sizeof limit, "%d distinct destinations", EG_SEEN_CAPACITY);
|
||||
eg_write_event(EG_OVERFLOW_FUNCTION, action, limit, 0);
|
||||
}
|
||||
return deny;
|
||||
}
|
||||
|
||||
// connect() with an AF_UNSPEC address on a datagram socket dissolves the
|
||||
// association and sends nothing; the kernel answers EAFNOSUPPORT. Leave that
|
||||
// to the kernel. On a stream socket the kernel connects to the address the
|
||||
// length implies, which eg_handle classifies.
|
||||
static int eg_dissolves_association(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2 || sa->sa_family != AF_UNSPEC) {
|
||||
return 0;
|
||||
}
|
||||
int type = 0;
|
||||
socklen_t type_len = sizeof type;
|
||||
return getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &type_len) == 0 && type == SOCK_DGRAM;
|
||||
}
|
||||
|
||||
typedef int (*eg_connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*eg_sendto_fn)(int, const void *, size_t, int, const struct sockaddr *,
|
||||
socklen_t);
|
||||
typedef ssize_t (*eg_sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
// The non-cancelable variants libsystem_kernel exports next to the cancelable
|
||||
// ones; dyld interposing is per symbol, so each needs its own entry.
|
||||
extern int eg_connect_nocancel_original(int, const struct sockaddr *, socklen_t) __asm__(
|
||||
"_connect$NOCANCEL");
|
||||
extern ssize_t eg_sendto_nocancel_original(int, const void *, size_t, int,
|
||||
const struct sockaddr *, socklen_t) __asm__(
|
||||
"_sendto$NOCANCEL");
|
||||
extern ssize_t eg_sendmsg_nocancel_original(int, const struct msghdr *, int) __asm__(
|
||||
"_sendmsg$NOCANCEL");
|
||||
|
||||
static int eg_connect_through(eg_connect_fn original, int fd, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
if (!eg_dissolves_association(fd, sa, len) && eg_handle("connect", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(connect, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connect_nocancel(int fd, const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_connect_through(eg_connect_nocancel_original, fd, sa, len);
|
||||
}
|
||||
|
||||
static int eg_connectx(int s, const sa_endpoints_t *endpoints, sae_associd_t associd,
|
||||
unsigned int flags, const struct iovec *iov, unsigned int iovcnt,
|
||||
size_t *len, sae_connid_t *connid) {
|
||||
if (endpoints != NULL && endpoints->sae_dstaddr != NULL &&
|
||||
!eg_dissolves_association(s, endpoints->sae_dstaddr, endpoints->sae_dstaddrlen) &&
|
||||
eg_handle("connectx", endpoints->sae_dstaddr, endpoints->sae_dstaddrlen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return connectx(s, endpoints, associd, flags, iov, iovcnt, len, connid);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_through(eg_sendto_fn original, int fd, const void *buf, size_t n,
|
||||
int flags, const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa != NULL && eg_handle("sendto", sa, len)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto(int fd, const void *buf, size_t n, int flags, const struct sockaddr *sa,
|
||||
socklen_t len) {
|
||||
return eg_sendto_through(sendto, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendto_nocancel(int fd, const void *buf, size_t n, int flags,
|
||||
const struct sockaddr *sa, socklen_t len) {
|
||||
return eg_sendto_through(eg_sendto_nocancel_original, fd, buf, n, flags, sa, len);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_through(eg_sendmsg_fn original, int fd, const struct msghdr *msg,
|
||||
int flags) {
|
||||
if (msg != NULL && msg->msg_name != NULL && msg->msg_namelen > 0 &&
|
||||
eg_handle("sendmsg", (const struct sockaddr *)msg->msg_name, msg->msg_namelen)) {
|
||||
errno = ECONNREFUSED;
|
||||
return -1;
|
||||
}
|
||||
return original(fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(sendmsg, fd, msg, flags);
|
||||
}
|
||||
|
||||
static ssize_t eg_sendmsg_nocancel(int fd, const struct msghdr *msg, int flags) {
|
||||
return eg_sendmsg_through(eg_sendmsg_nocancel_original, fd, msg, flags);
|
||||
}
|
||||
|
||||
// dyld applies these to every image in the process, including the shared
|
||||
// cache, which is how calls made inside CFNetwork and Network.framework are
|
||||
// caught.
|
||||
__attribute__((used)) static const struct {
|
||||
const void *replacement;
|
||||
const void *original;
|
||||
} eg_interposers[] __attribute__((section("__DATA,__interpose"))) = {
|
||||
{(const void *)eg_connect, (const void *)connect},
|
||||
{(const void *)eg_connect_nocancel, (const void *)eg_connect_nocancel_original},
|
||||
{(const void *)eg_connectx, (const void *)connectx},
|
||||
{(const void *)eg_sendto, (const void *)sendto},
|
||||
{(const void *)eg_sendto_nocancel, (const void *)eg_sendto_nocancel_original},
|
||||
{(const void *)eg_sendmsg, (const void *)sendmsg},
|
||||
{(const void *)eg_sendmsg_nocancel, (const void *)eg_sendmsg_nocancel_original},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
#include "policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int is_v4_local(uint32_t host_order) {
|
||||
return (host_order >> 24) == 127 || host_order == 0;
|
||||
}
|
||||
|
||||
// XNU accepts a sockaddr whose family is AF_UNSPEC as the family its length
|
||||
// implies: TCP connect() translates it on both socket families and IPv4
|
||||
// sendto()/sendmsg() deliver with it. Classify it the way the kernel uses it.
|
||||
static sa_family_t eg_family(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa->sa_family == AF_UNSPEC) {
|
||||
if (len == sizeof(struct sockaddr_in)) {
|
||||
return AF_INET;
|
||||
}
|
||||
if (len == sizeof(struct sockaddr_in6)) {
|
||||
return AF_INET6;
|
||||
}
|
||||
}
|
||||
return sa->sa_family;
|
||||
}
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len) {
|
||||
if (sa == NULL || len < 2) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET) {
|
||||
if (len < sizeof(struct sockaddr_in)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
return is_v4_local(ntohl(in->sin_addr.s_addr)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
if (family == AF_INET6) {
|
||||
if (len < sizeof(struct sockaddr_in6)) {
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
const struct in6_addr *a6 = &((const struct sockaddr_in6 *)sa)->sin6_addr;
|
||||
if (IN6_IS_ADDR_LOOPBACK(a6) || IN6_IS_ADDR_UNSPECIFIED(a6)) {
|
||||
return EG_LOOPBACK;
|
||||
}
|
||||
if (IN6_IS_ADDR_V4MAPPED(a6)) {
|
||||
uint32_t v4;
|
||||
memcpy(&v4, &a6->s6_addr[12], sizeof v4);
|
||||
return is_v4_local(ntohl(v4)) ? EG_LOOPBACK : EG_REMOTE;
|
||||
}
|
||||
return EG_REMOTE;
|
||||
}
|
||||
return EG_PASSTHROUGH;
|
||||
}
|
||||
|
||||
eg_mode_t eg_parse_mode(const char *value) {
|
||||
if (value != NULL && strcmp(value, "log") == 0) {
|
||||
return EG_MODE_LOG;
|
||||
}
|
||||
return EG_MODE_BLOCK;
|
||||
}
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls) {
|
||||
return mode == EG_MODE_BLOCK && cls == EG_REMOTE;
|
||||
}
|
||||
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n) {
|
||||
char ip[INET6_ADDRSTRLEN];
|
||||
int written;
|
||||
if (sa == NULL || len < 2) {
|
||||
return -1;
|
||||
}
|
||||
sa_family_t family = eg_family(sa, len);
|
||||
if (family == AF_INET && len >= sizeof(struct sockaddr_in)) {
|
||||
const struct sockaddr_in *in = (const struct sockaddr_in *)sa;
|
||||
if (inet_ntop(AF_INET, &in->sin_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "%s:%d", ip, ntohs(in->sin_port));
|
||||
} else if (family == AF_INET6 && len >= sizeof(struct sockaddr_in6)) {
|
||||
const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)sa;
|
||||
if (inet_ntop(AF_INET6, &in6->sin6_addr, ip, sizeof ip) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
written = snprintf(out, n, "[%s]:%d", ip, ntohs(in6->sin6_port));
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
return (written < 0 || (size_t)written >= n) ? -1 : 0;
|
||||
}
|
||||
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
for (int i = 0; i < seen->count; i++) {
|
||||
if (strncmp(seen->keys[i], key, EG_SEEN_KEY_LENGTH - 1) == 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (seen->count >= EG_SEEN_CAPACITY) {
|
||||
seen->overflow++;
|
||||
return 0;
|
||||
}
|
||||
strncpy(seen->keys[seen->count], key, EG_SEEN_KEY_LENGTH - 1);
|
||||
seen->keys[seen->count][EG_SEEN_KEY_LENGTH - 1] = '\0';
|
||||
seen->count++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Append `text` to out[*pos], replacing field separators; returns 0 when it fit.
|
||||
static int append_field(char *out, size_t n, size_t *pos, const char *text) {
|
||||
for (const char *c = text; *c; c++) {
|
||||
if (*pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[(*pos)++] = (*c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int append_raw(char *out, size_t n, size_t *pos, const char *text) {
|
||||
size_t len = strlen(text);
|
||||
if (*pos + len + 1 > n) {
|
||||
return -1;
|
||||
}
|
||||
memcpy(out + *pos, text, len);
|
||||
*pos += len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count) {
|
||||
size_t pos = 0;
|
||||
char pid_text[16];
|
||||
snprintf(pid_text, sizeof pid_text, "%d", pid);
|
||||
if (append_raw(out, n, &pos, EG_EVENT_PREFIX "\t") != 0 ||
|
||||
append_field(out, n, &pos, progname ? progname : "?") != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_raw(out, n, &pos, pid_text) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, function) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, action) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0 || append_field(out, n, &pos, peer) != 0 ||
|
||||
append_raw(out, n, &pos, "\t") != 0) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < caller_count && callers != NULL; i++) {
|
||||
if (i > 0 && append_raw(out, n, &pos, ",") != 0) {
|
||||
return -1;
|
||||
}
|
||||
// Commas separate callers, so they cannot appear inside one.
|
||||
for (const char *c = callers[i]; *c; c++) {
|
||||
char ch = (*c == ',' || *c == '\t' || *c == '\n' || *c == '\r') ? ' ' : *c;
|
||||
if (pos + 1 >= n) {
|
||||
return -1;
|
||||
}
|
||||
out[pos++] = ch;
|
||||
}
|
||||
}
|
||||
if (append_raw(out, n, &pos, "\n") != 0) {
|
||||
return -1;
|
||||
}
|
||||
out[pos] = '\0';
|
||||
return (int)pos;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Policy and formatting for the local egress guard. Pure functions with no
|
||||
// interposing, so they compile and test on macOS as well as in the simulator.
|
||||
#ifndef EAS_EGRESS_GUARD_POLICY_H
|
||||
#define EAS_EGRESS_GUARD_POLICY_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
// What a destination address is, as far as the guard cares.
|
||||
typedef enum {
|
||||
EG_PASSTHROUGH = 0, // not an internet address (unix sockets, NULL, short); never touched
|
||||
EG_LOOPBACK = 1, // 127.0.0.0/8, ::1, ::ffff:127.x, unspecified; the proxy and forwards live here
|
||||
EG_REMOTE = 2, // anything else, including link-local and multicast
|
||||
} eg_class_t;
|
||||
|
||||
typedef enum {
|
||||
EG_MODE_BLOCK = 0, // refuse EG_REMOTE with ECONNREFUSED
|
||||
EG_MODE_LOG = 1, // observe only
|
||||
} eg_mode_t;
|
||||
|
||||
eg_class_t eg_classify(const struct sockaddr *sa, socklen_t len);
|
||||
|
||||
// "block" or unset selects block; "log" selects log. Anything else is block:
|
||||
// an unknown mode must fail closed.
|
||||
eg_mode_t eg_parse_mode(const char *value);
|
||||
|
||||
int eg_should_deny(eg_mode_t mode, eg_class_t cls);
|
||||
|
||||
// Writes "1.2.3.4:443" or "[2001:db8::1]:443". Returns 0 on success.
|
||||
int eg_format_peer(const struct sockaddr *sa, socklen_t len, char *out, size_t n);
|
||||
|
||||
// Fixed-capacity set of strings, so a retrying client logs a destination once
|
||||
// per process instead of once per attempt.
|
||||
#define EG_SEEN_CAPACITY 128
|
||||
#define EG_SEEN_KEY_LENGTH 192
|
||||
typedef struct {
|
||||
char keys[EG_SEEN_CAPACITY][EG_SEEN_KEY_LENGTH];
|
||||
int count;
|
||||
int overflow; // insertions refused because the table was full
|
||||
} eg_seen_t;
|
||||
|
||||
// 1 when the key was not present and was inserted; 0 when already present or
|
||||
// when the table is full (counted in overflow).
|
||||
int eg_seen_insert(eg_seen_t *seen, const char *key);
|
||||
|
||||
#define EG_EVENT_PREFIX "eas-egress-guard"
|
||||
|
||||
// Function name of the one event a process writes when its seen table fills:
|
||||
// from then on distinct destinations are still refused but no longer listed.
|
||||
#define EG_OVERFLOW_FUNCTION "overflow"
|
||||
|
||||
// One tab-separated line, newline terminated:
|
||||
// eas-egress-guard\t<progname>\t<pid>\t<function>\t<action>\t<peer>\t<caller>,<caller>...\n
|
||||
// Tabs and newlines inside fields are replaced with spaces. Returns the length
|
||||
// written, or -1 when it does not fit.
|
||||
int eg_format_event(char *out, size_t n, const char *progname, int pid, const char *function,
|
||||
const char *action, const char *peer, const char *const *callers,
|
||||
int caller_count);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,291 @@
|
||||
// Black-box host tests of the built guard, loaded through
|
||||
// DYLD_INSERT_LIBRARIES like the simulator does. Remote calls use fd -1 or are
|
||||
// refused before the kernel; the only real sockets are on loopback. Build and
|
||||
// run with tests/run-guard-tests.sh.
|
||||
//
|
||||
// guard-insert-test <case> <log-path> [<extra-path>]
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/uio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef int (*connect_fn)(int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendto_fn)(int, const void *, size_t, int, const struct sockaddr *, socklen_t);
|
||||
typedef ssize_t (*sendmsg_fn)(int, const struct msghdr *, int);
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static struct sockaddr_in6 remote6(int port) {
|
||||
struct sockaddr_in6 a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6;
|
||||
a.sin6_len = sizeof a;
|
||||
a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, "2001:db8::1", &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static int refused_sendto(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
errno = 0;
|
||||
return sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) == -1 && errno == ECONNREFUSED;
|
||||
}
|
||||
|
||||
static off_t file_size(const char *path) {
|
||||
struct stat st;
|
||||
return stat(path, &st) == 0 ? st.st_size : -1;
|
||||
}
|
||||
|
||||
static int count_lines(const char *path, const char *needle) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (f == NULL) {
|
||||
return -1;
|
||||
}
|
||||
int count = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, needle)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Daemon-style cleanup: close every descriptor the process does not know
|
||||
// about, then open an application file. The guard's event must land in the
|
||||
// event log, not in that file.
|
||||
static int fd_reuse(const char *log_path, const char *app_path) {
|
||||
for (int fd = 3; fd < getdtablesize(); fd++) {
|
||||
close(fd);
|
||||
}
|
||||
int app_fd = open(app_path, O_CREAT | O_TRUNC | O_WRONLY, 0600);
|
||||
if (app_fd < 0 || !refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
close(app_fd);
|
||||
if (file_size(app_path) != 0) {
|
||||
printf("FAIL fd-reuse: %lld bytes written into an unrelated file\n",
|
||||
(long long)file_size(app_path));
|
||||
return 1;
|
||||
}
|
||||
if (count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") != 1) {
|
||||
printf("FAIL fd-reuse: event missing from the event log\n");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Started with stdout closed, as the runner arranges: the process's own stdout
|
||||
// output must not end up in the event log, and the event must.
|
||||
static int low_fd(const char *log_path) {
|
||||
printf("STDOUT-LINE\n");
|
||||
fflush(stdout);
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
if (count_lines(log_path, "STDOUT-LINE") != 0) {
|
||||
return 1;
|
||||
}
|
||||
return count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:9\t") == 1 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int nocancel(void) {
|
||||
connect_fn nc_connect = (connect_fn)dlsym(RTLD_DEFAULT, "connect$NOCANCEL");
|
||||
sendto_fn nc_sendto = (sendto_fn)dlsym(RTLD_DEFAULT, "sendto$NOCANCEL");
|
||||
sendmsg_fn nc_sendmsg = (sendmsg_fn)dlsym(RTLD_DEFAULT, "sendmsg$NOCANCEL");
|
||||
if (!nc_connect || !nc_sendto || !nc_sendmsg) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in to = remote(443);
|
||||
int failures = 0;
|
||||
errno = 0;
|
||||
if (nc_connect(-1, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: connect$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
errno = 0;
|
||||
if (nc_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendto$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (nc_sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL nocancel: sendmsg$NOCANCEL not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// The shapes the kernel accepts as IPv4/IPv6 despite sa_family == AF_UNSPEC.
|
||||
static int unspec(void) {
|
||||
int failures = 0;
|
||||
struct sockaddr_in to = remote(443);
|
||||
to.sin_family = AF_UNSPEC;
|
||||
int tcp = socket(AF_INET, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp);
|
||||
struct sockaddr_in6 to6 = remote6(443);
|
||||
to6.sin6_family = AF_UNSPEC;
|
||||
int tcp6 = socket(AF_INET6, SOCK_STREAM, 0);
|
||||
errno = 0;
|
||||
if (connect(tcp6, (struct sockaddr *)&to6, sizeof to6) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: TCP6 connect with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
close(tcp6);
|
||||
errno = 0;
|
||||
if (sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendto with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
char payload[] = "x";
|
||||
struct iovec iov = {.iov_base = payload, .iov_len = 1};
|
||||
struct msghdr msg = {.msg_name = &to, .msg_namelen = sizeof to, .msg_iov = &iov, .msg_iovlen = 1};
|
||||
errno = 0;
|
||||
if (sendmsg(-1, &msg, 0) != -1 || errno != ECONNREFUSED) {
|
||||
printf("FAIL unspec: sendmsg with AF_UNSPEC not refused (errno %d)\n", errno);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// A UDP socket associated with loopback dissolves the association through
|
||||
// connect(AF_UNSPEC), whatever address bytes follow the family. The guard
|
||||
// must let the kernel do that.
|
||||
static int udp_disconnect(void) {
|
||||
int fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
struct sockaddr_in loopback;
|
||||
memset(&loopback, 0, sizeof loopback);
|
||||
loopback.sin_family = AF_INET;
|
||||
loopback.sin_len = sizeof loopback;
|
||||
loopback.sin_port = htons(9);
|
||||
loopback.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
struct sockaddr_in peer;
|
||||
socklen_t peer_len = sizeof peer;
|
||||
if (getpeername(fd, (struct sockaddr *)&peer, &peer_len) != 0) {
|
||||
return 2;
|
||||
}
|
||||
int failures = 0;
|
||||
for (int stale = 0; stale < 2; stale++) {
|
||||
struct sockaddr_in dissolve = stale ? remote(443) : loopback;
|
||||
if (!stale) {
|
||||
memset(&dissolve, 0, sizeof dissolve);
|
||||
dissolve.sin_len = sizeof dissolve;
|
||||
}
|
||||
dissolve.sin_family = AF_UNSPEC;
|
||||
if (connect(fd, (struct sockaddr *)&loopback, sizeof loopback) != 0) {
|
||||
return 2;
|
||||
}
|
||||
errno = 0;
|
||||
int rc = connect(fd, (struct sockaddr *)&dissolve, sizeof dissolve);
|
||||
int err = errno;
|
||||
peer_len = sizeof peer;
|
||||
int still_connected = getpeername(fd, (struct sockaddr *)&peer, &peer_len) == 0;
|
||||
if (rc != -1 || err != EAFNOSUPPORT || still_connected) {
|
||||
printf("FAIL udp-disconnect (%s address): rc=%d errno=%d still_connected=%d\n",
|
||||
stale ? "stale remote" : "zeroed", rc, err, still_connected);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
// Past 128 distinct destinations a process writes one overflow line and
|
||||
// nothing more; refusal continues.
|
||||
static int overflow(const char *log_path) {
|
||||
for (int port = 1; port <= 140; port++) {
|
||||
if (!refused_sendto(port)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
int listed = count_lines(log_path, "\tsendto\tblocked\t192.0.2.1:");
|
||||
int overflow_lines = count_lines(log_path, "\toverflow\tblocked\t128 distinct destinations\t");
|
||||
if (listed != 128 || overflow_lines != 1) {
|
||||
printf("FAIL overflow: %d destinations listed, %d overflow line(s)\n", listed, overflow_lines);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The event names the images above the guard, not the guard itself.
|
||||
static int callers(const char *log_path) {
|
||||
if (!refused_sendto(9)) {
|
||||
return 2;
|
||||
}
|
||||
FILE *f = fopen(log_path, "r");
|
||||
if (f == NULL) {
|
||||
return 2;
|
||||
}
|
||||
char line[1024];
|
||||
int ok = 0;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
if (strstr(line, "\tsendto\tblocked\t192.0.2.1:9\t")) {
|
||||
const char *callers_field = strrchr(line, '\t') + 1;
|
||||
ok = strstr(callers_field, "guard-insert-test") != NULL &&
|
||||
strstr(callers_field, "egress-guard") == NULL;
|
||||
if (!ok) {
|
||||
printf("FAIL callers: %s", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 3) {
|
||||
return 2;
|
||||
}
|
||||
const char *name = argv[1];
|
||||
const char *log_path = argv[2];
|
||||
if (strcmp(name, "fd-reuse") == 0) {
|
||||
return argc == 4 ? fd_reuse(log_path, argv[3]) : 2;
|
||||
}
|
||||
if (strcmp(name, "low-fd") == 0) {
|
||||
return low_fd(log_path);
|
||||
}
|
||||
if (strcmp(name, "nocancel") == 0) {
|
||||
return nocancel();
|
||||
}
|
||||
if (strcmp(name, "unspec") == 0) {
|
||||
return unspec();
|
||||
}
|
||||
if (strcmp(name, "udp-disconnect") == 0) {
|
||||
return udp_disconnect();
|
||||
}
|
||||
if (strcmp(name, "callers") == 0) {
|
||||
return callers(log_path);
|
||||
}
|
||||
if (strcmp(name, "overflow") == 0) {
|
||||
return overflow(log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// White-box host tests of guard.c's dedupe lock: the schedules that would
|
||||
// abort the process are forced deterministically. Every case runs in its own
|
||||
// process; sockets are never opened (fd -1), so nothing can leave the host.
|
||||
// Build and run with tests/run-guard-tests.sh.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key);
|
||||
#define eg_seen_insert scheduled_seen_insert
|
||||
#include "../guard.c"
|
||||
#undef eg_seen_insert
|
||||
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#define CONTENTION_THREADS 8
|
||||
#define CONTENTION_KEYS 12
|
||||
#define CONTENTION_ROUNDS 25
|
||||
|
||||
static int interrupt_insert;
|
||||
static int ready_pipe[2];
|
||||
static int release_pipe[2];
|
||||
|
||||
static struct sockaddr_in remote(int port) {
|
||||
struct sockaddr_in a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET;
|
||||
a.sin_len = sizeof a;
|
||||
a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, "192.0.2.1", &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
// Block mode must refuse before touching the kernel, even on an invalid fd.
|
||||
static void send_remote(int port) {
|
||||
struct sockaddr_in to = remote(port);
|
||||
if (eg_sendto(-1, "x", 1, 0, (struct sockaddr *)&to, sizeof to) != -1 || errno != ECONNREFUSED) {
|
||||
_exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_signal(int sig) {
|
||||
(void)sig;
|
||||
send_remote(9);
|
||||
}
|
||||
|
||||
static int scheduled_seen_insert(eg_seen_t *seen, const char *key) {
|
||||
if (interrupt_insert) {
|
||||
interrupt_insert = 0;
|
||||
raise(SIGUSR1);
|
||||
}
|
||||
return eg_seen_insert(seen, key);
|
||||
}
|
||||
|
||||
static int signal_reentry(void) {
|
||||
signal(SIGUSR1, on_signal);
|
||||
interrupt_insert = 1;
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *hold_lock(void *unused) {
|
||||
(void)unused;
|
||||
char byte = 'x';
|
||||
os_unfair_lock_lock(&eg_lock);
|
||||
(void)write(ready_pipe[1], &byte, 1);
|
||||
(void)read(release_pipe[0], &byte, 1);
|
||||
os_unfair_lock_unlock(&eg_lock);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int fork_while_locked(void) {
|
||||
if (pipe(ready_pipe) || pipe(release_pipe)) {
|
||||
return 2;
|
||||
}
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, NULL, hold_lock, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
char byte;
|
||||
if (read(ready_pipe[0], &byte, 1) != 1) {
|
||||
return 2;
|
||||
}
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
return 2;
|
||||
}
|
||||
if (child == 0) {
|
||||
alarm(5);
|
||||
send_remote(9);
|
||||
send_remote(10);
|
||||
_exit(0);
|
||||
}
|
||||
(void)write(release_pipe[1], "x", 1);
|
||||
pthread_join(thread, NULL);
|
||||
int status;
|
||||
waitpid(child, &status, 0);
|
||||
if (WIFSIGNALED(status)) {
|
||||
printf("FAIL fork: child terminated by signal %d\n", WTERMSIG(status));
|
||||
return 1;
|
||||
}
|
||||
return WIFEXITED(status) ? WEXITSTATUS(status) : 2;
|
||||
}
|
||||
|
||||
static void *hammer(void *unused) {
|
||||
(void)unused;
|
||||
for (int round = 0; round < CONTENTION_ROUNDS; round++) {
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
send_remote(1000 + key);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Under contention a thread may give up its insert; the next attempt on the
|
||||
// same key must then record it, so every key ends up logged exactly once.
|
||||
static int contention(const char *log_path) {
|
||||
pthread_t threads[CONTENTION_THREADS];
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
if (pthread_create(&threads[i], NULL, hammer, NULL)) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < CONTENTION_THREADS; i++) {
|
||||
pthread_join(threads[i], NULL);
|
||||
}
|
||||
FILE *log = fopen(log_path, "r");
|
||||
if (log == NULL) {
|
||||
return 2;
|
||||
}
|
||||
int counts[CONTENTION_KEYS] = {0};
|
||||
int total = 0;
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof line, log)) {
|
||||
total++;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
char needle[64];
|
||||
snprintf(needle, sizeof needle, "\tsendto\tblocked\t192.0.2.1:%d\t", 1000 + key);
|
||||
if (strstr(line, needle)) {
|
||||
counts[key]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(log);
|
||||
int failures = 0;
|
||||
for (int key = 0; key < CONTENTION_KEYS; key++) {
|
||||
if (counts[key] != 1) {
|
||||
printf("FAIL contention: 192.0.2.1:%d logged %d times\n", 1000 + key, counts[key]);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
if (total != CONTENTION_KEYS) {
|
||||
printf("FAIL contention: %d lines for %d keys\n", total, CONTENTION_KEYS);
|
||||
failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 2) {
|
||||
return 2;
|
||||
}
|
||||
eg_mode = EG_MODE_BLOCK;
|
||||
if (strcmp(argv[1], "signal") == 0) {
|
||||
return signal_reentry();
|
||||
}
|
||||
if (strcmp(argv[1], "fork") == 0) {
|
||||
return fork_while_locked();
|
||||
}
|
||||
if (strcmp(argv[1], "contention") == 0) {
|
||||
return contention(eg_log_path);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Probe run inside a simulator by the egress guard end-to-end test. Exercises
|
||||
// every outbound path the guard must cover and prints one JSON object.
|
||||
//
|
||||
// nettest --proxy-port <port> --udp-port <port>
|
||||
//
|
||||
// proxy-port: a CONNECT proxy on the host's loopback, standing in for the
|
||||
// egress proxy. udp-port: a UDP echo on loopback.
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
var results: [String: String] = [:]
|
||||
let args = CommandLine.arguments
|
||||
func arg(_ name: String) -> Int {
|
||||
guard let i = args.firstIndex(of: name), i + 1 < args.count else { return 0 }
|
||||
return Int(args[i + 1]) ?? 0
|
||||
}
|
||||
let proxyPort = arg("--proxy-port")
|
||||
let udpPort = arg("--udp-port")
|
||||
let remoteHost = "example.com"
|
||||
let remotePort = 443
|
||||
|
||||
func classify(_ error: Error?) -> String {
|
||||
guard let e = error as NSError? else { return "ok" }
|
||||
if e.domain == NSURLErrorDomain && e.code == NSURLErrorCannotConnectToHost { return "refused" }
|
||||
if e.domain == NSPOSIXErrorDomain && e.code == Int(ECONNREFUSED) { return "refused" }
|
||||
return "error(\(e.domain):\(e.code))"
|
||||
}
|
||||
|
||||
// 1. URLSession straight to the internet. Blocked by the guard.
|
||||
func urlSessionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 10
|
||||
c.connectionProxyDictionary = [:] // explicitly no proxy, even if the host has one
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionDirect"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 2. URLSession through a CONNECT proxy on loopback. Allowed: the guard only
|
||||
// sees a loopback connect.
|
||||
func urlSessionProxied() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let c = URLSessionConfiguration.ephemeral; c.timeoutIntervalForRequest = 15
|
||||
// The kCFNetworkProxies* constants are not exposed to Swift on iOS; these are their values.
|
||||
c.connectionProxyDictionary = ["HTTPSEnable": 1, "HTTPSProxy": "127.0.0.1", "HTTPSPort": proxyPort]
|
||||
URLSession(configuration: c).dataTask(with: URL(string: "https://\(remoteHost)/")!) { _, r, e in
|
||||
results["urlsessionProxied"] = e == nil ? "ok(\((r as? HTTPURLResponse)?.statusCode ?? 0))" : classify(e); g.leave()
|
||||
}.resume(); g.wait()
|
||||
}
|
||||
|
||||
// 3. Network.framework connection straight out. Blocked.
|
||||
func nwConnectionDirect() {
|
||||
let g = DispatchGroup(); g.enter()
|
||||
let p = NWParameters.tls; p.preferNoProxies = true
|
||||
let c = NWConnection(host: NWEndpoint.Host(remoteHost), port: NWEndpoint.Port(integerLiteral: UInt16(remotePort)), using: p)
|
||||
var done = false
|
||||
c.stateUpdateHandler = { s in
|
||||
guard !done else { return }
|
||||
switch s {
|
||||
case .ready: done = true; results["nwconnectionDirect"] = "ok"; c.cancel(); g.leave()
|
||||
case .failed(let e): done = true; results["nwconnectionDirect"] = classify(e); g.leave()
|
||||
case .waiting(let e): done = true; results["nwconnectionDirect"] = "waiting(" + classify(e) + ")"; c.cancel(); g.leave()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
c.start(queue: .global())
|
||||
_ = g.wait(timeout: .now() + 15)
|
||||
if results["nwconnectionDirect"] == nil { results["nwconnectionDirect"] = "timeout"; c.cancel() }
|
||||
}
|
||||
|
||||
// 4. Plain BSD TCP connect to a resolved address. Blocked.
|
||||
func bsdConnectDirect() {
|
||||
var hints = addrinfo(); hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET
|
||||
var info: UnsafeMutablePointer<addrinfo>? = nil
|
||||
guard getaddrinfo(remoteHost, "443", &hints, &info) == 0, let ai = info else { results["bsdConnectDirect"] = "dns-failed"; return }
|
||||
results["dns"] = "ok"
|
||||
let fd = socket(ai.pointee.ai_family, ai.pointee.ai_socktype, ai.pointee.ai_protocol)
|
||||
let rc = connect(fd, ai.pointee.ai_addr, ai.pointee.ai_addrlen)
|
||||
results["bsdConnectDirect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd); freeaddrinfo(info)
|
||||
}
|
||||
|
||||
// 5. UDP datagram straight out. Blocked. 6. UDP datagram to loopback echo. Allowed.
|
||||
func udp(_ key: String, _ host: String, _ port: Int, expectEcho: Bool) {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(port)).bigEndian
|
||||
inet_pton(AF_INET, host, &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
sendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
if sent < 0 { results[key] = errno == ECONNREFUSED ? "refused" : "errno(\(errno))"; close(fd); return }
|
||||
if expectEcho {
|
||||
var tv = timeval(tv_sec: 3, tv_usec: 0); setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
var buf = [UInt8](repeating: 0, count: 16)
|
||||
let n = recv(fd, &buf, buf.count, 0)
|
||||
results[key] = n > 0 ? "ok" : "no-echo"
|
||||
} else { results[key] = "sent" }
|
||||
close(fd)
|
||||
}
|
||||
|
||||
urlSessionDirect()
|
||||
urlSessionProxied()
|
||||
nwConnectionDirect()
|
||||
bsdConnectDirect()
|
||||
udp("udpDirect", "8.8.8.8", 53, expectEcho: false)
|
||||
udp("udpLoopback", "127.0.0.1", udpPort, expectEcho: true)
|
||||
|
||||
// 7. The same literal destination twice: the guard must log it once per process.
|
||||
func bsdConnectLiteral(_ key: String) {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results[key] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
bsdConnectLiteral("literalFirst")
|
||||
bsdConnectLiteral("literalSecond")
|
||||
|
||||
// 8. A sockaddr_in whose family was left AF_UNSPEC: the kernel connects it as
|
||||
// IPv4, so the guard must refuse it. 9. connect(AF_UNSPEC) on a UDP socket
|
||||
// dissolves the association; the guard must leave that to the kernel.
|
||||
func unspecConnect() {
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_UNSPEC); to.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.0.0.1", &to.sin_addr)
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
let rc = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["unspecConnect"] = rc == 0 ? "ok" : (errno == ECONNREFUSED ? "refused" : "errno(\(errno))")
|
||||
close(fd)
|
||||
}
|
||||
func udpDisconnect() {
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(udpPort)).bigEndian
|
||||
inet_pton(AF_INET, "127.0.0.1", &to.sin_addr)
|
||||
let associated = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
var dissolve = sockaddr_in(); dissolve.sin_family = sa_family_t(AF_UNSPEC); dissolve.sin_port = in_port_t(UInt16(443)).bigEndian
|
||||
inet_pton(AF_INET, "1.1.1.1", &dissolve.sin_addr)
|
||||
let rc = withUnsafePointer(to: &dissolve) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
let err = errno
|
||||
var peer = sockaddr_in(); var peerLength = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let stillConnected = withUnsafeMutablePointer(to: &peer) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getpeername(fd, sa, &peerLength) } } == 0
|
||||
results["udpDisconnect"] = associated != 0 ? "associate-failed"
|
||||
: (rc == -1 && err == EAFNOSUPPORT && !stillConnected) ? "ok" : "errno(\(err)),connected=\(stillConnected)"
|
||||
close(fd)
|
||||
}
|
||||
// 10. The non-cancelable variant of sendto, as libsystem exports it. Blocked.
|
||||
func nocancelSendto() {
|
||||
typealias SendtoFn = @convention(c) (Int32, UnsafeRawPointer?, Int, Int32, UnsafePointer<sockaddr>?, socklen_t) -> Int
|
||||
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "sendto$NOCANCEL") else {
|
||||
results["nocancelSendto"] = "no-symbol"; return
|
||||
}
|
||||
let nocancelSendto = unsafeBitCast(symbol, to: SendtoFn.self)
|
||||
let fd = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
var to = sockaddr_in(); to.sin_family = sa_family_t(AF_INET); to.sin_port = in_port_t(UInt16(53)).bigEndian
|
||||
inet_pton(AF_INET, "8.8.8.8", &to.sin_addr)
|
||||
var payload: [UInt8] = Array("ping".utf8)
|
||||
let sent = withUnsafePointer(to: &to) { p in p.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
nocancelSendto(fd, &payload, payload.count, 0, sa, socklen_t(MemoryLayout<sockaddr_in>.size)) } }
|
||||
results["nocancelSendto"] = sent < 0 ? (errno == ECONNREFUSED ? "refused" : "errno(\(errno))") : "sent"
|
||||
close(fd)
|
||||
}
|
||||
unspecConnect()
|
||||
udpDisconnect()
|
||||
nocancelSendto()
|
||||
|
||||
let json = try! JSONSerialization.data(withJSONObject: results, options: [.sortedKeys])
|
||||
print(String(data: json, encoding: .utf8)!)
|
||||
@@ -0,0 +1,143 @@
|
||||
// Host-runnable tests for the guard's policy and formatting. Build and run
|
||||
// with tests/run-policy-tests.sh; every failure prints the failing line.
|
||||
#include "../policy.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static struct sockaddr_in v4(const char *ip, int port) {
|
||||
struct sockaddr_in a; memset(&a, 0, sizeof a);
|
||||
a.sin_family = AF_INET; a.sin_len = sizeof a; a.sin_port = htons(port);
|
||||
inet_pton(AF_INET, ip, &a.sin_addr);
|
||||
return a;
|
||||
}
|
||||
static struct sockaddr_in6 v6(const char *ip, int port) {
|
||||
struct sockaddr_in6 a; memset(&a, 0, sizeof a);
|
||||
a.sin6_family = AF_INET6; a.sin6_len = sizeof a; a.sin6_port = htons(port);
|
||||
inet_pton(AF_INET6, ip, &a.sin6_addr);
|
||||
return a;
|
||||
}
|
||||
|
||||
static void test_classify(void) {
|
||||
struct sockaddr_in a4; struct sockaddr_in6 a6; struct sockaddr_un un;
|
||||
|
||||
a4 = v4("127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("127.42.0.9", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("192.168.1.10", 8081); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("169.254.1.1", 80); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
a4 = v4("224.0.0.251", 5353); CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
|
||||
a6 = v6("::1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::", 80); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:127.0.0.1", 8899); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("2606:4700::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("fe80::1", 443); CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNIX; strcpy(un.sun_path, "/var/run/mDNSResponder");
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
CHECK(eg_classify(NULL, 0) == EG_PASSTHROUGH);
|
||||
a4 = v4("93.184.216.34", 443); CHECK(eg_classify((struct sockaddr *)&a4, 4) == EG_PASSTHROUGH); // too short to read
|
||||
|
||||
// AF_UNSPEC is what the kernel makes of it: the family the exact length implies.
|
||||
a4 = v4("93.184.216.34", 443); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_REMOTE);
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4 + 8) == EG_PASSTHROUGH); // kernel: EINVAL
|
||||
a4 = v4("127.0.0.1", 8899); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK);
|
||||
a4 = v4("0.0.0.0", 0); a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a4, sizeof a4) == EG_LOOPBACK); // UDP disconnect shape
|
||||
a6 = v6("2606:4700::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::ffff:93.184.216.34", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_REMOTE);
|
||||
a6 = v6("::1", 443); a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&a6, sizeof a6) == EG_LOOPBACK);
|
||||
memset(&un, 0, sizeof un); un.sun_family = AF_UNSPEC;
|
||||
CHECK(eg_classify((struct sockaddr *)&un, sizeof un) == EG_PASSTHROUGH);
|
||||
}
|
||||
|
||||
static void test_mode(void) {
|
||||
CHECK(eg_parse_mode(NULL) == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("block") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("log") == EG_MODE_LOG);
|
||||
CHECK(eg_parse_mode("LOG") == EG_MODE_BLOCK);
|
||||
CHECK(eg_parse_mode("permissive") == EG_MODE_BLOCK);
|
||||
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_REMOTE) == 1);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_LOOPBACK) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_BLOCK, EG_PASSTHROUGH) == 0);
|
||||
CHECK(eg_should_deny(EG_MODE_LOG, EG_REMOTE) == 0);
|
||||
}
|
||||
|
||||
static void test_format_peer(void) {
|
||||
char out[64];
|
||||
struct sockaddr_in a4 = v4("93.184.216.34", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
struct sockaddr_in6 a6 = v6("2606:4700::1", 443);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, 8) != 0);
|
||||
struct sockaddr_un un; memset(&un, 0, sizeof un); un.sun_family = AF_UNIX;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&un, sizeof un, out, sizeof out) != 0);
|
||||
a4.sin_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a4, sizeof a4, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "93.184.216.34:443") == 0);
|
||||
a6.sin6_family = AF_UNSPEC;
|
||||
CHECK(eg_format_peer((struct sockaddr *)&a6, sizeof a6, out, sizeof out) == 0);
|
||||
CHECK(strcmp(out, "[2606:4700::1]:443") == 0);
|
||||
}
|
||||
|
||||
static void test_seen(void) {
|
||||
static eg_seen_t seen; memset(&seen, 0, sizeof seen);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 1);
|
||||
CHECK(eg_seen_insert(&seen, "connect 1.1.1.1:443") == 0);
|
||||
CHECK(eg_seen_insert(&seen, "sendto 1.1.1.1:443") == 1);
|
||||
CHECK(seen.count == 2);
|
||||
char key[64];
|
||||
for (int i = 0; i < EG_SEEN_CAPACITY + 5; i++) { snprintf(key, sizeof key, "k%d", i); eg_seen_insert(&seen, key); }
|
||||
CHECK(seen.count == EG_SEEN_CAPACITY);
|
||||
CHECK(seen.overflow == 7); // 2 + 128 + 5 attempts, 128 fit
|
||||
CHECK(eg_seen_insert(&seen, "k0") == 0); // still remembered
|
||||
}
|
||||
|
||||
static void test_format_event(void) {
|
||||
char out[512];
|
||||
const char *callers[] = {"Network", "CFNetwork", "MyApp"};
|
||||
int n = eg_format_event(out, sizeof out, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp\n") == 0);
|
||||
|
||||
const char *tabbed[] = {"a\tb"};
|
||||
n = eg_format_event(out, sizeof out, "we\nird\tname", 1, "sendto", "logged", "1.2.3.4:53", tabbed, 1);
|
||||
CHECK(n > 0);
|
||||
CHECK(strcmp(out, "eas-egress-guard\twe ird name\t1\tsendto\tlogged\t1.2.3.4:53\ta b\n") == 0);
|
||||
|
||||
n = eg_format_event(out, sizeof out, "NoCallers", 7, "connectx", "blocked", "[::2]:80", NULL, 0);
|
||||
CHECK(n > 0 && strcmp(out, "eas-egress-guard\tNoCallers\t7\tconnectx\tblocked\t[::2]:80\t\n") == 0);
|
||||
|
||||
CHECK(eg_format_event(out, 16, "MyApp", 4242, "connect", "blocked", "93.184.216.34:443", callers, 3) == -1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_classify(); test_mode(); test_format_peer(); test_seen(); test_format_event();
|
||||
if (failures) { fprintf(stderr, "%d failure(s)\n", failures); return 1; }
|
||||
printf("egress-guard policy tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the guard for the host (macOS) and runs the guard tests against it:
|
||||
# white-box lock schedules with guard.c compiled in, and black-box cases with
|
||||
# the built library inserted the way the simulator inserts it. No socket is
|
||||
# ever opened to a non-loopback address.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -dynamiclib policy.c guard.c -o "$out/egress-guard.dylib"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 -pthread policy.c tests/guard_test.c -o "$out/guard-test"
|
||||
clang -std=c11 -Wall -Wextra -Werror -O2 tests/guard_insert_test.c -o "$out/guard-insert-test"
|
||||
|
||||
failures=0
|
||||
report() {
|
||||
if [[ $2 -eq 0 ]]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 (exit $2)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# White-box: guard.c compiled into the test, lock schedules forced.
|
||||
for name in signal fork contention; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_MODE=block EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-test" "$name" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
# Black-box: the built library inserted into a plain process.
|
||||
export DYLD_INSERT_LIBRARIES="$out/egress-guard.dylib"
|
||||
export EAS_EGRESS_GUARD_MODE=block
|
||||
|
||||
status=0
|
||||
: > "$out/fd-reuse.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/fd-reuse.log" \
|
||||
"$out/guard-insert-test" fd-reuse "$out/fd-reuse.log" "$out/application-file" || status=$?
|
||||
report fd-reuse "$status"
|
||||
|
||||
status=0
|
||||
: > "$out/low-fd.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/low-fd.log" \
|
||||
"$out/guard-insert-test" low-fd "$out/low-fd.log" 1>&- || status=$?
|
||||
report low-fd "$status"
|
||||
|
||||
for name in nocancel unspec udp-disconnect callers overflow; do
|
||||
status=0
|
||||
: > "$out/$name.log"
|
||||
EAS_EGRESS_GUARD_LOG="$out/$name.log" \
|
||||
"$out/guard-insert-test" "$name" "$out/$name.log" || status=$?
|
||||
report "$name" "$status"
|
||||
done
|
||||
|
||||
if [[ $failures -ne 0 ]]; then
|
||||
echo "egress-guard guard tests: $failures failure(s)"
|
||||
exit 1
|
||||
fi
|
||||
echo "egress-guard guard tests: all passed"
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the policy unit tests for the host (macOS) and runs them.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
out=$(mktemp -d)
|
||||
clang -std=c11 -Wall -Wextra -Werror -o "$out/policy_test" policy.c tests/policy_test.c
|
||||
"$out/policy_test"
|
||||
@@ -1,4 +1,4 @@
|
||||
import bunyan from 'bunyan';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
|
||||
export function createLogger(): bunyan {
|
||||
const logger = {
|
||||
|
||||
@@ -11,6 +11,19 @@ jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('copies the MCP server URL from the build context', () => {
|
||||
const ctx = new BuildContext(createTestIosJob(), {
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '',
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
});
|
||||
|
||||
expect(new CustomBuildContext(ctx).mcpServerUrl).toBe('ws://localhost:8787');
|
||||
});
|
||||
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
const contextUploadArtifact = jest.fn();
|
||||
const ctx = new BuildContext(
|
||||
|
||||
@@ -4,9 +4,11 @@ import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
import { stopLocalEgressResourcesAsync } from '../steps/utils/localEgress';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../steps/utils/localEgress', () => ({ stopLocalEgressResourcesAsync: jest.fn() }));
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
@@ -58,6 +60,25 @@ describe(runGenericJobAsync, () => {
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'releases local egress after workflow %s, before uploading outputs',
|
||||
async outcome => {
|
||||
const executeAsync =
|
||||
outcome === 'success'
|
||||
? jest.fn().mockResolvedValue(undefined)
|
||||
: jest.fn().mockRejectedValue(new Error(outcome));
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue({ executeAsync }),
|
||||
}));
|
||||
const { runResult } = await runGenericJobAsync(mockCtx);
|
||||
expect(runResult.ok).toBe(outcome === 'success');
|
||||
expect(stopLocalEgressResourcesAsync).toHaveBeenCalledWith(mockCtx.logger);
|
||||
expect(jest.mocked(stopLocalEgressResourcesAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockUploadJobOutputsToWwwAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
|
||||
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreCocoapodsCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -107,6 +111,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
|
||||
@@ -250,6 +260,12 @@ async function buildInnerAsync(
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -70,6 +70,48 @@ describe('easBuildInternal', () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides the minimum release age only for the internal config command', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
job: { platform: 'ios', buildProfile: 'production' },
|
||||
} as any;
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValueOnce({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
jest.mocked(spawn).mockResolvedValueOnce({
|
||||
stdout: Buffer.from(JSON.stringify({ buildProfile: { env: {} } })),
|
||||
} as any);
|
||||
|
||||
await resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ctx.env).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
|
||||
@@ -3,7 +3,10 @@ import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
import {
|
||||
installDependenciesAsync,
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
} from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
@@ -236,6 +239,85 @@ describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(installDependenciesAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockReturnValue(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_PNPM_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: { EAS_PNPM_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_PNPM_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.PNPM,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pnpm',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs only the selected workspace when EAS_BUN_FILTER_WORKSPACE is set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: { EAS_BUN_FILTER_WORKSPACE: 'my-app' },
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--filter', 'my-app', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
|
||||
it('installs all workspaces when EAS_BUN_FILTER_WORKSPACE is not set', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await installDependenciesAsync({
|
||||
packageManager: PackageManager.BUN,
|
||||
env: {},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: true,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--frozen-lockfile'],
|
||||
expect.objectContaining({ cwd: '/tmp/build' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
ArchiveSourceType,
|
||||
BuildMode,
|
||||
BuildTrigger,
|
||||
ErrorCode,
|
||||
Job,
|
||||
Platform,
|
||||
SystemError,
|
||||
Workflow,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
@@ -14,7 +17,7 @@ import { setTimeout } from 'timers/promises';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { shallowCloneRepositoryAsync } from '../git';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
import { prepareProjectSourcesAsync, unpackTarGzAsync } from '../projectSources';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-fetch');
|
||||
@@ -23,7 +26,28 @@ jest.mock('@expo/downloader');
|
||||
jest.mock('@urql/core');
|
||||
|
||||
describe('projectSources', () => {
|
||||
it('should use the refreshed repository URL', async () => {
|
||||
it('makes extracted project sources readable and writable by the worker', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await unpackTarGzAsync({
|
||||
source: '/workingdir/project.tar.gz',
|
||||
destination: '/workingdir/build',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'tar',
|
||||
['-C', '/workingdir/build', '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger }
|
||||
);
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'chmod', ['-R', 'u+rwX', '/workingdir/build'], {
|
||||
logger,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith('Normalizing project source permissions');
|
||||
});
|
||||
|
||||
it.each([true, false])('uses the refreshed URL (legacy URL: %s)', async includeRepositoryUrl => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
@@ -39,7 +63,9 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
...(includeRepositoryUrl
|
||||
? { repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git' }
|
||||
: {}),
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -89,102 +115,88 @@ describe('projectSources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to the original repository URL if the refresh fails', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
it.each(['http', 'network', 'json', 'schema', 'missing-url', 'PATH', 'GCS', 'R2'])(
|
||||
'throws a system error if refresh fails (%s)',
|
||||
async failure => {
|
||||
const robotAccessToken = randomUUID();
|
||||
const buildId = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
const ctx = new BuildContext(
|
||||
{
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
type: Workflow.MANAGED,
|
||||
mode: BuildMode.BUILD,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
platform: Platform.IOS,
|
||||
secrets: {
|
||||
robotAccessToken,
|
||||
environmentSecrets: [],
|
||||
},
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.dev',
|
||||
EXPO_TOKEN: robotAccessToken,
|
||||
EAS_BUILD_ID: buildId,
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const cause = new Error('Source request failed');
|
||||
fetchMock.mockImplementation(async () => {
|
||||
if (failure === 'network') {
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'http',
|
||||
status: failure === 'http' ? 500 : 200,
|
||||
json: async () => {
|
||||
if (failure === 'json') {
|
||||
throw cause;
|
||||
}
|
||||
if (['PATH', 'GCS', 'R2'].includes(failure)) {
|
||||
return { data: { type: failure, path: '/project.tar.gz', bucketKey: 'project' } };
|
||||
}
|
||||
if (failure === 'missing-url') {
|
||||
return {
|
||||
data: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { repository_url: 'https://github.com/expo/eas-cli.git' } };
|
||||
},
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: expect.any(Error),
|
||||
});
|
||||
if (failure === 'network') {
|
||||
await expect(result).rejects.toMatchObject({ cause });
|
||||
}
|
||||
);
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
text: async () => 'Failed to generate repository URL',
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
// repositoryUrl is the right key
|
||||
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
}),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
json: () => Promise.reject(new Error('Failed to generate repository URL')),
|
||||
}) as Response
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
archiveSource: {
|
||||
...ctx.job.projectArchive,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
|
||||
}, 15_000);
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
it('should retry fetching the repository URL', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
@@ -202,7 +214,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash,
|
||||
},
|
||||
@@ -259,7 +270,7 @@ describe('projectSources', () => {
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it(`should fallback to the original repository URL if we're missing some config`, async () => {
|
||||
it(`throws a system error if refresh configuration is missing`, async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
|
||||
const logger = createMockLogger();
|
||||
@@ -273,7 +284,6 @@ describe('projectSources', () => {
|
||||
appId: randomUUID(),
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: randomBytes(20).toString('hex'),
|
||||
},
|
||||
@@ -297,12 +307,11 @@ describe('projectSources', () => {
|
||||
}
|
||||
);
|
||||
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
cause: new Error('EAS_BUILD_ID is not set'),
|
||||
});
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('uploadProjectMetadataAsFireAndForget', () => {
|
||||
@@ -382,6 +391,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Call prepareProjectSourcesAsync and don't await metadata upload
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -495,6 +508,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
const startTime = Date.now();
|
||||
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
@@ -559,6 +576,10 @@ describe('projectSources', () => {
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({ ok: true } as Response);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
} as Response);
|
||||
|
||||
// Should not throw even though upload will fail
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
|
||||
@@ -573,3 +594,56 @@ describe('projectSources', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('local project sources', () => {
|
||||
function createContext(type: ArchiveSourceType): BuildContext<Job> {
|
||||
return new BuildContext(
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
projectArchive: { type, path: '/source.tar.gz' },
|
||||
} as Job,
|
||||
{
|
||||
env: { EAS_BUILD_RUNNER: 'local-build-plugin', __API_SERVER_URL: 'https://api.expo.dev' },
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('copies and unpacks a PATH source without fetching from www', async () => {
|
||||
await vol.promises.mkdir('/workingdir', { recursive: true });
|
||||
await vol.promises.writeFile('/source.tar.gz', 'local archive');
|
||||
const ctx = createContext(ArchiveSourceType.PATH);
|
||||
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.toEqual({
|
||||
handled: true,
|
||||
});
|
||||
expect(await vol.promises.readFile('/workingdir/project.tar.gz', 'utf8')).toBe('local archive');
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-C', ctx.buildDirectory, '--strip-components', '1', '-zxf', '/workingdir/project.tar.gz'],
|
||||
{ logger: ctx.logger }
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(
|
||||
Object.values(ArchiveSourceType).filter(
|
||||
type => type !== ArchiveSourceType.PATH && type !== ArchiveSourceType.NONE
|
||||
)
|
||||
)('rejects local %s sources before fetching or unpacking', async type => {
|
||||
const ctx = createContext(type);
|
||||
const result = prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
|
||||
await expect(result).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: 'SERVER_ERROR',
|
||||
trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE',
|
||||
message: `Expected a PATH project source for a local build, received ${type}.`,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(shallowCloneRepositoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export async function shallowCloneRepositoryAsync({
|
||||
destinationDirectory,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
|
||||
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT; repositoryUrl: string };
|
||||
destinationDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { repositoryUrl } = archiveSource;
|
||||
|
||||
@@ -32,7 +32,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_PNPM_FILTER_WORKSPACE'] ? ['--filter', env['EAS_PNPM_FILTER_WORKSPACE']] : []),
|
||||
useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile',
|
||||
];
|
||||
break;
|
||||
}
|
||||
case PackageManager.YARN: {
|
||||
@@ -58,7 +62,11 @@ export async function installDependenciesAsync({
|
||||
break;
|
||||
}
|
||||
case PackageManager.BUN:
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(env['EAS_BUN_FILTER_WORKSPACE'] ? ['--filter', env['EAS_BUN_FILTER_WORKSPACE']] : []),
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported package manager: ${packageManager}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, Job, SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -19,33 +19,30 @@ export async function prepareProjectSourcesAsync<TJob extends Job>(
|
||||
destinationDirectory: string
|
||||
): // Return type required to make switch exhaustive.
|
||||
Promise<{ handled: boolean }> {
|
||||
let projectArchive: ArchiveSource = ctx.job.projectArchive;
|
||||
if (ctx.isLocal) {
|
||||
console.warn('Local build, skipping project archive refresh');
|
||||
} else {
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
|
||||
if (!projectArchiveResult.ok) {
|
||||
ctx.logger.error(
|
||||
{ err: projectArchiveResult.reason },
|
||||
'Failed to refresh project archive, falling back to the original one'
|
||||
);
|
||||
}
|
||||
|
||||
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
|
||||
if (ctx.job.projectArchive.type === ArchiveSourceType.NONE) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
if (ctx.isLocal) {
|
||||
if (ctx.job.projectArchive.type !== ArchiveSourceType.PATH) {
|
||||
throw new SystemError(
|
||||
`Expected a PATH project source for a local build, received ${ctx.job.projectArchive.type}.`,
|
||||
{ trackingCode: 'INVALID_LOCAL_PROJECT_SOURCE' }
|
||||
);
|
||||
}
|
||||
await prepareProjectSourcesLocallyAsync(ctx, ctx.job.projectArchive.path, destinationDirectory);
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
|
||||
if (!projectArchiveResult.ok) {
|
||||
throw new SystemError('Failed to fetch project sources. Re-run the job.', {
|
||||
cause: projectArchiveResult.reason,
|
||||
});
|
||||
}
|
||||
const projectArchive = projectArchiveResult.value;
|
||||
|
||||
switch (projectArchive.type) {
|
||||
case ArchiveSourceType.R2:
|
||||
case ArchiveSourceType.GCS: {
|
||||
throw new Error('Remote project sources should be resolved earlier to URL');
|
||||
}
|
||||
|
||||
case ArchiveSourceType.PATH: {
|
||||
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
case ArchiveSourceType.NONE: {
|
||||
// May be used in no-sources jobs like submission jobs.
|
||||
return { handled: true };
|
||||
@@ -110,7 +107,7 @@ async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
|
||||
});
|
||||
}
|
||||
|
||||
async function unpackTarGzAsync({
|
||||
export async function unpackTarGzAsync({
|
||||
logger,
|
||||
source,
|
||||
destination,
|
||||
@@ -122,6 +119,8 @@ async function unpackTarGzAsync({
|
||||
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
|
||||
logger,
|
||||
});
|
||||
logger.info('Normalizing project source permissions');
|
||||
await spawn('chmod', ['-R', 'u+rwX', destination], { logger });
|
||||
}
|
||||
|
||||
function uploadProjectMetadataAsFireAndForget(
|
||||
@@ -247,7 +246,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
|
||||
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>) {
|
||||
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
@@ -285,7 +284,20 @@ async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<A
|
||||
);
|
||||
}
|
||||
|
||||
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
|
||||
const dataResult = z
|
||||
.object({
|
||||
data: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
z.object({ type: z.literal(ArchiveSourceType.URL), url: z.string().url() }),
|
||||
z.object({ type: z.literal(ArchiveSourceType.NONE) }),
|
||||
]),
|
||||
})
|
||||
.safeParse(jsonResult.value);
|
||||
if (!dataResult.success) {
|
||||
throw new Error(
|
||||
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BuildContextOptions {
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
mcpServerUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -84,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -112,6 +114,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.mcpServerUrl = options.mcpServerUrl;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
|
||||
@@ -63,6 +63,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public readonly mcpServerUrl?: string;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.mcpServerUrl = buildCtx.mcpServerUrl;
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BuildContext } from './context';
|
||||
import { CustomBuildContext } from './customBuildContext';
|
||||
import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { stopLocalEgressResourcesAsync } from './steps/utils/localEgress';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
|
||||
@@ -82,7 +83,17 @@ export async function runGenericJobAsync(
|
||||
}
|
||||
});
|
||||
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
const runResult = await asyncResult(
|
||||
(async () => {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
} finally {
|
||||
// The session-owning step may never run if simulator boot/setup fails,
|
||||
// or may time out while resource acquisition is still in flight.
|
||||
await stopLocalEgressResourcesAsync(ctx.logger);
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
|
||||
@@ -5,6 +5,8 @@ import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export { downloadCacheAsync } from './steps/functions/restoreCache';
|
||||
export { uploadCacheAsync } from './steps/functions/saveCache';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
|
||||
@@ -51,8 +51,10 @@ import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAp
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
@@ -103,11 +105,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
createStartLocalEgressBuildFunction(),
|
||||
createStartIosSimulatorRecordingsBuildFunction(),
|
||||
createFinishIosSimulatorRecordingsBuildFunction(),
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartServeSimRemoteSessionBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
|
||||
|
||||
@@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('restores and saves caches around an iOS simulator build', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
const stepNames = steps.map(step => step.displayName);
|
||||
const restoreCacheIndex = stepNames.indexOf('Restore Cache');
|
||||
const installPodsIndex = stepNames.indexOf('Install Pods');
|
||||
const saveCacheIndex = stepNames.indexOf('Save Cache');
|
||||
|
||||
expect(restoreCacheIndex).toBeGreaterThan(-1);
|
||||
expect(restoreCacheIndex).toBeLessThan(installPodsIndex);
|
||||
expect(saveCacheIndex).toBeGreaterThan(installPodsIndex);
|
||||
|
||||
const restoreCacheStep = steps[restoreCacheIndex];
|
||||
expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true);
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
});
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
@@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
},
|
||||
});
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
@@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
@@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const projectTargetDirectory = '/workingdir/project';
|
||||
|
||||
const GIT_PROJECT_ARCHIVE = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/app.git',
|
||||
gitRef: 'refs/heads/main',
|
||||
gitCommitHash: '0123456789abcdef0123456789abcdef01234567',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../../utils/cocoapodsCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreCocoapodsCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveCocoapodsCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../restoreCache', () => ({
|
||||
decompressCacheAsync: jest.fn(),
|
||||
downloadCacheAsync: jest.fn(),
|
||||
downloadPublicCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../saveCache', () => ({
|
||||
compressCacheAsync: jest.fn(),
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/cocoapodsCache', () => ({
|
||||
compressCocoapodsCacheAsync: jest.fn(),
|
||||
getCocoapodsCachePaths: jest.fn(),
|
||||
resolveCocoapodsCacheKeyAsync: jest.fn(),
|
||||
restoreCocoapodsCacheArchiveAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const env = {
|
||||
EAS_PODS_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const secrets = { robotAccessToken: 'robot-token' };
|
||||
|
||||
describe('CocoaPods build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/ios/Pods/Manifest.lock': 'manifest',
|
||||
'/workingdir/ios/Podfile.lock': 'lockfile',
|
||||
'/tmp/cocoapods-cache.tar.gz': 'archive',
|
||||
},
|
||||
'/'
|
||||
);
|
||||
jest.mocked(getCocoapodsCachePaths).mockReturnValue({
|
||||
iosDirectory: '/workingdir/ios',
|
||||
podsDirectory: '/workingdir/ios/Pods',
|
||||
podfileLockPath: '/workingdir/ios/Podfile.lock',
|
||||
});
|
||||
jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any);
|
||||
});
|
||||
|
||||
it('does nothing when the cache is disabled', async () => {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env: {},
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the newest matching CocoaPods cache', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
matchedKey: 'ios-pods-1.16.2-older-lock-hash',
|
||||
});
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
keyPrefixes: ['ios-pods-1.16.2-'],
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
workingDirectory: '/workingdir',
|
||||
});
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: 'prefix_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('compresses and saves the installed Pods directory', async () => {
|
||||
jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory: '/workingdir',
|
||||
env,
|
||||
secrets,
|
||||
});
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cocoapods-cache.tar.gz',
|
||||
key: 'ios-pods-1.16.2-lock-hash',
|
||||
paths: ['/workingdir/ios/Pods'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
ARGENT_EVENT_LOG_FILENAME: 'events.jsonl',
|
||||
startArgentEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({
|
||||
startAppiumEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
|
||||
}));
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn(),
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
const fields = {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
const controllers = [
|
||||
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
|
||||
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
|
||||
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
|
||||
] as const;
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
await createFunction(ctx).fn!(
|
||||
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
signal,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
lifetime = registerLocalEgressResources(stopEgress);
|
||||
stopEgress.mockResolvedValue(undefined);
|
||||
stopPreview.mockResolvedValue(undefined);
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/appium-egress-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.spyOn(fs.promises, 'readFile')
|
||||
.mockResolvedValue(JSON.stringify({ port: 1234, pid: 124, token: 'controller-token' }));
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'sim' }] as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
expect(lifetime.aborted).toBe(false);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
|
||||
await run();
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'session-id',
|
||||
logger,
|
||||
remoteConfig: expect.objectContaining({
|
||||
...fields,
|
||||
[controllerField]: expect.any(String),
|
||||
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
|
||||
}),
|
||||
});
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
|
||||
port: handoff.port,
|
||||
env,
|
||||
logger,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('leaves ordinary remote config unchanged and does not monitor', async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
stopEgress.mockClear();
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
await run();
|
||||
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
|
||||
expect(remoteConfig).toHaveProperty(controllerField);
|
||||
expect(Object.keys(remoteConfig).some(key => key.startsWith('egress'))).toBe(false);
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
|
||||
} else {
|
||||
stopPreview.mockRejectedValue(error);
|
||||
}
|
||||
await expect(run()).rejects.toThrow(error);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
it('passes cancellation to the monitor and releases the lifetime', async () => {
|
||||
const abort = new AbortController();
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
|
||||
await run(abort.signal);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: abort.signal })
|
||||
);
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not report or start a monitor after cancellation', async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
await expect(run(abort.signal)).rejects.toThrow();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import fs from 'fs';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { compressCacheAsync, uploadCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
@@ -81,3 +87,71 @@ describe('cache compress/decompress round trip', () => {
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadCacheAsync, () => {
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['normal upload', undefined, false],
|
||||
['forced upload', true, true],
|
||||
] as const)('sends force for %s', async (_name, force, expectedForce) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'upload-cache-test-'));
|
||||
const archivePath = path.join(tempDir, 'cache.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, 'cache archive');
|
||||
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
headers: {},
|
||||
url: 'https://storage.expo.test/cache',
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
.mockImplementationOnce(async (_url, request) => {
|
||||
const body = request!.body as NodeJS.ReadableStream & AsyncIterable<Buffer>;
|
||||
for await (const _chunk of body) {
|
||||
// Consume the archive stream as a real upload would.
|
||||
}
|
||||
return new Response(undefined, { status: 200 });
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadCacheAsync({
|
||||
logger: createLoggerMock(),
|
||||
jobId: 'build-id',
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
robotAccessToken: 'robot-token',
|
||||
paths: ['/cache/path'],
|
||||
key: 'cache-key',
|
||||
archivePath,
|
||||
size: 13,
|
||||
platform: Platform.ANDROID,
|
||||
force,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [uploadSessionUrl, uploadSessionRequest] = jest.mocked(fetch).mock.calls[0];
|
||||
expect(uploadSessionUrl.toString()).toBe(
|
||||
'https://api.expo.test/v2/turtle-builds/caches/upload-sessions'
|
||||
);
|
||||
expect(JSON.parse(uploadSessionRequest!.body as string)).toMatchObject({
|
||||
buildId: 'build-id',
|
||||
force: expectedForce,
|
||||
key: 'cache-key',
|
||||
size: 13,
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[1][0].toString()).toBe(
|
||||
'https://storage.expo.test/cache'
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,31 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
device_identifier: 'medium_phone',
|
||||
system_image_package: systemImagePackage,
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 262,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('sdkmanager', [systemImagePackage], expect.anything());
|
||||
expect(mockedAndroidUtils.createAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'medium_phone',
|
||||
systemImagePackage,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.createAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedAndroidUtils.startAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
|
||||
+39
-6
@@ -9,11 +9,13 @@ import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -40,14 +42,14 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
startServeSimWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -58,6 +60,7 @@ const EXPECTED_EVENT_LOG_PATH = path.join(ARGENT_STATE_DIR, 'tool-server-events.
|
||||
|
||||
const mockStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
|
||||
describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -68,6 +71,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
mockStopAsync.mockResolvedValue(undefined);
|
||||
mockTunnelStopAsync.mockResolvedValue(undefined);
|
||||
mockPreviewStopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
@@ -86,6 +90,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
@@ -120,18 +128,31 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
// (1) The event log flag is enabled, before the tool-server is launched.
|
||||
// (1) FFmpeg setup starts in the background before Argent setup.
|
||||
expect(ensureFfmpegInstalledOnceAsync).toHaveBeenCalledWith({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: { EXISTING: 'value' },
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(jest.mocked(ensureFfmpegInstalledOnceAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The event log flag is enabled, before the tool-server is launched.
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
const enableEventLogIndex = spawnCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'bunx' && Array.isArray(args) && args.includes('tool-server-event-log')
|
||||
command === 'bun' &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'x' &&
|
||||
args.includes('tool-server-event-log')
|
||||
);
|
||||
expect(enableEventLogIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[enableEventLogIndex]).toBeLessThan(
|
||||
jest.mocked(spawnDetached).mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
// (2) The tool-server and the collector are pinned to the exact same event log path.
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
@@ -140,7 +161,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
eventLogPath: EXPECTED_EVENT_LOG_PATH,
|
||||
});
|
||||
|
||||
// (3) Collection starts (before we wait for the session to stop) and (4) is torn down
|
||||
// (4) Collection starts (before we wait for the session to stop) and (5) is torn down
|
||||
// afterwards, exactly once.
|
||||
expect(startArgentEventCollectionAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
@@ -151,5 +172,17 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import spawn from '@expo/turtle-spawn';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../../utils/localEgressGuard';
|
||||
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -16,14 +22,36 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
enableAccessibilitySettingsAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn(),
|
||||
bootAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
configureSimulatorProxyEnvironmentAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
installLocalEgressGuardAsync: jest.fn(),
|
||||
resolveLocalEgressBootEnvironmentAsync: jest.fn(),
|
||||
verifyLocalEgressGuardAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedConfigureProxyEnvironment = jest.mocked(configureSimulatorProxyEnvironmentAsync);
|
||||
const mockedInstallGuard = jest.mocked(installLocalEgressGuardAsync);
|
||||
const mockedVerifyGuard = jest.mocked(verifyLocalEgressGuardAsync);
|
||||
const mockedResolveBootEnvironment = jest.mocked(resolveLocalEgressBootEnvironmentAsync);
|
||||
|
||||
// Names resolve to udids the way the step expects; udids pass through.
|
||||
const UDIDS: Record<string, string> = {
|
||||
'iPhone 15': 'base',
|
||||
'eas-simulator-1': 'clone-1',
|
||||
'eas-simulator-2': 'clone-2',
|
||||
};
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
@@ -41,27 +69,41 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.resolveUdidAsync.mockImplementation(
|
||||
async ({ deviceIdentifier }) => (UDIDS[deviceIdentifier] ?? deviceIdentifier) as any
|
||||
);
|
||||
mockedUtils.bootAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockImplementation(async ({ deviceIdentifier }) => ({
|
||||
udid: deviceIdentifier as any,
|
||||
}));
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
mockedConfigureProxyEnvironment.mockResolvedValue(false);
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
mockedVerifyGuard.mockResolvedValue(undefined);
|
||||
mockedResolveBootEnvironment.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({
|
||||
device_identifier: 'iPhone 15',
|
||||
count: 2,
|
||||
@@ -89,11 +131,6 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
@@ -110,6 +147,84 @@ describe(createStartIosSimulatorBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the proxy environment and guard between boot and boot completion, then verifies the guard', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
const udids = ['base', 'clone-1', 'clone-2'];
|
||||
expect(
|
||||
mockedUtils.bootAsync.mock.calls.map(([{ deviceIdentifier }]) => deviceIdentifier)
|
||||
).toEqual(udids);
|
||||
expect(mockedConfigureProxyEnvironment.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedInstallGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
expect(mockedVerifyGuard.mock.calls.map(([{ udid }]) => udid)).toEqual(udids);
|
||||
for (const [callIndex] of udids.entries()) {
|
||||
const bootOrder = mockedUtils.bootAsync.mock.invocationCallOrder[callIndex];
|
||||
const configureOrder = mockedConfigureProxyEnvironment.mock.invocationCallOrder[callIndex];
|
||||
const guardOrder = mockedInstallGuard.mock.invocationCallOrder[callIndex];
|
||||
const bootCompleteOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
|
||||
const verifyOrder = mockedVerifyGuard.mock.invocationCallOrder[callIndex];
|
||||
const readyOrder = mockedUtils.waitForReadyAsync.mock.invocationCallOrder[callIndex];
|
||||
// launchd is up when boot returns and has spawned nothing yet: that is the
|
||||
// only moment at which its environment reaches every process of the boot.
|
||||
expect(guardOrder).toBeGreaterThan(bootOrder);
|
||||
expect(configureOrder).toBeGreaterThan(guardOrder);
|
||||
expect(configureOrder).toBeLessThan(bootCompleteOrder);
|
||||
// The self-check needs a completed boot and runs before anything else.
|
||||
expect(verifyOrder).toBeGreaterThan(bootCompleteOrder);
|
||||
expect(verifyOrder).toBeLessThan(readyOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with the local egress environment so the first processes inherit it', async () => {
|
||||
mockedResolveBootEnvironment.mockResolvedValue({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
for (const [{ deviceIdentifier, launchdEnvironment }] of mockedUtils.bootAsync.mock.calls) {
|
||||
expect(['base', 'clone-1', 'clone-2']).toContain(deviceIdentifier);
|
||||
expect(launchdEnvironment).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('boots with an empty launchd environment when no local egress session is active', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.bootAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'base',
|
||||
env: expect.any(Object),
|
||||
launchdEnvironment: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the guard verification when no local egress session is active', async () => {
|
||||
mockedInstallGuard.mockResolvedValue(false);
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedVerifyGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the step when the guard cannot be installed or verified', async () => {
|
||||
mockedInstallGuard.mockRejectedValueOnce(new Error('guard library missing'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'guard library missing'
|
||||
);
|
||||
|
||||
mockedInstallGuard.mockResolvedValue(true);
|
||||
mockedVerifyGuard.mockRejectedValueOnce(new Error('self-check failed'));
|
||||
await expect(createStep({ device_identifier: 'iPhone 15' }).executeAsync()).rejects.toThrow(
|
||||
'self-check failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('continues when disabling apsd fails', async () => {
|
||||
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
downloadChiselAsync,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import { findAvailablePortAsync, startNgrokTunnelAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartLocalEgressBuildFunction } from '../startLocalEgress';
|
||||
|
||||
jest.mock('../../utils/localEgress', () => ({
|
||||
...jest.requireActual('../../utils/localEgress'),
|
||||
configureSystemProxyAsync: jest.fn(),
|
||||
downloadChiselAsync: jest.fn(),
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
findAvailablePortAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.com',
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the detached server and removes credentials when ngrok acquisition fails', async () => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockRejectedValue(new Error('listener quota'));
|
||||
await expect(start()).rejects.toThrow('listener quota');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts every release even when one resource cannot stop', async () => {
|
||||
await start();
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('ngrok close failed'));
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(LOCAL_EGRESS_HANDOFF_PATH, { force: true });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'disposes late listeners without delaying cleanup (close fails: %s)',
|
||||
async closeFails => {
|
||||
let finishTunnel!: (value: typeof tunnel) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishTunnel = resolve;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
const stopped = stopLocalEgressResourcesAsync(logger);
|
||||
await Promise.all([stopped, rejected]);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).not.toHaveBeenCalled();
|
||||
if (closeFails) {
|
||||
tunnel.stopAsync.mockRejectedValue(new Error('late close failed'));
|
||||
}
|
||||
finishTunnel(tunnel);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
if (closeFails) {
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not release a cancelled local egress acquisition.'
|
||||
);
|
||||
}
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['job', 'step'])('finishes %s cancellation when ngrok never settles', async source => {
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
if (source === 'step') {
|
||||
controller.abort(new Error('cancelled'));
|
||||
}
|
||||
const stopped = source === 'job' ? stopLocalEgressResourcesAsync(logger) : Promise.resolve();
|
||||
const outcome = await Promise.race([
|
||||
Promise.all([stopped, rejected]).then(() => 'stopped'),
|
||||
new Promise(resolve => setImmediate(() => resolve('still waiting'))),
|
||||
]);
|
||||
expect(outcome).toBe('stopped');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ngrok rejection after finalization without an unhandled rejection', async () => {
|
||||
let rejectTunnel!: (error: Error) => void;
|
||||
jest.mocked(startNgrokTunnelAsync).mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTunnel = reject;
|
||||
})
|
||||
);
|
||||
const started = start();
|
||||
const rejected = expect(started).rejects.toThrow();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await Promise.all([stopLocalEgressResourcesAsync(logger), rejected]);
|
||||
rejectTunnel(new Error('late connection failure'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch a server after a download finishes following cancellation', async () => {
|
||||
let finishDownload!: (value: string) => void;
|
||||
jest.mocked(downloadChiselAsync).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishDownload = resolve;
|
||||
})
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const started = start(controller.signal);
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(new Error('cancelled'));
|
||||
await rejected;
|
||||
finishDownload('/tmp/chisel');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(startChiselServerAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
jest
|
||||
.mocked(fs.promises.rm)
|
||||
.mock.calls.filter(([target]) => target === '/tmp/egress-acquisition-test')
|
||||
).toHaveLength(2);
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/egress-acquisition-test', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
const started = new Promise<void>(resolve => {
|
||||
requestStarted = resolve;
|
||||
});
|
||||
const fetchMock = jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer test-token');
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true });
|
||||
requestStarted();
|
||||
});
|
||||
});
|
||||
const graphqlClient = new Client({
|
||||
url: 'https://api.expo.test/graphql',
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-token' } },
|
||||
});
|
||||
try {
|
||||
const pending = markSandboxReadyAsync(
|
||||
{ graphqlClient } as any,
|
||||
'sandbox-id',
|
||||
controller.signal
|
||||
);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await started;
|
||||
controller.abort();
|
||||
await rejected;
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
it('does not mark the sandbox ready if startup was canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest
|
||||
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
|
||||
.mockImplementation(async options => {
|
||||
expect(options.signal).toBe(controller.signal);
|
||||
controller.abort();
|
||||
return { ready: Promise.resolve(), stopAsync };
|
||||
});
|
||||
const mutation = jest.fn();
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
await expect(
|
||||
fn.fn!({ logger: {} } as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
});
|
||||
it('provides one long-running step', () => {
|
||||
const ctx = { job: {} } as any;
|
||||
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
|
||||
});
|
||||
|
||||
it('throws a system error when the sandbox token is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: {},
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('throws a system error when the MCP server URL is missing', async () => {
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
job: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
fn.fn!({} as any, {
|
||||
inputs: { sandbox_id: { value: 'sandbox-id' } },
|
||||
outputs: {},
|
||||
env: {},
|
||||
})
|
||||
).rejects.toBeInstanceOf(SystemError);
|
||||
});
|
||||
|
||||
it('marks the sandbox as ready', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ data: { sandbox: { markSandboxReady: { id: 'sandbox-id' } } } }),
|
||||
}));
|
||||
|
||||
await markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id');
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sandboxId: 'sandbox-id' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a system error when marking the sandbox as ready fails', async () => {
|
||||
const mutation = jest.fn(() => ({
|
||||
toPromise: async () => ({ error: new Error('request failed') }),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
markSandboxReadyAsync({ graphqlClient: { mutation } } as any, 'sandbox-id')
|
||||
).rejects.toThrow('Failed to mark sandbox sandbox-id as ready.');
|
||||
});
|
||||
});
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
])('starts the web preview for %s', async (runtimePlatform, selectsXcode) => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
logger,
|
||||
maxDurationSeconds: 120,
|
||||
signal: undefined,
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getCcacheKeyPrefix,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
@@ -79,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -206,6 +218,67 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Restoring CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [podsDirectory],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match';
|
||||
logger.info(
|
||||
`CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
|
||||
);
|
||||
Datadog.log(`CocoaPods cache restored (${hitType})`, {
|
||||
event: 'cocoapods_cache_restored',
|
||||
cache_hit_type: hitType,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No CocoaPods cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore CocoaPods cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
} from '../../utils/cocoapodsCache';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
@@ -78,11 +83,77 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
} else {
|
||||
await saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveCocoapodsCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EAS_PODS_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
try {
|
||||
await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]);
|
||||
} catch {
|
||||
logger.warn('No CocoaPods installation found, skipping cache save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await spawnAsync('pod', ['--version'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout);
|
||||
logger.info(`Saving CocoaPods cache key: ${key}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing CocoaPods cache...');
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [podsDirectory],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save CocoaPods cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function uploadCacheAsync({
|
||||
archivePath,
|
||||
size,
|
||||
platform,
|
||||
force = false,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
jobId: string;
|
||||
@@ -113,6 +114,7 @@ export async function uploadCacheAsync({
|
||||
archivePath: string;
|
||||
size: number;
|
||||
platform: Platform | undefined;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const routerURL = platform
|
||||
? 'v2/turtle-builds/caches/upload-sessions'
|
||||
@@ -127,12 +129,14 @@ export async function uploadCacheAsync({
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
})
|
||||
: JSON.stringify({
|
||||
jobRunId: jobId,
|
||||
key,
|
||||
version: getCacheVersion(paths),
|
||||
size,
|
||||
force,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
|
||||
@@ -13,6 +13,10 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
@@ -23,9 +27,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -66,7 +69,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -107,30 +110,30 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${serveSim.previewUrl}`);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -163,8 +166,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
@@ -176,7 +179,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,21 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'count',
|
||||
required: false,
|
||||
@@ -86,6 +101,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
@@ -129,6 +147,9 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
@@ -26,9 +30,8 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -62,7 +65,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
@@ -118,7 +121,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -128,20 +131,19 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
@@ -151,7 +153,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -170,8 +173,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
@@ -180,7 +183,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,21 +14,24 @@ import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
ensureFfmpegInstalledAsync,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -79,7 +82,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
@@ -103,32 +106,39 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Argent shells out to ffmpeg to record the screen. Installing it pulls a
|
||||
// large Homebrew dependency tree, so do not block session readiness on it:
|
||||
// the session comes up at its usual speed and only a recording started in
|
||||
// the first moments misses ffmpeg. Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
// recording started in the first moments may miss ffmpeg.
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
|
||||
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
|
||||
// Keep Argent itself in foreground mode under the detached bun process. This preserves
|
||||
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
@@ -186,7 +196,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -199,26 +209,25 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -238,8 +247,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
@@ -255,7 +264,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,7 +306,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bunx resolve it.`
|
||||
`Continuing and letting bun x resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,17 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { minBy } from 'lodash';
|
||||
|
||||
import { configureSimulatorProxyEnvironmentAsync } from '../utils/localEgress';
|
||||
import {
|
||||
installLocalEgressGuardAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../utils/localEgressGuard';
|
||||
|
||||
import {
|
||||
IosSimulatorName,
|
||||
IosSimulatorUtils,
|
||||
@@ -73,9 +81,10 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
const udid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -116,9 +125,10 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
|
||||
const cloneUdid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -140,6 +150,43 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a device with the local egress environment in place before anything
|
||||
* inside it starts. `simctl boot` returns once the simulator's launchd is up
|
||||
* and before it has spawned anything else, which is the only moment at which
|
||||
* launchd environment reaches every process of the boot. The proxy variables
|
||||
* and the guard are set in that gap; then the boot is waited for and the
|
||||
* guard is verified in a fresh process. Nothing here applies when no local
|
||||
* egress session is active.
|
||||
*/
|
||||
async function bootWithLocalEgressAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
const udid = await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier, env });
|
||||
// The guard and proxy variables ride the boot itself, so launchd has them
|
||||
// before it spawns its first process.
|
||||
const launchdEnvironment = await resolveLocalEgressBootEnvironmentAsync();
|
||||
await IosSimulatorUtils.bootAsync({
|
||||
deviceIdentifier: udid,
|
||||
env,
|
||||
launchdEnvironment: launchdEnvironment ?? {},
|
||||
});
|
||||
// Also set them through launchctl, for a device that was already booted.
|
||||
const guardInstalled = await installLocalEgressGuardAsync({ udid, env, logger });
|
||||
await configureSimulatorProxyEnvironmentAsync({ udid, env, logger });
|
||||
await IosSimulatorUtils.startAsync({ deviceIdentifier: udid, env });
|
||||
if (guardInstalled) {
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger });
|
||||
}
|
||||
return udid;
|
||||
}
|
||||
|
||||
async function findMostGenericIphoneUuidAsync({
|
||||
env,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
configureSystemProxyAsync,
|
||||
createChiselAuthfileContents,
|
||||
downloadChiselAsync,
|
||||
generateEgressCredentials,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../utils/localEgress';
|
||||
import { stopLocalEgressGuardRelaysAsync } from '../utils/localEgressGuard';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type NgrokTunnelHandle,
|
||||
findAvailablePortAsync,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
startNgrokTunnelAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* The downloader and ngrok SDK do not accept an AbortSignal. Stop awaiting them
|
||||
* on cancellation, while retaining handlers that dispose any eventual result.
|
||||
*/
|
||||
function awaitLocalEgressAcquisitionAsync<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
disposeAbandonedAsync: (result?: T) => Promise<void>,
|
||||
logger: bunyan
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let abandoned = false;
|
||||
const abort = (): void => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
abandoned = true;
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
void pending
|
||||
.then(
|
||||
async result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync(result);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
async error => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
if (abandoned) {
|
||||
await disposeAbandonedAsync();
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(err =>
|
||||
logger.warn({ err }, 'Could not release a cancelled local egress acquisition.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the device host's system HTTP(S) proxy at the EAS CLI's egress client.
|
||||
* Must run before `eas/start_ios_simulator`: the simulator reads the system proxy
|
||||
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl)
|
||||
* and installs the local egress guard, which refuses connections that ignore
|
||||
* both. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
});
|
||||
// Register before acquisition. Cancellation ends setup promptly; pending
|
||||
// SDK calls retain their own late-result disposal handlers.
|
||||
const lifetimeSignal = registerLocalEgressResources(async () => {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a local egress resource.');
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
fs.promises.rm(LOCAL_EGRESS_HANDOFF_PATH, { force: true }),
|
||||
workDir ? fs.promises.rm(workDir, { recursive: true, force: true }) : undefined,
|
||||
]);
|
||||
});
|
||||
const startupSignal = signal ? AbortSignal.any([signal, lifetimeSignal]) : lifetimeSignal;
|
||||
|
||||
try {
|
||||
startupSignal.throwIfAborted();
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-local-egress-'));
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Downloading the reverse tunnel server (chisel ${CHISEL_VERSION}).`);
|
||||
const downloadDir = workDir;
|
||||
const chiselPath = await awaitLocalEgressAcquisitionAsync(
|
||||
downloadChiselAsync({ destinationDir: downloadDir, logger }),
|
||||
startupSignal,
|
||||
async () => await fs.promises.rm(downloadDir, { recursive: true, force: true }),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const credentials = generateEgressCredentials();
|
||||
const authfilePath = path.join(workDir, 'authfile.json');
|
||||
await fs.promises.writeFile(
|
||||
authfilePath,
|
||||
createChiselAuthfileContents({ ...credentials, port: LOCAL_EGRESS_PROXY_PORT }),
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const controlPort = await findAvailablePortAsync();
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(`Starting the reverse tunnel server on 127.0.0.1:${controlPort}.`);
|
||||
const started = await startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal: startupSignal,
|
||||
});
|
||||
server = started.process;
|
||||
startupSignal.throwIfAborted();
|
||||
tunnel = await awaitLocalEgressAcquisitionAsync(
|
||||
startNgrokTunnelAsync({
|
||||
port: controlPort,
|
||||
subdomainPrefix: 'egress',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
}),
|
||||
startupSignal,
|
||||
async listener => await listener?.stopAsync(),
|
||||
logger
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
signal: startupSignal,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
`Local egress is configured on network service "${service}". HTTP(S) and WebSocket ` +
|
||||
'requests that honor the system proxy (WebKit, URLSession and other CFNetwork clients) ' +
|
||||
'fail until the EAS CLI egress client connects, then exit from that machine. Once the ' +
|
||||
'Simulator boots, proxy environment variables are set inside it for clients that read ' +
|
||||
'them (gRPC, libcurl), and the local egress guard is installed so that connections which ' +
|
||||
'ignore both are refused in the process that makes them and reported here.'
|
||||
);
|
||||
} catch (error) {
|
||||
finishSetup();
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
throw error;
|
||||
} finally {
|
||||
finishSetup();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
const MARK_SANDBOX_READY_MUTATION = graphql(`
|
||||
mutation MarkSandboxReady($sandboxId: ID!) {
|
||||
sandbox {
|
||||
markSandboxReady(sandboxId: $sandboxId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RECONNECT_DELAY_MS = 1_000;
|
||||
const AWAIT_SANDBOX_STEP_NAME = 'Await sandbox completion';
|
||||
|
||||
export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_sandbox',
|
||||
name: 'Start sandbox daemon',
|
||||
__metricsId: 'eas/start_sandbox',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'sandbox_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal }) => {
|
||||
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
|
||||
if (!sandboxToken) {
|
||||
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
|
||||
}
|
||||
const mcpServerUrl = ctx.mcpServerUrl;
|
||||
if (!mcpServerUrl) {
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
reconnectDelayMs: RECONNECT_DELAY_MS,
|
||||
logger: stepCtx.logger,
|
||||
signal,
|
||||
});
|
||||
try {
|
||||
await daemon.ready;
|
||||
signal?.throwIfAborted();
|
||||
await markSandboxReadyAsync(ctx, sandboxId, signal);
|
||||
signal?.throwIfAborted();
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
await waitForCancellationAsync(signal);
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSandboxReadyAsync(
|
||||
ctx: CustomBuildContext,
|
||||
sandboxId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(`Failed to mark sandbox ${sandboxId} as ready.`, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
+25
-14
@@ -6,26 +6,28 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_remote_session',
|
||||
name: 'Start serve-sim remote session',
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
@@ -38,30 +40,39 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
});
|
||||
logger.info(`Preview URL: ${serveSim.previewUrl}`);
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl: serveSim.previewUrl },
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -73,8 +84,8 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await serveSim.stopAsync();
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -90,19 +90,24 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
|
||||
const ascApiKey = z
|
||||
.object({
|
||||
issuer_id: z.string(),
|
||||
issuer_id: z.string().nullish(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(ascApiKeyJson);
|
||||
|
||||
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
|
||||
const token = await new jose.SignJWT({})
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
|
||||
.setIssuer(ascApiKey.issuer_id)
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime('20m')
|
||||
.sign(privateKey);
|
||||
.setExpirationTime('20m');
|
||||
if (ascApiKey.issuer_id) {
|
||||
jwt.setIssuer(ascApiKey.issuer_id);
|
||||
} else {
|
||||
// Nullish issuer_id means an individual API key
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
const token = await jwt.sign(privateKey);
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_NO_PROXY,
|
||||
LOCAL_EGRESS_PROXY_PORT,
|
||||
buildEgressRemoteConfigFields,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
buildNetworksetupProxyArgs,
|
||||
collectSimulatorProcessIds,
|
||||
configureSimulatorProxyEnvironmentAsync,
|
||||
createChiselAuthfileContents,
|
||||
getChiselAssetName,
|
||||
getChiselDownloadUrl,
|
||||
parseChiselFingerprint,
|
||||
parseDefaultRouteInterface,
|
||||
parseDirectSimulatorConnections,
|
||||
parseExitIpResponse,
|
||||
parseNetworkServiceNameForDevice,
|
||||
readLocalEgressHandoffAsync,
|
||||
registerLocalEgressResources,
|
||||
startChiselServerAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
writeLocalEgressHandoffAsync,
|
||||
} from '../localEgress';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({ spawnDetached: jest.fn() }));
|
||||
|
||||
describe(getChiselAssetName, () => {
|
||||
it.each([
|
||||
['darwin', 'arm64', `chisel_${CHISEL_VERSION}_darwin_arm64.gz`],
|
||||
['darwin', 'x64', `chisel_${CHISEL_VERSION}_darwin_amd64.gz`],
|
||||
['linux', 'x64', `chisel_${CHISEL_VERSION}_linux_amd64.gz`],
|
||||
['linux', 'arm64', `chisel_${CHISEL_VERSION}_linux_arm64.gz`],
|
||||
] as const)('maps %s/%s to %s', (platform, arch, expected) => {
|
||||
expect(getChiselAssetName({ platform, arch })).toBe(expected);
|
||||
});
|
||||
|
||||
it('rejects platforms without a pinned binary', () => {
|
||||
expect(() => getChiselAssetName({ platform: 'win32', arch: 'x64' })).toThrow(
|
||||
'Local egress is not supported on win32/x64'
|
||||
);
|
||||
expect(() => getChiselAssetName({ platform: 'linux', arch: 'ia32' })).toThrow(
|
||||
'Local egress is not supported on linux/ia32'
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads from the pinned GitHub release', () => {
|
||||
expect(getChiselDownloadUrl(`chisel_${CHISEL_VERSION}_darwin_arm64.gz`)).toBe(
|
||||
`https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/chisel_${CHISEL_VERSION}_darwin_arm64.gz`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createChiselAuthfileContents, () => {
|
||||
it('allows reverse remotes on the loopback proxy port and unprivileged loopback ports only', () => {
|
||||
const contents = createChiselAuthfileContents({ user: 'eas', password: 'pw', port: 8899 });
|
||||
const parsed = JSON.parse(contents) as Record<string, string[]>;
|
||||
expect(parsed['eas:pw']).toHaveLength(2);
|
||||
expect(parsed['eas:pw'][0]).toBe('^R:127\\.0\\.0\\.1:8899$');
|
||||
|
||||
const patterns = parsed['eas:pw'].map(source => new RegExp(source));
|
||||
const allowed = (remote: string): boolean => patterns.some(pattern => pattern.test(remote));
|
||||
expect(allowed('R:127.0.0.1:8899')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:1024')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:3000')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:8081')).toBe(true);
|
||||
expect(allowed('R:127.0.0.1:65535')).toBe(true);
|
||||
// Privileged and out-of-range ports, other interfaces, and forward remotes stay denied.
|
||||
expect(allowed('R:127.0.0.1:1023')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:80')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:0')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:65536')).toBe(false);
|
||||
expect(allowed('R:127.0.0.1:88990')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:8899')).toBe(false);
|
||||
expect(allowed('R:0.0.0.0:3000')).toBe(false);
|
||||
expect(allowed('R:[::1]:3000')).toBe(false);
|
||||
expect(allowed('R:127x0x0x1:8899')).toBe(false);
|
||||
expect(allowed('R:socks')).toBe(false);
|
||||
expect(allowed('example.com:443')).toBe(false);
|
||||
expect(allowed('127.0.0.1:3000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseChiselFingerprint, () => {
|
||||
it('extracts the fingerprint chisel prints at startup', () => {
|
||||
const output =
|
||||
'2026/09/07 22:00:00 server: Fingerprint eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=\n' +
|
||||
'2026/09/07 22:00:00 server: Reverse tunnelling enabled\n' +
|
||||
'2026/09/07 22:00:00 server: Listening on http://127.0.0.1:52001\n';
|
||||
expect(parseChiselFingerprint(output)).toBe('eZm5d2ZQ+Zb2aS0lJm7A5GkWm9G9ZqnPz2W0i2Y6Q1c=');
|
||||
});
|
||||
|
||||
it('returns null before the fingerprint line appears', () => {
|
||||
expect(parseChiselFingerprint('')).toBeNull();
|
||||
expect(parseChiselFingerprint('2026/09/07 22:00:00 server: starting')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDefaultRouteInterface, () => {
|
||||
it('reads the interface from `route -n get default`', () => {
|
||||
const output =
|
||||
' route to: default\n' +
|
||||
'destination: default\n' +
|
||||
' mask: default\n' +
|
||||
' gateway: 172.20.10.1\n' +
|
||||
' interface: en0\n' +
|
||||
' flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>\n';
|
||||
expect(parseDefaultRouteInterface(output)).toBe('en0');
|
||||
});
|
||||
|
||||
it('returns null when there is no default route', () => {
|
||||
expect(
|
||||
parseDefaultRouteInterface('route: writing to routing socket: not in table\n')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseNetworkServiceNameForDevice, () => {
|
||||
const listOutput =
|
||||
'An asterisk (*) denotes that a network service is disabled.\n' +
|
||||
'(1) Thunderbolt Ethernet Slot 0\n' +
|
||||
'(Hardware Port: Thunderbolt Ethernet Slot 0, Device: en8)\n' +
|
||||
'\n' +
|
||||
'(2) Thunderbolt Bridge\n' +
|
||||
'(Hardware Port: Thunderbolt Bridge, Device: bridge0)\n' +
|
||||
'\n' +
|
||||
'(3) Wi-Fi\n' +
|
||||
'(Hardware Port: Wi-Fi, Device: en0)\n' +
|
||||
'\n' +
|
||||
'(4) iPhone USB\n' +
|
||||
'(Hardware Port: iPhone USB, Device: en9)\n';
|
||||
|
||||
it('returns the service whose device matches', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en0')).toBe('Wi-Fi');
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'en8')).toBe('Thunderbolt Ethernet Slot 0');
|
||||
});
|
||||
|
||||
it('returns null for an unknown device', () => {
|
||||
expect(parseNetworkServiceNameForDevice(listOutput, 'utun3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildNetworksetupProxyArgs, () => {
|
||||
it('sets both the HTTP and HTTPS proxy to the loopback egress port', () => {
|
||||
expect(
|
||||
buildNetworksetupProxyArgs({ service: 'Ethernet', host: '127.0.0.1', port: 8899 })
|
||||
).toEqual([
|
||||
['-setwebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
['-setsecurewebproxy', 'Ethernet', '127.0.0.1', '8899'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectSimulatorProcessIds, () => {
|
||||
const runtimeRoot =
|
||||
'/Library/Developer/CoreSimulator/Volumes/iOS_23F77/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 26.5.simruntime/Contents/Resources/RuntimeRoot';
|
||||
const psOutput =
|
||||
' 1 0 /sbin/launchd\n' +
|
||||
' 501 1 /usr/local/bin/node\n' +
|
||||
` 600 1 ${runtimeRoot}/sbin/launchd_sim\n` +
|
||||
' 610 600 /Users/expo/Library/Developer/CoreSimulator/Devices/ABC/data/Containers/Bundle/Application/DEF/App.app/App\n' +
|
||||
` 611 600 ${runtimeRoot}/System/Library/ExtensionKit/Extensions/NetworkingExtension.appex/com.apple.WebKit.Networking\n` +
|
||||
' 620 610 /usr/bin/helper\n' +
|
||||
' 700 1 /usr/sbin/lsof\n';
|
||||
|
||||
it('returns every descendant of launchd_sim and nothing else', () => {
|
||||
expect(collectSimulatorProcessIds(psOutput).sort((a, b) => a - b)).toEqual([610, 611, 620]);
|
||||
});
|
||||
|
||||
it('returns nothing when no simulator is running', () => {
|
||||
expect(
|
||||
collectSimulatorProcessIds(' 1 0 /sbin/launchd\n 501 1 /usr/local/bin/node\n')
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseDirectSimulatorConnections, () => {
|
||||
const lsofOutput = [
|
||||
'p610',
|
||||
'cApp',
|
||||
'f10',
|
||||
'PTCP',
|
||||
'n127.0.0.1:52344->127.0.0.1:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f11',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52345->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'f12',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52346->104.16.0.1:443',
|
||||
'TST=SYN_SENT',
|
||||
'f13',
|
||||
'PTCP',
|
||||
'n*:8080',
|
||||
'TST=LISTEN',
|
||||
'f14',
|
||||
'PUDP',
|
||||
'n*:5353',
|
||||
'f15',
|
||||
'PUDP',
|
||||
'n192.168.64.2:60000->1.1.1.1:443',
|
||||
'f16',
|
||||
'PTCP',
|
||||
'n[::1]:52347->[::1]:8899',
|
||||
'TST=ESTABLISHED',
|
||||
'f17',
|
||||
'PTCP',
|
||||
'n[fd00::2]:52348->[2606:4700:4700::1111]:443',
|
||||
'TST=ESTABLISHED',
|
||||
'p700',
|
||||
'cnode',
|
||||
'f20',
|
||||
'PTCP',
|
||||
'n192.168.64.2:52349->93.184.216.34:443',
|
||||
'TST=ESTABLISHED',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
it('reports simulator connections to non-loopback peers only', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([610]))).toEqual([
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '104.16.0.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'UDP', remote: '1.1.1.1:443' },
|
||||
{ pid: 610, command: 'App', protocol: 'TCP', remote: '[2606:4700:4700::1111]:443' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores processes outside the simulator', () => {
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([700]))).toEqual([
|
||||
{ pid: 700, command: 'node', protocol: 'TCP', remote: '93.184.216.34:443' },
|
||||
]);
|
||||
expect(parseDirectSimulatorConnections(lsofOutput, new Set([999]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty listing', () => {
|
||||
expect(parseDirectSimulatorConnections('', new Set([610]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressRemoteConfigFields, () => {
|
||||
it('returns nothing without a handoff', () => {
|
||||
expect(buildEgressRemoteConfigFields(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('maps the handoff to the remoteConfig field names', () => {
|
||||
expect(
|
||||
buildEgressRemoteConfigFields({
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
})
|
||||
).toEqual({
|
||||
egressUrl: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
egressToken: 'pw',
|
||||
egressFingerprint: 'fp=',
|
||||
egressPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseExitIpResponse, () => {
|
||||
it('reads the ip field', () => {
|
||||
expect(parseExitIpResponse('{"ip":"64.114.211.44"}')).toBe('64.114.211.44');
|
||||
});
|
||||
|
||||
it('rejects responses without an ip', () => {
|
||||
expect(() => parseExitIpResponse('{"error":"rate limited"}')).toThrow(
|
||||
'Unexpected exit IP response'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress handoff', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('round-trips through the handoff file', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
const handoff = {
|
||||
url: 'https://egress-abc.eas-simulator.ngrok.dev',
|
||||
token: 'pw',
|
||||
fingerprint: 'fp=',
|
||||
port: 8899,
|
||||
};
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).resolves.toEqual(handoff);
|
||||
});
|
||||
|
||||
it('returns null when no egress step ran', async () => {
|
||||
await expect(
|
||||
readLocalEgressHandoffAsync(path.join(tempDir, 'missing.json'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed handoff', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
await expect(readLocalEgressHandoffAsync(handoffPath)).rejects.toThrow('malformed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildLocalEgressSimulatorEnvironment, () => {
|
||||
it('points every proxy variable at the loopback port and excludes loopback', () => {
|
||||
expect(buildLocalEgressSimulatorEnvironment(8899)).toEqual({
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(configureSimulatorProxyEnvironmentAsync, () => {
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
let tempDir: string;
|
||||
let logger: bunyan;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'local-egress-env-test-'));
|
||||
logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing when no local egress session is active', async () => {
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(tempDir, 'missing.json'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets the proxy environment in the simulator when a handoff exists', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mockedSpawn.mock.calls.map(([, args]) => args)).toEqual([
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
...Object.entries(buildLocalEgressSimulatorEnvironment(8899)).flat(),
|
||||
],
|
||||
]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('proxy environment variables set')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when launchctl fails', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
mockedSpawn.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('will bypass local egress and exit from this worker')
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and continues when the handoff is malformed', async () => {
|
||||
const handoffPath = path.join(tempDir, 'handoff.json');
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify({ url: 'x' }), 'utf8');
|
||||
|
||||
await expect(
|
||||
configureSimulatorProxyEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
expect.stringContaining('could not read the local egress handoff')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startChiselServerAsync, () => {
|
||||
const options = {
|
||||
chiselPath: '/tmp/chisel',
|
||||
controlPort: 52001,
|
||||
authfilePath: '/tmp/authfile.json',
|
||||
env: { PATH: '/bin', AUTH: 'unexpected:password' },
|
||||
};
|
||||
let output: string;
|
||||
let stopAsync: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
output = 'server: Fingerprint pinned-key=\n';
|
||||
stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for the child to bind its exact port and clears inherited AUTH', async () => {
|
||||
let resolved = false;
|
||||
const started = startChiselServerAsync(options).then(result => {
|
||||
resolved = true;
|
||||
return result;
|
||||
});
|
||||
output += 'server: Listening on http://127.0.0.1:520010\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(resolved).toBe(false);
|
||||
output += 'server: Listening on http://127.0.0.1:52001\n';
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await expect(started).resolves.toMatchObject({ fingerprint: 'pinned-key=' });
|
||||
expect(spawnDetached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: { PATH: '/bin', AUTH: '' } })
|
||||
);
|
||||
expect(options.env.AUTH).toBe('unexpected:password');
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
|
||||
const started = startChiselServerAsync(options);
|
||||
const rejected = expect(started).rejects.toThrow('address already in use');
|
||||
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
|
||||
jest.mocked(process.kill).mockImplementation(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops the child when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
const started = startChiselServerAsync({ ...options, signal: controller.signal });
|
||||
const rejected = expect(started).rejects.toThrow('cancelled');
|
||||
controller.abort(new Error('cancelled'));
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await rejected;
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress resource lifetime', () => {
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
it('aborts the lifetime and awaits the same cleanup for concurrent callers', async () => {
|
||||
let finish!: () => void;
|
||||
const cleanup = jest.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve;
|
||||
})
|
||||
);
|
||||
const signal = registerLocalEgressResources(cleanup);
|
||||
const first = stopLocalEgressResourcesAsync(logger);
|
||||
const second = stopLocalEgressResourcesAsync(logger);
|
||||
expect(signal.aborted).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
let complete = false;
|
||||
void second.then(() => {
|
||||
complete = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(complete).toBe(false);
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports cleanup failure without masking the job result', async () => {
|
||||
const error = new Error('cleanup failed');
|
||||
registerLocalEgressResources(async () => {
|
||||
throw error;
|
||||
});
|
||||
await expect(stopLocalEgressResourcesAsync(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop a local egress resource.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* End-to-end test of the local egress guard against a real iOS Simulator.
|
||||
* Needs Xcode with a simulator runtime, a shut-down iPhone simulator, and
|
||||
* network access. Opt in with EAS_LOCAL_EGRESS_E2E=1; the test-egress-guard
|
||||
* EAS workflow runs it on macOS.
|
||||
*
|
||||
* Variables handed to launchd at boot cannot be changed afterwards with
|
||||
* `launchctl setenv`, so each configuration gets its own boot.
|
||||
*/
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import dgram from 'node:dgram';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
import {
|
||||
type GuardEvent,
|
||||
installLocalEgressGuardAsync,
|
||||
parseGuardLogLine,
|
||||
reportLocalEgressGuardCoverageAsync,
|
||||
resolveEgressGuardCheckAsync,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.setTimeout(15 * 60 * 1000);
|
||||
|
||||
const enabled = process.platform === 'darwin' && process.env.EAS_LOCAL_EGRESS_E2E === '1';
|
||||
const describeE2E = enabled ? describe : describe.skip;
|
||||
|
||||
const GUARD_DIR = path.join(__dirname, '..', '..', '..', '..', 'resources', 'egress-guard');
|
||||
const env = process.env;
|
||||
|
||||
function createLogger(): bunyan & { lines: string[] } {
|
||||
const lines: string[] = [];
|
||||
const record = (a: unknown, b?: unknown) => {
|
||||
lines.push(typeof a === 'string' ? a : String(b));
|
||||
};
|
||||
return { info: record, warn: record, debug: record, lines } as any;
|
||||
}
|
||||
|
||||
async function readEventsAsync(logPath: string): Promise<GuardEvent[]> {
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await fs.promises.readFile(logPath, 'utf8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split('\n')
|
||||
.map(parseGuardLogLine)
|
||||
.filter((e): e is GuardEvent => e !== null);
|
||||
}
|
||||
|
||||
async function waitForAsync<T>(
|
||||
probe: () => Promise<T | undefined>,
|
||||
{ timeoutMs, intervalMs = 1000 }: { timeoutMs: number; intervalMs?: number }
|
||||
): Promise<T | undefined> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const value = await probe();
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describeE2E('local egress guard in a simulator', () => {
|
||||
let workDir: string;
|
||||
let udid: IosSimulatorUuid;
|
||||
let libraryPath: string;
|
||||
let checkPath: string;
|
||||
let nettestPath: string;
|
||||
let handoffPath: string;
|
||||
let proxyServer: http.Server;
|
||||
let proxyPort: number;
|
||||
let udpServer: dgram.Socket;
|
||||
let udpPort: number;
|
||||
const logger = createLogger();
|
||||
|
||||
/** Shut the device down and boot it again with the given launchd environment. */
|
||||
async function rebootAsync(launchdEnvironment: Record<string, string>): Promise<void> {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', udid], { env, stdio: 'pipe' }).catch(() => {});
|
||||
await IosSimulatorUtils.bootAsync({ deviceIdentifier: udid, env, launchdEnvironment });
|
||||
await IosSimulatorUtils.startAsync({ deviceIdentifier: udid, env });
|
||||
}
|
||||
|
||||
async function bootEnvironmentAsync(
|
||||
mode: 'block' | 'log',
|
||||
logPath: string
|
||||
): Promise<Record<string, string>> {
|
||||
const variables = await resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode,
|
||||
});
|
||||
if (!variables) {
|
||||
throw new Error('The handoff was not written.');
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
async function runNettestAsync(): Promise<Record<string, string>> {
|
||||
const result = await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
nettestPath,
|
||||
'--proxy-port',
|
||||
String(proxyPort),
|
||||
'--udp-port',
|
||||
String(udpPort),
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
const last = result.stdout.trim().split('\n').at(-1) ?? '{}';
|
||||
return JSON.parse(last);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'egress-guard-e2e-'));
|
||||
|
||||
// The guard library and self-check, built on demand.
|
||||
let resolved = await resolveEgressGuardLibraryAsync();
|
||||
if (!resolved) {
|
||||
await spawn('bash', [path.join(GUARD_DIR, 'build.sh')], { env, stdio: 'pipe' });
|
||||
resolved = await resolveEgressGuardLibraryAsync();
|
||||
}
|
||||
if (!resolved) {
|
||||
throw new Error('egress-guard.dylib is not available and could not be built.');
|
||||
}
|
||||
libraryPath = resolved;
|
||||
const resolvedCheck = await resolveEgressGuardCheckAsync();
|
||||
if (!resolvedCheck) {
|
||||
throw new Error('egress-guard-check is not available and could not be built.');
|
||||
}
|
||||
checkPath = resolvedCheck;
|
||||
|
||||
// The probe binary, compiled for the simulator running on this host.
|
||||
nettestPath = path.join(workDir, 'nettest');
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'-sdk',
|
||||
'iphonesimulator',
|
||||
'swiftc',
|
||||
'-target',
|
||||
`${process.arch === 'arm64' ? 'arm64' : 'x86_64'}-apple-ios15.0-simulator`,
|
||||
path.join(GUARD_DIR, 'tests', 'nettest.swift'),
|
||||
'-o',
|
||||
nettestPath,
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
|
||||
handoffPath = path.join(workDir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{ url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 },
|
||||
handoffPath
|
||||
);
|
||||
|
||||
// A shut-down iPhone the test can boot as many times as it needs. A device
|
||||
// someone else booted is left alone. Nothing is ever deleted.
|
||||
const booted = new Set(
|
||||
(await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'booted' })).map(d => d.udid)
|
||||
);
|
||||
const available = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
const iphone =
|
||||
available.find(d => /^iPhone \d/.test(d.name) && !booted.has(d.udid)) ??
|
||||
available.find(d => /^iPhone/.test(d.name) && !booted.has(d.udid));
|
||||
if (!iphone) {
|
||||
throw new Error('No shut-down iPhone simulator is available for the test to boot.');
|
||||
}
|
||||
udid = iphone.udid;
|
||||
|
||||
// Loopback stand-ins for the egress proxy and for a local UDP service.
|
||||
proxyServer = http.createServer((_req, res) => {
|
||||
res.writeHead(502);
|
||||
res.end();
|
||||
});
|
||||
proxyServer.on('connect', (req, socket, head) => {
|
||||
const [host, port] = String(req.url).split(':');
|
||||
const upstream = net.connect(Number(port) || 443, host, () => {
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head.length) {
|
||||
upstream.write(new Uint8Array(head));
|
||||
}
|
||||
upstream.pipe(socket);
|
||||
socket.pipe(upstream);
|
||||
});
|
||||
upstream.on('error', () => socket.destroy());
|
||||
socket.on('error', () => upstream.destroy());
|
||||
});
|
||||
await new Promise<void>(resolve => proxyServer.listen(0, '127.0.0.1', () => resolve()));
|
||||
proxyPort = (proxyServer.address() as net.AddressInfo).port;
|
||||
|
||||
udpServer = dgram.createSocket('udp4');
|
||||
udpServer.on('message', (msg, rinfo) =>
|
||||
udpServer.send(new Uint8Array(msg), rinfo.port, rinfo.address)
|
||||
);
|
||||
await new Promise<void>(resolve => udpServer.bind(0, '127.0.0.1', () => resolve()));
|
||||
udpPort = udpServer.address().port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
proxyServer?.close();
|
||||
udpServer?.close();
|
||||
if (udid) {
|
||||
await spawn('xcrun', ['simctl', 'shutdown', udid], { env, stdio: 'pipe' }).catch(() => {});
|
||||
}
|
||||
if (workDir) {
|
||||
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('booted with the guard in launchd, as the worker boots', () => {
|
||||
let logPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
logPath = path.join(workDir, 'boot.log');
|
||||
await rebootAsync(await bootEnvironmentAsync('block', logPath));
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
});
|
||||
|
||||
it('covers every process of the boot', async () => {
|
||||
const coverage = await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
expect(coverage).not.toBeNull();
|
||||
expect(coverage!.covered.length).toBeGreaterThan(50);
|
||||
expect(coverage!.uncovered).toEqual([]);
|
||||
// Refusals were recorded by processes that started during the boot, before
|
||||
// any launchctl call could have reached them.
|
||||
expect((await readEventsAsync(logPath)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('refuses every direct path and leaves loopback and DNS alone', async () => {
|
||||
const results = await runNettestAsync();
|
||||
|
||||
expect(results).toMatchObject({
|
||||
urlsessionDirect: 'refused',
|
||||
urlsessionProxied: 'ok(200)',
|
||||
bsdConnectDirect: 'refused',
|
||||
dns: 'ok',
|
||||
udpDirect: 'refused',
|
||||
udpLoopback: 'ok',
|
||||
literalFirst: 'refused',
|
||||
literalSecond: 'refused',
|
||||
unspecConnect: 'refused',
|
||||
udpDisconnect: 'ok',
|
||||
nocancelSendto: 'refused',
|
||||
});
|
||||
expect(results.nwconnectionDirect).not.toMatch(/^ok|^timeout/);
|
||||
|
||||
const events = (await readEventsAsync(logPath)).filter(e => e.process === 'nettest');
|
||||
expect(events.length).toBeGreaterThanOrEqual(4);
|
||||
expect(events.every(e => e.action === 'blocked')).toBe(true);
|
||||
// URLSession and Network.framework connects are caught inside Apple's frameworks.
|
||||
expect(
|
||||
events.some(e => e.function === 'connect' && e.callers.some(c => /CFNetwork/.test(c)))
|
||||
).toBe(true);
|
||||
expect(
|
||||
events.some(
|
||||
e =>
|
||||
e.function === 'connect' &&
|
||||
e.callers.some(c => /Network/.test(c) && !/CFNetwork/.test(c))
|
||||
)
|
||||
).toBe(true);
|
||||
// UDP is covered, through the cancelable and non-cancelable entry points.
|
||||
expect(events.some(e => e.function === 'sendto' && e.peer === '8.8.8.8:53')).toBe(true);
|
||||
// An AF_UNSPEC sockaddr is recorded as what the kernel would have used.
|
||||
expect(events.some(e => e.function === 'connect' && e.peer === '1.0.0.1:443')).toBe(true);
|
||||
// The callers name the process and frameworks, never the guard itself.
|
||||
expect(events.every(e => !e.callers.some(c => /egress-guard/.test(c)))).toBe(true);
|
||||
// One line per destination per process, even when the process retries.
|
||||
expect(events.filter(e => e.function === 'connect' && e.peer === '1.1.1.1:443')).toHaveLength(
|
||||
1
|
||||
);
|
||||
// Nothing on loopback is ever reported.
|
||||
expect(events.some(e => /^127\./.test(e.peer))).toBe(false);
|
||||
});
|
||||
|
||||
it('guards processes the simulator launches on its own', async () => {
|
||||
await spawn('xcrun', ['simctl', 'openurl', udid, 'https://example.net/'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const daemonEvent = await waitForAsync(
|
||||
async () =>
|
||||
(await readEventsAsync(logPath)).find(
|
||||
e => e.process !== 'nettest' && e.action === 'blocked'
|
||||
),
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
expect(daemonEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not interfere with the simulator tooling the worker relies on', async () => {
|
||||
const screenshot = path.join(workDir, 'screenshot.png');
|
||||
for (const args of [
|
||||
['simctl', 'spawn', udid, 'launchctl', 'list'],
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
udid,
|
||||
'defaults',
|
||||
'write',
|
||||
'dev.expo.egress-guard-test',
|
||||
'key',
|
||||
'-string',
|
||||
'value',
|
||||
],
|
||||
['simctl', 'spawn', udid, '/usr/bin/env'],
|
||||
['simctl', 'launch', udid, 'com.apple.Preferences'],
|
||||
['simctl', 'io', udid, 'screenshot', screenshot],
|
||||
]) {
|
||||
await expect(spawn('xcrun', args, { env, stdio: 'pipe' })).resolves.toBeDefined();
|
||||
}
|
||||
expect(fs.existsSync(screenshot)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted in log mode', () => {
|
||||
let logPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
logPath = path.join(workDir, 'log-mode.log');
|
||||
await rebootAsync(await bootEnvironmentAsync('log', logPath));
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'log', checkPath });
|
||||
});
|
||||
|
||||
it('observes without refusing', async () => {
|
||||
const results = await runNettestAsync();
|
||||
|
||||
expect(results.urlsessionDirect).toBe('ok(200)');
|
||||
expect(results.bsdConnectDirect).toBe('ok');
|
||||
expect(results.udpDirect).toBe('sent');
|
||||
const events = (await readEventsAsync(logPath)).filter(e => e.process === 'nettest');
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events.every(e => e.action === 'logged')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted without the guard, as a device that was already running', () => {
|
||||
beforeAll(async () => {
|
||||
await rebootAsync({});
|
||||
});
|
||||
|
||||
it('is reported as not in effect by the self-check', async () => {
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath })
|
||||
).rejects.toThrow(/not in effect/);
|
||||
});
|
||||
|
||||
it('can be installed after boot through launchctl for processes started from then on', async () => {
|
||||
const logPath = path.join(workDir, 'install.log');
|
||||
expect(
|
||||
await installLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode: 'block',
|
||||
tailIntervalMs: 100,
|
||||
})
|
||||
).toBe(true);
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
|
||||
const results = await runNettestAsync();
|
||||
expect(results.urlsessionDirect).toBe('refused');
|
||||
expect(results.bsdConnectDirect).toBe('refused');
|
||||
|
||||
// The relay turns the guard's events into session log lines.
|
||||
const relayed = await waitForAsync(
|
||||
async () =>
|
||||
logger.lines.find(l =>
|
||||
/refused connect from nettest \(pid \d+\) to 1\.1\.1\.1:443/.test(l)
|
||||
),
|
||||
{ timeoutMs: 5_000, intervalMs: 200 }
|
||||
);
|
||||
expect(relayed).toBeDefined();
|
||||
|
||||
// Processes from before the install are the uncovered set the coverage
|
||||
// report names; nettest itself was covered.
|
||||
const coverage = await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
expect(coverage!.uncovered.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('booted with an unwritable event log', () => {
|
||||
beforeAll(async () => {
|
||||
await rebootAsync(
|
||||
await bootEnvironmentAsync('block', path.join(workDir, 'no-such-dir', 'guard.log'))
|
||||
);
|
||||
});
|
||||
|
||||
it('still refuses', async () => {
|
||||
await verifyLocalEgressGuardAsync({ udid, env, logger, mode: 'block', checkPath });
|
||||
const results = await runNettestAsync();
|
||||
expect(results.urlsessionDirect).toBe('refused');
|
||||
expect(results.bsdConnectDirect).toBe('refused');
|
||||
expect(results.urlsessionProxied).toBe('ok(200)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
import {
|
||||
EGRESS_GUARD_LOG_ENV,
|
||||
EGRESS_GUARD_MODE_ENV,
|
||||
GuardEventRelay,
|
||||
GuardLogTailer,
|
||||
buildGuardLaunchdEnvironment,
|
||||
installLocalEgressGuardAsync,
|
||||
mergeGuardCoverageSamples,
|
||||
parseGuardCoverage,
|
||||
parseGuardLogLine,
|
||||
rebindLocalEgressGuardRelays,
|
||||
resolveEgressGuardLibraryAsync,
|
||||
resolveLocalEgressBootEnvironmentAsync,
|
||||
stopLocalEgressGuardRelaysAsync,
|
||||
verifyLocalEgressGuardAsync,
|
||||
} from '../localEgressGuard';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { setLaunchdEnvironmentAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockedSetEnv = jest.mocked(IosSimulatorUtils.setLaunchdEnvironmentAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
function createLogger(): bunyan & { lines: { level: string; msg: string }[] } {
|
||||
const lines: { level: string; msg: string }[] = [];
|
||||
const record = (level: string) => (a: unknown, b?: unknown) =>
|
||||
lines.push({ level, msg: typeof a === 'string' ? a : String(b) });
|
||||
return { info: record('info'), warn: record('warn'), debug: record('debug'), lines } as any;
|
||||
}
|
||||
|
||||
const handoff = { url: 'https://egress.example', token: 'pw', fingerprint: 'fp=', port: 8899 };
|
||||
|
||||
describe(buildGuardLaunchdEnvironment, () => {
|
||||
it('inserts the library and passes the log path and mode', () => {
|
||||
expect(
|
||||
buildGuardLaunchdEnvironment({
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
logPath: '/tmp/guard.log',
|
||||
mode: 'block',
|
||||
})
|
||||
).toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
[EGRESS_GUARD_LOG_ENV]: '/tmp/guard.log',
|
||||
[EGRESS_GUARD_MODE_ENV]: 'block',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardLogLine, () => {
|
||||
it('parses a blocked event with callers', () => {
|
||||
expect(
|
||||
parseGuardLogLine(
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t93.184.216.34:443\tNetwork,CFNetwork,MyApp'
|
||||
)
|
||||
).toEqual({
|
||||
process: 'MyApp',
|
||||
pid: 4242,
|
||||
function: 'connect',
|
||||
action: 'blocked',
|
||||
peer: '93.184.216.34:443',
|
||||
callers: ['Network', 'CFNetwork', 'MyApp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a logged event without callers and IPv6 peers', () => {
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tdaemon\t7\tsendto\tlogged\t[2606:4700::1]:53\t')
|
||||
).toEqual({
|
||||
process: 'daemon',
|
||||
pid: 7,
|
||||
function: 'sendto',
|
||||
action: 'logged',
|
||||
peer: '[2606:4700::1]:53',
|
||||
callers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores lines that are not guard events', () => {
|
||||
expect(parseGuardLogLine('')).toBeNull();
|
||||
expect(parseGuardLogLine('something else\ta\tb')).toBeNull();
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tMyApp\tnot-a-pid\tconnect\tblocked\t1.1.1.1:443\t')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseGuardLogLine('eas-egress-guard\tMyApp\t1\tconnect\tmaybe\t1.1.1.1:443\t')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(GuardEventRelay, () => {
|
||||
const blocked = (process: string, peer: string, pid = 1) => ({
|
||||
process,
|
||||
pid,
|
||||
function: 'connect',
|
||||
action: 'blocked' as const,
|
||||
peer,
|
||||
callers: ['Network', 'CFNetwork'],
|
||||
});
|
||||
|
||||
it('logs the first sighting of a process and destination, then only counts', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443', 2));
|
||||
relay.handle(blocked('MyApp', '2.2.2.2:443'));
|
||||
expect(logger.lines.map(l => l.msg)).toEqual([
|
||||
expect.stringContaining(
|
||||
'refused connect from MyApp (pid 1) to 1.1.1.1:443; callers: Network, CFNetwork'
|
||||
),
|
||||
expect.stringContaining('refused connect from MyApp (pid 1) to 2.2.2.2:443'),
|
||||
]);
|
||||
expect(relay.summary()).toEqual({ blocked: 3, logged: 0, distinct: 2, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('words observed events differently and stops logging past the limit', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger, /* limit */ 1);
|
||||
relay.handle({ ...blocked('MyApp', '1.1.1.1:443'), action: 'logged' });
|
||||
relay.handle({ ...blocked('MyApp', '2.2.2.2:443'), action: 'logged' });
|
||||
expect(logger.lines).toHaveLength(1);
|
||||
expect(logger.lines[0].msg).toContain('observed connect from MyApp');
|
||||
expect(relay.summary()).toEqual({ blocked: 0, logged: 2, distinct: 2, suppressed: 1 });
|
||||
});
|
||||
|
||||
it('explains a process that filled its table instead of counting it as a destination', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle({
|
||||
...blocked('MyApp', '128 distinct destinations'),
|
||||
function: 'overflow',
|
||||
callers: [],
|
||||
});
|
||||
expect(logger.lines.at(-1)?.msg).toContain(
|
||||
'MyApp (pid 1) reached the limit of 128 distinct destinations listed per process; further distinct destinations from it are refused but not listed'
|
||||
);
|
||||
expect(relay.summary()).toEqual({ blocked: 1, logged: 0, distinct: 1, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('ignores the self-check probe, which trips the guard on purpose', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('egress-guard-check', '192.0.2.1:9'));
|
||||
expect(logger.lines).toHaveLength(0);
|
||||
expect(relay.summary()).toEqual({ blocked: 0, logged: 0, distinct: 0, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('writes a summary line', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('MyApp', '1.1.1.1:443'));
|
||||
relay.handle(blocked('daemon', '3.3.3.3:443', 9));
|
||||
relay.logSummary();
|
||||
expect(logger.lines.at(-1)?.msg).toContain(
|
||||
'refused 3 connection attempt(s) to 2 distinct destination(s) from 2 process(es)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(GuardLogTailer, () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-tailer-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('delivers whole lines as they are appended, keeps partial lines, and survives truncation', async () => {
|
||||
const file = path.join(dir, 'guard.log');
|
||||
const lines: string[] = [];
|
||||
const tailer = new GuardLogTailer({ path: file, onLine: l => lines.push(l), intervalMs: 20 });
|
||||
tailer.start();
|
||||
await sleep(50); // file does not exist yet; must not throw
|
||||
await fs.promises.writeFile(file, 'first\nsecond\npart', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second']);
|
||||
await fs.promises.appendFile(file, 'ial\n', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second', 'partial']);
|
||||
await fs.promises.writeFile(file, 'after-truncate\n', 'utf8');
|
||||
await sleep(80);
|
||||
expect(lines).toEqual(['first', 'second', 'partial', 'after-truncate']);
|
||||
await tailer.stopAsync();
|
||||
await fs.promises.appendFile(file, 'late\n', 'utf8');
|
||||
await sleep(60);
|
||||
expect(lines).not.toContain('late');
|
||||
});
|
||||
|
||||
it('flushes what is already in the file when stopped', async () => {
|
||||
const file = path.join(dir, 'guard.log');
|
||||
const lines: string[] = [];
|
||||
const tailer = new GuardLogTailer({
|
||||
path: file,
|
||||
onLine: l => lines.push(l),
|
||||
intervalMs: 10_000,
|
||||
});
|
||||
tailer.start();
|
||||
await fs.promises.writeFile(file, 'only\n', 'utf8');
|
||||
await tailer.stopAsync();
|
||||
expect(lines).toEqual(['only']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveEgressGuardLibraryAsync, () => {
|
||||
it('returns null when the library is not built', async () => {
|
||||
await expect(
|
||||
resolveEgressGuardLibraryAsync(path.join(os.tmpdir(), 'no-such-dir'))
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns the packaged path when present', async () => {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-bin-'));
|
||||
const lib = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(lib, '');
|
||||
await expect(resolveEgressGuardLibraryAsync(dir)).resolves.toBe(lib);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe(installLocalEgressGuardAsync, () => {
|
||||
let dir: string;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
beforeEach(async () => {
|
||||
mockedSetEnv.mockReset();
|
||||
mockedSetEnv.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-install-'));
|
||||
logger = createLogger();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does nothing without a local egress session', async () => {
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath: path.join(dir, 'missing.json'),
|
||||
libraryPath: path.join(dir, 'egress-guard.dylib'),
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the session when the library is not available', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath: null,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
expect(mockedSetEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the simulator processes that were already running and are not covered', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 4000 1 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/sbin/launchd_sim',
|
||||
' 4001 4000 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
' 4002 4000 /Library/Developer/CoreSimulator/Volumes/iOS/Runtimes/x/usr/libexec/backboardd',
|
||||
' 3758 1 node',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
});
|
||||
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/2 simulator process\(es\) were already running .* not covered by it: SpringBoard, backboardd\./.test(
|
||||
l.msg
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('sets the launchd environment, creates the log file, and relays events into the session log', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode: 'block',
|
||||
tailIntervalMs: 20,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mockedSetEnv).toHaveBeenCalledWith({
|
||||
udid: 'u',
|
||||
env: process.env,
|
||||
variables: buildGuardLaunchdEnvironment({ libraryPath, logPath, mode: 'block' }),
|
||||
});
|
||||
expect(fs.existsSync(logPath)).toBe(true);
|
||||
expect(logger.lines.some(l => /guard installed/.test(l.msg) && /block/.test(l.msg))).toBe(true);
|
||||
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await sleep(80);
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/refused connect from MyApp \(pid 4242\) to 1\.1\.1\.1:443/.test(l.msg)
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(logger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('attributes later events and the summary to a rebound logger', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
const logPath = path.join(dir, 'guard.log');
|
||||
await installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath,
|
||||
tailIntervalMs: 20,
|
||||
});
|
||||
|
||||
const sessionLogger = createLogger();
|
||||
rebindLocalEgressGuardRelays(sessionLogger);
|
||||
await fs.promises.appendFile(
|
||||
logPath,
|
||||
'eas-egress-guard\tMyApp\t4242\tconnect\tblocked\t1.1.1.1:443\tNetwork\n'
|
||||
);
|
||||
await sleep(80);
|
||||
expect(sessionLogger.lines.some(l => /refused connect from MyApp/.test(l.msg))).toBe(true);
|
||||
expect(logger.lines.some(l => /refused connect from MyApp/.test(l.msg))).toBe(false);
|
||||
|
||||
await stopLocalEgressGuardRelaysAsync(logger);
|
||||
expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)');
|
||||
});
|
||||
|
||||
it('fails the session when launchctl fails', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
const libraryPath = path.join(dir, 'egress-guard.dylib');
|
||||
await fs.promises.writeFile(libraryPath, '');
|
||||
mockedSetEnv.mockRejectedValue(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
installLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
handoffPath,
|
||||
libraryPath,
|
||||
logPath: path.join(dir, 'guard.log'),
|
||||
})
|
||||
).rejects.toThrow(/Could not install the local egress guard/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveLocalEgressBootEnvironmentAsync, () => {
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-bootenv-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null without a local egress session', async () => {
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({ handoffPath: path.join(dir, 'missing.json') })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('fails when the guard library is not available', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync(handoff, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({ handoffPath, libraryPath: null })
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('combines the guard variables with the proxy variables for the handoff port', async () => {
|
||||
const handoffPath = path.join(dir, 'handoff.json');
|
||||
await writeLocalEgressHandoffAsync({ ...handoff, port: 8899 }, handoffPath);
|
||||
await expect(
|
||||
resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath,
|
||||
libraryPath: '/w/bin/egress-guard.dylib',
|
||||
logPath: '/tmp/guard.log',
|
||||
mode: 'block',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
DYLD_INSERT_LIBRARIES: '/w/bin/egress-guard.dylib',
|
||||
[EGRESS_GUARD_LOG_ENV]: '/tmp/guard.log',
|
||||
[EGRESS_GUARD_MODE_ENV]: 'block',
|
||||
http_proxy: 'http://127.0.0.1:8899',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
HTTP_PROXY: 'http://127.0.0.1:8899',
|
||||
HTTPS_PROXY: 'http://127.0.0.1:8899',
|
||||
grpc_proxy: 'http://127.0.0.1:8899',
|
||||
no_proxy: 'localhost,127.0.0.1,::1',
|
||||
NO_PROXY: 'localhost,127.0.0.1,::1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseGuardCoverage, () => {
|
||||
const ps = [
|
||||
' 1 0 /sbin/launchd',
|
||||
' 4000 1 /Runtimes/x/sbin/launchd_sim',
|
||||
' 4001 4000 /Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
' 4002 4000 /Runtimes/x/usr/libexec/backboardd',
|
||||
' 4003 4001 /Containers/Bundle/Application/1/Expo Go.app/Expo Go',
|
||||
' 3758 1 node',
|
||||
].join('\n');
|
||||
const lsof = [
|
||||
'p4001',
|
||||
'n/Runtimes/x/System/Library/CoreServices/SpringBoard.app/SpringBoard',
|
||||
'n/usr/lib/dyld',
|
||||
'p4003',
|
||||
'n/Containers/Bundle/Application/1/Expo Go.app/Expo Go',
|
||||
'n/Users/expo/build-tools/bin/egress-guard.dylib',
|
||||
'p3758',
|
||||
'n/usr/local/bin/node',
|
||||
].join('\n');
|
||||
|
||||
it('splits simulator processes by whether the guard library is mapped', () => {
|
||||
expect(parseGuardCoverage(ps, lsof)).toEqual({
|
||||
covered: ['Expo Go'],
|
||||
uncovered: ['SpringBoard', 'backboardd'],
|
||||
uncoveredPids: [4001, 4002],
|
||||
});
|
||||
});
|
||||
|
||||
it('only keeps processes uncovered in both samples, so exec-in-progress ones drop out', () => {
|
||||
const first = parseGuardCoverage(ps, lsof);
|
||||
const secondPs = ps + '\n 4009 4000 /Runtimes/x/usr/libexec/newlystarted';
|
||||
const second = parseGuardCoverage(
|
||||
secondPs,
|
||||
lsof + '\np4002\nn/Users/expo/bin/egress-guard.dylib'
|
||||
);
|
||||
expect(mergeGuardCoverageSamples(first, second)).toEqual({
|
||||
covered: ['Expo Go', 'backboardd'],
|
||||
uncovered: ['SpringBoard'],
|
||||
uncoveredPids: [4001],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores launchd's exec trampoline, which has nothing mapped yet", () => {
|
||||
const withTrampoline = ps + '\n 4004 4000 /Runtimes/x/usr/libexec/xpcproxy_sim';
|
||||
expect(parseGuardCoverage(withTrampoline, lsof).uncovered).toEqual([
|
||||
'SpringBoard',
|
||||
'backboardd',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(verifyLocalEgressGuardAsync, () => {
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
logger = createLogger();
|
||||
});
|
||||
|
||||
it('runs the packaged self-check inside the simulator and logs its verdict', async () => {
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'egress-guard-check: guard loaded; non-loopback connections are refused\n',
|
||||
stderr: '',
|
||||
} as any)
|
||||
.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await verifyLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'u', '/w/bin/egress-guard-check', '--mode', 'block'],
|
||||
expect.objectContaining({ stdio: 'pipe' })
|
||||
);
|
||||
expect(
|
||||
logger.lines.some(l =>
|
||||
/verified in the Simulator: egress-guard-check: guard loaded/.test(l.msg)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails the session with the self-check output when the check fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exited with non-zero code: 2'), {
|
||||
status: 2,
|
||||
stdout: 'egress-guard-check: the guard library is not loaded in this process\n',
|
||||
stderr: '',
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({
|
||||
udid: 'u' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
checkPath: '/w/bin/egress-guard-check',
|
||||
})
|
||||
).rejects.toThrow(/not in effect .*\(exit 2\).*guard library is not loaded/);
|
||||
});
|
||||
|
||||
it('fails the session when the self-check binary is missing', async () => {
|
||||
await expect(
|
||||
verifyLocalEgressGuardAsync({ udid: 'u' as any, env: process.env, logger, checkPath: null })
|
||||
).rejects.toThrow(/self-check is not available/);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,16 +11,20 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
ensureFfmpegInstalledAsync,
|
||||
fetchServeSimTurnArgsAsync,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForServeSimReadyAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -30,6 +34,11 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
return { ...actual, readServeSimServersAsync: jest.fn(actual.readServeSimServersAsync) };
|
||||
});
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
@@ -127,12 +136,13 @@ describe(createServeSimArgs, () => {
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'--max-dimension',
|
||||
'1280',
|
||||
'960',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
@@ -173,6 +183,49 @@ describe(createServeSimArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'expo-device-hub@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'[email protected]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
it('returns no args when the origin is unset or empty', () => {
|
||||
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
|
||||
@@ -200,7 +253,7 @@ describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(waitForServeSimReadyAsync, () => {
|
||||
describe(waitForWebPreviewReadyAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest.mocked(sleepAsync).mockReset();
|
||||
@@ -215,8 +268,9 @@ describe(waitForServeSimReadyAsync, () => {
|
||||
json: async () => ({ status: 'ready', device: 'DEVICE-A' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
await waitForServeSimReadyAsync({
|
||||
serveSim: { pid: undefined, getOutput: () => '' },
|
||||
await waitForWebPreviewReadyAsync({
|
||||
previewServer: { pid: undefined, getOutput: () => '' },
|
||||
serverName: 'expo-device-hub',
|
||||
port: 4321,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
@@ -261,14 +315,239 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--turn-username',
|
||||
'turn-user',
|
||||
'--turn-credential',
|
||||
'turn-credential',
|
||||
];
|
||||
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1', token: 'tok-1' }]);
|
||||
|
||||
const spawnPromise = Object.assign(Promise.resolve(undefined), {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
|
||||
jest.mocked(turtleFetch).mockImplementation(async url => {
|
||||
if (url.endsWith('/turn-ice-servers')) {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{
|
||||
urls: ['turns:turn.example.test:443'],
|
||||
username: 'turn-user',
|
||||
credential: 'turn-credential',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
}
|
||||
return {
|
||||
json: async () => ({ status: 'ready', device: 'device-id' }),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
|
||||
});
|
||||
});
|
||||
|
||||
it('installs ffmpeg before starting expo-device-hub for Linux', async () => {
|
||||
const packageVersion = '1.2.3';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(
|
||||
Promise.reject(new Error('ffmpeg is missing')) as ReturnType<typeof spawn>
|
||||
)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
expect(spawnCalls[1]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'update'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
expect(spawnCalls[2]).toEqual([
|
||||
'sudo',
|
||||
['apt-get', 'install', '-y', 'ffmpeg'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }),
|
||||
}),
|
||||
]);
|
||||
const expoDeviceHubCallIndex = spawnCalls.findIndex(([command]) => command === 'npx');
|
||||
expect(expoDeviceHubCallIndex).toBeGreaterThan(2);
|
||||
expect(jest.mocked(spawn).mock.invocationCallOrder[2]).toBeLessThan(
|
||||
jest.mocked(spawn).mock.invocationCallOrder[expoDeviceHubCallIndex]
|
||||
);
|
||||
const [command, args] = spawnCalls[expoDeviceHubCallIndex];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('carries the serve-sim session token for Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
// ungated on a public tunnel. Failing beats handing out a preview that is dead or unprotected.
|
||||
it('fails for Darwin when serve-sim reports no token', async () => {
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
})
|
||||
).rejects.toThrow(/wrote no session token/);
|
||||
});
|
||||
|
||||
// expo-device-hub mints no token, so the Android preview must not require one.
|
||||
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
|
||||
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args).not.toContain('--require-token');
|
||||
});
|
||||
|
||||
it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
|
||||
const packageVersion = '4.5.6';
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
expect(turnIceServersToWebPreviewArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
turnIceServersToWebPreviewArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
@@ -293,7 +572,7 @@ describe(turnIceServersToServeSimArgs, () => {
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
turnIceServersToWebPreviewArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
@@ -311,19 +590,19 @@ describe(turnIceServersToServeSimArgs, () => {
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
expect(turnIceServersToWebPreviewArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
describe(fetchWebPreviewTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
@@ -339,7 +618,7 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
@@ -363,11 +642,11 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
it('returns [] and warns when the request fails so the web preview falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
@@ -628,7 +907,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(ensureFfmpegInstalledAsync, () => {
|
||||
describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
const spawnMock = jest.mocked(spawn);
|
||||
|
||||
function spawnResolved(): ReturnType<typeof spawn> {
|
||||
@@ -647,7 +926,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
it('does not install when ffmpeg is on PATH', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -660,7 +939,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
it('installs ffmpeg with Homebrew on darwin when it is missing', async () => {
|
||||
spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved());
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -681,7 +960,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
@@ -703,6 +982,33 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares an in-flight ffmpeg setup between callers', async () => {
|
||||
let finishInstall: (() => void) | undefined;
|
||||
const pendingInstall = new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
.mockReturnValueOnce(spawnResolved()) // apt-get update
|
||||
.mockReturnValueOnce(pendingInstall as unknown as ReturnType<typeof spawn>); // apt-get install
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
|
||||
const firstSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const secondSetup = ensureFfmpegInstalledOnceAsync(options);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
finishInstall?.();
|
||||
await Promise.all([firstSetup, secondSetup]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // ffmpeg -version
|
||||
@@ -710,7 +1016,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
.mockReturnValueOnce(spawnResolved()); // apt-get install
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await ensureFfmpegInstalledAsync({
|
||||
await ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -729,7 +1035,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledAsync({
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
@@ -750,7 +1056,7 @@ describe(ensureFfmpegInstalledAsync, () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await expect(
|
||||
ensureFfmpegInstalledAsync({
|
||||
ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import Log from '@expo/logger';
|
||||
import http from 'node:http';
|
||||
import net, { AddressInfo } from 'node:net';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
|
||||
import { startSandboxDaemonAsync } from '../sandboxDaemon';
|
||||
|
||||
jest.unmock('@expo/logger');
|
||||
|
||||
describe(startSandboxDaemonAsync.name, () => {
|
||||
const logger = Log.child({ buildStepId: 'sandbox-test' });
|
||||
it.each(['ftp://localhost', 'not a URL'])('reports invalid server URL %s', async serverUrl => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.any(String) }) },
|
||||
expect.any(String)
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs errors after the connection opens', async () => {
|
||||
const server = new WebSocketServer({ port: 0, host: '127.0.0.1' });
|
||||
await new Promise<void>(resolve => server.once('listening', resolve));
|
||||
const connection = new Promise<WebSocket>(resolve => server.once('connection', resolve));
|
||||
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
const socket = await connection;
|
||||
await daemon.ready;
|
||||
// An invalid opcode makes the client emit a protocol error.
|
||||
const closed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
(socket as any)._socket.write(Buffer.from([0x83, 0x00]));
|
||||
await closed;
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.stringContaining('invalid opcode') }) },
|
||||
expect.stringContaining('invalid opcode')
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
it('fails if the server does not complete the handshake', async () => {
|
||||
const sockets = new Set<net.Socket>();
|
||||
const tcpServer = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = tcpServer.address() as AddressInfo;
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'Opening handshake has timed out' }) },
|
||||
expect.stringContaining('Opening handshake has timed out')
|
||||
);
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it.each(['stop', 'abort'])('can %s while the initial connection is pending', async action => {
|
||||
const controller = new AbortController();
|
||||
const tcpServer = net.createServer();
|
||||
await new Promise<void>(resolve => tcpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = tcpServer.address() as AddressInfo;
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const connectionError = daemon.ready.catch(error => error);
|
||||
|
||||
if (action === 'abort') {
|
||||
controller.abort();
|
||||
} else {
|
||||
await daemon.stopAsync();
|
||||
}
|
||||
|
||||
await expect(connectionError).resolves.toBeInstanceOf(Error);
|
||||
await daemon.stopAsync();
|
||||
await new Promise<void>(resolve => tcpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('fails when the initial connection cannot be opened', async () => {
|
||||
const errorLog = jest.spyOn(logger, 'error').mockImplementation(() => {});
|
||||
const httpServer = http.createServer();
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
|
||||
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: expect.stringContaining('ECONNREFUSED') }) },
|
||||
expect.stringContaining('ECONNREFUSED')
|
||||
);
|
||||
await daemon.stopAsync();
|
||||
errorLog.mockRestore();
|
||||
});
|
||||
|
||||
it('connects with the credential and closes on stop', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
expect(request.url).toBe('/sandbox/connect');
|
||||
expect(request.headers.authorization).toBe('Bearer secret-token');
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
|
||||
const daemonPromise = startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 10,
|
||||
logger,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
await daemon.ready;
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
|
||||
await daemon.stopAsync();
|
||||
await socketClosed;
|
||||
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('cancels the reconnect delay when stopped', async () => {
|
||||
const httpServer = http.createServer();
|
||||
const mcpServer = new WebSocketServer({ noServer: true });
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
mcpServer.handleUpgrade(request, socket, head, client =>
|
||||
mcpServer.emit('connection', client)
|
||||
);
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address() as AddressInfo;
|
||||
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
|
||||
const daemonPromise = startSandboxDaemonAsync({
|
||||
credential: 'secret-token',
|
||||
serverUrl: `ws://127.0.0.1:${address.port}`,
|
||||
reconnectDelayMs: 60_000,
|
||||
logger,
|
||||
});
|
||||
const socket = await connection;
|
||||
const daemon = await daemonPromise;
|
||||
await daemon.ready;
|
||||
const socketClosed = new Promise<void>(resolve => socket.once('close', () => resolve()));
|
||||
socket.close();
|
||||
await socketClosed;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
|
||||
await daemon.stopAsync();
|
||||
|
||||
mcpServer.close();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,14 @@ describe(readServeSimServersAsync, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes the session token when the state file carries one', async () => {
|
||||
await writeServerStateAsync('E', { device: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' });
|
||||
|
||||
expect(await readServeSimServersAsync(stateDir)).toEqual([
|
||||
{ udid: 'E', url: 'http://127.0.0.1:5', token: 'tok-abc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when the state dir does not exist', async () => {
|
||||
expect(await readServeSimServersAsync(path.join(workDir, 'nope'))).toEqual([]);
|
||||
});
|
||||
@@ -120,6 +128,20 @@ describe(streamServeSimMetricsToFileAsync, () => {
|
||||
expect(String(jest.mocked(fetch).mock.calls[0][0])).toBe('https://sim.example/metrics');
|
||||
});
|
||||
|
||||
it('sends the session token as a bearer header when given one', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(sseResponse());
|
||||
await streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: 'https://sim.example',
|
||||
serveSimToken: 'tok-abc',
|
||||
filePath: path.join(workDir, 'auth.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing on a non-ok response', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
const filePath = path.join(workDir, 'empty.ndjson');
|
||||
|
||||
@@ -370,6 +370,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileSimctl: 'Ran a simctl command',
|
||||
mobileSiriCommand: 'Sent a Siri command',
|
||||
mobileStartActivity: 'Started an activity',
|
||||
mobileStartAutomationSession: 'Started an automation session',
|
||||
mobileStartLogsBroadcast: 'Started log streaming',
|
||||
mobileStartMediaProjectionRecording: 'Started media projection recording',
|
||||
mobileStartNetworkMonitor: 'Started network monitoring',
|
||||
@@ -379,6 +380,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileStartService: 'Started a service',
|
||||
mobileStartSystemMonitor: 'Started system monitoring',
|
||||
mobileStartXctestScreenRecording: 'Started XCTest screen recording',
|
||||
mobileStopAutomationSession: 'Stopped an automation session',
|
||||
mobileStopLogsBroadcast: 'Stopped log streaming',
|
||||
mobileStopMediaProjectionRecording: 'Stopped media projection recording',
|
||||
mobileStopNetworkMonitor: 'Stopped network monitoring',
|
||||
@@ -400,6 +402,7 @@ export const APPIUM_COMMAND_SUMMARIES: Record<AppiumCommand, string> = {
|
||||
mobileUnscheduleAction: 'Canceled a scheduled action',
|
||||
mobileUpdateSafariPreferences: 'Updated Safari preferences',
|
||||
mobileViewPortRect: 'Read the viewport size',
|
||||
mobileViewportElementRect: 'Read an element position and size in the viewport',
|
||||
mobileViewportScreenshot: 'Took a viewport screenshot',
|
||||
mobileVoiceOverCurrentSpeech: 'Read current VoiceOver speech',
|
||||
mobileVoiceOverMove: 'Moved the VoiceOver cursor',
|
||||
|
||||
@@ -234,12 +234,14 @@ export type XCUITestAppiumCommand =
|
||||
| 'mobileShake'
|
||||
| 'mobileSimctl'
|
||||
| 'mobileSiriCommand'
|
||||
| 'mobileStartAutomationSession'
|
||||
| 'mobileStartLogsBroadcast'
|
||||
| 'mobileStartNetworkMonitor'
|
||||
| 'mobileStartPerfRecord'
|
||||
| 'mobileStartScreenRecording'
|
||||
| 'mobileStartSystemMonitor'
|
||||
| 'mobileStartXctestScreenRecording'
|
||||
| 'mobileStopAutomationSession'
|
||||
| 'mobileStopLogsBroadcast'
|
||||
| 'mobileStopNetworkMonitor'
|
||||
| 'mobileStopPerfRecord'
|
||||
@@ -374,6 +376,7 @@ export type UiAutomator2AppiumCommand =
|
||||
| 'mobileUnlock'
|
||||
| 'mobileUnscheduleAction'
|
||||
| 'mobileViewPortRect'
|
||||
| 'mobileViewportElementRect'
|
||||
| 'mobileViewportScreenshot'
|
||||
| 'networkSpeed'
|
||||
| 'openNotifications'
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import { type DetachedProcessHandle, spawnDetached } from './remoteDeviceRunSession';
|
||||
|
||||
/**
|
||||
* Local egress provides an HTTP(S) proxy through the machine running the EAS CLI.
|
||||
* Requests that use this proxy exit through that machine's network.
|
||||
*
|
||||
* Worker side: a chisel server in reverse mode listens on loopback and is exposed
|
||||
* through the session's ngrok domain. When the CLI's egress client connects, chisel
|
||||
* opens LOCAL_EGRESS_PROXY_PORT on this host and forwards every connection to the
|
||||
* HTTP proxy the CLI runs. The macOS system proxy points at that port. Until the
|
||||
* client connects, nothing listens on the port and proxied requests fail.
|
||||
*
|
||||
* Contract: HTTP(S) and WebSocket requests that honor the system proxy (WebKit,
|
||||
* URLSession and other CFNetwork clients) exit from the egress client's network
|
||||
* and fail while the client is disconnected. Requests from libraries that bypass
|
||||
* the system proxy are not covered and exit from this host. Nothing here enforces
|
||||
* routing; the session monitor reports such direct connections instead.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_PROXY_HOST = '127.0.0.1';
|
||||
export const LOCAL_EGRESS_PROXY_PORT = 8899;
|
||||
export const LOCAL_EGRESS_USERNAME = 'eas';
|
||||
export const LOCAL_EGRESS_HANDOFF_PATH = path.join(os.tmpdir(), 'eas-simulator-local-egress.json');
|
||||
|
||||
export const CHISEL_VERSION = '1.12.0';
|
||||
// sha256 of the release .gz assets, cross-checked against chisel_1.12.0_checksums.txt.
|
||||
const CHISEL_SHA256_BY_ASSET: Record<string, string> = {
|
||||
'chisel_1.12.0_darwin_arm64.gz':
|
||||
'707a4b932eea214765146504a0df246cefc415b4297af65a80dd67cf69ba85a9',
|
||||
'chisel_1.12.0_darwin_amd64.gz':
|
||||
'4aeae36c867f11c8e8c3f2b913a0e063ea3c6d29e1c14a52ed2e6eef8cfc4395',
|
||||
'chisel_1.12.0_linux_amd64.gz':
|
||||
'f3f180f1d93aa72cce4e6386f98cc06569a0146fbd65eb4423cf83e6434bcfe6',
|
||||
'chisel_1.12.0_linux_arm64.gz':
|
||||
'2ec6152cd2c74fe0146d4d79e4e7aa174521368c56e433d55e023a92ea404ec3',
|
||||
};
|
||||
|
||||
const CHISEL_STARTUP_TIMEOUT_MS = 15_000;
|
||||
const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
/** Secret the client presents to the tunnel server, paired with LOCAL_EGRESS_USERNAME. */
|
||||
token: string;
|
||||
/** Fingerprint of the tunnel server key, for the client to pin. */
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
platform,
|
||||
arch,
|
||||
}: {
|
||||
platform: NodeJS.Platform;
|
||||
arch: string;
|
||||
}): string {
|
||||
const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : null;
|
||||
const cpu = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : null;
|
||||
if (!os || !cpu) {
|
||||
throw new SystemError(
|
||||
`Local egress is not supported on ${platform}/${arch}. The reverse tunnel binary is available for macOS and Linux on arm64 and x64.`
|
||||
);
|
||||
}
|
||||
return `chisel_${CHISEL_VERSION}_${os}_${cpu}.gz`;
|
||||
}
|
||||
|
||||
export function getChiselDownloadUrl(assetName: string): string {
|
||||
return `https://github.com/jpillora/chisel/releases/download/v${CHISEL_VERSION}/${assetName}`;
|
||||
}
|
||||
|
||||
async function sha256FileAsync(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(fs.createReadStream(filePath), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function downloadChiselAsync({
|
||||
destinationDir,
|
||||
logger,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
}: {
|
||||
destinationDir: string;
|
||||
logger: bunyan;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
}): Promise<string> {
|
||||
const assetName = getChiselAssetName({ platform, arch });
|
||||
const expectedSha256 = CHISEL_SHA256_BY_ASSET[assetName];
|
||||
if (!expectedSha256) {
|
||||
throw new SystemError(`No pinned checksum for ${assetName}.`);
|
||||
}
|
||||
const url = getChiselDownloadUrl(assetName);
|
||||
const archivePath = path.join(destinationDir, assetName);
|
||||
logger.info(`Downloading ${url}.`);
|
||||
await downloadFile(url, archivePath, { retry: 3, timeout: 60_000 });
|
||||
|
||||
const actualSha256 = await sha256FileAsync(archivePath);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new SystemError(
|
||||
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}. ` +
|
||||
'The download may be corrupted or tampered with; the session was not started.'
|
||||
);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(destinationDir, 'chisel');
|
||||
await pipeline(
|
||||
fs.createReadStream(archivePath),
|
||||
zlib.createGunzip(),
|
||||
fs.createWriteStream(binaryPath)
|
||||
);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
export function generateEgressCredentials(): { user: string; password: string } {
|
||||
return { user: LOCAL_EGRESS_USERNAME, password: randomBytes(24).toString('base64url') };
|
||||
}
|
||||
|
||||
// Matches 1024-65535: every unprivileged TCP port.
|
||||
const UNPRIVILEGED_PORT_PATTERN =
|
||||
'(102[4-9]|10[3-9][0-9]|1[1-9][0-9]{2}|[2-9][0-9]{3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])';
|
||||
|
||||
/**
|
||||
* chisel authfile: one user, allowed to open reverse remotes on loopback only.
|
||||
* The proxy port carries proxied requests. Any unprivileged loopback port may be
|
||||
* forwarded too: the EAS CLI opens one per `--egress-allow localhost:<port>`
|
||||
* entry so `127.0.0.1:<port>` inside the simulator reaches that port on the
|
||||
* developer's machine, like `adb reverse`. iOS never sends loopback-literal
|
||||
* requests to the system proxy, so the proxy alone cannot serve them.
|
||||
* Reverse remotes are matched as `R:<interface>:<port>`.
|
||||
*/
|
||||
export function createChiselAuthfileContents({
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
}: {
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}): string {
|
||||
const escapedHost = LOCAL_EGRESS_PROXY_HOST.replace(/\./g, '\\.');
|
||||
return JSON.stringify({
|
||||
[`${user}:${password}`]: [
|
||||
`^R:${escapedHost}:${port}$`,
|
||||
`^R:${escapedHost}:${UNPRIVILEGED_PORT_PATTERN}$`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChiselFingerprint(output: string): string | null {
|
||||
const match = /Fingerprint\s+(\S+)/.exec(output);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startChiselServerAsync({
|
||||
chiselPath,
|
||||
controlPort,
|
||||
authfilePath,
|
||||
env,
|
||||
signal,
|
||||
}: {
|
||||
chiselPath: string;
|
||||
controlPort: number;
|
||||
authfilePath: string;
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ process: DetachedProcessHandle; fingerprint: string }> {
|
||||
const server = spawnDetached({
|
||||
command: chiselPath,
|
||||
args: [
|
||||
'server',
|
||||
'--host',
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
'--port',
|
||||
String(controlPort),
|
||||
'--reverse',
|
||||
'--authfile',
|
||||
authfilePath,
|
||||
],
|
||||
// AUTH adds an unrestricted user even when --authfile is supplied.
|
||||
env: { ...env, AUTH: '' },
|
||||
});
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + CHISEL_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
if (server.pid === undefined || !isProcessRunning(server.pid)) {
|
||||
break;
|
||||
}
|
||||
const output = server.getOutput();
|
||||
const fingerprint = parseChiselFingerprint(output);
|
||||
// Chisel prints the fingerprint before binding. This line comes from the
|
||||
// child only after its listener succeeds, unlike a probe of a reused port.
|
||||
const listening = output
|
||||
.split('\n')
|
||||
.some(line =>
|
||||
line
|
||||
.trimEnd()
|
||||
.endsWith(`server: Listening on http://${LOCAL_EGRESS_PROXY_HOST}:${controlPort}`)
|
||||
);
|
||||
if (fingerprint && listening) {
|
||||
return { process: server, fingerprint };
|
||||
}
|
||||
await sleepAsync(250);
|
||||
}
|
||||
throw new SystemError(
|
||||
`The reverse tunnel server did not start within ${CHISEL_STARTUP_TIMEOUT_MS / 1000}s. Output:\n${
|
||||
server.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
await server.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDefaultRouteInterface(routeOutput: string): string | null {
|
||||
const match = /^\s*interface:\s*(\S+)/m.exec(routeOutput);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `networksetup -listnetworkserviceorder` prints each service as two lines:
|
||||
* "(1) Wi-Fi" followed by "(Hardware Port: Wi-Fi, Device: en0)". Return the
|
||||
* service name whose device matches.
|
||||
*/
|
||||
export function parseNetworkServiceNameForDevice(
|
||||
listOutput: string,
|
||||
device: string
|
||||
): string | null {
|
||||
const lines = listOutput.split('\n').map(line => line.trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const hardwarePortMatch = /^\(Hardware Port: (.+), Device: (\S+)\)$/.exec(lines[i]);
|
||||
if (!hardwarePortMatch || hardwarePortMatch[2] !== device) {
|
||||
continue;
|
||||
}
|
||||
const serviceLineMatch = /^\(\d+\) (.+)$/.exec(lines[i - 1] ?? '');
|
||||
return serviceLineMatch?.[1] ?? hardwarePortMatch[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveActiveNetworkServiceNameAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const routeResult = await spawn('route', ['-n', 'get', 'default'], { env, stdio: 'pipe' });
|
||||
const device = parseDefaultRouteInterface(routeResult.stdout);
|
||||
if (!device) {
|
||||
throw new SystemError(
|
||||
'Could not determine the default network interface of the device host, so the system proxy ' +
|
||||
'cannot be configured. Output of `route -n get default`:\n' +
|
||||
(routeResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
const listResult = await spawn('networksetup', ['-listnetworkserviceorder'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const service = parseNetworkServiceNameForDevice(listResult.stdout, device);
|
||||
if (!service) {
|
||||
throw new SystemError(
|
||||
`Could not find the network service for interface ${device}, so the system proxy cannot be ` +
|
||||
'configured. Output of `networksetup -listnetworkserviceorder`:\n' +
|
||||
(listResult.stdout || '<empty>')
|
||||
);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
export function buildNetworksetupProxyArgs({
|
||||
service,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
service: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}): string[][] {
|
||||
return [
|
||||
['-setwebproxy', service, host, String(port)],
|
||||
['-setsecurewebproxy', service, host, String(port)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the macOS system HTTP and HTTPS proxy at the loopback egress port. The
|
||||
* simulator reads these settings when it boots, so this must run before
|
||||
* `start_ios_simulator`. Existing bypass and automatic proxy settings are left
|
||||
* unchanged. Requests that bypass this proxy can leave from this host's address.
|
||||
*/
|
||||
export async function configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
port,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
port: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ service: string }> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not changing the system proxy.');
|
||||
return { service: '<development>' };
|
||||
}
|
||||
const service = await resolveActiveNetworkServiceNameAsync({ env });
|
||||
for (const args of buildNetworksetupProxyArgs({ service, host: LOCAL_EGRESS_PROXY_HOST, port })) {
|
||||
signal?.throwIfAborted();
|
||||
await spawn('networksetup', args, { env, logger, signal });
|
||||
}
|
||||
logger.info(`System proxy for "${service}" set to ${LOCAL_EGRESS_PROXY_HOST}:${port}.`);
|
||||
return { service };
|
||||
}
|
||||
|
||||
export const LOCAL_EGRESS_NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
/**
|
||||
* Proxy environment for processes inside the simulator. CFNetwork ignores these
|
||||
* variables and follows the system proxy, but clients with their own network
|
||||
* stack that read them (gRPC and therefore Firestore, libcurl, Go) then send
|
||||
* HTTP CONNECT requests to the same loopback port. Loopback is excluded so the
|
||||
* ports forwarded for `--egress-allow` behave the same on both paths. Lowercase
|
||||
* and uppercase forms are both set because clients differ in which they read.
|
||||
*/
|
||||
export function buildLocalEgressSimulatorEnvironment(port: number): Record<string, string> {
|
||||
const proxyUrl = `http://${LOCAL_EGRESS_PROXY_HOST}:${port}`;
|
||||
return {
|
||||
http_proxy: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
grpc_proxy: proxyUrl,
|
||||
no_proxy: LOCAL_EGRESS_NO_PROXY,
|
||||
NO_PROXY: LOCAL_EGRESS_NO_PROXY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the local egress proxy environment in a booted simulator when a local
|
||||
* egress session is active. Returns false when local egress is not configured
|
||||
* or the environment could not be set. Failure is a warning, not an error: the
|
||||
* session still works, but clients that read these variables then exit from
|
||||
* this worker instead of the egress client, which the warning spells out.
|
||||
*/
|
||||
export async function configureSimulatorProxyEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
}): Promise<boolean> {
|
||||
let handoff: LocalEgressHandoff | null;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not read the local egress handoff, so proxy environment variables were ' +
|
||||
'not set in the Simulator. Clients that read http_proxy/https_proxy (for example gRPC and ' +
|
||||
'libcurl) will bypass local egress and exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
const variables = buildLocalEgressSimulatorEnvironment(handoff.port);
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({ udid, env, variables });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not set proxy environment variables in the Simulator. Clients that read ' +
|
||||
'http_proxy/https_proxy (for example gRPC and libcurl) will bypass local egress and exit from ' +
|
||||
'this worker. CFNetwork clients (WebKit, URLSession) still follow the system proxy.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
logger.info(
|
||||
`Local egress: proxy environment variables set in the Simulator (${Object.keys(variables).join(
|
||||
', '
|
||||
)}), so clients that read them, such as gRPC and libcurl, also exit from the egress client.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function writeLocalEgressHandoffAsync(
|
||||
handoff: LocalEgressHandoff,
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<void> {
|
||||
await fs.promises.writeFile(handoffPath, JSON.stringify(handoff), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalEgressHandoffAsync(
|
||||
handoffPath: string = LOCAL_EGRESS_HANDOFF_PATH
|
||||
): Promise<LocalEgressHandoff | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(handoffPath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LocalEgressHandoff>;
|
||||
if (
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
return {
|
||||
url: parsed.url,
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
};
|
||||
}
|
||||
|
||||
/** remoteConfig fields the CLI needs to start the egress client. */
|
||||
export function buildEgressRemoteConfigFields(
|
||||
handoff: LocalEgressHandoff | null
|
||||
): Record<string, string | number> {
|
||||
if (!handoff) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
egressUrl: handoff.url,
|
||||
egressToken: handoff.token,
|
||||
egressFingerprint: handoff.fingerprint,
|
||||
egressPort: handoff.port,
|
||||
};
|
||||
}
|
||||
|
||||
// The egress step returns before the session ends, so the resources it created
|
||||
// are held here and released by the session step or the job's finally block.
|
||||
let activeLocalEgressResources:
|
||||
| { stopAsync: () => Promise<void>; controller: AbortController; stopping?: Promise<void> }
|
||||
| undefined;
|
||||
|
||||
export function registerLocalEgressResources(stopAsync: () => Promise<void>): AbortSignal {
|
||||
if (activeLocalEgressResources) {
|
||||
throw new SystemError('Local egress resources are already registered for this job.');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
activeLocalEgressResources = { stopAsync, controller };
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export async function stopLocalEgressResourcesAsync(logger: bunyan): Promise<void> {
|
||||
const resources = activeLocalEgressResources;
|
||||
if (!resources) {
|
||||
return;
|
||||
}
|
||||
resources.controller.abort();
|
||||
resources.stopping ??= Promise.resolve()
|
||||
.then(resources.stopAsync)
|
||||
.catch(err => logger.warn({ err }, 'Could not stop a local egress resource.'))
|
||||
.finally(() => {
|
||||
activeLocalEgressResources = undefined;
|
||||
});
|
||||
await resources.stopping;
|
||||
}
|
||||
|
||||
export async function isPortListeningAsync({
|
||||
host,
|
||||
port,
|
||||
timeoutMs = 1_000,
|
||||
}: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<boolean> {
|
||||
return await new Promise<boolean>(resolve => {
|
||||
const socket = net.connect({ host, port });
|
||||
const finish = (listening: boolean): void => {
|
||||
socket.destroy();
|
||||
resolve(listening);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
export function parseExitIpResponse(body: string): string {
|
||||
const parsed = JSON.parse(body) as { ip?: unknown };
|
||||
if (typeof parsed.ip !== 'string' || parsed.ip.length === 0) {
|
||||
throw new SystemError(`Unexpected exit IP response: ${body}`);
|
||||
}
|
||||
return parsed.ip;
|
||||
}
|
||||
|
||||
export async function fetchExitIpThroughProxyAsync({
|
||||
port,
|
||||
env,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const result = await spawn(
|
||||
'curl',
|
||||
[
|
||||
'-sS',
|
||||
'--max-time',
|
||||
'10',
|
||||
'-x',
|
||||
`http://${LOCAL_EGRESS_PROXY_HOST}:${port}`,
|
||||
'https://api.ipify.org?format=json',
|
||||
],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseExitIpResponse(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes inside the simulator run on the host as descendants of launchd_sim,
|
||||
* so `ps -axo pid=,ppid=,comm=` ancestry identifies them. Returns every
|
||||
* descendant pid; launchd_sim itself opens no network connections.
|
||||
*/
|
||||
export function collectSimulatorProcessIds(psOutput: string): number[] {
|
||||
const childrenByParent = new Map<number, number[]>();
|
||||
const roots: number[] = [];
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*)$/.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const parentPid = Number(match[2]);
|
||||
const command = match[3].trim();
|
||||
const siblings = childrenByParent.get(parentPid) ?? [];
|
||||
siblings.push(pid);
|
||||
childrenByParent.set(parentPid, siblings);
|
||||
if (path.basename(command) === 'launchd_sim') {
|
||||
roots.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const descendants: number[] = [];
|
||||
const seen = new Set<number>(roots);
|
||||
const queue = [...roots];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const child of childrenByParent.get(queue[i]) ?? []) {
|
||||
if (seen.has(child)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(child);
|
||||
descendants.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
export type DirectSimulatorConnection = {
|
||||
pid: number;
|
||||
command: string;
|
||||
protocol: string;
|
||||
/** Remote peer as lsof prints it, e.g. `93.184.216.34:443` or `[2606::1]:443`. */
|
||||
remote: string;
|
||||
};
|
||||
|
||||
function remoteHostOf(remote: string): string {
|
||||
if (remote.startsWith('[')) {
|
||||
const end = remote.indexOf(']');
|
||||
return end === -1 ? remote : remote.slice(1, end);
|
||||
}
|
||||
const colon = remote.lastIndexOf(':');
|
||||
return colon === -1 ? remote : remote.slice(0, colon);
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return (
|
||||
host === 'localhost' || host.startsWith('127.') || host === '::1' || host === '::ffff:127.0.0.1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `lsof -nP -i -F pcPnT` output. Returns the TCP and UDP connections that
|
||||
* simulator processes hold to peers outside loopback. Those did not go through
|
||||
* the proxy on 127.0.0.1, so they exit from this host instead of the egress
|
||||
* client. Listening sockets, unconnected UDP sockets and connections to
|
||||
* loopback (the proxy itself) are ignored.
|
||||
*/
|
||||
export function parseDirectSimulatorConnections(
|
||||
lsofOutput: string,
|
||||
simulatorPids: ReadonlySet<number>
|
||||
): DirectSimulatorConnection[] {
|
||||
const connections: DirectSimulatorConnection[] = [];
|
||||
let pid: number | null = null;
|
||||
let command = '';
|
||||
let protocol: string | null = null;
|
||||
let name: string | null = null;
|
||||
let tcpState: string | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (pid !== null && simulatorPids.has(pid) && protocol && name) {
|
||||
const arrow = name.indexOf('->');
|
||||
const remote = arrow === -1 ? null : name.slice(arrow + 2);
|
||||
const active =
|
||||
protocol !== 'TCP' ||
|
||||
tcpState === null ||
|
||||
tcpState === 'ESTABLISHED' ||
|
||||
tcpState === 'SYN_SENT';
|
||||
if (remote && active && !isLoopbackHost(remoteHostOf(remote))) {
|
||||
connections.push({ pid, command, protocol, remote });
|
||||
}
|
||||
}
|
||||
protocol = null;
|
||||
name = null;
|
||||
tcpState = null;
|
||||
};
|
||||
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
const field = line[0];
|
||||
const value = line.slice(1);
|
||||
switch (field) {
|
||||
case 'p':
|
||||
flush();
|
||||
pid = Number(value);
|
||||
command = '';
|
||||
break;
|
||||
case 'c':
|
||||
command = value;
|
||||
break;
|
||||
case 'f':
|
||||
flush();
|
||||
break;
|
||||
case 'P':
|
||||
protocol = value;
|
||||
break;
|
||||
case 'n':
|
||||
name = value;
|
||||
break;
|
||||
case 'T':
|
||||
if (value.startsWith('ST=')) {
|
||||
tcpState = value.slice('ST='.length);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return connections;
|
||||
}
|
||||
|
||||
async function findDirectSimulatorConnectionsAsync({
|
||||
env,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
}): Promise<DirectSimulatorConnection[]> {
|
||||
const ps = await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' });
|
||||
const pids = collectSimulatorProcessIds(ps.stdout);
|
||||
if (pids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
const lsof = await spawn('lsof', ['-nP', '-i', '-F', 'pcPnT', '-a', '-p', pids.join(',')], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
lsofOutput = lsof.stdout;
|
||||
} catch (err) {
|
||||
// lsof exits with 1 when none of the processes holds a matching socket, and
|
||||
// when one of them exited between `ps` and `lsof`. Its stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
throw err;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseDirectSimulatorConnections(lsofOutput, new Set(pids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log proxy listener availability, the exit IP observed by a worker request
|
||||
* through it, and simulator connections that bypassed the proxy. Neither check
|
||||
* verifies that proxied simulator requests reach the egress client. Never
|
||||
* rejects: it runs in the background for the whole session.
|
||||
*/
|
||||
export async function monitorLocalEgressAsync({
|
||||
port,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
while (!signal.aborted && !lifetimeSignal?.aborted) {
|
||||
const listening = await isPortListeningAsync({ host: LOCAL_EGRESS_PROXY_HOST, port });
|
||||
if (listening && !connected) {
|
||||
connected = true;
|
||||
logger.info('Local egress proxy listener is available.');
|
||||
try {
|
||||
const exitIp = await fetchExitIpThroughProxyAsync({ port, env });
|
||||
logger.info(
|
||||
`Worker proxy exit-IP check observed ${exitIp}. This does not verify simulator routing.`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'The local egress proxy listener is available, but the worker exit-IP check through it failed.'
|
||||
);
|
||||
}
|
||||
} else if (!listening && connected) {
|
||||
connected = false;
|
||||
logger.warn(
|
||||
'Local egress proxy listener is unavailable. Proxied HTTP(S) requests fail until it returns.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!escapeScanBroken && Date.now() - lastEscapeScanAt >= EGRESS_ESCAPE_SCAN_INTERVAL_MS) {
|
||||
lastEscapeScanAt = Date.now();
|
||||
try {
|
||||
for (const connection of await findDirectSimulatorConnectionsAsync({ env })) {
|
||||
const key = `${connection.pid}|${connection.protocol}|${connection.remote}`;
|
||||
if (reportedEscapes.has(key)) {
|
||||
continue;
|
||||
}
|
||||
reportedEscapes.add(key);
|
||||
if (reportedEscapes.size > EGRESS_ESCAPE_LOG_LIMIT) {
|
||||
if (reportedEscapes.size === EGRESS_ESCAPE_LOG_LIMIT + 1) {
|
||||
logger.warn(
|
||||
`Local egress: more than ${EGRESS_ESCAPE_LOG_LIMIT} direct connections were reported; further ones are not logged.`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
`Local egress: ${connection.command} (pid ${connection.pid}) opened a direct ${connection.protocol} connection to ${connection.remote}, bypassing the system proxy. That traffic exits from this worker, not from the egress client.`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
escapeScanBroken = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress: could not inspect simulator connections. Direct connections that bypass the proxy will not be reported.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(EGRESS_MONITOR_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Local egress monitoring stopped unexpectedly.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
buildLocalEgressSimulatorEnvironment,
|
||||
collectSimulatorProcessIds,
|
||||
readLocalEgressHandoffAsync,
|
||||
} from './localEgress';
|
||||
|
||||
/**
|
||||
* Worker side of the local egress guard: a dylib injected into every process
|
||||
* the simulator launches, which refuses connections that do not go to
|
||||
* loopback (where the proxy and the `--egress-allow` forwards live) and
|
||||
* records one event per destination per process. This module installs it
|
||||
* through the simulator's launchd environment and relays its events into the
|
||||
* session log. See resources/egress-guard/README.md.
|
||||
*/
|
||||
|
||||
export const EGRESS_GUARD_LIBRARY_FILE = 'egress-guard.dylib';
|
||||
export const EGRESS_GUARD_CHECK_FILE = 'egress-guard-check';
|
||||
export const EGRESS_GUARD_LOG_ENV = 'EAS_EGRESS_GUARD_LOG';
|
||||
export const EGRESS_GUARD_MODE_ENV = 'EAS_EGRESS_GUARD_MODE';
|
||||
export const LOCAL_EGRESS_GUARD_LOG_PATH = path.join(os.tmpdir(), 'eas-local-egress-guard.log');
|
||||
const GUARD_EVENT_PREFIX = 'eas-egress-guard';
|
||||
/** Written once by a process whose per-process table of destinations is full; see policy.h. */
|
||||
const GUARD_OVERFLOW_FUNCTION = 'overflow';
|
||||
const GUARD_RELAY_LOG_LIMIT = 200;
|
||||
const GUARD_TAIL_INTERVAL_MS = 1_000;
|
||||
|
||||
export type EgressGuardMode = 'block' | 'log';
|
||||
|
||||
export function buildGuardLaunchdEnvironment({
|
||||
libraryPath,
|
||||
logPath,
|
||||
mode,
|
||||
}: {
|
||||
libraryPath: string;
|
||||
logPath: string;
|
||||
mode: EgressGuardMode;
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
DYLD_INSERT_LIBRARIES: libraryPath,
|
||||
[EGRESS_GUARD_LOG_ENV]: logPath,
|
||||
[EGRESS_GUARD_MODE_ENV]: mode,
|
||||
};
|
||||
}
|
||||
|
||||
export type GuardEvent = {
|
||||
process: string;
|
||||
pid: number;
|
||||
function: string;
|
||||
action: 'blocked' | 'logged';
|
||||
peer: string;
|
||||
/** Image names above the interposer, innermost first. */
|
||||
callers: string[];
|
||||
};
|
||||
|
||||
/** One tab-separated line written by the guard; see policy.h for the format. */
|
||||
export function parseGuardLogLine(line: string): GuardEvent | null {
|
||||
const fields = line.split('\t');
|
||||
if (fields.length < 7 || fields[0] !== GUARD_EVENT_PREFIX) {
|
||||
return null;
|
||||
}
|
||||
const [, process, pidText, fn, action, peer, callerText] = fields;
|
||||
const pid = Number(pidText);
|
||||
if (!Number.isInteger(pid) || (action !== 'blocked' && action !== 'logged') || !fn || !peer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
process,
|
||||
pid,
|
||||
function: fn,
|
||||
action,
|
||||
peer,
|
||||
callers: callerText ? callerText.split(',').filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const PACKAGED_BIN_DIR = path.join(__dirname, '..', '..', '..', 'bin');
|
||||
|
||||
async function resolvePackagedFileAsync(binDir: string, file: string): Promise<string | null> {
|
||||
const filePath = path.join(binDir, file);
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return filePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The packaged library, next to the compiled package like record-sim. */
|
||||
export async function resolveEgressGuardLibraryAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_LIBRARY_FILE);
|
||||
}
|
||||
|
||||
/** The packaged self-check binary, built alongside the library. */
|
||||
export async function resolveEgressGuardCheckAsync(
|
||||
binDir: string = PACKAGED_BIN_DIR
|
||||
): Promise<string | null> {
|
||||
return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns guard events into session log lines: one line the first time a
|
||||
* process reaches a destination through a given call, counts after that, and
|
||||
* a summary at the end.
|
||||
*/
|
||||
export class GuardEventRelay {
|
||||
private readonly seen = new Set<string>();
|
||||
private readonly peers = new Set<string>();
|
||||
private readonly processes = new Set<string>();
|
||||
private blocked = 0;
|
||||
private logged = 0;
|
||||
private suppressed = 0;
|
||||
private logger: bunyan;
|
||||
|
||||
constructor(
|
||||
logger: bunyan,
|
||||
private readonly limit: number = GUARD_RELAY_LOG_LIMIT
|
||||
) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log through a different step's logger from now on. Refusals happen for
|
||||
* the life of the session, so they belong to the step that runs the
|
||||
* session, not the one that booted the simulator minutes earlier.
|
||||
*/
|
||||
setLogger(logger: bunyan): void {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
handle(event: GuardEvent): void {
|
||||
if (event.process === EGRESS_GUARD_CHECK_FILE) {
|
||||
// The self-check deliberately trips the guard once; not a bypass.
|
||||
return;
|
||||
}
|
||||
if (event.function === GUARD_OVERFLOW_FUNCTION) {
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${event.process} (pid ${event.pid}) reached the limit of ${event.peer} listed per process; further distinct destinations from it are ${verb} but not listed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.action === 'blocked') {
|
||||
this.blocked++;
|
||||
} else {
|
||||
this.logged++;
|
||||
}
|
||||
this.peers.add(event.peer);
|
||||
this.processes.add(event.process);
|
||||
const key = `${event.process}|${event.function}|${event.peer}`;
|
||||
if (this.seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.seen.add(key);
|
||||
if (this.seen.size > this.limit) {
|
||||
this.suppressed++;
|
||||
return;
|
||||
}
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
const callers = event.callers.length ? `; callers: ${event.callers.join(', ')}` : '';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process} (pid ${event.pid}) to ${event.peer}${callers}`
|
||||
);
|
||||
}
|
||||
|
||||
summary(): { blocked: number; logged: number; distinct: number; suppressed: number } {
|
||||
return {
|
||||
blocked: this.blocked,
|
||||
logged: this.logged,
|
||||
distinct: this.peers.size,
|
||||
suppressed: this.suppressed,
|
||||
};
|
||||
}
|
||||
|
||||
logSummary(): void {
|
||||
const { blocked, logged, distinct, suppressed } = this.summary();
|
||||
const observed = logged ? ` and observed ${logged} more without refusing` : '';
|
||||
const dropped = suppressed
|
||||
? ` ${suppressed} further distinct destination(s) were not logged individually.`
|
||||
: '';
|
||||
this.logger.info(
|
||||
`Local egress guard: refused ${blocked} connection attempt(s) to ${distinct} distinct destination(s) from ${this.processes.size} process(es)${observed}.${dropped}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a file the simulator processes append to and delivers whole lines.
|
||||
* Tolerates the file not existing yet, partial trailing lines, and truncation.
|
||||
*/
|
||||
export class GuardLogTailer {
|
||||
private readonly path: string;
|
||||
private readonly onLine: (line: string) => void;
|
||||
private readonly intervalMs: number;
|
||||
private offset = 0;
|
||||
private partial = '';
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private reading: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor({
|
||||
path: filePath,
|
||||
onLine,
|
||||
intervalMs = GUARD_TAIL_INTERVAL_MS,
|
||||
}: {
|
||||
path: string;
|
||||
onLine: (line: string) => void;
|
||||
intervalMs?: number;
|
||||
}) {
|
||||
this.path = filePath;
|
||||
this.onLine = onLine;
|
||||
this.intervalMs = intervalMs;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.reading = this.reading.then(() => this.readAsync()).catch(() => {});
|
||||
}, this.intervalMs);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
async stopAsync(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
await this.reading.catch(() => {});
|
||||
await this.readAsync().catch(() => {});
|
||||
}
|
||||
|
||||
private async readAsync(): Promise<void> {
|
||||
let handle: fs.promises.FileHandle;
|
||||
try {
|
||||
handle = await fs.promises.open(this.path, 'r');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
if (size < this.offset) {
|
||||
// Truncated or replaced; start over.
|
||||
this.offset = 0;
|
||||
this.partial = '';
|
||||
}
|
||||
if (size === this.offset) {
|
||||
return;
|
||||
}
|
||||
const buffer = new Uint8Array(size - this.offset);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.offset);
|
||||
this.offset += bytesRead;
|
||||
const text = this.partial + Buffer.from(buffer.buffer, 0, bytesRead).toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
this.partial = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (line.length > 0) {
|
||||
this.onLine(line);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment the simulator's launchd must have from its first process:
|
||||
* the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`,
|
||||
* which hands it to launchd before anything is spawned; `launchctl setenv`
|
||||
* after boot only reaches later processes. Returns null when no local egress
|
||||
* session is active. Throws when the guard library is not packaged, since a
|
||||
* local egress session without it would silently leak.
|
||||
*/
|
||||
export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
}: {
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
} = {}): Promise<Record<string, string> | null> {
|
||||
const handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
if (!handoff) {
|
||||
return null;
|
||||
}
|
||||
const resolvedLibrary =
|
||||
libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
|
||||
if (!resolvedLibrary) {
|
||||
throw new SystemError(
|
||||
'The local egress guard library is not available on this device host, so this local egress session ' +
|
||||
'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
|
||||
'missing bin/egress-guard.dylib; this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
...buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
|
||||
...buildLocalEgressSimulatorEnvironment(handoff.port),
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveRelay = { tailer: GuardLogTailer; relay: GuardEventRelay };
|
||||
const activeRelays = new Map<string, ActiveRelay>();
|
||||
|
||||
/**
|
||||
* Install the guard into a simulator when a local egress session is active,
|
||||
* and start relaying its events into the session log. Returns false when
|
||||
* there is no local egress session. Throws when the library is not packaged
|
||||
* or launchd could not be configured: a local egress session without the
|
||||
* guard would silently leak, so it must not start. Only an unwritable event
|
||||
* log is a warning, since refusals still happen and only reporting is lost.
|
||||
*
|
||||
* Call this as soon as `simctl boot` returns: launchd is up and nothing else
|
||||
* has started, so every process the boot spawns inherits the guard. Verify
|
||||
* with `verifyLocalEgressGuardAsync` once boot completes.
|
||||
*/
|
||||
export async function installLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
libraryPath,
|
||||
logPath = LOCAL_EGRESS_GUARD_LOG_PATH,
|
||||
mode = 'block',
|
||||
tailIntervalMs,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
handoffPath?: string;
|
||||
/** Explicit library path, `null` for "not available"; resolved from the package when omitted. */
|
||||
libraryPath?: string | null;
|
||||
logPath?: string;
|
||||
mode?: EgressGuardMode;
|
||||
tailIntervalMs?: number;
|
||||
}): Promise<boolean> {
|
||||
let handoff;
|
||||
try {
|
||||
handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress guard: could not read the local egress handoff, so the guard was not installed. ' +
|
||||
'Connections that bypass the proxy will exit from this worker.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!handoff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedLibrary =
|
||||
libraryPath === undefined ? await resolveEgressGuardLibraryAsync() : libraryPath;
|
||||
if (!resolvedLibrary) {
|
||||
throw new SystemError(
|
||||
'The local egress guard library is not available on this device host, so this local egress session ' +
|
||||
'cannot guarantee that connections bypassing the system proxy are refused. The device host image is ' +
|
||||
'missing bin/egress-guard.dylib; this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
await logAlreadyRunningProcessesAsync({ env, logger });
|
||||
|
||||
let logWritable = true;
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
|
||||
await fs.promises.appendFile(logPath, '');
|
||||
} catch (err) {
|
||||
logWritable = false;
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables: buildGuardLaunchdEnvironment({ libraryPath: resolvedLibrary, logPath, mode }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
'Could not install the local egress guard in the Simulator (launchctl setenv failed), so this local ' +
|
||||
'egress session cannot guarantee that connections bypassing the system proxy are refused. Retry the ' +
|
||||
'session; if it keeps failing, please contact support.',
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
if (logWritable && !activeRelays.has(logPath)) {
|
||||
const relay = new GuardEventRelay(logger);
|
||||
const tailer = new GuardLogTailer({
|
||||
path: logPath,
|
||||
intervalMs: tailIntervalMs,
|
||||
onLine: line => {
|
||||
const event = parseGuardLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { tailer, relay });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${
|
||||
mode === 'block' ? 'refused' : 'observed'
|
||||
} in the process that makes them and reported here as they happen.`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the simulator already runs when the guard is installed never get
|
||||
* it. Right after `simctl boot` that is nothing; later it is SpringBoard and
|
||||
* the early daemons, which follow the system proxy anyway. Log them so the
|
||||
* uncovered set is visible rather than assumed.
|
||||
*/
|
||||
async function logAlreadyRunningProcessesAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
let psOutput = '';
|
||||
try {
|
||||
psOutput = (await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const pids = new Set(collectSimulatorProcessIds(psOutput));
|
||||
const names = new Set<string>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match && pids.has(Number(match[1]))) {
|
||||
names.add(path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
if (names.size === 0) {
|
||||
logger.info(
|
||||
'Local egress guard: no simulator process was running before the guard was installed.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const listed = [...names].sort();
|
||||
const shown =
|
||||
listed.slice(0, 20).join(', ') + (listed.length > 20 ? `, and ${listed.length - 20} more` : '');
|
||||
logger.info(
|
||||
`Local egress guard: ${names.size} simulator process(es) were already running before the guard was installed and are not covered by it: ${shown}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* launchd's trampoline exists for milliseconds between fork and exec of the
|
||||
* real service, with nothing mapped yet; it never makes a connection itself.
|
||||
*/
|
||||
const COVERAGE_IGNORED_PROCESSES = new Set(['xpcproxy_sim']);
|
||||
|
||||
export type GuardCoverage = {
|
||||
/** Simulator processes with the guard library mapped. */
|
||||
covered: string[];
|
||||
/** Simulator processes without it: started before the guard was installed. */
|
||||
uncovered: string[];
|
||||
/** Pids behind `uncovered`, for comparing samples. */
|
||||
uncoveredPids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which simulator processes have the guard library mapped, from
|
||||
* `ps -axo pid=,ppid=,comm=` and `lsof -nP -a -p <pids> -d txt -F pn` output.
|
||||
* A process is covered when any of its mapped images is the guard library.
|
||||
*/
|
||||
export function parseGuardCoverage(psOutput: string, lsofOutput: string): GuardCoverage {
|
||||
const simulatorPids = new Set(collectSimulatorProcessIds(psOutput));
|
||||
const commandsByPid = new Map<number, string>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+\d+\s+(\S.*)$/.exec(line);
|
||||
if (match) {
|
||||
commandsByPid.set(Number(match[1]), path.basename(match[2].trim()));
|
||||
}
|
||||
}
|
||||
const loaded = new Set<number>();
|
||||
let pid: number | null = null;
|
||||
for (const line of lsofOutput.split('\n')) {
|
||||
if (line[0] === 'p') {
|
||||
pid = Number(line.slice(1));
|
||||
} else if (line[0] === 'n' && pid !== null && line.endsWith(EGRESS_GUARD_LIBRARY_FILE)) {
|
||||
loaded.add(pid);
|
||||
}
|
||||
}
|
||||
const covered: string[] = [];
|
||||
const uncovered: string[] = [];
|
||||
const uncoveredPids: number[] = [];
|
||||
for (const simulatorPid of simulatorPids) {
|
||||
const name = commandsByPid.get(simulatorPid) ?? String(simulatorPid);
|
||||
if (COVERAGE_IGNORED_PROCESSES.has(name)) {
|
||||
continue;
|
||||
}
|
||||
if (loaded.has(simulatorPid)) {
|
||||
covered.push(name);
|
||||
} else {
|
||||
uncovered.push(name);
|
||||
uncoveredPids.push(simulatorPid);
|
||||
}
|
||||
}
|
||||
covered.sort();
|
||||
uncovered.sort();
|
||||
return { covered, uncovered, uncoveredPids };
|
||||
}
|
||||
|
||||
/**
|
||||
* A process caught between fork and exec has nothing mapped yet and looks
|
||||
* uncovered for an instant. Two samples a moment apart separate those from
|
||||
* processes that really run without the guard: only pids uncovered in both
|
||||
* count, reported with the later sample's names.
|
||||
*/
|
||||
export function mergeGuardCoverageSamples(
|
||||
first: GuardCoverage,
|
||||
second: GuardCoverage
|
||||
): GuardCoverage {
|
||||
const persistent = new Set(first.uncoveredPids.filter(pid => second.uncoveredPids.includes(pid)));
|
||||
const uncovered: string[] = [];
|
||||
const uncoveredPids: number[] = [];
|
||||
second.uncoveredPids.forEach((pid, index) => {
|
||||
if (persistent.has(pid)) {
|
||||
uncovered.push(second.uncovered[index]);
|
||||
uncoveredPids.push(pid);
|
||||
}
|
||||
});
|
||||
return { covered: second.covered, uncovered, uncoveredPids };
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure and log guard coverage across the simulator's processes. Observation
|
||||
* only: the uncovered set is whatever started before the guard was installed,
|
||||
* which is nothing when installation runs right after `simctl boot`.
|
||||
*/
|
||||
export async function reportLocalEgressGuardCoverageAsync({
|
||||
env,
|
||||
logger,
|
||||
sampleIntervalMs = 1_000,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
sampleIntervalMs?: number;
|
||||
}): Promise<GuardCoverage | null> {
|
||||
const first = await sampleGuardCoverageAsync({ env });
|
||||
if (!first) {
|
||||
return null;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, sampleIntervalMs));
|
||||
const second = await sampleGuardCoverageAsync({ env });
|
||||
const coverage = second ? mergeGuardCoverageSamples(first, second) : first;
|
||||
const total = coverage.covered.length + coverage.uncovered.length;
|
||||
if (coverage.uncovered.length === 0) {
|
||||
logger.info(
|
||||
`Local egress guard coverage: all ${total} simulator process(es) have the guard loaded.`
|
||||
);
|
||||
} else {
|
||||
const shown =
|
||||
coverage.uncovered.slice(0, 20).join(', ') +
|
||||
(coverage.uncovered.length > 20 ? `, and ${coverage.uncovered.length - 20} more` : '');
|
||||
logger.info(
|
||||
`Local egress guard coverage: ${coverage.covered.length} of ${total} simulator process(es) have the guard loaded. Not covered, started before the guard was installed: ${shown}.`
|
||||
);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
async function sampleGuardCoverageAsync({
|
||||
env,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<GuardCoverage | null> {
|
||||
let psOutput: string;
|
||||
try {
|
||||
psOutput = (await spawn('ps', ['-axo', 'pid=,ppid=,comm='], { env, stdio: 'pipe' })).stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pids = collectSimulatorProcessIds(psOutput);
|
||||
if (pids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let lsofOutput: string;
|
||||
try {
|
||||
lsofOutput = (
|
||||
await spawn('lsof', ['-nP', '-a', '-p', pids.join(','), '-d', 'txt', '-F', 'pn'], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
).stdout;
|
||||
} catch (err) {
|
||||
// lsof exits 1 when some listed pid has already exited; stdout is still valid.
|
||||
const result = err as { status?: number | null; stdout?: string };
|
||||
if (result.status !== 1) {
|
||||
return null;
|
||||
}
|
||||
lsofOutput = result.stdout ?? '';
|
||||
}
|
||||
return parseGuardCoverage(psOutput, lsofOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the packaged self-check inside the simulator: it must find the guard
|
||||
* loaded in a fresh process and see it behave as `mode` says. Throws when the
|
||||
* check binary is missing or the check fails, which fails the session.
|
||||
*/
|
||||
export async function verifyLocalEgressGuardAsync({
|
||||
udid,
|
||||
env,
|
||||
logger,
|
||||
mode = 'block',
|
||||
checkPath,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
mode?: EgressGuardMode;
|
||||
/** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */
|
||||
checkPath?: string | null;
|
||||
}): Promise<void> {
|
||||
const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath;
|
||||
if (!resolvedCheck) {
|
||||
throw new SystemError(
|
||||
'The local egress guard self-check is not available on this device host, so this session cannot ' +
|
||||
'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' +
|
||||
'this is a service problem, please contact support.'
|
||||
);
|
||||
}
|
||||
let output: string;
|
||||
try {
|
||||
const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
output = result.stdout.trim();
|
||||
} catch (err) {
|
||||
const failed = err as { status?: number | null; stdout?: string; stderr?: string };
|
||||
const detail = [failed.stdout, failed.stderr].filter(Boolean).join('\n').trim();
|
||||
throw new SystemError(
|
||||
'The local egress guard is not in effect in the Simulator: the self-check run right after boot ' +
|
||||
`failed${failed.status != null ? ` (exit ${failed.status})` : ''}. Connections that bypass the ` +
|
||||
'system proxy would leave from the device host, so the session was stopped. Retry the session; if it ' +
|
||||
`keeps failing, please contact support.${detail ? ` Self-check output: ${detail}` : ''}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
logger.info(`Local egress guard verified in the Simulator: ${output || 'self-check passed'}.`);
|
||||
await reportLocalEgressGuardCoverageAsync({ env, logger });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute guard events to the step that runs the session from now on. The
|
||||
* relay starts under the simulator boot step's logger; once the session step
|
||||
* takes over, its lines should appear under that step in the job log, next
|
||||
* to the rest of the session's output, rather than under a step that already
|
||||
* finished.
|
||||
*/
|
||||
export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
for (const { relay } of activeRelays.values()) {
|
||||
relay.setLogger(logger);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop relaying and write each relay's summary; called from the session cleanup. */
|
||||
export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<void> {
|
||||
const relays = [...activeRelays.values()];
|
||||
activeRelays.clear();
|
||||
for (const { tailer, relay } of relays) {
|
||||
try {
|
||||
await tailer.stopAsync();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Local egress guard: could not read the last events from the guard log.'
|
||||
);
|
||||
}
|
||||
relay.logSummary();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type BuildFunction, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import {
|
||||
buildEgressRemoteConfigFields,
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from './localEgress';
|
||||
import { rebindLocalEgressGuardRelays } from './localEgressGuard';
|
||||
import { uploadRemoteSessionConfigAsync } from './remoteDeviceRunSession';
|
||||
|
||||
/** Release the pre-boot egress resources even if controller startup or teardown fails. */
|
||||
export function withLocalEgressSession(
|
||||
fn: NonNullable<BuildFunction['fn']>
|
||||
): NonNullable<BuildFunction['fn']> {
|
||||
return async (context, args) => {
|
||||
try {
|
||||
args.signal?.throwIfAborted();
|
||||
await fn(context, args);
|
||||
} finally {
|
||||
await stopLocalEgressResourcesAsync(context.logger);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** All simulator controllers publish the same egress handoff and run the same monitor. */
|
||||
export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
...options
|
||||
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
|
||||
env: BuildStepEnv;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
|
||||
const localEgress = await readLocalEgressHandoffAsync();
|
||||
signal?.throwIfAborted();
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
...options,
|
||||
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
|
||||
});
|
||||
if (localEgress && !signal?.aborted) {
|
||||
// Guard refusals from here on show up under this step in the job log,
|
||||
// alongside the monitor's reports, instead of under the boot step.
|
||||
rebindLocalEgressGuardRelays(options.logger);
|
||||
options.logger.info(
|
||||
'Local egress: waiting for the EAS CLI egress client to connect. Proxied HTTP(S) ' +
|
||||
'requests are unavailable until it does.'
|
||||
);
|
||||
// The monitor also observes the registered resources' lifetime signal, which
|
||||
// withLocalEgressSession aborts on success, failure, or cancellation.
|
||||
void monitorLocalEgressAsync({
|
||||
port: localEgress.port,
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,19 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -293,10 +298,9 @@ async function sleepUntilAbortedAsync(
|
||||
}
|
||||
}
|
||||
|
||||
// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
|
||||
// which it resolves from PATH. The tool-server inherits this step's env, so
|
||||
// spawning ffmpeg resolves against the same PATH argent will search: it rejects
|
||||
// with ENOENT when the binary is absent, and running it also proves it works.
|
||||
// Device-session tools resolve `ffmpeg` from PATH. Spawning it with the step's
|
||||
// environment rejects with ENOENT when the binary is absent, and running it also
|
||||
// proves that the installed binary works.
|
||||
async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok;
|
||||
}
|
||||
@@ -329,14 +333,15 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it, so argent's
|
||||
* `screen-recording-start` tool can encode a video. The worker images do not
|
||||
* ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
|
||||
* on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
* tools use it for video encoding on macOS (iOS simulators) and Linux (Android
|
||||
* emulators) alike, but the worker images do not ship it yet.
|
||||
*
|
||||
* Best-effort by design: screen recording is one optional argent tool, so a
|
||||
* failure here is logged and the session continues without it.
|
||||
* Best-effort by design: a failure here is logged and the session continues
|
||||
* without FFmpeg-dependent features.
|
||||
*
|
||||
* The whole body is wrapped because the caller runs this in the background with
|
||||
* `void`. There is no unhandledRejection handler in the worker, so a rejection
|
||||
@@ -344,7 +349,7 @@ async function installFfmpegWithAptAsync({
|
||||
* `spawn` is not an async function and can throw synchronously, which
|
||||
* `asyncResult` cannot catch — it only wraps an already-created promise.
|
||||
*/
|
||||
export async function ensureFfmpegInstalledAsync({
|
||||
async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
@@ -363,7 +368,7 @@ export async function ensureFfmpegInstalledAsync({
|
||||
logger.info(
|
||||
`ffmpeg is not installed, installing it with ${
|
||||
isDarwin ? 'Homebrew' : 'apt'
|
||||
} for argent screen recording.`
|
||||
} for the device session.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
@@ -373,16 +378,41 @@ export async function ensureFfmpegInstalledAsync({
|
||||
logger.info('Installed ffmpeg.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install ffmpeg. Argent screen recording will not work in this session.'
|
||||
'Could not install ffmpeg. FFmpeg-dependent features may not work in this session.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (ffmpegSetupPromise) {
|
||||
await ffmpegSetupPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
@@ -390,11 +420,12 @@ const TurnIceServersResponseSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials).
|
||||
* (the entry carrying the short-lived credentials). serve-sim and expo-device-hub
|
||||
* intentionally expose the same ICE flag contract.
|
||||
*/
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
@@ -420,14 +451,14 @@ export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string
|
||||
/**
|
||||
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
|
||||
* (minted on demand, mirroring how the worker fetches project clone URLs) and
|
||||
* translate them into serve-sim CLI flags.
|
||||
* translate them into web preview CLI flags.
|
||||
*
|
||||
* Best-effort: on any failure we log and return [] so serve-sim falls back to
|
||||
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
|
||||
* process arg and deliberately not logged (turtle-spawn never logs argv and the
|
||||
* worker is single-tenant).
|
||||
* Best-effort: on any failure we log and return [] so the preview server falls
|
||||
* back to its built-in P2P/STUN behavior. The credential is passed as a process
|
||||
* arg and deliberately not logged (turtle-spawn never logs argv and the worker
|
||||
* is single-tenant).
|
||||
*/
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
export async function fetchWebPreviewTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
@@ -456,9 +487,9 @@ export async function fetchServeSimTurnArgsAsync(
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
const args = turnIceServersToWebPreviewArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
@@ -466,7 +497,7 @@ export async function fetchServeSimTurnArgsAsync(
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -600,6 +631,10 @@ function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
@@ -617,7 +652,8 @@ export function createServeSimArgs({
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
SERVE_SIM_HOST,
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
@@ -635,44 +671,84 @@ export function createServeSimArgs({
|
||||
];
|
||||
}
|
||||
|
||||
async function findAvailablePortAsync(): Promise<number> {
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, SERVE_SIM_HOST, () => resolve());
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(err => (err ? reject(err) : resolve()));
|
||||
});
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new SystemError('Could not allocate a local port for serve-sim.');
|
||||
throw new SystemError('Could not allocate a local port for the web preview.');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const ServeSimReadyResponseSchema = z.object({
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForServeSimReadyAsync({
|
||||
serveSim,
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
serveSim: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -680,25 +756,102 @@ export async function waitForServeSimReadyAsync({
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
ServeSimReadyResponseSchema.parse(await response.json());
|
||||
return;
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim readiness at ${readyUrl}${
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type ServeSimPreviewHandle = {
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewServer = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createArgs(port, turnArgs),
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
@@ -715,42 +868,84 @@ export async function startServeSimWithTunnelAsync(
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(
|
||||
`Launching ${createServeSimPackageSpec(packageVersion)} on ${SERVE_SIM_HOST}:${port}.`
|
||||
);
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Waiting for serve-sim to become ready.');
|
||||
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await serveSim.stopAsync();
|
||||
throw error;
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export interface SandboxDaemonOptions {
|
||||
credential: string;
|
||||
serverUrl: string;
|
||||
reconnectDelayMs: number;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SandboxDaemon {
|
||||
ready: Promise<void>;
|
||||
stopAsync(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startSandboxDaemonAsync(
|
||||
options: SandboxDaemonOptions
|
||||
): Promise<SandboxDaemon> {
|
||||
options.signal?.throwIfAborted();
|
||||
let socket: WebSocket | undefined;
|
||||
const abortController = new AbortController();
|
||||
let hasConnected = false;
|
||||
let resolveConnected!: () => void;
|
||||
let rejectConnected!: (error: Error) => void;
|
||||
const connected = new Promise<void>((resolve, reject) => {
|
||||
resolveConnected = resolve;
|
||||
rejectConnected = reject;
|
||||
});
|
||||
const stop = (): void => {
|
||||
abortController.abort();
|
||||
socket?.close(1000, 'sandbox stopped');
|
||||
};
|
||||
options.signal?.addEventListener('abort', stop, { once: true });
|
||||
|
||||
const connectionLoop = (async () => {
|
||||
while (!abortController.signal.aborted) {
|
||||
try {
|
||||
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
|
||||
handshakeTimeout: 10_000,
|
||||
headers: { Authorization: `Bearer ${options.credential}` },
|
||||
});
|
||||
await waitForOpen(socket);
|
||||
options.logger.info('Sandbox MCP server connected.');
|
||||
hasConnected = true;
|
||||
resolveConnected();
|
||||
await waitForClose(socket);
|
||||
} catch (error: any) {
|
||||
if (!hasConnected) {
|
||||
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
|
||||
if (!abortController.signal.aborted) {
|
||||
options.logger.error({ err: error }, message);
|
||||
}
|
||||
rejectConnected(new SystemError(message, { cause: error }));
|
||||
return;
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
options.logger.warn(
|
||||
{ err: error },
|
||||
`Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
try {
|
||||
await setTimeoutAsync(options.reconnectDelayMs, undefined, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ready: connected,
|
||||
async stopAsync(): Promise<void> {
|
||||
options.signal?.removeEventListener('abort', stop);
|
||||
stop();
|
||||
await connectionLoop;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function waitForOpen(socket: WebSocket): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('open', resolve);
|
||||
socket.once('error', reject);
|
||||
socket.once('close', () => reject(new Error('Sandbox MCP server closed before it connected.')));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(socket: WebSocket): Promise<void> {
|
||||
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.once('close', resolve);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,9 @@ const POLL_INTERVAL_MS = 2_000;
|
||||
const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
|
||||
const END_STREAM_TIMEOUT_MS = 2_000;
|
||||
// serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
|
||||
const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
export const SERVE_SIM_STATE_DIR = path.join(os.tmpdir(), 'serve-sim');
|
||||
|
||||
type ServeSimServer = { udid: string; url: string };
|
||||
type ServeSimServer = { udid: string; url: string; token?: string };
|
||||
|
||||
type ServeSimMetricsSession = {
|
||||
logger: bunyan;
|
||||
@@ -130,6 +130,7 @@ async function pollServeSimMetricsAsync(session: ServeSimMetricsSession): Promis
|
||||
logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
|
||||
const donePromise = streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
filePath,
|
||||
signal: streamSignal,
|
||||
logger,
|
||||
@@ -172,9 +173,14 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
const state = JSON.parse(await readFile(path.join(stateDir, entry), 'utf-8')) as {
|
||||
device?: unknown;
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
};
|
||||
if (typeof state.device === 'string' && typeof state.url === 'string') {
|
||||
servers.push({ udid: state.device, url: state.url });
|
||||
servers.push({
|
||||
udid: state.device,
|
||||
url: state.url,
|
||||
...(typeof state.token === 'string' ? { token: state.token } : {}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
@@ -185,11 +191,13 @@ export async function readServeSimServersAsync(stateDir: string): Promise<ServeS
|
||||
|
||||
export async function streamServeSimMetricsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
@@ -201,7 +209,10 @@ export async function streamServeSimMetricsToFileAsync({
|
||||
let receivedData = false;
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), { signal });
|
||||
const response = await fetch(new URL('/metrics', serveSimUrl).toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
|
||||
return { receivedData, metadata };
|
||||
|
||||
@@ -99,12 +99,18 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier: AndroidDeviceName | null;
|
||||
lcdWidth: number | null;
|
||||
lcdHeight: number | null;
|
||||
lcdDensity: number | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -176,6 +182,13 @@ export namespace AndroidEmulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
if (lcdWidth !== null && lcdHeight !== null && lcdDensity !== null) {
|
||||
logger.info(
|
||||
`Setting screen resolution to ${lcdWidth}x${lcdHeight} and density to ${lcdDensity} ppi.`
|
||||
);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.lcd.height=${lcdHeight}\nhw.lcd.width=${lcdWidth}\nhw.lcd.density=${lcdDensity}\n`;
|
||||
}
|
||||
|
||||
const shouldAdjustHeapSize =
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_HEAP_SIZE !== '0';
|
||||
|
||||
@@ -185,6 +185,67 @@ export namespace IosSimulatorUtils {
|
||||
}
|
||||
}
|
||||
|
||||
const UDID_PATTERN = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i;
|
||||
|
||||
/**
|
||||
* The UDID for a device name or UDID. A name picks the first available
|
||||
* device with that name, as `simctl` itself does.
|
||||
*/
|
||||
export async function resolveUdidAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<IosSimulatorUuid> {
|
||||
if (UDID_PATTERN.test(deviceIdentifier)) {
|
||||
return deviceIdentifier as IosSimulatorUuid;
|
||||
}
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(candidate => candidate.name === deviceIdentifier);
|
||||
if (!device) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
`No available iOS Simulator is named "${deviceIdentifier}". Run \`xcrun simctl list devices available\` on the device host to see the devices it offers.`
|
||||
);
|
||||
}
|
||||
return device.udid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start booting without waiting for boot to complete; follow with
|
||||
* `startAsync` to wait for it. `launchdEnvironment` is handed to the
|
||||
* simulator's launchd before it spawns anything: `simctl` forwards every
|
||||
* `SIMCTL_CHILD_`-prefixed variable of its own environment to the process
|
||||
* it starts, and for `boot` that process is launchd itself. This is the only
|
||||
* way to give the first processes of a boot an environment; `launchctl
|
||||
* setenv` after boot only reaches processes started later. A device that is
|
||||
* already booted keeps its environment.
|
||||
*/
|
||||
export async function bootAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
launchdEnvironment = {},
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
launchdEnvironment?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const bootEnv = { ...env };
|
||||
for (const [name, value] of Object.entries(launchdEnvironment)) {
|
||||
bootEnv[`SIMCTL_CHILD_${name}`] = value;
|
||||
}
|
||||
try {
|
||||
await spawn('xcrun', ['simctl', 'boot', deviceIdentifier], { env: bootEnv, stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
const failed = err as { stderr?: string };
|
||||
if (/current state: Booted/.test(failed.stderr ?? '')) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
@@ -285,6 +346,32 @@ export namespace IosSimulatorUtils {
|
||||
throw lastError ?? new SystemError('Unable to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set environment variables in the Simulator's launchd. Every process that
|
||||
* launchd spawns afterwards inherits them: apps launched by SpringBoard
|
||||
* (deep links, taps, WebDriverAgent) as well as by `simctl launch`.
|
||||
* Processes that are already running keep their environment.
|
||||
*/
|
||||
export async function setLaunchdEnvironmentAsync({
|
||||
udid,
|
||||
env,
|
||||
variables,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
variables: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
// One invocation for every variable: each `simctl spawn` costs a few
|
||||
// hundred milliseconds on a device host, and this runs in the window
|
||||
// between `simctl boot` returning and launchd spawning the boot's
|
||||
// processes, which must inherit these.
|
||||
const pairs = Object.entries(variables).flat();
|
||||
if (pairs.length === 0) {
|
||||
return;
|
||||
}
|
||||
await spawn('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'setenv', ...pairs], { env });
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -95,6 +98,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -158,6 +164,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -209,6 +218,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
@@ -290,6 +302,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
@@ -361,6 +376,9 @@ describe('AndroidEmulatorUtils', () => {
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -48,6 +52,120 @@ describe('AndroidEmulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.createAsync, () => {
|
||||
it('applies an explicit LCD configuration after screen scaling', async () => {
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
`${avdDirectory}/config.ini`,
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
].join('\n')
|
||||
);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
const write = jest.fn();
|
||||
const end = jest.fn();
|
||||
avdManagerPromise.child = { stdin: { write, end } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: 'system-images;android-35;default;x86_64',
|
||||
deviceIdentifier: 'medium_phone' as AndroidDeviceName,
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 262,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_SCREEN: '1',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
[
|
||||
'create',
|
||||
'avd',
|
||||
'--name',
|
||||
deviceName,
|
||||
'--package',
|
||||
'system-images;android-35;default;x86_64',
|
||||
'--force',
|
||||
'--device',
|
||||
'medium_phone',
|
||||
],
|
||||
{ env: expect.any(Object), stdio: 'pipe' }
|
||||
);
|
||||
expect(write).toHaveBeenCalledWith('no');
|
||||
expect(end).toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting scaled screen resolution: hw.lcd.height to 1270 and hw.lcd.width to 566.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Setting screen resolution to 720x1600 and density to 262 ppi.'
|
||||
);
|
||||
await expect(fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8')).resolves.toBe(
|
||||
[
|
||||
'hw.ramSize=1536',
|
||||
'hw.lcd.height=2424',
|
||||
'hw.lcd.width=1080',
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
'hw.lcd.height=1270',
|
||||
'hw.lcd.width=566',
|
||||
'',
|
||||
'hw.lcd.height=1600',
|
||||
'hw.lcd.width=720',
|
||||
'hw.lcd.density=262',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply an LCD configuration when it is omitted', async () => {
|
||||
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
|
||||
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
|
||||
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
|
||||
await fs.promises.mkdir(avdDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(`${avdDirectory}/config.ini`, initialConfig);
|
||||
const avdManagerPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
avdManagerPromise.child = { stdin: { write: jest.fn(), end: jest.fn() } };
|
||||
mockedSpawn.mockReturnValue(avdManagerPromise);
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
lcdWidth: null,
|
||||
lcdHeight: null,
|
||||
lcdDensity: null,
|
||||
env: {
|
||||
HOME: '/home/expo',
|
||||
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
|
||||
expect(config).toContain(initialConfig);
|
||||
expect(config).not.toContain('hw.lcd.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
|
||||
@@ -340,4 +340,136 @@ describe('IosSimulatorUtils', () => {
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
describe(IosSimulatorUtils.setLaunchdEnvironmentAsync, () => {
|
||||
it('sets every variable in the simulator launchd with one invocation', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899', no_proxy: 'localhost,127.0.0.1' },
|
||||
});
|
||||
|
||||
expect(mockedSpawn.mock.calls).toEqual([
|
||||
[
|
||||
'xcrun',
|
||||
[
|
||||
'simctl',
|
||||
'spawn',
|
||||
'test-udid',
|
||||
'launchctl',
|
||||
'setenv',
|
||||
'https_proxy',
|
||||
'http://127.0.0.1:8899',
|
||||
'no_proxy',
|
||||
'localhost,127.0.0.1',
|
||||
],
|
||||
{ env: process.env },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing for an empty variable set', async () => {
|
||||
await IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: {},
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a launchctl failure', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('launchctl failed'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.setLaunchdEnvironmentAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
variables: { https_proxy: 'http://127.0.0.1:8899' },
|
||||
})
|
||||
).rejects.toThrow('launchctl failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.resolveUdidAsync, () => {
|
||||
it('passes a udid through without listing devices', async () => {
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: '8027F627-9534-4679-85BF-0F14AFF228E8' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('8027F627-9534-4679-85BF-0F14AFF228E8');
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a device name to the first available device with that name', async () => {
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-26-5': [
|
||||
{ udid: 'AAAA', name: 'iPhone 17', isAvailable: true, state: 'Shutdown' },
|
||||
{ udid: 'BBBB', name: 'iPhone Air', isAvailable: true, state: 'Shutdown' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: 'iPhone Air' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBe('BBBB');
|
||||
await expect(
|
||||
IosSimulatorUtils.resolveUdidAsync({
|
||||
deviceIdentifier: 'iPhone 99' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.bootAsync, () => {
|
||||
it('boots without waiting and tolerates an already booted device', async () => {
|
||||
await IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env });
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAAA'], {
|
||||
env: process.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
mockedSpawn.mockRejectedValueOnce(
|
||||
Object.assign(new Error('boot failed'), {
|
||||
stderr: 'Unable to boot device in current state: Booted',
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
mockedSpawn.mockRejectedValueOnce(
|
||||
Object.assign(new Error('boot failed'), { stderr: 'other' })
|
||||
);
|
||||
await expect(
|
||||
IosSimulatorUtils.bootAsync({ deviceIdentifier: 'AAAA' as any, env: process.env })
|
||||
).rejects.toThrow('boot failed');
|
||||
});
|
||||
|
||||
it('hands launchd environment to the boot through SIMCTL_CHILD_ variables', async () => {
|
||||
await IosSimulatorUtils.bootAsync({
|
||||
deviceIdentifier: 'AAAA' as any,
|
||||
env: { PATH: '/usr/bin' },
|
||||
launchdEnvironment: {
|
||||
DYLD_INSERT_LIBRARIES: '/w/guard.dylib',
|
||||
https_proxy: 'http://127.0.0.1:8899',
|
||||
},
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('xcrun', ['simctl', 'boot', 'AAAA'], {
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SIMCTL_CHILD_DYLD_INSERT_LIBRARIES: '/w/guard.dylib',
|
||||
SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
compressCocoapodsCacheAsync,
|
||||
getCocoapodsCachePaths,
|
||||
resolveCocoapodsCacheKeyAsync,
|
||||
restoreCocoapodsCacheArchiveAsync,
|
||||
} from '../cocoapodsCache';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('CocoaPods cache utilities', () => {
|
||||
let workingDirectory: string;
|
||||
const cocoapodsVersion = '1.16.2';
|
||||
|
||||
beforeEach(async () => {
|
||||
workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-'));
|
||||
await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(workingDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => {
|
||||
await expect(
|
||||
resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion)
|
||||
).resolves.toEqual({
|
||||
key: 'ios-pods-1.16.2-',
|
||||
keyPrefix: 'ios-pods-1.16.2-',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the Podfile.lock hash when it exists', async () => {
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n');
|
||||
|
||||
const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/);
|
||||
expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-');
|
||||
|
||||
await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n');
|
||||
const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion);
|
||||
expect(secondResult.key).not.toBe(firstResult.key);
|
||||
});
|
||||
|
||||
it('preserves symlinks and executable permissions through an archive round trip', async () => {
|
||||
const { podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions');
|
||||
const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A');
|
||||
await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary');
|
||||
await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current'));
|
||||
|
||||
const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh');
|
||||
await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.promises.writeFile(scriptPath, '#!/bin/sh\n');
|
||||
await fs.promises.chmod(scriptPath, 0o755);
|
||||
|
||||
const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory });
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory });
|
||||
|
||||
const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current');
|
||||
expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true);
|
||||
await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A');
|
||||
|
||||
const restoredScriptStat = await fs.promises.stat(scriptPath);
|
||||
expect(restoredScriptStat.mode & 0o111).toBe(0o111);
|
||||
|
||||
await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${STAGING_VERSION}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
@@ -133,7 +133,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(stagingResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0', EXPO_STAGING: '1' },
|
||||
});
|
||||
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
@@ -141,7 +141,7 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(productionResult).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
@@ -173,9 +173,17 @@ describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${PRODUCTION_VERSION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: { NPM_CONFIG_MIN_RELEASE_AGE: '0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the minimum release age for every npx invocation', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result.extraEnv).toEqual({ NPM_CONFIG_MIN_RELEASE_AGE: '0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe(runEasCliCommand, () => {
|
||||
@@ -218,4 +226,25 @@ describe(runEasCliCommand, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides a user-provided minimum release age', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
|
||||
await runEasCliCommand({
|
||||
args: ['deploy', '--json'],
|
||||
options: {
|
||||
cwd: '/tmp/project',
|
||||
env: {
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '365',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const spawnEnv = jest.mocked(spawn).mock.calls[0][2]?.env;
|
||||
expect(spawnEnv).toEqual({
|
||||
FOO: 'bar',
|
||||
NPM_CONFIG_MIN_RELEASE_AGE: '0',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
|
||||
import { withLogPhaseAsync } from '../logPhase';
|
||||
|
||||
describe(withLogPhaseAsync.name, () => {
|
||||
const phaseLogger = { info: jest.fn() };
|
||||
const logger = { child: jest.fn(() => phaseLogger) };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the callback result and ends the phase successfully', async () => {
|
||||
const result = await withLogPhaseAsync(logger as any, 'Phase name', async callbackLogger => {
|
||||
expect(callbackLogger).toBe(phaseLogger);
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(logger.child).toHaveBeenCalledWith({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: expect.stringMatching(/^step-\d{3,}$/),
|
||||
buildStepDisplayName: 'Phase name',
|
||||
});
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ marker: LogMarker.START_PHASE },
|
||||
'Start phase: Phase name'
|
||||
);
|
||||
expect(phaseLogger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
|
||||
it('ends the phase with failure and rethrows the error', async () => {
|
||||
const error = new Error('failed');
|
||||
|
||||
await expect(
|
||||
withLogPhaseAsync(logger as any, 'Phase name', async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(phaseLogger.info).toHaveBeenLastCalledWith(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
'End phase: Phase name'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import * as tar from 'tar';
|
||||
|
||||
export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-';
|
||||
|
||||
const PODS_DIRECTORY_NAME = 'Pods';
|
||||
const PODFILE_LOCK_NAME = 'Podfile.lock';
|
||||
|
||||
export function getCocoapodsCachePaths(workingDirectory: string): {
|
||||
iosDirectory: string;
|
||||
podsDirectory: string;
|
||||
podfileLockPath: string;
|
||||
} {
|
||||
const iosDirectory = path.join(workingDirectory, 'ios');
|
||||
return {
|
||||
iosDirectory,
|
||||
podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME),
|
||||
podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCocoapodsCacheKeyAsync(
|
||||
workingDirectory: string,
|
||||
cocoapodsVersion: string
|
||||
): Promise<{ key: string; keyPrefix: string }> {
|
||||
const normalizedVersion = cocoapodsVersion.trim();
|
||||
if (!normalizedVersion) {
|
||||
throw new Error('Failed to determine CocoaPods version');
|
||||
}
|
||||
|
||||
const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`;
|
||||
const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory);
|
||||
|
||||
try {
|
||||
await fs.promises.access(podfileLockPath);
|
||||
} catch {
|
||||
return { key: keyPrefix, keyPrefix };
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${hashFiles([podfileLockPath])}`,
|
||||
keyPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressCocoapodsCacheAsync({
|
||||
workingDirectory,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.access(podsDirectory);
|
||||
|
||||
const archiveDestinationDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'save-cocoapods-cache-')
|
||||
);
|
||||
const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz');
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: iosDirectory,
|
||||
gzip: true,
|
||||
},
|
||||
[PODS_DIRECTORY_NAME]
|
||||
);
|
||||
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function restoreCocoapodsCacheArchiveAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<void> {
|
||||
const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory);
|
||||
await fs.promises.mkdir(iosDirectory, { recursive: true });
|
||||
|
||||
const temporaryRestoreDirectory = await fs.promises.mkdtemp(
|
||||
path.join(iosDirectory, '.eas-pods-cache-')
|
||||
);
|
||||
try {
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: temporaryRestoreDirectory,
|
||||
filter: entryPath =>
|
||||
entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`),
|
||||
});
|
||||
|
||||
const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME);
|
||||
const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory);
|
||||
if (!restoredPodsStat.isDirectory()) {
|
||||
throw new Error('CocoaPods cache archive does not contain a Pods directory');
|
||||
}
|
||||
|
||||
await fs.promises.rm(podsDirectory, { recursive: true, force: true });
|
||||
await fs.promises.rename(restoredPodsDirectory, podsDirectory);
|
||||
} finally {
|
||||
await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { Sentry } from '../sentry';
|
||||
|
||||
let cachedEasCliVersionsPromise: Promise<EasCliVersions> | undefined;
|
||||
|
||||
const NPX_ENV = { NPM_CONFIG_MIN_RELEASE_AGE: '0' };
|
||||
|
||||
/**
|
||||
* Resolves the `eas-cli` versions to install for staging/production. Prefers the
|
||||
* versions committed to `cli-versions.json` (fetched from expo/eas-cli); on any
|
||||
@@ -74,21 +76,21 @@ export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
extraEnv: { ...NPX_ENV, EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
const versions = await resolveEasCliVersionsAsync();
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${versions.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
extraEnv: NPX_ENV,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildStep } from '@expo/steps';
|
||||
|
||||
export async function withLogPhaseAsync<T>(
|
||||
logger: bunyan,
|
||||
name: string,
|
||||
fn: (logger: bunyan) => Promise<T>
|
||||
): Promise<T> {
|
||||
const phaseLogger = logger.child({
|
||||
phase: BuildPhase.CUSTOM,
|
||||
buildStepId: BuildStep.getNewId(),
|
||||
buildStepDisplayName: name,
|
||||
});
|
||||
phaseLogger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${name}`);
|
||||
try {
|
||||
const result = await fn(phaseLogger);
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.SUCCESS },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
phaseLogger.info(
|
||||
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.FAIL },
|
||||
`End phase: ${name}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "22.4.0",
|
||||
"version": "24.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "22.4.0"
|
||||
"@expo/steps": "24.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "22.0.0",
|
||||
"version": "24.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "22.0.0",
|
||||
"version": "24.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "22.0.0",
|
||||
"version": "24.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -240,7 +240,6 @@ describe('Android.JobSchema', () => {
|
||||
username: 'turtle-tutorial',
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'http://localhost:3000',
|
||||
gitRef: 'master',
|
||||
gitCommitHash: '1b57db5b1cd12638aba0d12da71a2d691416700d',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchema,
|
||||
ArchiveSourceSchemaZ,
|
||||
ArchiveSourceType,
|
||||
EasCliVersionsFetchTimeoutError,
|
||||
EnvSchema,
|
||||
SshSettingsZ,
|
||||
@@ -675,3 +679,36 @@ describe('fetchEasCliVersionsAsync', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git archive sources', () => {
|
||||
const source: ArchiveSource = {
|
||||
type: ArchiveSourceType.GIT,
|
||||
gitRef: null,
|
||||
gitCommitHash: '1234567890',
|
||||
};
|
||||
|
||||
it('accepts Git job sources without a repository URL', () => {
|
||||
expect(ArchiveSourceSchema.validate(source)).toMatchObject({ value: source });
|
||||
expect(ArchiveSourceSchema.validate(source).error).toBeUndefined();
|
||||
expect(ArchiveSourceSchemaZ.parse(source)).toEqual(source);
|
||||
});
|
||||
|
||||
it('strips the legacy repository URL with the job validation options', () => {
|
||||
const archive = {
|
||||
...source,
|
||||
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
|
||||
};
|
||||
const result = ArchiveSourceSchema.validate(archive, { stripUnknown: true });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toEqual(source);
|
||||
expect(ArchiveSourceSchemaZ.parse(archive)).toEqual(source);
|
||||
});
|
||||
|
||||
it('still requires the commit and ref', () => {
|
||||
for (const field of ['gitCommitHash', 'gitRef'] as const) {
|
||||
const archive = { ...source, [field]: undefined };
|
||||
expect(ArchiveSourceSchema.validate(archive).error).toBeDefined();
|
||||
expect(ArchiveSourceSchemaZ.safeParse(archive).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -51,7 +50,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -96,7 +94,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -131,7 +128,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -183,7 +179,6 @@ describe('Generic.JobZ', () => {
|
||||
const job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
@@ -232,7 +227,6 @@ describe('Generic.JobZ', () => {
|
||||
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SandboxDaemonCommands, SandboxDaemonRequestZ, SandboxDaemonResponseZ } from '../sandbox';
|
||||
|
||||
describe('sandbox daemon protocol', () => {
|
||||
it('validates command parameters', () => {
|
||||
expect(
|
||||
SandboxDaemonCommands.execCommand.params.parse({
|
||||
cmd: 'pwd',
|
||||
workdir: '/tmp',
|
||||
tty: true,
|
||||
yieldTimeMs: 10,
|
||||
})
|
||||
).toEqual({ cmd: 'pwd', workdir: '/tmp', tty: true, yieldTimeMs: 10 });
|
||||
expect(
|
||||
SandboxDaemonCommands.writeStdin.params.parse({
|
||||
sessionId: 1,
|
||||
chars: '\u0003',
|
||||
yieldTimeMs: 0,
|
||||
})
|
||||
).toEqual({ sessionId: 1, chars: '\u0003', yieldTimeMs: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output: '', wallTimeSeconds: 0, exitCode: 0 },
|
||||
{ output: '', wallTimeSeconds: 0, terminationSignal: 'SIGTERM' },
|
||||
{ output: '', wallTimeSeconds: 0, sessionId: 1 },
|
||||
])('validates a command result with one terminal state', result => {
|
||||
expect(SandboxDaemonCommands.execCommand.result.parse(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ output: '', wallTimeSeconds: 0 },
|
||||
{ output: '', wallTimeSeconds: 0, exitCode: 0, sessionId: 1 },
|
||||
])('rejects a command result without exactly one terminal state', result => {
|
||||
expect(() => SandboxDaemonCommands.writeStdin.result.parse(result)).toThrow();
|
||||
});
|
||||
|
||||
it('validates success and error response envelopes', () => {
|
||||
expect(
|
||||
SandboxDaemonRequestZ.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
})
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
method: 'execCommand',
|
||||
params: { cmd: 'pwd' },
|
||||
});
|
||||
expect(
|
||||
SandboxDaemonResponseZ.parse({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } })
|
||||
).toEqual({ jsonrpc: '2.0', id: 'request-id', result: { output: '' } });
|
||||
expect(
|
||||
SandboxDaemonResponseZ.parse({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
error: { code: -32603, message: 'Command failed' },
|
||||
})
|
||||
).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 'request-id',
|
||||
error: { code: -32603, message: 'Command failed' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,11 +45,6 @@ export type ArchiveSource =
|
||||
| { type: ArchiveSourceType.PATH; path: string }
|
||||
| {
|
||||
type: ArchiveSourceType.GIT;
|
||||
/**
|
||||
* Url that can be used to clone repository.
|
||||
* It should contain embedded credentials for private registries.
|
||||
*/
|
||||
repositoryUrl: string;
|
||||
/** A Git ref - points to a branch head, tag head or a branch name. */
|
||||
gitRef: string | null;
|
||||
/**
|
||||
@@ -79,7 +74,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
|
||||
.when(Joi.object({ type: ArchiveSourceType.GIT }).unknown(), {
|
||||
then: Joi.object({
|
||||
type: Joi.string().valid(ArchiveSourceType.GIT).required(),
|
||||
repositoryUrl: Joi.string().required(),
|
||||
gitCommitHash: Joi.string().required(),
|
||||
gitRef: Joi.string().allow(null).required(),
|
||||
}),
|
||||
@@ -94,7 +88,6 @@ export const ArchiveSourceSchema = Joi.object<ArchiveSource>({
|
||||
export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(ArchiveSourceType.GIT),
|
||||
repositoryUrl: z.string().url(),
|
||||
gitRef: z.string().nullable(),
|
||||
gitCommitHash: z.string(),
|
||||
}),
|
||||
|
||||
@@ -34,6 +34,7 @@ export * from './compositeFunction';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
export * from './deviceRunSession';
|
||||
export * from './sandbox';
|
||||
|
||||
const version = require('../package.json').version;
|
||||
export { version };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SandboxDaemonCommands = {
|
||||
execCommand: {
|
||||
params: z.object({
|
||||
cmd: z.string().min(1),
|
||||
workdir: z.string().optional(),
|
||||
tty: z.boolean().optional(),
|
||||
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
|
||||
}),
|
||||
result: z.union([
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
exitCode: z.number().int(),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
terminationSignal: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
sessionId: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
writeStdin: {
|
||||
params: z.object({
|
||||
sessionId: z.number().int().positive(),
|
||||
chars: z.string().optional(),
|
||||
yieldTimeMs: z.number().int().min(0).max(30_000).optional(),
|
||||
}),
|
||||
result: z.union([
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
exitCode: z.number().int(),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
terminationSignal: z.string().min(1),
|
||||
}),
|
||||
z.strictObject({
|
||||
output: z.string(),
|
||||
wallTimeSeconds: z.number().nonnegative(),
|
||||
sessionId: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
} as const satisfies Record<string, { params: z.ZodType; result: z.ZodType }>;
|
||||
|
||||
export type SandboxDaemonMethod = keyof typeof SandboxDaemonCommands;
|
||||
|
||||
export type SandboxDaemonCommandParams<Method extends SandboxDaemonMethod> = z.output<
|
||||
(typeof SandboxDaemonCommands)[Method]['params']
|
||||
>;
|
||||
|
||||
export type SandboxDaemonCommandResult<Method extends SandboxDaemonMethod> = z.output<
|
||||
(typeof SandboxDaemonCommands)[Method]['result']
|
||||
>;
|
||||
|
||||
export const SandboxDaemonRequestZ = z.object({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.string(),
|
||||
method: z.string(),
|
||||
params: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type SandboxDaemonRequest = z.output<typeof SandboxDaemonRequestZ>;
|
||||
|
||||
export const SandboxDaemonResponseZ = z.union([
|
||||
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
|
||||
z.strictObject({
|
||||
jsonrpc: z.literal('2.0'),
|
||||
id: z.string(),
|
||||
error: z.strictObject({ code: z.number().int(), message: z.string() }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type SandboxDaemonResponse = z.output<typeof SandboxDaemonResponseZ>;
|
||||
@@ -299,4 +299,4 @@ describe('MyCommand', () => {
|
||||
- **README**: Auto-generated from oclif - update via `yarn oclif readme`
|
||||
- **Manifest**: Generated during `prepack` step
|
||||
- **Templates**: `src/commandUtils/new/templates` are copied to `build/` during build
|
||||
- **Node Version**: >= 18.0.0
|
||||
- **Node Version**: ^20.18.3 or >= 22.0.0
|
||||
+781
-334
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ const config: CodegenConfig = {
|
||||
'src/graphql/generated.ts': {
|
||||
plugins: ['typescript', 'typescript-operations'],
|
||||
config: {
|
||||
addUnderscoreToArgsType: true,
|
||||
dedupeOperationSuffix: true,
|
||||
},
|
||||
hooks: {
|
||||
|
||||
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user