acrylblends 1.0.6 → 1.0.8
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/dist/index.js +272 -1
- package/dist/index.js.map +1 -1
- package/docs/framework/guides/production.md +3 -3
- package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
- package/lib/cordis/door.d.ts +2 -2
- package/lib/cordis/door.js +79 -6
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +24 -1
- package/lib/door/page.js +138 -9
- package/lib/door/protocol.d.ts +1 -1
- package/lib/door/protocol.js +1 -1
- package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
- package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
- package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +33 -4
- package/plugins/acryl-door/src/host/channel.ts +107 -18
- package/plugins/acryl-door/src/host/door.ts +5 -2
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/host/shim.ts +2 -1
- package/plugins/acryl-door/src/index.ts +10 -0
- package/plugins/acryl-door/src/page/channel.ts +67 -5
- package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
- package/plugins/acryl-door/src/page/index.ts +16 -1
- package/plugins/acryl-door/src/page/protocol.ts +3 -0
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/agent-panel/client/main.ts +182 -20
- package/plugins/agent-panel/client/markdown.ts +219 -0
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +190 -50
- package/plugins/desktop/main.ts +50 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +48 -2
- package/plugins/extensions/index.ts +81 -4
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/src/app.ts +9 -1
- package/templates/agent/src/routes.ts +13 -3
- package/templates/agent/test/app.test.ts +27 -2
|
@@ -10,7 +10,7 @@
|
|
|
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 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
|
|
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` and `builder` (since 1.0.1) and starts the `extensions` read-only (since 1.0.7: the plugins in `plugins/` run as deployed, nothing can be installed or changed) unless `ACRYL_PRODUCTION_KEEP` names them |
|
|
14
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) |
|
|
15
15
|
| `acrylblends/bun` | **experimental**: one smoke gate runs it on Bun | the same |
|
|
16
16
|
|
|
@@ -18,9 +18,9 @@ Experimental means: it works and is gated, its interface may still change in a m
|
|
|
18
18
|
|
|
19
19
|
## The `agent` template in production
|
|
20
20
|
|
|
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
|
|
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` and `builder` 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. **What the agent built is still served**: 1.0.1 to 1.0.6 also left the `extensions` row out, so an app whose pages were extensions (the workshop's board) lost them in production. Since 1.0.7 the row starts again, read-only: the extension folders of `plugins/` (what the agent installed in development, committed with the app) are loaded once from where they are, nothing can be installed, updated, removed or approved while the app runs, and the start prints one line, `extensions (read-only): board, notes` (or `extensions: none found in plugins/`). If `.acrylblends/extensions.json` records an extension that `plugins/` has no folder for, a `WARNING` names it: commit its folder, or install it again in development.
|
|
22
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.
|
|
23
|
+
- To keep a row anyway, name it: `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` (a comma list; naming `extensions` keeps the live install, not just the read-only start). 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
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
25
|
|
|
26
26
|
## The `chat` template in production
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as UiRequest, n as AuditEntry, r as ChannelToHost, v as UiResult } from "./protocol-
|
|
1
|
+
import { _ as UiRequest, n as AuditEntry, r as ChannelToHost, v as UiResult } from "./protocol-a2aqrp2X.js";
|
|
2
2
|
|
|
3
3
|
//#region ../../plugins/acryl-door/src/host/channel.d.ts
|
|
4
4
|
/** The slice of a page's stream the channel needs, so tests can supply their own. */
|
|
@@ -6,7 +6,19 @@ interface PageStream {
|
|
|
6
6
|
send(data: string): void;
|
|
7
7
|
close(): void;
|
|
8
8
|
}
|
|
9
|
+
interface ChannelOptions {
|
|
10
|
+
/** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
|
|
11
|
+
readonly ackMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The pages and who drives which. One page answers a call, chosen the same way every time: the page the Host just sent somewhere (it comes back as a new page, `prefer`), else the
|
|
15
|
+
* one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
16
|
+
*/
|
|
9
17
|
declare class UiChannel {
|
|
18
|
+
private readonly ackMs;
|
|
19
|
+
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
20
|
+
private preferred;
|
|
21
|
+
constructor(options?: ChannelOptions);
|
|
10
22
|
private readonly connections;
|
|
11
23
|
private readonly pending;
|
|
12
24
|
private nextId;
|
|
@@ -29,8 +41,11 @@ declare class UiChannel {
|
|
|
29
41
|
focused: boolean;
|
|
30
42
|
shellWindow?: number;
|
|
31
43
|
}[];
|
|
44
|
+
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
45
|
+
prefer(windowId: string): void;
|
|
32
46
|
/**
|
|
33
|
-
* Ask the current page to do something.
|
|
47
|
+
* Ask the current page to do something. A read-only call (`look`, `wait`) that the page does not acknowledge in time goes to the next page, and the silent one is last in line
|
|
48
|
+
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
34
49
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
35
50
|
*/
|
|
36
51
|
call(request: UiRequest, options?: {
|
|
@@ -38,11 +53,17 @@ declare class UiChannel {
|
|
|
38
53
|
timeoutMs?: number;
|
|
39
54
|
window?: number;
|
|
40
55
|
}): Promise<UiResult>;
|
|
56
|
+
private callOne;
|
|
41
57
|
/**
|
|
42
58
|
* A promise for the next time a page connects (a page that reloaded or moved opens a new stream). Ask for it BEFORE the call that makes the page leave, or a fast page is missed.
|
|
43
59
|
* @returns whether a page connected within `timeoutMs`.
|
|
44
60
|
*/
|
|
45
61
|
expectAttach(timeoutMs: number, count?: number): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
|
|
64
|
+
* @returns that id, or `undefined` when no new page connected within `timeoutMs`.
|
|
65
|
+
*/
|
|
66
|
+
expectNewPage(timeoutMs: number): Promise<string | undefined>;
|
|
46
67
|
/**
|
|
47
68
|
* Ask every connected page to reload (the app just changed under them) and wait, up to `timeoutMs`, for them to come back. Best effort: a page that does not come back
|
|
48
69
|
* (it was closed, or it moved to a page without the door) is not an error here.
|
|
@@ -51,7 +72,8 @@ declare class UiChannel {
|
|
|
51
72
|
reloadAll(timeoutMs?: number): Promise<number>;
|
|
52
73
|
/** Close every page's stream and settle every pending call as `unloaded`. */
|
|
53
74
|
close(): void;
|
|
54
|
-
|
|
75
|
+
/** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
76
|
+
private ordered;
|
|
55
77
|
/** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
|
|
56
78
|
private drop;
|
|
57
79
|
private settle;
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { f as UiActionResult, y as UiSnapshot } from "../protocol-
|
|
2
|
-
import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-
|
|
1
|
+
import { f as UiActionResult, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
2
|
+
import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-BD8_2pkh.js";
|
|
3
3
|
import { Context } from "@deepseek-ai/cordis";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
|
package/lib/cordis/door.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
2
2
|
import { r as streamSSE } from "../streaming-BlDaph8U.js";
|
|
3
|
+
import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
|
|
3
4
|
import "../helpers-BEjcdbgw.js";
|
|
4
5
|
import { n as clientModules } from "../client-modules-drj4mjhj.js";
|
|
5
|
-
import { a as MAX_WAIT_MS, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES } from "../protocol-
|
|
6
|
-
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-
|
|
6
|
+
import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
7
|
+
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-m13Pk-AI.js";
|
|
7
8
|
import "../src-BTBm4OPD.js";
|
|
9
|
+
import "../middleware-BfG_fp8P.js";
|
|
8
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
11
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
12
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -701,7 +703,7 @@ var Door = class {
|
|
|
701
703
|
const { window, rest } = this.windowOf(input);
|
|
702
704
|
const request = this.parse(rest);
|
|
703
705
|
if (request.op !== "reload" && request.op !== "navigate") return await this.callPage(`act ${String(input.op)}`, request, signal, window);
|
|
704
|
-
const back = this.deps.channel.
|
|
706
|
+
const back = this.deps.channel.expectNewPage(this.deps.pageBackMs ?? PAGE_BACK_MS);
|
|
705
707
|
let answer;
|
|
706
708
|
try {
|
|
707
709
|
answer = await this.callPage(`act ${request.op}`, request, signal, window);
|
|
@@ -712,7 +714,9 @@ var Door = class {
|
|
|
712
714
|
detail: request.op === "reload" ? "reloading" : `going to ${request.path}`
|
|
713
715
|
};
|
|
714
716
|
}
|
|
715
|
-
|
|
717
|
+
const came = await back;
|
|
718
|
+
if (came !== void 0) this.deps.channel.prefer(came);
|
|
719
|
+
if (came === void 0) throw new UiControlError("timeout", request.op === "reload" ? "the page did not come back after the reload" : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`);
|
|
716
720
|
const landed = request.op === "navigate" ? await this.statusAt(request.path) : void 0;
|
|
717
721
|
return {
|
|
718
722
|
...answer,
|
|
@@ -1151,6 +1155,66 @@ function operatorRoutes(prefix, secret, door, reportError) {
|
|
|
1151
1155
|
}
|
|
1152
1156
|
});
|
|
1153
1157
|
}
|
|
1158
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
1159
|
+
const ownOrigin = async (c, next) => {
|
|
1160
|
+
const authority = loopbackAuthority(c);
|
|
1161
|
+
const origin = c.req.header("origin");
|
|
1162
|
+
const site = c.req.header("sec-fetch-site");
|
|
1163
|
+
if (authority === void 0 || origin === void 0 || origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json(error("only this app's own page may open the channel"), 403);
|
|
1164
|
+
const windowId = c.req.query("window");
|
|
1165
|
+
if (windowId === void 0 || !WINDOW_ID.test(windowId)) return c.json(error("a window id is required"), 400);
|
|
1166
|
+
await next();
|
|
1167
|
+
};
|
|
1168
|
+
function pageSocketRoutes(channel, options) {
|
|
1169
|
+
const heartbeatMs = options.heartbeatMs ?? 15e3;
|
|
1170
|
+
return new Acryl().get(`${options.prefix}/channel/ws`, ownOrigin, options.upgrade((c) => {
|
|
1171
|
+
const windowId = c.req.query("window");
|
|
1172
|
+
const focused = c.req.query("focused") === "1";
|
|
1173
|
+
const shellQuery = Number(c.req.query("shell"));
|
|
1174
|
+
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : void 0;
|
|
1175
|
+
let detach;
|
|
1176
|
+
let beat;
|
|
1177
|
+
return {
|
|
1178
|
+
onOpen(_event, ws) {
|
|
1179
|
+
const write = (frame) => {
|
|
1180
|
+
ws.send(JSON.stringify(frame));
|
|
1181
|
+
};
|
|
1182
|
+
detach = channel.attach(windowId, focused, {
|
|
1183
|
+
send: (data) => {
|
|
1184
|
+
write({
|
|
1185
|
+
e: "call",
|
|
1186
|
+
d: data
|
|
1187
|
+
});
|
|
1188
|
+
},
|
|
1189
|
+
close: () => {
|
|
1190
|
+
ws.close(1e3, "replaced");
|
|
1191
|
+
}
|
|
1192
|
+
}, shellWindow);
|
|
1193
|
+
write({ e: "ready" });
|
|
1194
|
+
beat = setInterval(() => {
|
|
1195
|
+
try {
|
|
1196
|
+
write({ e: "ping" });
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}, heartbeatMs);
|
|
1199
|
+
},
|
|
1200
|
+
onMessage(event) {
|
|
1201
|
+
if (typeof event.data !== "string") return;
|
|
1202
|
+
let message;
|
|
1203
|
+
try {
|
|
1204
|
+
message = JSON.parse(event.data);
|
|
1205
|
+
} catch {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const parsed = parseChannelToHost(message);
|
|
1209
|
+
if (parsed !== null) channel.receive(windowId, parsed);
|
|
1210
|
+
},
|
|
1211
|
+
onClose() {
|
|
1212
|
+
if (beat !== void 0) clearInterval(beat);
|
|
1213
|
+
detach?.();
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}, { textOnly: true }));
|
|
1217
|
+
}
|
|
1154
1218
|
/** The oldest Node the client runs on (the framework's own floor). */
|
|
1155
1219
|
const MIN_NODE = [22, 19];
|
|
1156
1220
|
/**
|
|
@@ -1195,7 +1259,7 @@ function posixShim(root, target) {
|
|
|
1195
1259
|
"--dir",
|
|
1196
1260
|
root
|
|
1197
1261
|
].map(shWord).join(" ");
|
|
1198
|
-
return `#!/bin/sh\n# This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\n${target.electron ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}exec ${words} "$@"\n`;
|
|
1262
|
+
return `#!/bin/sh\n# This app's door (acryl-door): reaches only the app running from this folder. Rewritten each time the app starts.\n${`${target.electron ? "export ELECTRON_RUN_AS_NODE=1\n" : ""}export MSYS_NO_PATHCONV=1\nexport MSYS2_ARG_CONV_EXCL='*'\n`}exec ${words} "$@"\n`;
|
|
1199
1263
|
}
|
|
1200
1264
|
function windowsShim(root, target) {
|
|
1201
1265
|
const words = [
|
|
@@ -1361,7 +1425,16 @@ function apply(ctx, rawConfig = {}) {
|
|
|
1361
1425
|
const router = ctx.router;
|
|
1362
1426
|
if (typeof router.wrap === "function") router.wrap(addScript);
|
|
1363
1427
|
else ctx.router.route("/", new Acryl().use("*", addScript));
|
|
1364
|
-
|
|
1428
|
+
let upgrade;
|
|
1429
|
+
try {
|
|
1430
|
+
upgrade = ctx.nodeServer?.upgrades?.();
|
|
1431
|
+
} catch (cause) {
|
|
1432
|
+
reportError("open the page channel over a WebSocket (the pages use the event stream)", cause);
|
|
1433
|
+
}
|
|
1434
|
+
ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), ...upgrade === void 0 ? [] : [pageSocketRoutes(channel, {
|
|
1435
|
+
prefix: DOOR_PREFIX,
|
|
1436
|
+
upgrade
|
|
1437
|
+
})], shellRoutes(DOOR_PREFIX, secret, shell), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
|
|
1365
1438
|
ctx.inject(["tools"], (inner) => {
|
|
1366
1439
|
inner.effect(() => registerChatTools(inner.get("tools"), door), "acryl-door: the verbs in the tools registry");
|
|
1367
1440
|
});
|
package/lib/door/host.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-D8riatvv.js";
|
|
2
|
-
import { n as AuditEntry, y as UiSnapshot } from "../protocol-
|
|
3
|
-
import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-
|
|
2
|
+
import { n as AuditEntry, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
3
|
+
import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-BD8_2pkh.js";
|
|
4
4
|
|
|
5
5
|
//#region ../../plugins/acryl-door/src/host/page-routes.d.ts
|
|
6
6
|
/** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
|
package/lib/door/host.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-
|
|
1
|
+
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-m13Pk-AI.js";
|
|
2
2
|
export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
|
package/lib/door/page.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as UiRequest, v as UiResult } from "../protocol-
|
|
1
|
+
import { _ as UiRequest, v as UiResult } from "../protocol-a2aqrp2X.js";
|
|
2
2
|
|
|
3
3
|
//#region ../../plugins/acryl-door/src/page/driver/driver.d.ts
|
|
4
4
|
interface DriverActivity {
|
|
@@ -55,6 +55,12 @@ declare class UiDriver {
|
|
|
55
55
|
private emit;
|
|
56
56
|
}
|
|
57
57
|
//#endregion
|
|
58
|
+
//#region ../../plugins/acryl-door/src/page/socket-source.d.ts
|
|
59
|
+
interface SocketSource extends ChannelSource {
|
|
60
|
+
/** Send one message up; false when the socket is not open (the caller posts it instead). */
|
|
61
|
+
send(text: string): boolean;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
58
64
|
//#region ../../plugins/acryl-door/src/page/channel.d.ts
|
|
59
65
|
/** The slice of an `EventSource` the channel uses, so tests can supply their own. */
|
|
60
66
|
interface ChannelSource {
|
|
@@ -73,6 +79,11 @@ interface PageChannelOptions {
|
|
|
73
79
|
/** Where the door's routes are, e.g. `/api/acryl-door`. */
|
|
74
80
|
readonly base: string;
|
|
75
81
|
readonly createSource?: (url: string) => ChannelSource;
|
|
82
|
+
/** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
|
|
83
|
+
readonly createSocket?: (url: string) => SocketSource;
|
|
84
|
+
readonly transport?: 'auto' | 'stream';
|
|
85
|
+
/** Where the page says it fell back to the event stream (the console by default). */
|
|
86
|
+
readonly warn?: (message: string) => void;
|
|
76
87
|
readonly post?: (url: string, body: string) => Promise<unknown>;
|
|
77
88
|
readonly silenceMs?: number;
|
|
78
89
|
readonly now?: () => number;
|
|
@@ -82,6 +93,9 @@ declare class PageChannel {
|
|
|
82
93
|
private reconnectTimer;
|
|
83
94
|
private watchdog;
|
|
84
95
|
private backoff;
|
|
96
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
97
|
+
private socketAllowed;
|
|
98
|
+
private socketReady;
|
|
85
99
|
private lastHeard;
|
|
86
100
|
private disposed;
|
|
87
101
|
private readonly now;
|
|
@@ -89,8 +103,17 @@ declare class PageChannel {
|
|
|
89
103
|
private readonly options;
|
|
90
104
|
constructor(driver: UiDriver, options: PageChannelOptions);
|
|
91
105
|
connect(): void;
|
|
106
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
107
|
+
private fallBack;
|
|
92
108
|
/** Tell the Host whether this window is the one in front. */
|
|
93
109
|
reportFocus(focused: boolean): void;
|
|
110
|
+
/**
|
|
111
|
+
* Let go of the stream without ending the channel: the page is going away but the browser may keep it (the back/forward cache keeps a page that was left, still holding its
|
|
112
|
+
* connections). A browser allows six connections per host, so pages kept like that would queue the live page's own answers behind them. `resume` opens the stream again.
|
|
113
|
+
*/
|
|
114
|
+
suspend(): void;
|
|
115
|
+
/** The page is back (restored from the cache): open the stream again with the same window id. */
|
|
116
|
+
resume(): void;
|
|
94
117
|
dispose(): void;
|
|
95
118
|
/** Drop the stream and open a new one after the backoff. */
|
|
96
119
|
private restart;
|
package/lib/door/page.js
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
|
-
import { a as MAX_WAIT_MS, f as parseChannelToPage, l as UiControlError, o as MUTATING_OPS, r as MAX_SNAPSHOT_NODES } from "../protocol-
|
|
1
|
+
import { a as MAX_WAIT_MS, f as parseChannelToPage, l as UiControlError, o as MUTATING_OPS, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
|
|
2
|
+
//#region ../../plugins/acryl-door/src/page/socket-source.ts
|
|
3
|
+
function socketSource(socket) {
|
|
4
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
5
|
+
let closed = false;
|
|
6
|
+
const source = {
|
|
7
|
+
onerror: null,
|
|
8
|
+
get readyState() {
|
|
9
|
+
return closed || socket.readyState >= 2 ? 2 : socket.readyState;
|
|
10
|
+
},
|
|
11
|
+
addEventListener(type, listener) {
|
|
12
|
+
listeners.set(type, [...listeners.get(type) ?? [], listener]);
|
|
13
|
+
},
|
|
14
|
+
send(text) {
|
|
15
|
+
if (socket.readyState !== 1) return false;
|
|
16
|
+
try {
|
|
17
|
+
socket.send(text);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
close() {
|
|
24
|
+
closed = true;
|
|
25
|
+
socket.onmessage = null;
|
|
26
|
+
socket.onerror = null;
|
|
27
|
+
socket.onclose = null;
|
|
28
|
+
try {
|
|
29
|
+
socket.close(1e3, "page left");
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
socket.onmessage = (event) => {
|
|
34
|
+
if (typeof event.data !== "string") return;
|
|
35
|
+
let frame;
|
|
36
|
+
try {
|
|
37
|
+
frame = JSON.parse(event.data);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (typeof frame.e !== "string") return;
|
|
42
|
+
for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === "string" ? frame.d : "" });
|
|
43
|
+
};
|
|
44
|
+
const lost = (event) => {
|
|
45
|
+
if (!closed) source.onerror?.(event);
|
|
46
|
+
};
|
|
47
|
+
socket.onerror = lost;
|
|
48
|
+
socket.onclose = lost;
|
|
49
|
+
return source;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
2
52
|
//#region ../../plugins/acryl-door/src/page/channel.ts
|
|
3
53
|
/**
|
|
4
54
|
* The page's end of the door's channel.
|
|
@@ -18,6 +68,9 @@ var PageChannel = class {
|
|
|
18
68
|
reconnectTimer;
|
|
19
69
|
watchdog;
|
|
20
70
|
backoff = BACKOFF_START_MS;
|
|
71
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
72
|
+
socketAllowed;
|
|
73
|
+
socketReady = false;
|
|
21
74
|
lastHeard = 0;
|
|
22
75
|
disposed = false;
|
|
23
76
|
now;
|
|
@@ -27,32 +80,49 @@ var PageChannel = class {
|
|
|
27
80
|
this.driver = driver;
|
|
28
81
|
this.options = options;
|
|
29
82
|
this.now = options.now ?? (() => Date.now());
|
|
83
|
+
this.socketAllowed = options.transport !== "stream" && (options.createSocket !== void 0 || options.createSource === void 0 && typeof WebSocket !== "undefined");
|
|
30
84
|
}
|
|
31
85
|
connect() {
|
|
32
86
|
if (this.disposed || this.source !== void 0) return;
|
|
33
|
-
const create = this.options.createSource ?? ((url) => new EventSource(url));
|
|
34
87
|
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? "1" : "0"}${this.options.shellWindow === void 0 ? "" : `&shell=${String(this.options.shellWindow)}`}`;
|
|
35
|
-
const
|
|
88
|
+
const viaSocket = this.socketAllowed;
|
|
89
|
+
this.socketReady = false;
|
|
90
|
+
const source = viaSocket ? (this.options.createSocket ?? ((url) => socketSource(new WebSocket(url))))(`${socketBase(this.options.base)}/channel/ws?${query}`) : (this.options.createSource ?? ((url) => new EventSource(url)))(`${this.options.base}/channel?${query}`);
|
|
36
91
|
this.source = source;
|
|
37
92
|
this.lastHeard = this.now();
|
|
38
93
|
const heard = () => {
|
|
39
94
|
this.lastHeard = this.now();
|
|
40
95
|
this.backoff = BACKOFF_START_MS;
|
|
41
96
|
};
|
|
42
|
-
source.addEventListener("ready",
|
|
97
|
+
source.addEventListener("ready", () => {
|
|
98
|
+
this.socketReady = true;
|
|
99
|
+
heard();
|
|
100
|
+
});
|
|
43
101
|
source.addEventListener("ping", heard);
|
|
44
102
|
source.addEventListener("call", (event) => {
|
|
45
103
|
heard();
|
|
46
104
|
if (typeof event.data === "string") this.answer(event.data);
|
|
47
105
|
});
|
|
48
106
|
source.onerror = () => {
|
|
49
|
-
if (source.readyState
|
|
107
|
+
if (source.readyState !== CLOSED) return;
|
|
108
|
+
if (viaSocket && !this.socketReady) this.fallBack(source);
|
|
109
|
+
else this.restart(source);
|
|
50
110
|
};
|
|
51
111
|
const silence = this.options.silenceMs ?? 45e3;
|
|
52
112
|
this.watchdog = setInterval(() => {
|
|
53
113
|
if (this.now() - this.lastHeard > silence) this.restart(source);
|
|
54
114
|
}, Math.max(50, Math.floor(silence / 3)));
|
|
55
115
|
}
|
|
116
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
117
|
+
fallBack(source) {
|
|
118
|
+
if (this.source !== source) return;
|
|
119
|
+
this.socketAllowed = false;
|
|
120
|
+
(this.options.warn ?? ((message) => {
|
|
121
|
+
console.warn(message);
|
|
122
|
+
}))("acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser's six connections per host");
|
|
123
|
+
this.stop();
|
|
124
|
+
this.connect();
|
|
125
|
+
}
|
|
56
126
|
/** Tell the Host whether this window is the one in front. */
|
|
57
127
|
reportFocus(focused) {
|
|
58
128
|
this.send({
|
|
@@ -60,6 +130,22 @@ var PageChannel = class {
|
|
|
60
130
|
focused
|
|
61
131
|
});
|
|
62
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Let go of the stream without ending the channel: the page is going away but the browser may keep it (the back/forward cache keeps a page that was left, still holding its
|
|
135
|
+
* connections). A browser allows six connections per host, so pages kept like that would queue the live page's own answers behind them. `resume` opens the stream again.
|
|
136
|
+
*/
|
|
137
|
+
suspend() {
|
|
138
|
+
if (this.disposed) return;
|
|
139
|
+
if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
|
|
140
|
+
this.reconnectTimer = void 0;
|
|
141
|
+
this.stop();
|
|
142
|
+
}
|
|
143
|
+
/** The page is back (restored from the cache): open the stream again with the same window id. */
|
|
144
|
+
resume() {
|
|
145
|
+
if (this.disposed) return;
|
|
146
|
+
this.backoff = BACKOFF_START_MS;
|
|
147
|
+
this.connect();
|
|
148
|
+
}
|
|
63
149
|
dispose() {
|
|
64
150
|
this.disposed = true;
|
|
65
151
|
if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
|
|
@@ -93,6 +179,10 @@ var PageChannel = class {
|
|
|
93
179
|
} catch {
|
|
94
180
|
return;
|
|
95
181
|
}
|
|
182
|
+
this.send({
|
|
183
|
+
t: "ack",
|
|
184
|
+
id: call.id
|
|
185
|
+
});
|
|
96
186
|
this.driver.handle(call.request).then((value) => {
|
|
97
187
|
this.send({
|
|
98
188
|
t: "result",
|
|
@@ -110,6 +200,7 @@ var PageChannel = class {
|
|
|
110
200
|
});
|
|
111
201
|
}
|
|
112
202
|
send(message) {
|
|
203
|
+
if (this.source?.send?.(JSON.stringify(message)) === true) return;
|
|
113
204
|
(this.options.post ?? ((url, body) => fetch(url, {
|
|
114
205
|
method: "POST",
|
|
115
206
|
headers: { "content-type": "application/json" },
|
|
@@ -120,16 +211,53 @@ var PageChannel = class {
|
|
|
120
211
|
})).catch(() => {});
|
|
121
212
|
}
|
|
122
213
|
};
|
|
214
|
+
/** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
|
|
215
|
+
function socketBase(base) {
|
|
216
|
+
const here = globalThis.location;
|
|
217
|
+
if (here === void 0) return base;
|
|
218
|
+
return `${here.protocol === "https:" ? "wss" : "ws"}://${here.host}${base}`;
|
|
219
|
+
}
|
|
123
220
|
//#endregion
|
|
124
221
|
//#region ../../plugins/acryl-door/src/page/driver/sensitivity.ts
|
|
125
222
|
/**
|
|
126
223
|
* What the agent may never see or type into, decided by rule and not by trusting the agent:
|
|
127
224
|
* secrets and payment fields (sensitive), and controls that govern the agent itself (protected).
|
|
128
225
|
*/
|
|
129
|
-
/**
|
|
130
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Field names and labels that unmistakably name a secret or a payment detail. A word that is only sometimes one is not here: a bare "card" is the title of a card on a board,
|
|
228
|
+
* "pin" a map marker, "routing" a page. What a field *is* decides first: a password input, an `autocomplete` of a credential or a payment detail, a value
|
|
229
|
+
* that looks like a key. The words only catch a field that says what it holds in plain text.
|
|
230
|
+
*/
|
|
231
|
+
const SENSITIVE_WORDS = new RegExp([
|
|
232
|
+
"pass(?:word|code|phrase)",
|
|
233
|
+
"secret",
|
|
234
|
+
"api[-_ ]?key",
|
|
235
|
+
"private[-_ ]?key",
|
|
236
|
+
"credential",
|
|
237
|
+
"\\btoken\\b",
|
|
238
|
+
"(?:pin|security)[-_ ]?(?:code|number)",
|
|
239
|
+
"\\bcvv\\b",
|
|
240
|
+
"\\bcvc\\b",
|
|
241
|
+
"(?:credit|debit|payment)?[-_ ]?card[-_ ]?(?:number|no|num|code|expir)",
|
|
242
|
+
"\\bcc[-_ ]?(?:num|number|exp|csc)",
|
|
243
|
+
"iban",
|
|
244
|
+
"(?:bank[-_ ]?)?routing[-_ ]?number",
|
|
245
|
+
"account[-_ ]?number",
|
|
246
|
+
"\\bssn\\b",
|
|
247
|
+
"social[-_ ]?security",
|
|
248
|
+
"one[-_ ]?time[-_ ]?(?:code|password)",
|
|
249
|
+
"\\botp\\b"
|
|
250
|
+
].join("|"), "i");
|
|
131
251
|
/** `autocomplete` values that browsers treat as credentials or payment data. */
|
|
132
252
|
const SENSITIVE_AUTOCOMPLETE = /^(?:cc-|current-password|new-password|one-time-code)/;
|
|
253
|
+
/** A value that is a key or a token by its shape: a known prefix, a JSON web token, or one long unbroken string that mixes lower case, upper case and digits (a git hash is lower-case hex and is not one). */
|
|
254
|
+
const KEY_PREFIX = /^(?:sk[-_]|pk[-_]|rk[-_]|ghp_|gho_|ghs_|github_pat_|glpat-|xox[abprs]-|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.)/;
|
|
255
|
+
function looksLikeSecret(value) {
|
|
256
|
+
const text = value.trim();
|
|
257
|
+
if (text.length < 20 || /\s/u.test(text)) return false;
|
|
258
|
+
if (KEY_PREFIX.test(text)) return true;
|
|
259
|
+
return text.length >= 32 && /^[A-Za-z0-9+/_=-]+$/u.test(text) && /[a-z]/u.test(text) && /[A-Z]/u.test(text) && /\d/u.test(text);
|
|
260
|
+
}
|
|
133
261
|
const label = (el, attr) => el.getAttribute(attr) ?? "";
|
|
134
262
|
/** True for a form field (or anything marked) that holds a secret or payment detail. */
|
|
135
263
|
function isSensitive(el) {
|
|
@@ -137,12 +265,13 @@ function isSensitive(el) {
|
|
|
137
265
|
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) return false;
|
|
138
266
|
if (el instanceof HTMLInputElement && el.type === "password") return true;
|
|
139
267
|
if (label(el, "autocomplete").trim().toLowerCase().split(/\s+/).some((token) => SENSITIVE_AUTOCOMPLETE.test(token))) return true;
|
|
140
|
-
|
|
268
|
+
if (SENSITIVE_WORDS.test([
|
|
141
269
|
el.name,
|
|
142
270
|
el.id,
|
|
143
271
|
label(el, "placeholder"),
|
|
144
272
|
label(el, "aria-label")
|
|
145
|
-
].join(" "));
|
|
273
|
+
].join(" "))) return true;
|
|
274
|
+
return !(el instanceof HTMLSelectElement) && looksLikeSecret(el.value);
|
|
146
275
|
}
|
|
147
276
|
/** Areas whose controls the agent may look at but never operate. */
|
|
148
277
|
const PROTECTED_REGION = /approval|permission|policy|agent control|ui control|acryl-agent-control|acryl-door|kill switch/i;
|
package/lib/door/protocol.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as parseUiRequest, S as parseChannelToPage, _ as UiRequest, a as DEFAULT_SNAPSHOT_NODES, b as WINDOW_ID, c as MAX_WAIT_MS, d as UI_OPS, f as UiActionResult, g as UiOp, h as UiNode, i as ChannelToPage, l as MUTATING_OPS, m as UiErrorCode, n as AuditEntry, o as MAX_SNAPSHOT_NODES, p as UiControlError, r as ChannelToHost, s as MAX_TEXT_LENGTH, t as APP_PATH, u as UI_ERROR_CODES, v as UiResult, x as parseChannelToHost, y as UiSnapshot } from "../protocol-
|
|
1
|
+
import { C as parseUiRequest, S as parseChannelToPage, _ as UiRequest, a as DEFAULT_SNAPSHOT_NODES, b as WINDOW_ID, c as MAX_WAIT_MS, d as UI_OPS, f as UiActionResult, g as UiOp, h as UiNode, i as ChannelToPage, l as MUTATING_OPS, m as UiErrorCode, n as AuditEntry, o as MAX_SNAPSHOT_NODES, p as UiControlError, r as ChannelToHost, s as MAX_TEXT_LENGTH, t as APP_PATH, u as UI_ERROR_CODES, v as UiResult, x as parseChannelToHost, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
2
2
|
export { APP_PATH, AuditEntry, ChannelToHost, ChannelToPage, DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiActionResult, UiControlError, UiErrorCode, UiNode, UiOp, UiRequest, UiResult, UiSnapshot, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
|
package/lib/door/protocol.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as MAX_WAIT_MS, c as UI_OPS, d as parseChannelToHost, f as parseChannelToPage, i as MAX_TEXT_LENGTH, l as UiControlError, n as DEFAULT_SNAPSHOT_NODES, o as MUTATING_OPS, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, s as UI_ERROR_CODES, t as APP_PATH, u as WINDOW_ID } from "../protocol-
|
|
1
|
+
import { a as MAX_WAIT_MS, c as UI_OPS, d as parseChannelToHost, f as parseChannelToPage, i as MAX_TEXT_LENGTH, l as UiControlError, n as DEFAULT_SNAPSHOT_NODES, o as MUTATING_OPS, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, s as UI_ERROR_CODES, t as APP_PATH, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
2
2
|
export { APP_PATH, DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiControlError, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
|
|
@@ -196,6 +196,10 @@ function parseChannelToHost(value) {
|
|
|
196
196
|
t: "focus",
|
|
197
197
|
focused: value.focused
|
|
198
198
|
};
|
|
199
|
+
if (value.t === "ack" && typeof value.id === "number") return {
|
|
200
|
+
t: "ack",
|
|
201
|
+
id: value.id
|
|
202
|
+
};
|
|
199
203
|
if (value.t === "result" && typeof value.id === "number" && isRecord(value.value)) return {
|
|
200
204
|
t: "result",
|
|
201
205
|
id: value.id,
|
|
@@ -107,6 +107,9 @@ type ChannelToPage = {
|
|
|
107
107
|
type ChannelToHost = {
|
|
108
108
|
readonly t: 'focus';
|
|
109
109
|
readonly focused: boolean;
|
|
110
|
+
} /** The page received call `id` and is about to run it: proof that it is alive, sent before anything is done. */ | {
|
|
111
|
+
readonly t: 'ack';
|
|
112
|
+
readonly id: number;
|
|
110
113
|
} | {
|
|
111
114
|
readonly t: 'result';
|
|
112
115
|
readonly id: number;
|