feat(shell): AppForge-2 — install an app from vault code files - #366
Merged
Conversation
Close the "an app built inside Brainstorm becomes a real app" loop: the
code files a user writes in the Code editor install as a real sandboxed
app, with no disk round-trip.
Two new privileged, dashboard-only channels alongside AppForge-1's:
- `apps:list-vault-app-sources` — groups the vault's `CodeFile/v1`
rows into install candidates (one per `manifest.json`, carrying
everything under its folder) and parses each manifest server-side.
- `apps:install-from-vault` — takes a list of entity **ids only**,
resolves type/path/content server-side from the entity rows,
materialises them into a fresh `mkdtemp` staging dir, and runs the
SAME `AppInstaller.install({ bundleDir, provenance: LocalFile })`
→ icon pin → typed result. Staging removed in a `finally`.
The new trust edge is a vault string becoming a filesystem path for the
first time, so `sanitizeVaultRelPath` is fail-closed: relative `/`-form
only — no absolute, drive-letter, backslash, `.`/`..`/empty segment or
control character — bounded to 512 chars / 16 segments, with a resolve()
containment re-check at write time. On top: 256-file / 4 MiB-per-file /
32 MiB-total bounds, case-insensitive duplicate + file-vs-directory
conflict rejection, a single-root-manifest requirement, and every file
required to live under that root. New typed codes (`bad-request`,
`not-code-files`, `bad-path`, `no-manifest`, `source-too-large`).
UI seam: the affordance lives in the DASHBOARD (Marketplace →
"Install from…" → "From vault code files…"), so the Code editor gains
no new capability surface and the shell — not an app — drives consent.
The pre-install confirm reuses the shared `confirm` + update-consent
chrome: app name, id, version, requested capabilities, unsigned
advisory.
Tests: 10 new cases in `sideload-handlers.test.ts` (real DataStores /
EntitiesRepository / CapabilityLedger / AppInstaller, mocked Electron)
— happy two-file install asserting the installed bundle bytes AND that
the source entities are untouched, type-spoof refusal, traversal /
absolute / backslash / dup-path refusals, outside-root, two-root,
no-manifest, oversized file + selection, malformed request, foreign
sender, plus a `sanitizeVaultRelPath` table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the AppForge loop: the code files a user writes in the Code editor install as a real sandboxed app — no export, no disk round-trip. Builds on AppForge-1 (#364, now merged), reusing its installer path, provenance stamping, icon pin and typed-result posture.
The seam I chose (and why)
The verb lives in the DASHBOARD, not the Code editor. Marketplace →
Install from…→From vault code files…opens a shell-rendered picker of app candidates found in the vault'sCodeFile/v1rows.The Code editor has no
filescaps and must not gain any. The alternatives were an intent verb or a cap-gated broker method that forwards ids — both add a new app-reachable surface whose only job is to reach a privileged action, and both still need the shell to own the confirm (an app can be driven, so app-side consent is worthless). The dashboard placement gets the same user flow with zero new app-side surface: the picker, the consent, and the install all run in the trusted shell, and the Marketplace is already the place installs happen. If a Code-editor-side entry point is wanted later it can dispatch to this exact handler without changing the trust story.What landed
Two new privileged, dashboard-only
ipcMain.handlechannels (sameevent.sender === dashboard.webContentsgate as #364):apps:list-vault-app-sources— groupsCodeFile/v1rows into candidates (one permanifest.json, carrying everything under its folder), parses + validates each manifest server-side, returns name/id/version/capabilities or aproblemstring.apps:install-from-vault— input is entity ids only. Type,path, and content are resolved server-side from the entity rows (per the resolve-entity-type-server-side rule — aNoteid claimed as a CodeFile is refused). Files are materialised into a freshmkdtempstaging dir, then the SAMEAppInstaller.install({ bundleDir, provenance: { origin: InstallOrigin.LocalFile, … } })→placeDashboardIcon→ typedSideloadInstallResult. Staging dir removed in afinally.The shell-side confirm reuses the existing trust chrome (shared
confirm+ the update-consent pattern — not forked): app name, id, version, requested capabilities, and the Unsigned advisory. A candidate with an invalid manifest renders disabled with the validator's reason.Path sanitisation (the new trust edge)
CodeFile.pathis a vault string becoming a filesystem path for the first time, sosanitizeVaultRelPathis fail-closed — it allowlists one shape rather than scrubbing:undefined/null/ non-string /""/etc/passwd,C:/x,c:\xa\b.js(a separator on Windows — ambiguity is refused, not normalised)./../ empty segments../up.js,a/../../up.js,a/./b.js,a//b.js\n, DELOn top of that, before anything touches disk: ids validated against
isSafeEntityIdwith no duplicates; ≤ 256 files; ≤ 4 MiB per file and ≤ 32 MiB total; exactly one shallowestmanifest.json(two roots →no-manifest); every file must live under that root (root prefix stripped so the bundle root is the app root); duplicate and file-vs-directory-conflicting relative paths rejected case-insensitively (macOS/Windows would silently clobber); and aresolve()containment re-check per write so nothing can leave staging even if the sanitiser were bypassed. The candidate scan itself is row-bounded.New typed failure codes:
bad-request,not-code-files,bad-path,no-manifest,source-too-large— surfaced as distinct toast strings.Tests
packages/shell/src/main/ipc/sideload-handlers.test.ts— 23 cases total (13 from #364, 10 new), realDataStores/EntitiesRepository/CapabilityLedger/DashboardStore/AppInstaller, only Electron mocked:local-fileprovenance, pinned icon, unsigned surfaced, the installed bundle's bytes match the sources, and the source entities are untouched (the Code editor can re-open them), staging dir cleaned upNoteid claimed as a CodeFile) and an id that isn't in the vault →not-code-files////.// empty paths →bad-path, with a check that nothing escaped into the temp rootbad-path; duplicate ids →bad-requestbad-path; two root manifests →no-manifestno-manifest; unparseable manifest →bad-manifestsource-too-largeundefined, string,[], non-string id, traversing id, over-long id) →bad-requestnot-allowed; no vault session →no-vault-session(both channels)problem, a traversing candidate dropped, listed ids install as-is, foreign sender refusedsanitizeVaultRelPathtable over every accept/reject shapebun run verifygreen (36 files / 418 tests),bun run typecheck(packages + apps) green,bun run lintgreen (all ratchets pass), targeted marketplace + sideload suites green (41 tests).i18n
New shell-renderer strings in
en.json(ICU plural for the file count) with matching entries inen.descriptions.json— description coverage stays at zero uncovered. No app-side strings (no app surface was added).Review
🔒 Flagging for security review — vault-content → filesystem materialisation is a new trust edge: an attacker-authored (or sync-delivered)
CodeFile.pathis the first vault string this codebase turns into a real file path. The sanitiser, the containment re-check, the bounds, and the id-only wire contract are the mitigations; please attack them.🤖 Generated with Claude Code