acrylblends 1.0.0 → 1.0.1

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.
@@ -10,14 +10,17 @@
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
14
  | `acrylblends/bun` | **experimental**: one smoke gate runs it on Bun | the same |
15
15
 
16
16
  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
17
 
18
- ## The `agent` template is a development app
18
+ ## The `agent` template in production
19
19
 
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`.
20
+ 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.
21
+
22
+ - 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.
23
+ - **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.
21
24
 
22
25
  ## What live plugin install does and does not promise
23
26
 
@@ -38,6 +41,6 @@ Installing a plugin (`ctx.extensions.install`, the door's `plugin add`, the buil
38
41
  ## Checklist for a deployment
39
42
 
40
43
  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.
44
+ 2. With the `agent` template, leave `ACRYL_PRODUCTION_KEEP` unset (1.0.1 and later), or remove the agent rows.
42
45
  3. Do not set `allowInProduction` on the door unless the host is a single-user desktop app.
43
46
  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.1",
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",
@@ -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
+ }))