Skip to content

Commit 329e413

Browse files
committed
Merge branch 'staging-v4' of github.com:simstudioai/sim into staging-v4
2 parents 36bb046 + dc8c39c commit 329e413

61 files changed

Lines changed: 4651 additions & 710 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,48 @@ jobs:
5252
echo "ℹ️ Not a release commit"
5353
fi
5454
55+
# Detect shell-code changes on dev/staging pushes. Web-only changes never
56+
# need a desktop build (installed shells load the web app live); changes to
57+
# the Electron app or the bridge packages trigger a per-env prerelease build
58+
# (dev → alpha channel, staging → beta) that the env's update feed
59+
# (/api/desktop/update) starts offering automatically.
60+
detect-desktop-changes:
61+
name: Detect Desktop Changes
62+
runs-on: blacksmith-4vcpu-ubuntu-2404
63+
timeout-minutes: 5
64+
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging')
65+
outputs:
66+
changed: ${{ steps.diff.outputs.changed }}
67+
steps:
68+
- name: Checkout code
69+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
70+
with:
71+
fetch-depth: 50
72+
73+
- name: Diff desktop paths
74+
id: diff
75+
env:
76+
BEFORE: ${{ github.event.before }}
77+
run: |
78+
# Force pushes (dev resets) can reference a BEFORE we don't have;
79+
# fall back to the previous commit, and to no build when even that
80+
# is unavailable.
81+
if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null; then
82+
BEFORE="$(git rev-parse HEAD^ 2>/dev/null || echo '')"
83+
fi
84+
if [ -z "$BEFORE" ]; then
85+
echo "changed=false" >> "$GITHUB_OUTPUT"
86+
echo "ℹ️ No comparable base commit; skipping desktop prerelease"
87+
exit 0
88+
fi
89+
if git diff --name-only "$BEFORE" HEAD | grep -qE '^(apps/desktop/|packages/desktop-bridge/|packages/browser-protocol/)'; then
90+
echo "changed=true" >> "$GITHUB_OUTPUT"
91+
echo "✅ Desktop shell code changed"
92+
else
93+
echo "changed=false" >> "$GITHUB_OUTPUT"
94+
echo "ℹ️ No desktop shell changes"
95+
fi
96+
5597
# Run database migrations before images are promoted: the ECR latest/staging
5698
# tag push triggers CodePipeline, so migrating first guarantees the schema is
5799
# in place before the new app version deploys (replaces the removed ECS
@@ -541,8 +583,10 @@ jobs:
541583
name: Check Desktop Signing Secrets
542584
runs-on: blacksmith-4vcpu-ubuntu-2404
543585
timeout-minutes: 2
544-
needs: [detect-version]
545-
if: needs.detect-version.outputs.is_release == 'true'
586+
needs: [detect-version, detect-desktop-changes]
587+
# !cancelled(): detect-desktop-changes is skipped on main (and
588+
# detect-version tags only on main); either path may need the probe.
589+
if: ${{ !cancelled() && (needs.detect-version.outputs.is_release == 'true' || needs.detect-desktop-changes.outputs.changed == 'true') }}
546590
outputs:
547591
configured: ${{ steps.check.outputs.configured }}
548592
steps:
@@ -567,3 +611,92 @@ jobs:
567611
version: ${{ needs.detect-version.outputs.version }}
568612
publish: true
569613
secrets: inherit
614+
615+
# Per-env desktop prereleases: a dev/staging push that touches shell code
616+
# publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev,
617+
# vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed
618+
# offers only its channel, so dev-pointed shells pick up alpha builds,
619+
# staging-pointed shells beta builds, and prod-pointed shells stable
620+
# releases — independently. Unlike stable releases, prereleases build even
621+
# before the Apple signing secrets exist — unsigned, so the update pipeline
622+
# is testable end to end; installed shells detect the missing Developer ID
623+
# and offer a manual download instead of a Squirrel install.
624+
create-desktop-prerelease:
625+
name: Create Desktop Prerelease
626+
runs-on: blacksmith-4vcpu-ubuntu-2404
627+
timeout-minutes: 5
628+
needs: [detect-desktop-changes, check-desktop-signing]
629+
if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' }}
630+
permissions:
631+
contents: write
632+
outputs:
633+
version: ${{ steps.version.outputs.version }}
634+
steps:
635+
- name: Checkout code
636+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
637+
638+
- name: Compute prerelease version and create release
639+
id: version
640+
env:
641+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
642+
GH_REPO: ${{ github.repository }}
643+
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
644+
run: |
645+
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi
646+
# Prerelease core = next patch after the latest stable release, so
647+
# channel builds always outrank the stable they are built on top of
648+
# and are always superseded by the next stable.
649+
LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' || true)"
650+
LATEST="${LATEST:-v0.0.0}"
651+
IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}"
652+
TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))-${CHANNEL}.${GITHUB_RUN_NUMBER}"
653+
NOTES="Automated ${CHANNEL}-channel desktop build from ${GITHUB_REF_NAME} @ ${GITHUB_SHA::7}."
654+
if [ "$SIGNED" != "true" ]; then
655+
NOTES="$NOTES
656+
657+
⚠️ Unsigned test build (Apple signing secrets not configured). Gatekeeper will quarantine a downloaded copy: right-click → Open, or clear the flag with \`xattr -dr com.apple.quarantine /Applications/Sim.app\`."
658+
fi
659+
gh release create "$TAG" \
660+
--prerelease \
661+
--target "$GITHUB_SHA" \
662+
--title "$TAG" \
663+
--notes "$NOTES"
664+
echo "version=$TAG" >> "$GITHUB_OUTPUT"
665+
echo "✅ Created prerelease $TAG"
666+
667+
desktop-prerelease:
668+
name: Desktop Prerelease Build
669+
needs: [create-desktop-prerelease, check-desktop-signing]
670+
permissions:
671+
contents: write
672+
uses: ./.github/workflows/desktop-release.yml
673+
with:
674+
version: ${{ needs.create-desktop-prerelease.outputs.version }}
675+
publish: true
676+
sign: ${{ needs.check-desktop-signing.outputs.configured == 'true' }}
677+
secrets: inherit
678+
679+
# Keep the release list tidy: per channel, retain the newest 5 prereleases
680+
# and delete the rest (with their tags, so dev force-resets don't strand
681+
# commits behind stale tags).
682+
prune-desktop-prereleases:
683+
name: Prune Desktop Prereleases
684+
runs-on: blacksmith-4vcpu-ubuntu-2404
685+
timeout-minutes: 5
686+
needs: [desktop-prerelease]
687+
permissions:
688+
contents: write
689+
env:
690+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
691+
GH_REPO: ${{ github.repository }}
692+
steps:
693+
- name: Delete stale prereleases
694+
run: |
695+
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi
696+
gh release list --limit 100 --json tagName,isPrerelease,createdAt \
697+
--jq "[.[] | select(.isPrerelease and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
698+
while read -r TAG; do
699+
[ -n "$TAG" ] || continue
700+
echo "Deleting stale prerelease $TAG"
701+
gh release delete "$TAG" --cleanup-tag --yes
702+
done

.github/workflows/desktop-release.yml

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ on:
1818
required: false
1919
type: boolean
2020
default: true
21+
sign:
22+
description: Sign and notarize with the Apple Developer identity. When
23+
false (prerelease testing before the signing secrets exist) the build
24+
is packaged unsigned; installed shells detect this and offer manual
25+
downloads instead of Squirrel installs.
26+
required: false
27+
type: boolean
28+
default: true
2129
workflow_dispatch:
2230
inputs:
2331
version:
@@ -29,6 +37,11 @@ on:
2937
required: false
3038
type: boolean
3139
default: false
40+
sign:
41+
description: Sign and notarize with the Apple Developer identity
42+
required: false
43+
type: boolean
44+
default: true
3245

3346
permissions:
3447
contents: write
@@ -78,11 +91,39 @@ jobs:
7891
exit 1
7992
fi
8093
94+
# Prerelease versions carry their environment in the tag: -alpha.N is a
95+
# dev build, -beta.N a staging build. The channel decides the app's
96+
# identity (name/bundle id — a separate app per environment, installable
97+
# side by side) and the default origin baked into the bundle, which in
98+
# turn selects the update feed the installed app polls.
99+
- name: Resolve channel identity
100+
id: channel
101+
env:
102+
VERSION: ${{ inputs.version }}
103+
run: |
104+
case "$VERSION" in
105+
*-alpha.*)
106+
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
107+
*-beta.*)
108+
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
109+
*)
110+
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;
111+
esac
112+
{
113+
echo "name=$NAME"
114+
echo "app_id=$APP_ID"
115+
echo "origin=$ORIGIN"
116+
} >> "$GITHUB_OUTPUT"
117+
echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}"
118+
81119
- name: Bundle main and preload
82120
working-directory: apps/desktop
121+
env:
122+
SIM_DESKTOP_DEFAULT_ORIGIN: ${{ steps.channel.outputs.origin }}
83123
run: bun run build
84124

85125
- name: Write App Store Connect API key
126+
if: ${{ inputs.sign }}
86127
env:
87128
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
88129
run: |
@@ -91,6 +132,7 @@ jobs:
91132
chmod 600 "$RUNNER_TEMP/appstoreconnect/AuthKey.p8"
92133
93134
- name: Package, sign, and notarize
135+
if: ${{ inputs.sign }}
94136
working-directory: apps/desktop
95137
env:
96138
CSC_LINK: ${{ secrets.CSC_LINK }}
@@ -101,9 +143,28 @@ jobs:
101143
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
102144
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
103145
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
104-
run: bunx electron-builder --mac --publish never
146+
PRODUCT_NAME: ${{ steps.channel.outputs.name }}
147+
APP_ID: ${{ steps.channel.outputs.app_id }}
148+
run: >
149+
bunx electron-builder --mac --publish never
150+
-c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"
151+
152+
# Unsigned prerelease path: no Developer ID, no notarization. The
153+
# binaries end up ad-hoc/linker-signed, which runs locally but gets
154+
# quarantined when downloaded — fine for testing the update pipeline.
155+
- name: Package unsigned
156+
if: ${{ !inputs.sign }}
157+
working-directory: apps/desktop
158+
env:
159+
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
160+
PRODUCT_NAME: ${{ steps.channel.outputs.name }}
161+
APP_ID: ${{ steps.channel.outputs.app_id }}
162+
run: >
163+
bunx electron-builder --mac --publish never -c.mac.notarize=false
164+
-c.productName="$PRODUCT_NAME" -c.appId="$APP_ID"
105165
106166
- name: Validate signature and notarization
167+
if: ${{ inputs.sign }}
107168
run: |
108169
DMG="$(ls apps/desktop/release/*.dmg | head -1)"
109170
xcrun stapler validate "$DMG"

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ jobs:
113113
- name: API contract boundary audit
114114
run: bun run check:api-validation:strict
115115

116+
- name: Desktop bridge contract audit
117+
run: bun run check:desktop-bridge
118+
116119
- name: Shared utils enforcement audit
117120
run: bun run check:utils
118121

apps/desktop/README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,17 @@ Good fits for the bridge: OS notifications + dock badge on workflow completion,
137137

138138
### Local filesystem access
139139

140-
The main Copilot agent can inspect a user-selected local directory in the desktop app through request-local client tools (`local_mount_directory`, `local_list_mounts`, `local_list`, `local_glob`, `local_read`, `local_grep`, `local_stat`, and `local_forget_mount`). This capability is:
140+
Copilot can inspect user-selected local directories through the ordinary VFS tools. Granted folders appear beneath the top-level `user-local/` namespace, and `glob`, `grep`, and `read` are routed to Electron only when their path/pattern is explicitly scoped there. This capability is:
141141

142-
- **Explicit and read-only:** the native folder picker creates the grant; there are no write/delete/execute operations.
142+
- **Explicit and read-only:** only a user click may open the native folder picker or revoke a grant; model tool calls cannot do either. There are no write/delete/execute/upload operations.
143143
- **Remembered securely:** grants are encrypted in Electron's private app data with OS-backed `safeStorage` and restored with the same opaque URI after a normal app restart. (A security-scoped bookmark is stored alongside each grant, but it is a no-op in the current Developer ID build — only the macOS App Sandbox consumes it — and is kept purely for forward-compatibility should a sandboxed/MAS build ever ship.) There is no plaintext fallback: when secure storage is unavailable, the returned mount has `remembered: false` and lasts only for that app session.
144-
- **Revocable:** `local_forget_mount` removes one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants.
145-
- **Opaque:** renderer and model see only `localfs://<mount-id>/...` URIs. Electron resolves every URI, checks lexical and realpath containment, and refuses symlink escapes.
146-
- **Desktop-only:** the web app advertises these request-local tools only when `window.simDesktop.localFilesystem` is present. They are available directly to the main agent and are not added to subagent allowlists.
144+
- **Revocable:** Desktop settings removes one grant. All grants are removed on explicit sign-out or server-origin change so another Sim account or server cannot inherit them. Normal app quit only releases active OS handles and keeps the encrypted grants.
145+
- **Opaque:** the model sees canonical paths such as `user-local/Project--<mount-id>/README.md`, never host paths or internal `localfs://` URIs. Electron resolves every request, checks lexical and realpath containment, and refuses symlink escapes.
146+
- **Desktop-only:** the web app advertises `desktopCapabilities.localFilesystem` only when the Electron bridge is present. Mothership adds the `user-local/` prompt surface and per-call client routing only for that capability, including delegated and resumed work.
147+
- **Bound to a live Copilot call:** before a native read/search or browser action, Electron asks the authenticated Sim origin for the pending tool-call record. Local requests must exactly match its persisted operation, path, and options; browser actions run with the persisted arguments rather than renderer-supplied ones. Completed, failed, and aborted runs are rejected.
148+
- **Abort-aware and bounded:** stop/cancel propagates to active native scans and reads. File size, aggregate grep bytes, line, result, traversal-depth, and scan-count limits remain enforced in Electron, and unsafe regular expressions are rejected before execution.
147149

148-
Server-side tools cannot consume a `localfs://` URI. `local_stage_file` reads the granted file in Electron and uploads its bytes as a normal chat upload, returning `uploads/...` plus the collision-safe `fileName`. The agent then calls `materialize_file(fileName)` to promote it to durable `files/...` before passing that path to tools such as `function_execute` or `generate_image`:
149-
150-
```text
151-
localfs://... → local_stage_file → uploads/... → materialize_file → files/... → server tool
152-
```
150+
Raw local file bytes are never exposed through the preload bridge and cannot be staged or uploaded by a model. Bounded text read/search results are returned to the active Copilot request; a user must use the normal attachment UI when they want the file itself to leave the device.
153151

154152
## Auto-update, channels, rollout, rollback
155153

apps/desktop/electron-builder.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ files:
1313

1414
asar: true
1515

16+
# Space-free regardless of productName ("Sim Dev" etc.): GitHub rewrites
17+
# asset names containing spaces, which would desync the electron-updater
18+
# manifest from the uploaded files.
19+
artifactName: "Sim-${version}-${arch}.${ext}"
20+
1621
electronFuses:
1722
runAsNode: false
1823
enableCookieEncryption: true

apps/desktop/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@
3535
"@sim/logger": "workspace:*",
3636
"@sim/security": "workspace:*",
3737
"@sim/utils": "workspace:*",
38-
"electron-updater": "6.8.9"
38+
"electron-updater": "6.8.9",
39+
"micromatch": "4.0.8",
40+
"safe-regex2": "5.1.0"
3941
},
4042
"devDependencies": {
4143
"@playwright/test": "1.61.1",
4244
"@sim/tsconfig": "workspace:*",
45+
"@types/micromatch": "4.0.10",
4346
"@types/node": "24.2.1",
4447
"electron": "43.1.1",
4548
"electron-builder": "26.15.3",

apps/desktop/scripts/build.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ const watch = process.argv.includes('--watch')
88
// unlike the SIM_DESKTOP_ORIGIN env var, which only affects terminal-launched
99
// processes. Official builds leave it unset (default https://sim.ai).
1010
const bakedDefaultOrigin = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''
11-
if (bakedDefaultOrigin && !/^https:\/\/[^\s/]+$/.test(bakedDefaultOrigin)) {
11+
if (
12+
bakedDefaultOrigin &&
13+
!/^https:\/\/[^\s/]+$/.test(bakedDefaultOrigin) &&
14+
!/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(bakedDefaultOrigin)
15+
) {
1216
console.error(
13-
`SIM_DESKTOP_DEFAULT_ORIGIN must be a bare https origin (got "${bakedDefaultOrigin}")`
17+
`SIM_DESKTOP_DEFAULT_ORIGIN must be a bare https origin or http://localhost (got "${bakedDefaultOrigin}")`
1418
)
1519
process.exit(1)
1620
}

0 commit comments

Comments
 (0)