acrylblends 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,24 @@ The `agent` template (a Cordis app; `acryl-app` is its old name and still works)
33
33
 
34
34
  See [Acryl in a Cordis app](../cordis/acryl-in-a-cordis-app.md), [Plugin lifecycle](../cordis/plugin-lifecycle.md) and [Mounting](../cordis/mounting.md).
35
35
 
36
+ ## The agent panel and the terminals
37
+
38
+ The panel is a chat button on every page of the running app, and `driver-pty` adds real terminals to it. Both are **development only** (see [what runs in production](../guides/production.md)). They need the rows of the `agent` template (`agent` and `extensions`, with the `model`, `tools` and `memory` the agent brings), so start from that template:
39
+
40
+ ```sh
41
+ npm create acrylblends@latest my-app agent
42
+ cd my-app
43
+ npm install
44
+ npx acrylblends add agent-panel driver-pty
45
+ npm install
46
+ npm run dev
47
+ ```
48
+
49
+ - `add` puts the `agent-panel` row (and `driver-pty`'s after it) in `blend.yaml` before the `routes` row and registers both in `src/app.ts`. On an app without the `agent` and `extensions` rows, such as `blank`, it refuses before it copies anything and says which rows are missing.
50
+ - **The panel's coding agents are yours.** `claude`, `codex` and `pi` run on the machine that runs the app, in the app folder, with the login of the person who started it: `claude` and `codex` use their own signed-in sessions, and `pi` is offered when `DEEPSEEK_API_KEY` is set or `pi` has stored a DeepSeek login (`~/.pi/agent/auth.json`). A program that is not installed, or is not logged in, is not offered; nothing is shared between machines, and a workshop laptop needs its own login. The panel's own `agent` (the offline scripted one) needs none.
51
+ - `pi` has to be an executable on the `PATH`; a shell function that wraps the binary is fine as long as the binary itself is found by `which pi`.
52
+ - `driver-pty` brings `node-pty`, which ships prebuilt binaries for macOS, Windows and Linux. npm 11 and pnpm warn that its install script was not run (they ask you to approve dependency install scripts); you can ignore the warning. If no `pty:` terminal is offered and the app prints why at start, approve and rebuild: `npm approve-scripts node-pty`, then `npm rebuild node-pty` (pnpm: `pnpm approve-builds`).
53
+
36
54
  ## Checking that an agent can use the docs
37
55
 
38
56
  The repository has a manual gate: it creates a fresh app, starts a headless coding agent inside it with a one-line task (for example "add a /customers/count route with a test"),
@@ -7,12 +7,13 @@
7
7
  An app made by `acrylblends init` carries copies of the framework plugins (`acryl-router`, `acryl-node-server`, `acryl-door`, `extensions`, ...) in `plugins/`. `acrylblends upgrade` brings them to the version of the installed package:
8
8
 
9
9
  ```sh
10
- npm install acrylblends@latest # in an app that depends on the package; the agent template does not
11
- npx acrylblends upgrade --dry-run # what would change, and the diff of every file you edited
12
- npx acrylblends upgrade # do it
10
+ npx acrylblends@latest upgrade --dry-run # what would change, and the diff of every file you edited
11
+ npx acrylblends@latest upgrade # do it
13
12
  npm install && npm run check && npm test
14
13
  ```
15
14
 
15
+ **Write `@latest`.** A bare `npx acrylblends upgrade` runs the `acrylblends` the app has installed, and an app made before 1.0 has a 0.x copy with no `upgrade` verb. It does not upgrade anything: depending on the platform and the version it prints its old help and exits 0 (so nothing happens and nothing says so), or answers "unknown command" and exits 2. `npx acrylblends@latest` fetches the current package and runs that. (After the first upgrade the app depends on 1.x, and the bare form works too.)
16
+
16
17
  - A file nobody edited is replaced. A file the app edited stops the upgrade: nothing is changed, the diff of what would be lost is shown, and the command exits 1. Move your change out of the plugin (a plugin of your own in `plugins/<name>/` is never touched), or run `npx acrylblends upgrade --force`, which saves the edited versions in `.acrylblends/upgrade-backup/<time>/` before replacing them.
17
18
  - `--diff` prints the diff of every changed file, not only the edited ones. `acrylblends upgrade acryl-router extensions` upgrades only those plugins.
18
19
  - A file of your own inside a library plugin folder (a note, a test) is kept and reported. A plugin that a newer version needs and the app lacks is added.
@@ -36,6 +36,26 @@ In a Blend app, the same two commands (`npx acrylblends add desktop`, `npm insta
36
36
  `npm install` does not download Electron's binary (Electron 44 has no install script). The first `npm run desktop` does: about 100 MB, once per machine, then it is cached. Behind a proxy or mirror set `ELECTRON_MIRROR`
37
37
  (and `ELECTRON_CACHE` to reuse a download); if the download is blocked, run `npx install-electron` where the mirror is reachable.
38
38
 
39
+ ## Linux: Ubuntu 23.10 and later
40
+
41
+ Ubuntu 23.10 and later restrict unprivileged user namespaces (`kernel.apparmor_restrict_unprivileged_userns=1`). Chromium's sandbox needs them, and an Electron unpacked by npm into `node_modules` has no AppArmor profile, so Electron stops at the start with "The SUID sandbox helper binary was found, but is not configured correctly" before the app runs.
42
+
43
+ `npm run desktop` starts through `plugins/desktop/launch.ts`, which reads that sysctl. **Only when it is `1` on Linux** it starts Electron with `--no-sandbox` and prints one line saying so; on macOS, on Windows and on any Linux without the restriction Electron starts with its sandbox, as it always does. The window's renderer is still locked down (context isolation, no Node in the page, web security); what is lost is Chromium's process sandbox around it.
44
+
45
+ To keep the sandbox, give Electron an AppArmor profile that allows `userns`, as the VS Code and Chrome `.deb` packages do. Save this as `/etc/apparmor.d/my-app-electron` (the path is the one in your app's `node_modules`), then `sudo apparmor_parser -r /etc/apparmor.d/my-app-electron`:
46
+
47
+ ```text
48
+ abi <abi/4.0>,
49
+ include <tunables/global>
50
+
51
+ profile my-app-electron /path/to/my-app/node_modules/electron/dist/electron flags=(unconfined) {
52
+ userns,
53
+ include if exists <local/my-app-electron>
54
+ }
55
+ ```
56
+
57
+ The launcher then still sees the sysctl as `1` and passes `--no-sandbox`; set `ACRYLBLENDS_DESKTOP_SANDBOX=1` to start with the sandbox anyway.
58
+
39
59
  ## A different start page
40
60
 
41
61
  The window opens on `/`. To open another path, set it in `package.json`: `"acrylblends": { "desktopPath": "/todo" }`.
@@ -54,6 +54,8 @@ bun create acrylblends@latest my-app --template nodejs
54
54
  | `[directory]` | The folder to create. It must not exist or must be empty. Asked for when it is missing. | `my-app` |
55
55
  | `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
56
56
  | `--template <template>` | One of `blank`, `nodejs`, `notes`, `chat`, `todo`, `agent` (the same list as `acrylblends init --template`; `acryl-app` is accepted as `agent`). Skips the template question; a bare name after the directory means the same. | `--template notes` |
57
+ | `--from <id \| source>` | A starter from the registry by its id (`acrylblends.notes-desktop`), or any source above. Only starters that declare `runtime: acrylblends` are made; an ACRYL app starter is refused. See [Starters from the registry](#starters-from-the-registry). | `-- --from acrylblends.notes-desktop` |
58
+ | `--registry <source>` | Read the ids from this registry instead of the public one: `github:owner/repo` or a folder, with an `index.json` (at its root or in `registry/`). | `-- --from login.thing --registry github:acryldev/workshop-2026-11-20` |
57
59
  | `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
58
60
  | `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
59
61
  | `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
@@ -61,6 +63,25 @@ bun create acrylblends@latest my-app --template nodejs
61
63
 
62
64
  The templates are inside the package, so creating a project needs no network except for `--install`.
63
65
 
66
+ ## Starters from the registry
67
+
68
+ The [registry](https://acrylblends.github.io/blends) lists starters people published. Each is a whole app (`blend.yaml`, `plugins/`, `src/`, `package.json`, `README.md`) that other people can start from, and its index says which runtime it is for:
69
+
70
+ ```sh
71
+ npm create acrylblends@latest my-notes -- --from acrylblends.notes-desktop
72
+ cd my-notes
73
+ npm install
74
+ npm run dev
75
+ ```
76
+
77
+ - npm needs the `--` before `--from`; with `pnpm`, `yarn` and `bun` it is not needed.
78
+ - A starter with `runtime: acrylblends` is an `acrylblends` app: this command downloads its folder, gives the manifest to the new app (`metadata.id: acrylblends.my-notes`, `visibility: private`; the starter's id and licence stay the starter's), checks it for anything that looks like a secret, and writes it. Nothing in it is installed or run: `npm install` is yours to run, and the dependencies of the starter (a desktop starter brings `electron`) are in its `package.json`.
79
+ - A starter for the ACRYL app (`runtime: acryl`, the older `acryl.*` entries) is refused with one line that says so, because its plugins run only inside the ACRYL app. Nothing is written.
80
+ - An unknown id lists the starters this command can make.
81
+ - The ids come from `https://acrylblends.github.io/registry/index.json`. `--registry github:owner/repo` reads another registry with the same layout (`index.json` and `blends/<id>/`), a team's own or a workshop's; `--registry ./folder` reads one on disk.
82
+
83
+ To publish a starter of your own, see [the registry README](https://github.com/acrylblends/acrylblends.github.io/tree/main/registry): the folder of your app under `blends/<id>/` with an `acryl.template.json`, in a pull request.
84
+
64
85
  ## Templates from a repository or a package
65
86
 
66
87
  A second argument after the directory makes the app from someone else's template:
@@ -10,14 +10,27 @@
10
10
  | **The door** (`acryl-door`: `.acryl/bin/acryl`, the page script, the operator routes) | **experimental**: starts when the server is up | starts nothing: no service, no route, no `.acryl/` folder, no script in the page. `ACRYL_DOOR=off` also turns it off. A host that means to run it in production sets `allowInProduction: true` in the plugin's config |
11
11
  | **The agent panel** (`agent-panel`) | **experimental**: a script tag on every HTML page and `/api/agent-panel/*` | does nothing (no tag, no routes). `ACRYL_DOOR=off` too |
12
12
  | **The PTY driver** (`driver-pty`) | **experimental**: terminal drivers in the panel, `/api/driver-pty/*` | does nothing. `ACRYL_DOOR=off` too |
13
- | **Live plugin install** (`extensions`, `builder`) | **experimental**: install, update, remove and reload plugins while the app runs | the `nodejs`, `notes` and `chat` templates leave the `extensions` row out; the `agent` template does not (see below) |
13
+ | **Live plugin install and the agent** (`extensions`, `builder`, `agent`: `POST /ask`) | **experimental**: install, update, remove and reload plugins while the app runs; the agent builds them | left out: the `nodejs`, `notes` and `chat` templates drop the `extensions` row, and the `agent` template drops `agent`, `builder` and `extensions` (since 1.0.1) unless `ACRYL_PRODUCTION_KEEP` names them |
14
+ | **The chat template's model endpoint** (`POST /api/chat`) | answers anyone | **closed** (503) unless `CHAT_ACCESS_TOKEN` is set, or `ACRYL_PRODUCTION_KEEP=chat` makes it public with a warning and a per-address limit (since 1.0.2) |
14
15
  | `acrylblends/bun` | **experimental**: one smoke gate runs it on Bun | the same |
15
16
 
16
17
  Experimental means: it works and is gated, its interface may still change in a minor version, and it is not for the public internet. The door and the panel are loopback and own-page only, with a per-run secret and an audit log; that is a convenience fence for a developer's machine, not an authentication system.
17
18
 
18
- ## The `agent` template is a development app
19
+ ## The `agent` template in production
19
20
 
20
- The `agent` template mounts an agent behind `POST /ask` that can write plugins into the running app (`builder`) and install them (`extensions`). Only its door is left out in production. Do not deploy it as it is: remove the `agent`, `builder` and `extensions` rows from `blend.yaml` (or start from `blank`, `nodejs`, `notes`, `chat` or `todo`), and if live install is wanted anywhere shared, set `approval: true` so an install waits for `POST /extensions/approve`.
21
+ The `agent` template mounts an agent behind `POST /ask` that spends the model key and can write plugins into the running app (`builder`) and install them (`extensions`, with `POST /extensions/approve` deciding installs). **None of that is behind a login.** Since 1.0.1, with `NODE_ENV=production` the template leaves the `agent`, `builder` and `extensions` rows out, along with the door: `/ask`, `/extensions*` and `/tasks*` are not mounted, and the page and `GET /health` stay. 1.0.0 left them mounted.
22
+
23
+ - To keep a row anyway, name it: `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` (a comma list). Each row kept prints a `WARNING` line at start saying what it gives a visitor. Put your own login in front before you do.
24
+ - **An app made by 1.0.0 keeps its own `src/app.ts` and `src/routes.ts`**, which `acrylblends upgrade` never touches (they are your files). Until you change them: deploy with the `agent`, `builder` and `extensions` rows set to `disabled: true` in `blend.yaml`, or start from another template, or copy the new `src/app.ts` and `src/routes.ts` of a fresh `acrylblends init my-app --template agent` over yours.
25
+
26
+ ## The `chat` template in production
27
+
28
+ The chat template answers with the owner's model key, so anyone who can reach it spends that key. Since 1.0.2, with `NODE_ENV=production` it is **closed**: every route but `GET /health` answers 503 until you choose one of:
29
+
30
+ - `CHAT_ACCESS_TOKEN=<a long random string>`: the browser asks for a login and takes any user name with the token as the password. Share the token with the people who may use the chat.
31
+ - `ACRYL_PRODUCTION_KEEP=chat`: the chat is public, with a `WARNING` line at start and a limit of `CHAT_RATE_PER_MINUTE` messages a minute from one address (default 20; behind a proxy you run, set `TRUST_PROXY=1` so the limit counts the visitor, not the proxy). Prefer your own login in front.
32
+
33
+ An app made by 1.0.0 or 1.0.1 keeps its own `src/routes.ts`: copy `src/access.ts`, `src/chat-app.ts` and `src/routes.ts` from a fresh `acrylblends init my-app --template chat`, or put a login in front.
21
34
 
22
35
  ## What live plugin install does and does not promise
23
36
 
@@ -38,6 +51,6 @@ Installing a plugin (`ctx.extensions.install`, the door's `plugin add`, the buil
38
51
  ## Checklist for a deployment
39
52
 
40
53
  1. `NODE_ENV=production` (the process manager or the Dockerfile sets it; the templates' Dockerfile does).
41
- 2. Start from a template that is not `agent`, or remove the agent rows.
54
+ 2. With the `agent` template, leave `ACRYL_PRODUCTION_KEEP` unset (1.0.1 and later), or remove the agent rows. With the `chat` template, set `CHAT_ACCESS_TOKEN` (1.0.2 and later) or put your own login in front.
42
55
  3. Do not set `allowInProduction` on the door unless the host is a single-user desktop app.
43
56
  4. Keep `ACRYL_DOOR=off` in any environment where an agent should not be able to see the page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,8 @@
1
1
  /** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
2
2
  import { spawn, spawnSync } from 'node:child_process'
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
3
6
  import type { Context } from '@deepseek-ai/cordis'
4
7
  import type { ChatDriver, ChatEvent } from './drivers.ts'
5
8
 
@@ -62,11 +65,30 @@ export function onPath(program: string): boolean {
62
65
  return found.status === 0
63
66
  }
64
67
 
65
- /** The headless programs this machine can offer: installed (pi also needs its key in the environment). */
68
+ /** Whether pi can reach DeepSeek: the key in the environment, or the one pi stored for itself when its owner logged in (`~/.pi/agent/auth.json`, a `deepseek` entry). */
69
+ export function piHasDeepseekKey(
70
+ env: NodeJS.ProcessEnv = process.env,
71
+ home: string = homedir(),
72
+ ): boolean {
73
+ if (env.DEEPSEEK_API_KEY) return true
74
+ try {
75
+ const stored = JSON.parse(
76
+ readFileSync(
77
+ join(env.PI_CODING_AGENT_DIR ?? join(home, '.pi', 'agent'), 'auth.json'),
78
+ 'utf8',
79
+ ),
80
+ ) as Record<string, { key?: unknown } | undefined>
81
+ return typeof stored.deepseek?.key === 'string' && stored.deepseek.key !== ''
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /** The headless programs this machine can offer: installed (an executable on the PATH; a wrapper function around it is fine) and, for pi, able to reach its model. */
66
88
  export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessName[] {
67
89
  const found: HeadlessName[] = []
68
90
  for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
69
- if (driver === 'pi' && !process.env.DEEPSEEK_API_KEY) continue
91
+ if (driver === 'pi' && !piHasDeepseekKey()) continue
70
92
  if (onPath(driver)) found.push(driver)
71
93
  }
72
94
  return found
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
3
+ * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
+ * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
+ export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
14
+
15
+ /** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
16
+ export function sandboxBlocked(
17
+ platform: string,
18
+ read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
19
+ ): boolean {
20
+ if (platform !== 'linux') return false
21
+ try {
22
+ return read(RESTRICT_USERNS).trim() === '1'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export const SANDBOX_NOTICE =
29
+ "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
+ 'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
+
32
+ /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
+ export function electronArgs(entry: string, blocked: boolean): string[] {
34
+ return blocked ? ['--no-sandbox', entry] : [entry]
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ const electron = createRequire(import.meta.url)('electron') as string
39
+ // ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
40
+ const blocked =
41
+ process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
+ if (blocked) console.warn(SANDBOX_NOTICE)
43
+ const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
+ stdio: 'inherit',
45
+ })
46
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
48
+ }
@@ -9,7 +9,7 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron plugins/desktop/main.ts"
12
+ "desktop": "node plugins/desktop/launch.ts"
13
13
  },
14
14
  "omit": ["**/*.test.*"],
15
15
  "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop)"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `npm run desktop`: starts Electron on `main.ts` next to this file. It exists for one case: on Linux where the kernel restricts unprivileged user namespaces
3
+ * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
+ * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
+ */
6
+ import { spawn } from 'node:child_process'
7
+ import { readFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
+ export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
14
+
15
+ /** Whether the sandbox cannot start here: Linux, with the restriction on. `read` returns a file's text, or throws when there is none. */
16
+ export function sandboxBlocked(
17
+ platform: string,
18
+ read: (path: string) => string = (path) => readFileSync(path, 'utf8'),
19
+ ): boolean {
20
+ if (platform !== 'linux') return false
21
+ try {
22
+ return read(RESTRICT_USERNS).trim() === '1'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export const SANDBOX_NOTICE =
29
+ "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
+ 'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
+
32
+ /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
+ export function electronArgs(entry: string, blocked: boolean): string[] {
34
+ return blocked ? ['--no-sandbox', entry] : [entry]
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ const electron = createRequire(import.meta.url)('electron') as string
39
+ // ACRYLBLENDS_DESKTOP_SANDBOX=1: the owner has given Electron an AppArmor profile and wants the sandbox.
40
+ const blocked =
41
+ process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
+ if (blocked) console.warn(SANDBOX_NOTICE)
43
+ const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
+ stdio: 'inherit',
45
+ })
46
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
48
+ }
@@ -9,7 +9,7 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron plugins/desktop-web/main.ts"
12
+ "desktop": "node plugins/desktop-web/launch.ts"
13
13
  },
14
14
  "gitignore": [".acrylblends/"],
15
15
  "omit": ["**/*.test.*"],
@@ -4,3 +4,5 @@
4
4
  Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plugins into `plugins/` while the app runs and install them with
5
5
  rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
6
6
  and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
7
+
8
+ **Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves those three rows out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
@@ -62,6 +62,14 @@ export interface App {
62
62
  stop(): Promise<void>
63
63
  }
64
64
 
65
+ /** The development rows production leaves out, and what each one gives a visitor. */
66
+ const DEVELOPMENT_ROWS: Record<string, string> = {
67
+ agent: 'POST /ask runs the agent, which spends the model key and calls its tools',
68
+ builder: 'the agent can write plugin source into plugins/',
69
+ extensions:
70
+ 'a plugin installed live runs in this process, and POST /extensions/approve decides installs',
71
+ }
72
+
65
73
  /**
66
74
  * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's `plugins/`
67
75
  * folder is trusted to install at start, and is where the builder writes.
@@ -84,6 +92,29 @@ export async function createApp(
84
92
  if (!listen) leaveOut.add('acryl-node-server').add('acryl-door')
85
93
  if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off')
86
94
  leaveOut.add('acryl-door')
95
+ // Production: the rows that give a visitor power over the running app or the owner's model key are left out too. The agent (`POST /ask` spends the model key and runs tools),
96
+ // the builder and the extensions (live plugin install runs code in this process, and `POST /extensions/approve` decides it) are development tools. `ACRYL_PRODUCTION_KEEP`
97
+ // names the ones to keep (`agent,builder,extensions`); each one kept is announced at start, because none of them is behind a login.
98
+ if (process.env.NODE_ENV === 'production') {
99
+ const keep = new Set(
100
+ (process.env.ACRYL_PRODUCTION_KEEP ?? '')
101
+ .split(',')
102
+ .map((name) => name.trim())
103
+ .filter((name) => name !== ''),
104
+ )
105
+ for (const name of keep)
106
+ if (!(name in DEVELOPMENT_ROWS))
107
+ console.warn(
108
+ `WARNING: ACRYL_PRODUCTION_KEEP names "${name}", which is not one of ${Object.keys(DEVELOPMENT_ROWS).join(', ')}`,
109
+ )
110
+ for (const [row, danger] of Object.entries(DEVELOPMENT_ROWS)) {
111
+ if (keep.has(row))
112
+ console.warn(
113
+ `WARNING: the "${row}" row runs in production because ACRYL_PRODUCTION_KEEP names it: ${danger}. Nothing here is behind a login; put the app behind your own.`,
114
+ )
115
+ else leaveOut.add(row)
116
+ }
117
+ }
87
118
  const manifest = {
88
119
  ...read,
89
120
  plugins: read.plugins.map((row) => (leaveOut.has(row.use) ? { ...row, disabled: true } : row)),
@@ -14,13 +14,14 @@ const DOOR_TAG =
14
14
  : ''
15
15
 
16
16
  /**
17
- * The app's own routes on the router: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a
18
- * durable task owning one task per tool call); `POST /tasks/cancel`. What changes is also streamed: `GET /api/state/tasks/events` and
19
- * `GET /api/state/extensions/events`. The mount is removed when the plugin is disposed.
17
+ * The app's own routes on the router. Always: the page and `GET /health`. While the agent, the extensions and the task recorder run: `POST /ask` for the agent; `GET /extensions`
18
+ * (and `POST /extensions/approve` and `/deny`) and `GET /tasks` to observe and steer what it did (every run is a durable task owning one task per tool call); `POST /tasks/cancel`.
19
+ * What changes is also streamed: `GET /api/state/tasks/events` and `GET /api/state/extensions/events`. In production `src/app.ts` leaves the agent, builder and extensions rows
20
+ * out (unless `ACRYL_PRODUCTION_KEEP` names them), and then those routes are not mounted at all. The mounts are removed when the plugin is disposed.
20
21
  */
21
22
  export const routes = {
22
23
  name: 'routes',
23
- inject: ['router', 'agent', 'extensions', 'tasks'],
24
+ inject: ['router'],
24
25
  apply(ctx: Context) {
25
26
  // A body here is a name, an id or a question; anything bigger is refused before it is read.
26
27
  const json = async (c: {
@@ -29,48 +30,54 @@ export const routes = {
29
30
  const body = await c.req.json().catch(() => undefined)
30
31
  return typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {}
31
32
  }
32
- const app = new Acryl()
33
- .use(bodyLimit({ maxSize: 64 * 1024 }))
33
+ const limit = bodyLimit({ maxSize: 64 * 1024 })
34
+ const base = new Acryl()
35
+ .use(limit)
34
36
  // A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
35
37
  .get('/', (c) =>
36
38
  (c.req.header('accept') ?? '').includes('text/html')
37
39
  ? c.html(
38
- `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent. Ask it for a feature (<code>POST /ask</code>), or add the chat panel with <code>npx acrylblends add agent-panel</code>.</p>${DOOR_TAG}</body></html>`,
40
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature (<code>POST /ask</code>), or add the chat panel with <code>npx acrylblends add agent-panel</code>.</p>${DOOR_TAG}</body></html>`,
39
41
  )
40
42
  : c.json({ app: APP_NAME, ok: true }),
41
43
  )
42
44
  .get('/health', (c) => c.json({ ok: true }))
43
- .get('/extensions', (c) =>
45
+ ctx.router.route('/', base)
46
+ // The routes that give a visitor the agent: mounted only while the agent, the extensions and the tasks run (see the header).
47
+ ctx.inject(['agent', 'extensions', 'tasks'], (inner) => {
48
+ const app = new Acryl().use(limit).get('/extensions', (c) =>
44
49
  c.json({
45
- installed: ctx.extensions.list(),
46
- notInstalled: ctx.extensions.pending(),
47
- waitingForApproval: ctx.extensions.approvals(),
50
+ installed: inner.extensions.list(),
51
+ notInstalled: inner.extensions.pending(),
52
+ waitingForApproval: inner.extensions.approvals(),
48
53
  }),
49
54
  )
50
- // The owner's decision on an install that is waiting (the `approval` parameter in blend.yaml).
51
- for (const [path, decide] of [
52
- ['/extensions/approve', (name: string) => ctx.extensions.approve(name)],
53
- ['/extensions/deny', (name: string) => ctx.extensions.deny(name)],
54
- ] as const) {
55
- app.post(path, async (c) => {
56
- const { name } = await json(c)
57
- if (typeof name !== 'string') return c.json({ error: 'name must be a string' }, 400)
58
- return c.json(await decide(name))
59
- })
60
- }
61
- app
62
- .get('/tasks', (c) => c.json(ctx.tasks.list().slice(0, 100)))
63
- .post('/tasks/cancel', async (c) => {
64
- const { id } = await json(c)
65
- if (typeof id !== 'string' || !ctx.tasks.get(id))
66
- return c.json({ error: 'no such task' }, 404)
67
- return c.json({ cancelled: ctx.tasks.cancel(id) })
68
- })
69
- .post('/ask', async (c) => {
70
- const { question } = await json(c)
71
- if (typeof question !== 'string') return c.json({ error: 'question must be a string' }, 400)
72
- return c.json(await ctx.agent.run(question))
73
- })
74
- ctx.router.route('/', app)
55
+ // The owner's decision on an install that is waiting (the `approval` parameter in blend.yaml).
56
+ for (const [path, decide] of [
57
+ ['/extensions/approve', (name: string) => inner.extensions.approve(name)],
58
+ ['/extensions/deny', (name: string) => inner.extensions.deny(name)],
59
+ ] as const) {
60
+ app.post(path, async (c) => {
61
+ const { name } = await json(c)
62
+ if (typeof name !== 'string') return c.json({ error: 'name must be a string' }, 400)
63
+ return c.json(await decide(name))
64
+ })
65
+ }
66
+ app
67
+ .get('/tasks', (c) => c.json(inner.tasks.list().slice(0, 100)))
68
+ .post('/tasks/cancel', async (c) => {
69
+ const { id } = await json(c)
70
+ if (typeof id !== 'string' || !inner.tasks.get(id))
71
+ return c.json({ error: 'no such task' }, 404)
72
+ return c.json({ cancelled: inner.tasks.cancel(id) })
73
+ })
74
+ .post('/ask', async (c) => {
75
+ const { question } = await json(c)
76
+ if (typeof question !== 'string')
77
+ return c.json({ error: 'question must be a string' }, 400)
78
+ return c.json(await inner.agent.run(question))
79
+ })
80
+ inner.router.route('/', app)
81
+ })
75
82
  },
76
83
  }
@@ -65,3 +65,77 @@ test('stops listening when stopped', { skip }, async () => {
65
65
  await assert.rejects(fetch(`${app.url}/health`))
66
66
  await rm(home, { recursive: true, force: true })
67
67
  })
68
+
69
+ /** Boot in a given environment, with the warnings collected. */
70
+ async function inEnvironment(
71
+ env: Record<string, string | undefined>,
72
+ run: (app: App, warnings: string[]) => Promise<void>,
73
+ ) {
74
+ const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
75
+ const warnings: string[] = []
76
+ const warn = console.warn
77
+ console.warn = (message: unknown) => void warnings.push(String(message))
78
+ for (const [key, value] of Object.entries(env)) {
79
+ if (value === undefined) delete process.env[key]
80
+ else process.env[key] = value
81
+ }
82
+ try {
83
+ await withApp((app) => run(app, warnings))
84
+ } finally {
85
+ console.warn = warn
86
+ for (const [key, value] of Object.entries(saved)) {
87
+ if (value === undefined) delete process.env[key]
88
+ else process.env[key] = value
89
+ }
90
+ }
91
+ }
92
+
93
+ const asking = (app: App) =>
94
+ app.root.router.request('/ask', {
95
+ method: 'POST',
96
+ headers: { 'content-type': 'application/json' },
97
+ body: JSON.stringify({ question: 'add 2 3' }),
98
+ })
99
+
100
+ test('in production the agent, the builder and the extensions are left out: no /ask, no live install, no approve route; the page and /health stay', () =>
101
+ inEnvironment({ NODE_ENV: 'production', ACRYL_PRODUCTION_KEEP: undefined }, async (app) => {
102
+ assert.equal((await app.root.router.request('/health')).status, 200)
103
+ assert.equal((await app.root.router.request('/')).status, 200)
104
+ for (const [path, method] of [
105
+ ['/ask', 'POST'],
106
+ ['/extensions', 'GET'],
107
+ ['/extensions/approve', 'POST'],
108
+ ['/tasks', 'GET'],
109
+ ] as const) {
110
+ const response = await app.root.router.request(path, { method })
111
+ assert.equal(response.status, 404, `${method} ${path} answered ${response.status}`)
112
+ await response.body?.cancel()
113
+ }
114
+ assert.equal((app.root as unknown as { agent?: unknown }).agent, undefined)
115
+ assert.equal((app.root as unknown as { extensions?: unknown }).extensions, undefined)
116
+ }))
117
+
118
+ test('ACRYL_PRODUCTION_KEEP keeps the named rows in production and says so at start; a name it does not know is reported', () =>
119
+ inEnvironment(
120
+ { NODE_ENV: 'production', ACRYL_PRODUCTION_KEEP: 'agent,extensions,nope' },
121
+ async (app, warnings) => {
122
+ const answer = await asking(app)
123
+ assert.equal(answer.status, 200)
124
+ assert.equal(((await answer.json()) as { status: string }).status, 'completed')
125
+ assert.equal((await app.root.router.request('/extensions')).status, 200)
126
+ assert.ok(
127
+ warnings.some((line) => line.includes('"agent" row runs in production')),
128
+ warnings.join('\n'),
129
+ )
130
+ assert.ok(warnings.some((line) => line.includes('"extensions" row runs in production')))
131
+ assert.ok(!warnings.some((line) => line.includes('"builder" row runs in production')))
132
+ assert.ok(warnings.some((line) => line.includes('names "nope"')))
133
+ },
134
+ ))
135
+
136
+ test('outside production nothing changes: the agent answers and the extensions route is there', () =>
137
+ inEnvironment({ NODE_ENV: 'test', ACRYL_PRODUCTION_KEEP: undefined }, async (app, warnings) => {
138
+ assert.equal((await asking(app)).status, 200)
139
+ assert.equal((await app.root.router.request('/extensions')).status, 200)
140
+ assert.deepEqual(warnings, [])
141
+ }))
@@ -13,4 +13,4 @@ A Cordis app: `blend.yaml` lists the plugins that are mounted (`acryl-router`, `
13
13
 
14
14
  ## Before you ship
15
15
 
16
- Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key: put it behind a login or on a private network before it is public.
16
+ Read `docs/framework/getting-started/deploy.md`. In short: `npm ci --omit=dev`, `npm run build`, `NODE_ENV=production npm start`; the app listens on `127.0.0.1` (`HOST=0.0.0.0` only in a container); the SQLite file goes outside the code folder (`CHAT_DB=/var/lib/chat/chat.sqlite`) and is backed up; the model key is an environment variable of the service (a root-only environment file for systemd), never a file in the repository. Anyone who can reach the app can spend your key, so with `NODE_ENV=production` the chat is **closed**: every route but `GET /health` answers 503 (`src/access.ts`). Set `CHAT_ACCESS_TOKEN` (the browser asks for it; any user name, the token as the password), or name the chat in `ACRYL_PRODUCTION_KEEP=chat` to make it public on purpose: that prints a `WARNING` at start and limits one address to `CHAT_RATE_PER_MINUTE` messages a minute (default 20). Prefer your own login in front of a public chat.