title: "Apps and capabilities" description: "Loading other people's code into Ohm, and the capability list that decides what it can reach." nodes: 39 progress: 90% generated: ohm spec editor#

Apps and capabilities#

Loading other people's code into Ohm, and the capability list that decides what it can reach.

Contents#

☑ Overview#

☑ Scope#

note

packages/core/src/apps.ts (the capability model and the manifest parser), packages/core/src/app-service.ts (finding, starting and stopping), packages/core/src/example-apps.ts (the seeded example and the published standard), the apps.* protocol methods, the enforcement point in packages/core/src/script-host.ts, and apps/ui/src/components/AppsSettings.tsx.

☑ An app is a folder, and the folders are the list#

decision

An app is a directory under apps/ in the scripts folder containing an ohm.app.json. There is no registry file, no install database and no manifest index — the disk is the only state, scanned on start and on demand.

The alternative, a list of installed apps kept beside the folders, has one failure mode that never goes away: the list and the disk disagree. Someone copies a folder in and it does not appear; someone deletes one and it is still listed. Scanning is cheap — a listDir and one readFile per app — and cannot drift.

The only thing Ohm remembers about an app is whether the user switched it off.

  • spec: 03-scripts

☑ The folder is the identity#

decision #security

A manifest whose id is not its folder name is an error, not a warning. The capability check keys on the id taken from the path of the running script, so an app claiming to be something else would make the permission check and the folder it protects disagree — apps/evil/ declaring "id": "hello-world" would run with Hello World's grants.

☑ The capability model#

☑ One way out of a script, so one place to refuse#

decision #security

Every host function a script can reach goes through ScriptHost.call(name, args, caller). A run carries the app it belongs to — derived from its path by appOfPath — and call refuses by name before dispatching anything.

This is the whole reason a capability list can be a limit rather than a description. A second path out of the sandbox (a direct import, a global, a second bridge) would make it a description again.

  • spec: 02-ohm-language

☑ Nine capabilities, each with a risk#

data-model

tabs, page, ai, files, library, results, projects, models, remote — each with a label, a sentence of detail and a risk of low, medium or high.

Risk is not decoration: it orders the chips in Settings, so "can read every page you visit" does not look like "can print to the console". page and remote are high; library and results are low.

☑ Prefixes map host functions to capabilities, longest match wins#

requirement

NEEDS is a list of [prefix, capability] pairs and capabilityFor takes the longest matching prefix. That is what lets tab.close need only tabs while tab.text needs page: tab. → page is the general rule and the three navigational calls are listed explicitly above it.

Splitting opening a tab from reading it is the single most useful line in the model — an app that opens a URL you asked it to open does not need to see every page you have open.

  • node: capabilities

☑ The tabs/page line is changing versus observing#

decision #regression

tabs is browser.open, browser.close, browser.show, browser.hide, browser.activate, tab.go, tab.show, tab.hide, tab.close — everything that changes what is open. browser.active, browser.tabs, browser.get and tab.info are page, with the reading.

Discovery has to sit with reading. The first version put it with tabs, and the shipped example app — which asks for page, ai and results and wants only to summarise the tab you are looking at — refused itself on its first line. An app that reads should not have to ask for the power to open tabs to get a handle on one, and page already means "can see every page you have open".

  • node: longest-prefix

☑ A function nobody listed needs no capability#

requirement #security

capabilityFor returns null for anything unmatched, and an unmatched call is allowed: print, sleep, json.*, the ohm.* concurrency helpers. These cannot reach the disk, the network, the page or the model.

The risk this accepts is a new host function that does reach something and is not added to NEEDS — it would be callable by every app. The test covers every host function with a capability or no capability at all walks HOST_API so that a new entry has to be classified deliberately.

  • node: every-function-classified

☑ A stopped app is granted nothing#

requirement #security

grantedTo returns [] for a disabled app and for one that is not installed. Stopping is therefore not cosmetic and not a flag the UI honours — it is the same refusal path as asking for a capability you never declared.

  • node: one-chokepoint

☑ The refusal names the capability and what to do#

requirement

"hello-world" tried to browser.open() but did not ask for the "tabs" capability (open and close tabs). Add it to the app's ohm.app.json, or stop the app in Settings -> Apps.

It reaches the user as an ordinary script error, on the line that made the call. Both readers are served: the app's author learns their manifest is wrong, and the person running it learns the app is doing something it did not declare.

☑ Capabilities come from the path, so they cannot be escaped sideways#

requirement #security

A script outside apps/ is not an app and is unrestricted — that is the user's own code in their own folder. Moving a script into apps/<id>/ restricts it. A command whose script points outside the app folder is rejected at parse time (.., an absolute path, or a drive letter), because a command that could run anything in the scripts folder would make the capability list a suggestion.

  • node: folder-is-identity

☑ The manifest#

☑ ohm.app.json#

data-model

id (lower-case letters, digits and dashes, 3–40 characters), name, version (major.minor.patch), optional description, author, homepage, capabilities, commands and onStart.

A command is { title, script, description?, icon? }; the icon is clipped to two characters so one cannot push the row apart.

☑ An unknown capability is an error, not a dropped field#

requirement #correctness

parseManifest is strict and says what is wrong with the file. An app asking for filesystem when the capability is files must not end up looking like it asked for nothing at all: that is an app that appears harmless in Settings and then fails at the first call, and a user who learns to ignore the capability list.

Same reasoning for a missing name, a version that is not 1.0.0-shaped, and a capabilities that is a string rather than a list.

  • node: manifest

☑ A broken app is listed with its reason#

requirement

A manifest that does not parse produces a placeholder entry: the folder name as the id and name, no capabilities, enabled: false, and error set to the message. It is shown in Settings with the reason and cannot be started.

An app that silently fails to appear is worse than one that says why — the user's next move is to look for a typo, and they can only do that if they are told there is one.

  • node: strict-manifest

☑ Lifecycle#

☑ Start#

flow

core.start() loads settings, seeds the scripts folder, then apps.refresh() → apps.startAll(). Scanning after seeding is deliberate: the example app is seeded like any other example, so it has to be on disk before the scan to be found on a first run.

  • spec: 03-scripts

☑ onStart runs once per enable, and finishes before anything else starts#

requirement #regression

startAll awaits each onStart to completion through ScriptService.runToEnd, and a started set stops it running twice. A failure is swallowed so one broken app does not stop the others — and does not half-start the app either, since nothing after onStart depends on it.

  • node: fire-and-forget-run

☑ Why onStart has to be awaited: two live scripts, one Lua VM#

note #regression #gotcha

ScriptService.run is fire-and-forget by design — the UI gets a RunInfo immediately and watches scripts.status. Wiring onStart to it meant the app's script was still running when the next script started, and with the in-process runner both share one wasmoon module: the second run hung forever. Nine tests in packages/core/test/scripts.test.ts timed out, every one of them a test that ran a script, while every test that only read files passed.

runToEnd was added for callers that own the ordering. The ordering is right regardless of the runner — an app's startup should be over before the user's first script runs — but it took a deadlock to notice.

☑ Stopping and starting are immediate and remembered#

requirement

setEnabled writes the disabled set to settings, rescans, and starts or forgets the app. The capability check reads the live list, so a stop takes effect on the next call, not the next launch.

☑ Uninstall is recoverable#

requirement #data-loss

uninstall deletes the folder through ScriptFiles, which moves it to .ohm/trash like any other delete, and removes the id from the disabled set so a reinstalled app does not come back mysteriously switched off.

  • spec: 03-scripts

☑ apps.list / refresh / setEnabled / uninstall / run / capabilities#

api

apps.run takes an id and a command title and resolves it through commandScript, which refuses a stopped app and a title the manifest does not have. The UI never constructs a script path itself.

apps.capabilities returns every capability with its label, detail, risk and the exact host functions it unlocks, computed from HOST_API and capabilityFor. apps.changed is emitted on every scan and every start or stop.

  • spec: 01-engine-and-shell

☑ Settings → Apps#

☑ AppsSettings#

component

One row per app: name, version, author, running or stopped, description, the capability chips worst-risk first, a button per command, and Start/Stop/Uninstall. What it can reach expands into the app's folder, its menu entries, whether anything runs by itself, its homepage, and the complete list of host functions it may call.

Below the list, every capability with its risk and its calls.

☑ The lists shown are the lists enforced#

requirement

The function lists come from apps.capabilities, which derives them from the host API and the same capabilityFor the refusal uses. Prose in the UI describing what a capability "roughly" allows would be wrong within a month of someone adding a host function.

  • node: one-chokepoint
  • node: apps-settings

☑ The published standard#

☑ The example app is a real app#

requirement

apps/hello-world/ is seeded with the other examples: a manifest asking for page, ai and results, two commands — one that reads the active tab and summarises it with the configured model into a view template, one that lists what you could click or type on the page — and a quiet onStart.

A test runs its commands through the protocol against a fake page, so the shipped example cannot ask for the wrong capabilities — which it did once, and the symptom was the example refusing itself on its first line.

A stub would teach the manifest and nothing else. Someone's first app is a copy of this folder with the id changed, so it has to work, and it has to be the shape an app should be.

  • spec: 03-scripts
  • spec: 02-ohm-language

☑ Publishing is copying a folder#

reference

apps/README.md travels with the app folder and states the standard: ask for the fewest capabilities that work, describe it in one sentence, version it, keep onStart fast, keep scripts inside the folder. Installing is putting the folder in apps/ and pressing Rescan.

No registry, no signing, no account — and therefore no central party who can decide what Ohm is allowed to run. The capability list is what makes that defensible.

  • doc: docs/guide/apps.md

☑ Tests#

☑ Every host function is classified#

test #security

packages/core/test/apps.test.ts walks HOST_API and asserts that with every capability granted every function is callable, and that with none granted the only callable ones are those needing no capability. A new host function that reaches something has to be put in NEEDS or appear in the second list.

☑ An app is refused by name, end to end#

test #security

The apps block in packages/core/test/scripts.test.ts installs an app over the protocol, runs its command, and asserts the error text and that the side effect did not happen — no tab was opened. A second case grants tabs and shows the tab opening and the read being refused, which is the tabs/page split holding.

  • node: longest-prefix

☑ The folder scan#

test

A loose file in apps/ is ignored, a manifest whose id is not its folder is listed with that reason, a broken manifest cannot be started, uninstall empties the folder and the disabled set, and a missing apps/ folder is not an error.

☑ Review findings#

☐ Commands belong in the window, not only in Settings#

task

An app's commands are currently reachable from Settings → Apps. They should also appear somewhere a person actually works — the activity bar, a command palette, or a per-app menu — because "run this on the page I am looking at" is the whole point and Settings is the wrong place to be standing when you want it.

☐ No prompt before the first run of a newly found app#

task high #security

A folder dropped into apps/ is found by the next scan and, unless it is in the disabled set, is enabled — so its onStart runs without anyone having read its capabilities. Installing is a deliberate act (you copied the folder), so this is defensible, but the honest design is: newly discovered apps start stopped, and Settings shows them with a Start button beside the list of what they want.

  • node: start-flow

☐ The capability names are coarser than what they allow#

risk #security

page covers reading text and running arbitrary JavaScript in any open page. projects covers reading and writing every prompt, curve and document. Both are honestly labelled and both are larger than most apps need — a per-origin or per-project narrowing is the obvious next refinement, and the manifest format has room for it.

  • node: capabilities

☐ Nothing bounds what an app may spend#

risk #safety

ai is unlimited: an app with it can call a paid model in a loop. Scripts have the same freedom, which is fine for code you wrote, and not obviously fine for code you installed. A per-app call or token budget, visible in Settings beside the capability, is the missing piece.

graph LR
  longest_prefix["Prefixes map host functions to capabilities, longest match wins"] -.-> capabilities["Nine capabilities, each with a risk"]
  tabs_vs_page["The tabs/page line is changing versus observing"] -.-> longest_prefix["Prefixes map host functions to capabilities, longest match wins"]
  unlisted_is_free["A function nobody listed needs no capability"] -.-> every_function_classified["Every host function is classified"]
  a_stopped_app_is_granted_nothing["A stopped app is granted nothing"] -.-> one_chokepoint["One way out of a script, so one place to refuse"]
  capabilities_come_from_the_path_so_they_cannot_b["Capabilities come from the path, so they cannot be escaped sideways"] -.-> folder_is_identity["The folder is the identity"]
  strict_manifest["An unknown capability is an error, not a dropped field"] -.-> manifest["ohm.app.json"]
  a_broken_app_is_listed_with_its_reason["A broken app is listed with its reason"] -.-> strict_manifest["An unknown capability is an error, not a dropped field"]
  onstart_once["onStart runs once per enable, and finishes before anything else starts"] -.-> fire_and_forget_run["Why onStart has to be awaited: two live scripts, one Lua VM"]
  the_lists_shown_are_the_lists_enforced["The lists shown are the lists enforced"] -.-> one_chokepoint["One way out of a script, so one place to refuse"]
  the_lists_shown_are_the_lists_enforced["The lists shown are the lists enforced"] -.-> apps_settings["AppsSettings"]
  an_app_is_refused_by_name_end_to_end["An app is refused by name, end to end"] -.-> longest_prefix["Prefixes map host functions to capabilities, longest match wins"]
  no_prompt_before_the_first_run_of_a_newly_found_["No prompt before the first run of a newly found app"] -.-> start_flow["Start"]
  the_capability_names_are_coarser_than_what_they_["The capability names are coarser than what they allow"] -.-> capabilities["Nine capabilities, each with a risk"]