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
|
|
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
|
|
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`).
|
|
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.
|
|
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
|
@@ -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: `
|
|
18
|
-
* durable task owning one task per tool call); `POST /tasks/cancel`.
|
|
19
|
-
* `GET /api/state/extensions/events`.
|
|
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'
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
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:
|
|
46
|
-
notInstalled:
|
|
47
|
-
waitingForApproval:
|
|
50
|
+
installed: inner.extensions.list(),
|
|
51
|
+
notInstalled: inner.extensions.pending(),
|
|
52
|
+
waitingForApproval: inner.extensions.approvals(),
|
|
48
53
|
}),
|
|
49
54
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}))
|