acrylblends 1.1.0 → 1.3.0
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.
- package/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
* - `GET /api/agent-panel/session` gives the page the drivers it may use and a token; `POST /api/agent-panel/ask` runs one message and answers server-sent events:
|
|
8
8
|
* `text`, `tool`, `change`, `done`, `error`.
|
|
9
9
|
* - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
|
|
10
|
-
* -
|
|
10
|
+
* - Who asks (`visitors.ts`): in development one person, the owner, whose pages share one token and one run. As the Assistant each visitor has their own token, run and
|
|
11
|
+
* replay, named by an HttpOnly cookie: no visitor sees, follows or stops another's run, and the asks are bounded (per visitor per hour, per day, runs at once).
|
|
12
|
+
* - The routes live under the app's mount: the root of the app's own server, or the frame's prefix (`/app`) when the frame hosts it, where the host's own
|
|
13
|
+
* `/api` routes would otherwise answer first. The row's `mount` config names it.
|
|
14
|
+
* - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`, except as the Assistant (`assistant: true`): then it runs in production too,
|
|
15
|
+
* docked open on the right, with the app's own agent only. The app gives that agent the page tools alone and a page-only door, so it uses the app for a visitor and
|
|
16
|
+
* cannot change it (src/app.ts, `ACRYL_ASSISTANT=on`). In the ACRYL frame's canvas the panel follows the frame's view: Build hides it, and Preview shows it only as
|
|
17
|
+
* the Assistant and only when the app's production environment names one (`assistantInProduction` in the session).
|
|
11
18
|
*/
|
|
12
|
-
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
13
19
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
14
20
|
import { dirname, join } from 'node:path'
|
|
15
21
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -19,7 +25,7 @@ import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
|
|
|
19
25
|
import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
|
|
20
26
|
import type {} from '../agent/index.ts'
|
|
21
27
|
import type {} from '../extensions/index.ts'
|
|
22
|
-
import type { ChatDriver } from './drivers.ts'
|
|
28
|
+
import type { ChatDriver, Driver } from './drivers.ts'
|
|
23
29
|
import {
|
|
24
30
|
agentDriver,
|
|
25
31
|
availableHeadless,
|
|
@@ -27,7 +33,9 @@ import {
|
|
|
27
33
|
headlessDriver,
|
|
28
34
|
whyNotChat,
|
|
29
35
|
} from './headless.ts'
|
|
36
|
+
import { mountedBase, mountedPrefix } from './mount.ts'
|
|
30
37
|
import { provideDrivers } from './registry.ts'
|
|
38
|
+
import { type AssistantLimits, type Visitor, Visitors } from './visitors.ts'
|
|
31
39
|
|
|
32
40
|
export type {
|
|
33
41
|
ChatDriver,
|
|
@@ -41,7 +49,11 @@ export type {
|
|
|
41
49
|
export const name = 'agent-panel'
|
|
42
50
|
export const inject = ['router', 'agent', 'extensions', 'appInstance']
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
/** The panel's own routes, fixed relative to the app's mount. */
|
|
53
|
+
const PANEL_PATH = '/api/agent-panel'
|
|
54
|
+
|
|
55
|
+
/** The one driver the Assistant offers its visitors: the app's own agent, held to the page tools. */
|
|
56
|
+
const ASSISTANT_DRIVER = 'agent'
|
|
45
57
|
|
|
46
58
|
/** The slice of the door the panel uses; the door is optional (it is a development plugin, and a different row). */
|
|
47
59
|
interface DoorPort {
|
|
@@ -52,8 +64,25 @@ const CLIENT_DIR = join(import.meta.dirname, 'client')
|
|
|
52
64
|
export interface PanelConfig {
|
|
53
65
|
/** Headless coding agents to offer besides the app's own agent; default: the ones found on the machine. */
|
|
54
66
|
drivers?: readonly HeadlessName[]
|
|
67
|
+
/**
|
|
68
|
+
* The URL prefix the app is mounted at: empty on the app's own server, `/app` when the ACRYL frame hosts it (the same prefix `makeApp` and
|
|
69
|
+
* `routerHttp.mount` take). The panel's routes, script tag and client live under it, so they belong to the app and are served wherever it is.
|
|
70
|
+
*/
|
|
71
|
+
mount?: string
|
|
72
|
+
/** The Assistant: a production panel for the app's visitors, with the app's own agent only (no coding agent, no terminal). Runs with `NODE_ENV=production`. */
|
|
73
|
+
assistant?: boolean
|
|
74
|
+
/** The Assistant's limits on what visitors may ask (`visitors.ts` has the defaults); the development panel has none but one run at a time. */
|
|
75
|
+
limits?: AssistantLimits
|
|
55
76
|
}
|
|
56
77
|
|
|
78
|
+
/** Whether the page came over https (directly, or through a proxy that says so): the visitor cookie is then Secure. */
|
|
79
|
+
const overHttps = (url: string, headers: Headers): boolean =>
|
|
80
|
+
url.startsWith('https:') || headers.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https'
|
|
81
|
+
|
|
82
|
+
/** The panel's routes under a checked mount: empty, or an absolute path such as `/app` with no trailing slash (the `routerHttp.mount` rule). */
|
|
83
|
+
const panelPrefix = (mount: string | undefined): string =>
|
|
84
|
+
mountedPrefix(mount, PANEL_PATH, 'agent-panel')
|
|
85
|
+
|
|
57
86
|
/** Whether a request comes from the app's own page: a browser says so itself (`Sec-Fetch-Site: same-origin`), or the Origin is the Host. */
|
|
58
87
|
export function fromOwnPage(headers: Headers): boolean {
|
|
59
88
|
const site = headers.get('sec-fetch-site')
|
|
@@ -64,9 +93,24 @@ export function fromOwnPage(headers: Headers): boolean {
|
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
67
|
-
|
|
96
|
+
const assistant = config.assistant === true
|
|
97
|
+
if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && !assistant))
|
|
98
|
+
return
|
|
99
|
+
const PREFIX = panelPrefix(config.mount)
|
|
100
|
+
// The panel's routes mount at the app's own mount and keep the fixed path inside (`/app` + `/api/agent-panel/...`); the frame's router prefixes the
|
|
101
|
+
// mount path itself, so a prefix inside the routes too would become `/app/app/...`. PREFIX stays absolute for the page's script tag and client URLs.
|
|
102
|
+
const MOUNT = mountedBase(config.mount, 'agent-panel')
|
|
68
103
|
const root = ctx.appInstance.root
|
|
69
|
-
const
|
|
104
|
+
const visitors = new Visitors<Run>({
|
|
105
|
+
shared: !assistant,
|
|
106
|
+
...(config.limits === undefined ? {} : { limits: config.limits }),
|
|
107
|
+
})
|
|
108
|
+
/** The visitor of a request that must carry its token, or `undefined` when it does not come from the app's own page with the right one. */
|
|
109
|
+
const caller = (headers: Headers, presented: unknown): Visitor<Run> | undefined => {
|
|
110
|
+
if (!fromOwnPage(headers)) return undefined
|
|
111
|
+
const visitor = visitors.of(headers.get('cookie'))
|
|
112
|
+
return visitors.accepts(visitor, presented) ? visitor : undefined
|
|
113
|
+
}
|
|
70
114
|
const auditFile = join(root, '.acryl', 'audit', 'agent-panel.jsonl')
|
|
71
115
|
const audit = (entry: Record<string, unknown>): void => {
|
|
72
116
|
try {
|
|
@@ -76,13 +120,17 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
76
120
|
// The audit never stops a run.
|
|
77
121
|
}
|
|
78
122
|
}
|
|
123
|
+
// The Assistant's visitors use the app's own agent and nothing else: no plugin's driver (a terminal, a coding agent) is open to them, whatever plugins the app mounts.
|
|
79
124
|
const registry = provideDrivers(
|
|
80
125
|
ctx,
|
|
81
|
-
(headers) =>
|
|
126
|
+
(headers) => !assistant && caller(headers, headers.get('x-agent-panel-token')) !== undefined,
|
|
82
127
|
)
|
|
128
|
+
const offered = (): Driver[] =>
|
|
129
|
+
registry.list().filter((driver) => !assistant || driver.name === ASSISTANT_DRIVER)
|
|
83
130
|
ctx.effect(() => registry.register(agentDriver(ctx)))
|
|
84
|
-
|
|
85
|
-
|
|
131
|
+
if (!assistant)
|
|
132
|
+
for (const program of availableHeadless(config.drivers))
|
|
133
|
+
ctx.effect(() => registry.register(headlessDriver(program, root)))
|
|
86
134
|
|
|
87
135
|
const tag = `<script type="module" src="${PREFIX}/panel.js"></script>`
|
|
88
136
|
const addScript = async (c: { res: Response }, next: () => Promise<void>): Promise<void> => {
|
|
@@ -110,62 +158,95 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
110
158
|
if (wrapped) return next()
|
|
111
159
|
await addScript(c, next)
|
|
112
160
|
})
|
|
113
|
-
.get(`${
|
|
114
|
-
c.body(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
161
|
+
.get(`${PANEL_PATH}/panel.js`, (c) =>
|
|
162
|
+
c.body(
|
|
163
|
+
`import { start } from '${PREFIX}/client/main.ts'\nstart(${JSON.stringify(PREFIX)})\n`,
|
|
164
|
+
200,
|
|
165
|
+
{
|
|
166
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
167
|
+
'cache-control': 'no-cache',
|
|
168
|
+
},
|
|
169
|
+
),
|
|
118
170
|
)
|
|
119
|
-
.route(`${
|
|
120
|
-
.get(`${
|
|
171
|
+
.route(`${PANEL_PATH}/client`, clientModules({ root: CLIENT_DIR, mount: `${PREFIX}/client` }))
|
|
172
|
+
.get(`${PANEL_PATH}/session`, (c) => {
|
|
121
173
|
if (!fromOwnPage(c.req.raw.headers))
|
|
122
174
|
return c.json({ error: "only the app's own page may use the panel" }, 403)
|
|
175
|
+
const { visitor, setCookie } = visitors.session(
|
|
176
|
+
c.req.header('cookie'),
|
|
177
|
+
overHttps(c.req.url, c.req.raw.headers),
|
|
178
|
+
)
|
|
179
|
+
if (setCookie !== undefined) c.header('set-cookie', setCookie)
|
|
123
180
|
return c.json({
|
|
124
|
-
token,
|
|
125
|
-
|
|
181
|
+
token: visitor.token,
|
|
182
|
+
...(assistant ? { assistant: true } : {}),
|
|
183
|
+
// The frame's Preview shows the app as production would. This is the fact that lets its page show the Assistant when the app's production
|
|
184
|
+
// environment names it (ACRYL_ASSISTANT=on), even though the frame host itself runs in development and never mounts the Assistant row.
|
|
185
|
+
...(process.env.ACRYL_ASSISTANT === 'on' ? { assistantInProduction: true } : {}),
|
|
186
|
+
// A terminal driver names the browser module that draws its view and the prefix its endpoints live under: the plugin that owns the view
|
|
187
|
+
// also knows where the app is mounted, so the panel never guesses either.
|
|
188
|
+
drivers: offered().map((driver) => ({
|
|
189
|
+
name: driver.name,
|
|
190
|
+
kind: driver.kind,
|
|
191
|
+
...(driver.kind === 'terminal' ? { view: driver.view } : {}),
|
|
192
|
+
})),
|
|
126
193
|
})
|
|
127
194
|
})
|
|
128
|
-
.post(`${
|
|
129
|
-
|
|
130
|
-
|
|
195
|
+
.post(`${PANEL_PATH}/ask`, async (c) => {
|
|
196
|
+
const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
|
|
197
|
+
if (visitor === undefined) return c.json({ error: "only the app's own page may ask" }, 403)
|
|
131
198
|
const body = (await c.req.json().catch(() => ({}))) as {
|
|
132
199
|
message?: unknown
|
|
133
200
|
driver?: unknown
|
|
134
201
|
page?: unknown
|
|
135
202
|
}
|
|
136
|
-
const checked = checkAsk(body.message, body.driver)
|
|
203
|
+
const checked = checkAsk(visitor, body.message, body.driver)
|
|
137
204
|
if ('error' in checked) return c.json({ error: checked.error }, checked.status)
|
|
138
|
-
const run = startRun(
|
|
205
|
+
const run = startRun(
|
|
206
|
+
visitor,
|
|
207
|
+
checked.driverName,
|
|
208
|
+
checked.message,
|
|
209
|
+
checked.driver,
|
|
210
|
+
pageOf(body.page),
|
|
211
|
+
)
|
|
139
212
|
return follow(c, run, false)
|
|
140
213
|
})
|
|
141
214
|
// Stop: the only thing that cancels a run. A page that goes away (a reload, a move to another page, a closed tab) does not: the agent goes on, and the page picks it up again.
|
|
142
|
-
.post(`${
|
|
143
|
-
|
|
215
|
+
.post(`${PANEL_PATH}/stop`, (c) => {
|
|
216
|
+
const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
|
|
217
|
+
if (visitor === undefined)
|
|
144
218
|
return c.json({ error: "only the app's own page may stop a run" }, 403)
|
|
145
|
-
|
|
219
|
+
// A visitor's own run, never another's.
|
|
220
|
+
visitor.current?.cancel.abort()
|
|
146
221
|
return c.json({ ok: true })
|
|
147
222
|
})
|
|
148
223
|
// The latest run, for a page that just loaded: what was asked, then every event so far, then the rest as it happens (until the run ends).
|
|
149
|
-
.get(`${
|
|
150
|
-
|
|
224
|
+
.get(`${PANEL_PATH}/run`, (c) => {
|
|
225
|
+
const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
|
|
226
|
+
if (visitor === undefined)
|
|
151
227
|
return c.json({ error: "only the app's own page may read the run" }, 403)
|
|
152
|
-
return current === undefined
|
|
228
|
+
return visitor.current === undefined
|
|
229
|
+
? c.json({ run: null })
|
|
230
|
+
: follow(c, visitor.current, true)
|
|
153
231
|
})
|
|
154
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run of this visitor going; for the Assistant also its
|
|
234
|
+
* limits (an admitted ask is counted).
|
|
235
|
+
*/
|
|
155
236
|
const checkAsk = (
|
|
237
|
+
visitor: Visitor<Run>,
|
|
156
238
|
rawMessage: unknown,
|
|
157
239
|
rawDriver: unknown,
|
|
158
240
|
):
|
|
159
241
|
| { message: string; driverName: string; driver: ChatDriver }
|
|
160
|
-
| { error: string; status: 400 | 409 } => {
|
|
242
|
+
| { error: string; status: 400 | 409 | 429 | 503 } => {
|
|
161
243
|
const message = typeof rawMessage === 'string' ? rawMessage.trim() : ''
|
|
162
244
|
const driverName = typeof rawDriver === 'string' ? rawDriver : 'agent'
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const driver =
|
|
245
|
+
const tooLong = visitors.checkMessage(message)
|
|
246
|
+
if (tooLong !== undefined) return tooLong
|
|
247
|
+
const driver = offered().find((one) => one.name === driverName)
|
|
166
248
|
if (driver?.kind !== 'chat') {
|
|
167
|
-
const chat =
|
|
168
|
-
.list()
|
|
249
|
+
const chat = offered()
|
|
169
250
|
.filter((d) => d.kind === 'chat')
|
|
170
251
|
.map((d) => d.name)
|
|
171
252
|
.join(', ')
|
|
@@ -175,8 +256,11 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
175
256
|
status: 400,
|
|
176
257
|
}
|
|
177
258
|
}
|
|
178
|
-
|
|
179
|
-
|
|
259
|
+
const refused = visitors.admit(visitor)
|
|
260
|
+
if (refused !== undefined) {
|
|
261
|
+
if (assistant) audit({ ask: 'refused', visitor: visitor.tag, status: refused.status })
|
|
262
|
+
return refused
|
|
263
|
+
}
|
|
180
264
|
return { message, driverName, driver }
|
|
181
265
|
}
|
|
182
266
|
/** One run of an agent: its events as they came, and who is following. It lives in the app, not in a page. */
|
|
@@ -191,12 +275,12 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
191
275
|
ended: boolean
|
|
192
276
|
endedAt?: number
|
|
193
277
|
}
|
|
194
|
-
let current: Run | undefined
|
|
195
278
|
let runs = 0
|
|
196
279
|
/** The door's id of the page that asked, when it sent one that looks like it. */
|
|
197
280
|
const pageOf = (raw: unknown): string | undefined =>
|
|
198
281
|
typeof raw === 'string' && /^[A-Za-z0-9-]{1,80}$/u.test(raw) ? raw : undefined
|
|
199
282
|
const startRun = (
|
|
283
|
+
visitor: Visitor<Run>,
|
|
200
284
|
driverName: string,
|
|
201
285
|
message: string,
|
|
202
286
|
driver: ChatDriver,
|
|
@@ -213,7 +297,8 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
213
297
|
cancel: new AbortController(),
|
|
214
298
|
ended: false,
|
|
215
299
|
}
|
|
216
|
-
current = run
|
|
300
|
+
visitor.current = run
|
|
301
|
+
const who = assistant ? { visitor: visitor.tag } : {}
|
|
217
302
|
const emit = (event: string, data: unknown): void => {
|
|
218
303
|
const item = { event, data }
|
|
219
304
|
run.events.push(item)
|
|
@@ -224,14 +309,19 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
224
309
|
changed = true
|
|
225
310
|
emit('change', { extension, action })
|
|
226
311
|
})
|
|
227
|
-
|
|
228
|
-
|
|
312
|
+
// The run ends when the plugin unloads; the effect goes when the run ends, so runs do not pile up on the plugin.
|
|
313
|
+
const unloadStops = ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
|
|
314
|
+
audit({ ask: 'start', ...who, driver: driverName, characters: message.length })
|
|
229
315
|
// The agent's door calls name no window: while this run goes, they are for the page whose panel asked, not for another tab or window that happens to be open.
|
|
230
316
|
const door = page === undefined ? undefined : (ctx.get('door') as DoorPort | undefined)
|
|
231
317
|
const releasePage = door?.driveFor(page as string)
|
|
232
318
|
void (async () => {
|
|
233
319
|
try {
|
|
234
|
-
for await (const { event, data } of driver.start({
|
|
320
|
+
for await (const { event, data } of driver.start({
|
|
321
|
+
message,
|
|
322
|
+
signal: run.cancel.signal,
|
|
323
|
+
...(page === undefined ? {} : { page }),
|
|
324
|
+
}))
|
|
235
325
|
emit(event, data)
|
|
236
326
|
emit('done', { changed })
|
|
237
327
|
} catch (error) {
|
|
@@ -241,8 +331,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
241
331
|
releasePage?.()
|
|
242
332
|
run.ended = true
|
|
243
333
|
run.endedAt = Date.now()
|
|
244
|
-
audit({ ask: 'end', driver: driverName, changed })
|
|
334
|
+
audit({ ask: 'end', ...who, driver: driverName, changed })
|
|
245
335
|
for (const follower of [...run.followers]) follower({ event: 'end', data: {} })
|
|
336
|
+
void unloadStops()
|
|
246
337
|
}
|
|
247
338
|
})()
|
|
248
339
|
return run
|
|
@@ -340,17 +431,13 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
340
431
|
return c.json({ error: "only the app's own page may use the panel" }, 403)
|
|
341
432
|
await next()
|
|
342
433
|
}
|
|
343
|
-
const sameToken = (presented: unknown): boolean => {
|
|
344
|
-
if (typeof presented !== 'string') return false
|
|
345
|
-
const a = Buffer.from(presented)
|
|
346
|
-
const b = Buffer.from(token)
|
|
347
|
-
return a.length === b.length && timingSafeEqual(a, b)
|
|
348
|
-
}
|
|
349
434
|
ws.get(
|
|
350
435
|
`${PREFIX}/run/ws`,
|
|
351
436
|
own,
|
|
352
437
|
upgrade(
|
|
353
|
-
() => {
|
|
438
|
+
(c) => {
|
|
439
|
+
// The visitor the socket's cookie names (the owner in development): its hello must carry that visitor's token, and it reaches that visitor's runs only.
|
|
440
|
+
const visitor = visitors.of(c.req.header('cookie'))
|
|
354
441
|
let authed = false
|
|
355
442
|
let following: { detach(): void } | undefined
|
|
356
443
|
let hello: NodeJS.Timeout | undefined
|
|
@@ -373,7 +460,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
373
460
|
return
|
|
374
461
|
}
|
|
375
462
|
if (!authed) {
|
|
376
|
-
if (message.t !== 'hello' || !
|
|
463
|
+
if (message.t !== 'hello' || !visitors.accepts(visitor, message.token)) {
|
|
377
464
|
ws.close(4401, 'token')
|
|
378
465
|
return
|
|
379
466
|
}
|
|
@@ -386,8 +473,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
386
473
|
const send = (e: string, d: unknown): void => {
|
|
387
474
|
ws.send(JSON.stringify({ e, d }))
|
|
388
475
|
}
|
|
476
|
+
if (visitor === undefined) return
|
|
389
477
|
if (message.t === 'stop') {
|
|
390
|
-
current?.cancel.abort()
|
|
478
|
+
visitor.current?.cancel.abort()
|
|
391
479
|
return
|
|
392
480
|
}
|
|
393
481
|
if (following !== undefined) return
|
|
@@ -397,7 +485,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
397
485
|
void pumped.done.then(() => ws.close(1000, 'run ended'))
|
|
398
486
|
}
|
|
399
487
|
if (message.t === 'ask') {
|
|
400
|
-
const checked = checkAsk(message.message, message.driver)
|
|
488
|
+
const checked = checkAsk(visitor, message.message, message.driver)
|
|
401
489
|
if ('error' in checked) {
|
|
402
490
|
send('error', { message: checked.error })
|
|
403
491
|
ws.close(1000, 'refused')
|
|
@@ -405,6 +493,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
405
493
|
}
|
|
406
494
|
watch(
|
|
407
495
|
startRun(
|
|
496
|
+
visitor,
|
|
408
497
|
checked.driverName,
|
|
409
498
|
checked.message,
|
|
410
499
|
checked.driver,
|
|
@@ -413,10 +502,10 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
413
502
|
false,
|
|
414
503
|
)
|
|
415
504
|
} else if (message.t === 'follow') {
|
|
416
|
-
if (current === undefined) {
|
|
505
|
+
if (visitor.current === undefined) {
|
|
417
506
|
send('idle', {})
|
|
418
507
|
ws.close(1000, 'no run')
|
|
419
|
-
} else watch(current, true)
|
|
508
|
+
} else watch(visitor.current, true)
|
|
420
509
|
}
|
|
421
510
|
},
|
|
422
511
|
onClose() {
|
|
@@ -430,5 +519,5 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
430
519
|
)
|
|
431
520
|
inner.router.route('/', ws)
|
|
432
521
|
})
|
|
433
|
-
ctx.router.route('/', app)
|
|
522
|
+
ctx.router.route(MOUNT === '' ? '/' : MOUNT, app)
|
|
434
523
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's own route prefix under the app's mount. The app is served at the root of its own server `''`, or at the prefix a host serves it at (`/app` in
|
|
3
|
+
* the ACRYL frame); a plugin that mounts routes of its own (the panel, the PTY driver) takes that mount as config and derives its prefix from it, so its
|
|
4
|
+
* routes belong to the app wherever the app is served. The same rule `routerHttp.mount` applies.
|
|
5
|
+
*/
|
|
6
|
+
export function mountedPrefix(mount: string | undefined, path: string, plugin: string): string {
|
|
7
|
+
if (mount === undefined || mount === '') return path
|
|
8
|
+
if (!/^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u.test(mount))
|
|
9
|
+
throw new TypeError(
|
|
10
|
+
`${plugin}: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
|
|
11
|
+
)
|
|
12
|
+
return `${mount}${path}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The app's own mount, validated like {@link mountedPrefix}: empty at the root or the prefix a host serves the app at (`/app`). A plugin's routes are
|
|
17
|
+
* mounted at this path and keep only their own fixed path inside, so the frame's router (which prefixes a mount with the app's base) composes
|
|
18
|
+
* `/app` + `/api/agent-panel/panel.js` once, not twice.
|
|
19
|
+
*/
|
|
20
|
+
export function mountedBase(mount: string | undefined, plugin: string): string {
|
|
21
|
+
if (mount === undefined || mount === '') return ''
|
|
22
|
+
if (!/^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u.test(mount))
|
|
23
|
+
throw new TypeError(
|
|
24
|
+
`${plugin}: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
|
|
25
|
+
)
|
|
26
|
+
return mount
|
|
27
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who uses the panel, and what each one may do.
|
|
3
|
+
*
|
|
4
|
+
* - In development one person does, the app's owner: every page of the app shares one token and one run, so a run asked in the frame is followed from any tab of the app.
|
|
5
|
+
* - As the Assistant every visitor of the app is their own: a visitor is known by an HttpOnly cookie that the panel's session sets, and has their own token, their own run and
|
|
6
|
+
* their own limits. A visitor never sees, follows or stops another visitor's run, and never waits for one.
|
|
7
|
+
*
|
|
8
|
+
* The Assistant spends the owner's model key for anyone who opens the app, so its asks are bounded: per visitor per hour, for all visitors per day, runs at once, and the length of
|
|
9
|
+
* a question. The agent's own step budget bounds each run.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
/** The cookie that names a visitor of the Assistant. HttpOnly: the page's scripts never read it. */
|
|
14
|
+
export const VISITOR_COOKIE = 'acryl-visitor'
|
|
15
|
+
|
|
16
|
+
/** The Assistant's limits; each one has a default. */
|
|
17
|
+
export interface AssistantLimits {
|
|
18
|
+
/** Questions one visitor may ask in an hour. */
|
|
19
|
+
readonly perVisitorPerHour?: number
|
|
20
|
+
/** Questions all visitors together may ask in a day: every one of them spends the owner's model key. */
|
|
21
|
+
readonly perDay?: number
|
|
22
|
+
/** Runs going at once, across visitors. */
|
|
23
|
+
readonly concurrent?: number
|
|
24
|
+
/** Characters in one question. */
|
|
25
|
+
readonly messageLength?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_LIMITS: Required<AssistantLimits> = {
|
|
29
|
+
perVisitorPerHour: 20,
|
|
30
|
+
perDay: 300,
|
|
31
|
+
concurrent: 4,
|
|
32
|
+
messageLength: 1000,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The question length the development panel accepts (one person, who owns the key). */
|
|
36
|
+
const OWNER_MESSAGE_LENGTH = 8000
|
|
37
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
38
|
+
const DAY_MS = 24 * HOUR_MS
|
|
39
|
+
/** Visitors kept at most; the one seen longest ago without a run going is let go first. */
|
|
40
|
+
const DEFAULT_CAPACITY = 5000
|
|
41
|
+
|
|
42
|
+
/** What a visitor's run must say about itself. */
|
|
43
|
+
export interface RunState {
|
|
44
|
+
readonly ended: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Visitor<R extends RunState> {
|
|
48
|
+
readonly id: string
|
|
49
|
+
readonly token: string
|
|
50
|
+
/** The visitor in the audit: a short hash, never the cookie itself. */
|
|
51
|
+
readonly tag: string
|
|
52
|
+
/** The visitor's latest run, going or ended (what a page that loads shows again). */
|
|
53
|
+
current: R | undefined
|
|
54
|
+
/** When this visitor's asks of the last hour started. */
|
|
55
|
+
readonly asks: number[]
|
|
56
|
+
lastSeen: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Why an ask is refused, with the HTTP status to answer. */
|
|
60
|
+
export interface Refusal {
|
|
61
|
+
readonly error: string
|
|
62
|
+
readonly status: 400 | 409 | 429 | 503
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface VisitorsOptions {
|
|
66
|
+
/** One visitor for every page (development); otherwise one per cookie (the Assistant). */
|
|
67
|
+
readonly shared: boolean
|
|
68
|
+
readonly limits?: AssistantLimits
|
|
69
|
+
readonly now?: () => number
|
|
70
|
+
readonly capacity?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const newSecret = (): string => randomBytes(24).toString('base64url')
|
|
74
|
+
|
|
75
|
+
/** The value of one cookie in a `Cookie` header. */
|
|
76
|
+
export function cookieValue(header: string | undefined | null, name: string): string | undefined {
|
|
77
|
+
if (header === undefined || header === null) return undefined
|
|
78
|
+
for (const part of header.split(';')) {
|
|
79
|
+
const at = part.indexOf('=')
|
|
80
|
+
if (at === -1) continue
|
|
81
|
+
if (part.slice(0, at).trim() === name) return part.slice(at + 1).trim()
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether two secrets are the same, in constant time. */
|
|
87
|
+
export function sameSecret(presented: unknown, expected: string): boolean {
|
|
88
|
+
if (typeof presented !== 'string') return false
|
|
89
|
+
const a = Buffer.from(presented)
|
|
90
|
+
const b = Buffer.from(expected)
|
|
91
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const minutes = (ms: number): string => {
|
|
95
|
+
const count = Math.max(1, Math.ceil(ms / 60_000))
|
|
96
|
+
return `${String(count)} minute${count === 1 ? '' : 's'}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class Visitors<R extends RunState> {
|
|
100
|
+
private readonly shared: boolean
|
|
101
|
+
private readonly limits: Required<AssistantLimits>
|
|
102
|
+
private readonly now: () => number
|
|
103
|
+
private readonly capacity: number
|
|
104
|
+
private readonly byId = new Map<string, Visitor<R>>()
|
|
105
|
+
/** When every ask of the last day started, across visitors. */
|
|
106
|
+
private readonly day: number[] = []
|
|
107
|
+
private readonly owner: Visitor<R> | undefined
|
|
108
|
+
|
|
109
|
+
constructor(options: VisitorsOptions) {
|
|
110
|
+
this.shared = options.shared
|
|
111
|
+
this.limits = { ...DEFAULT_LIMITS, ...options.limits }
|
|
112
|
+
this.now = options.now ?? Date.now
|
|
113
|
+
this.capacity = options.capacity ?? DEFAULT_CAPACITY
|
|
114
|
+
this.owner = this.shared ? this.make() : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The visitor a request belongs to: the owner in development, else the one its cookie names; `undefined` when it names none this app knows. */
|
|
118
|
+
of(cookieHeader: string | undefined | null): Visitor<R> | undefined {
|
|
119
|
+
if (this.owner !== undefined) return this.owner
|
|
120
|
+
const id = cookieValue(cookieHeader, VISITOR_COOKIE)
|
|
121
|
+
const visitor = id === undefined ? undefined : this.byId.get(id)
|
|
122
|
+
if (visitor !== undefined) visitor.lastSeen = this.now()
|
|
123
|
+
return visitor
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The visitor of a session request, made when the request names none. `setCookie` is the header to answer with, only for a new visitor: HttpOnly, SameSite=Strict, the whole
|
|
128
|
+
* app (`Path=/`), a day long, and Secure when the page came over https.
|
|
129
|
+
*/
|
|
130
|
+
session(
|
|
131
|
+
cookieHeader: string | undefined | null,
|
|
132
|
+
secure: boolean,
|
|
133
|
+
): { visitor: Visitor<R>; setCookie?: string } {
|
|
134
|
+
const known = this.of(cookieHeader)
|
|
135
|
+
if (known !== undefined) return { visitor: known }
|
|
136
|
+
const visitor = this.make()
|
|
137
|
+
this.keep(visitor)
|
|
138
|
+
const setCookie = `${VISITOR_COOKIE}=${visitor.id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${String(DAY_MS / 1000)}${secure ? '; Secure' : ''}`
|
|
139
|
+
return { visitor, setCookie }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether `presented` is this visitor's token. */
|
|
143
|
+
accepts(visitor: Visitor<R> | undefined, presented: unknown): visitor is Visitor<R> {
|
|
144
|
+
return visitor !== undefined && sameSecret(presented, visitor.token)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether any visitor holds this token (a terminal driver checks a request with no other context). */
|
|
148
|
+
knows(cookieHeader: string | undefined | null, presented: unknown): boolean {
|
|
149
|
+
return this.accepts(this.of(cookieHeader), presented)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The question's length check, which comes before anything is counted. */
|
|
153
|
+
checkMessage(message: string): Refusal | undefined {
|
|
154
|
+
const most = this.shared ? OWNER_MESSAGE_LENGTH : this.limits.messageLength
|
|
155
|
+
if (message === '' || message.length > most)
|
|
156
|
+
return { error: `write a message of at most ${String(most)} characters`, status: 400 }
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether this visitor may start a run now. The visitor's own run going is refused first (409); then, for the Assistant, the visitor's hour, the runs going at once and the
|
|
162
|
+
* day. An admitted ask is counted.
|
|
163
|
+
*/
|
|
164
|
+
admit(visitor: Visitor<R>): Refusal | undefined {
|
|
165
|
+
if (visitor.current !== undefined && !visitor.current.ended)
|
|
166
|
+
return { error: 'a run is already going; wait for it to end', status: 409 }
|
|
167
|
+
if (this.shared) return undefined
|
|
168
|
+
const now = this.now()
|
|
169
|
+
prune(visitor.asks, now - HOUR_MS)
|
|
170
|
+
prune(this.day, now - DAY_MS)
|
|
171
|
+
const hour = visitor.asks[0]
|
|
172
|
+
if (visitor.asks.length >= this.limits.perVisitorPerHour && hour !== undefined)
|
|
173
|
+
return {
|
|
174
|
+
error: `You have asked ${String(this.limits.perVisitorPerHour)} questions this hour. Ask again in ${minutes(hour + HOUR_MS - now)}.`,
|
|
175
|
+
status: 429,
|
|
176
|
+
}
|
|
177
|
+
let going = 0
|
|
178
|
+
for (const other of this.byId.values())
|
|
179
|
+
if (other.current !== undefined && !other.current.ended) going += 1
|
|
180
|
+
if (going >= this.limits.concurrent)
|
|
181
|
+
return {
|
|
182
|
+
error: 'The assistant is helping other people right now. Ask again in a moment.',
|
|
183
|
+
status: 503,
|
|
184
|
+
}
|
|
185
|
+
const first = this.day[0]
|
|
186
|
+
if (this.day.length >= this.limits.perDay && first !== undefined)
|
|
187
|
+
return {
|
|
188
|
+
error: `The assistant has answered all the questions it can today. It is back in ${minutes(first + DAY_MS - now)}.`,
|
|
189
|
+
status: 429,
|
|
190
|
+
}
|
|
191
|
+
visitor.asks.push(now)
|
|
192
|
+
this.day.push(now)
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** How many visitors are kept (for tests and diagnostics). */
|
|
197
|
+
get size(): number {
|
|
198
|
+
return this.owner === undefined ? this.byId.size : 1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private make(): Visitor<R> {
|
|
202
|
+
const id = newSecret()
|
|
203
|
+
return {
|
|
204
|
+
id,
|
|
205
|
+
token: newSecret(),
|
|
206
|
+
tag: createHash('sha256').update(id).digest('hex').slice(0, 12),
|
|
207
|
+
current: undefined,
|
|
208
|
+
asks: [],
|
|
209
|
+
lastSeen: this.now(),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Keep a new visitor; past the capacity, let go of the visitors seen longest ago that have no run going. */
|
|
214
|
+
private keep(visitor: Visitor<R>): void {
|
|
215
|
+
if (this.byId.size >= this.capacity) {
|
|
216
|
+
const idle = [...this.byId.values()]
|
|
217
|
+
.filter((other) => other.current === undefined || other.current.ended)
|
|
218
|
+
.sort((a, b) => a.lastSeen - b.lastSeen)
|
|
219
|
+
for (const other of idle.slice(0, this.byId.size - this.capacity + 1))
|
|
220
|
+
this.byId.delete(other.id)
|
|
221
|
+
}
|
|
222
|
+
this.byId.set(visitor.id, visitor)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Drop the times up to `since` from the front of an ascending list: an ask exactly an hour (or a day) old is out of the window. */
|
|
227
|
+
function prune(times: number[], since: number): void {
|
|
228
|
+
let drop = 0
|
|
229
|
+
while (drop < times.length && (times[drop] ?? 0) <= since) drop += 1
|
|
230
|
+
if (drop > 0) times.splice(0, drop)
|
|
231
|
+
}
|