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.
Files changed (40) hide show
  1. package/dist/index.js +272 -1
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/guides/production.md +3 -3
  4. package/lib/{audit-GcLtvNLV.d.ts → audit-BD8_2pkh.d.ts} +25 -3
  5. package/lib/cordis/door.d.ts +2 -2
  6. package/lib/cordis/door.js +79 -6
  7. package/lib/door/host.d.ts +2 -2
  8. package/lib/door/host.js +1 -1
  9. package/lib/door/page.d.ts +24 -1
  10. package/lib/door/page.js +138 -9
  11. package/lib/door/protocol.d.ts +1 -1
  12. package/lib/door/protocol.js +1 -1
  13. package/lib/{protocol-Dt0AZBOz.js → protocol-CQ_if7Dg.js} +4 -0
  14. package/lib/{protocol-CR31M7K_.d.ts → protocol-a2aqrp2X.d.ts} +3 -0
  15. package/lib/{snapshot-text-CtQm3n8n.js → snapshot-text-m13Pk-AI.js} +95 -17
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/plugin.json +3 -0
  18. package/plugins/acryl-door/src/cli/acryl.ts +33 -4
  19. package/plugins/acryl-door/src/host/channel.ts +107 -18
  20. package/plugins/acryl-door/src/host/door.ts +5 -2
  21. package/plugins/acryl-door/src/host/page-socket.ts +78 -0
  22. package/plugins/acryl-door/src/host/shim.ts +2 -1
  23. package/plugins/acryl-door/src/index.ts +10 -0
  24. package/plugins/acryl-door/src/page/channel.ts +67 -5
  25. package/plugins/acryl-door/src/page/driver/sensitivity.ts +39 -3
  26. package/plugins/acryl-door/src/page/index.ts +16 -1
  27. package/plugins/acryl-door/src/page/protocol.ts +3 -0
  28. package/plugins/acryl-door/src/page/socket-source.ts +75 -0
  29. package/plugins/agent-panel/client/main.ts +182 -20
  30. package/plugins/agent-panel/client/markdown.ts +219 -0
  31. package/plugins/agent-panel/headless.ts +20 -0
  32. package/plugins/agent-panel/index.ts +190 -50
  33. package/plugins/desktop/main.ts +50 -3
  34. package/plugins/desktop/menu.ts +60 -0
  35. package/plugins/desktop/window.ts +48 -2
  36. package/plugins/extensions/index.ts +81 -4
  37. package/templates/agent/AGENTS.md +4 -2
  38. package/templates/agent/src/app.ts +9 -1
  39. package/templates/agent/src/routes.ts +13 -3
  40. 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`, `builder` and `extensions` (since 1.0.1) unless `ACRYL_PRODUCTION_KEEP` names them |
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`, `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
+ 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-CR31M7K_.js";
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
- private byRecency;
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;
@@ -1,5 +1,5 @@
1
- import { f as UiActionResult, y as UiSnapshot } from "../protocol-CR31M7K_.js";
2
- import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-GcLtvNLV.js";
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
 
@@ -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-Dt0AZBOz.js";
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-CtQm3n8n.js";
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.expectAttach(this.deps.pageBackMs ?? PAGE_BACK_MS);
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
- if (!await back) 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?`);
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
- ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), shellRoutes(DOOR_PREFIX, secret, shell), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
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
  });
@@ -1,6 +1,6 @@
1
1
  import { t as Acryl } from "../app-D8riatvv.js";
2
- import { n as AuditEntry, y as UiSnapshot } from "../protocol-CR31M7K_.js";
3
- import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-GcLtvNLV.js";
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-CtQm3n8n.js";
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 };
@@ -1,4 +1,4 @@
1
- import { _ as UiRequest, v as UiResult } from "../protocol-CR31M7K_.js";
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-Dt0AZBOz.js";
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 source = create(`${this.options.base}/channel?${query}`);
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", heard);
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 === CLOSED) this.restart(source);
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
- /** Field names and labels that mean a secret or a payment detail. */
130
- const SENSITIVE_WORDS = /pass(?:word|code|phrase)|secret|token|api[-_ ]?key|credential|private[-_ ]?key|\bpin\b|card|cvv|cvc|iban|routing|account[-_ ]?number|ssn|social[-_ ]?security|one[-_ ]?time|otp/i;
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
- return SENSITIVE_WORDS.test([
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;
@@ -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-CR31M7K_.js";
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 };
@@ -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-Dt0AZBOz.js";
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;