@staix/agent-console 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.1] - 2026-11-04
6
+
7
+ ### Added
8
+
9
+ - H2 static shell (#46): `acon build --static --nodes id=https://origin[,id=origin] [--out dist]`, fixed credential-free assets and Vercel/Workers static config, per-node bearer/Access auth, namespaced tools, node-owned actions and allowlisted credentialed CORS. Node-specific expiring GET-only grants serve authenticated modules/assets without exposing bearer tokens in URLs. Two-node ui-check covers ownership and distinct offline/origin-refused states. Hosted preview readback remains unverified.
10
+ - H1 follow-ups (#45): `acon serve --read-only` / `ACON_READ_ONLY=1` refuses every plugin action (403 `read-only node`, no plugin call, no confirm token, a replayed token is not honoured), reports `readOnly: true` in `/tools`, and the shell shows a Read-only chip with verb, answer and confirm buttons disabled. `acon token new [--file] [--force]` writes a 0600 base64url bearer token and prints only its path. `acon setup launchd [--label] [--apply]` prints (or installs) a LaunchAgent for `acon serve` with auth passed as env names and file paths. The Dockerfile carries OCI `source` and `revision` labels.
11
+ - Release images (#45): behind `IMAGE_PUBLISH_ENABLED`, tagged releases push multi-arch (`linux/amd64`, `linux/arm64`) images to `ghcr.io/staixbwlb/agent-console:<version>` (and `latest` for stable versions) with OCI labels, read the manifest back and run `--version` on amd64.
12
+
5
13
  ## [0.1.0] - 2026-11-04
6
14
 
7
15
  ### Compatibility
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `acon` is a local web console for the agent tool family: agent-hub (agents and tasks), agent-mem (memory) and vault-mem (knowledge vault). Each tool contributes panels as a plugin. The console adds what no single tool has: one inbox of everything waiting for you, a command palette across tools, and one confirm dialog for every destructive action.
4
4
 
5
- Status: H0 (`acon ui`, local mode) and H1 (`acon serve`, an authenticated resident node). vault-mem and agent-hub register their plugins; a fixture plugin in `test/fixtures/` shows the contract. The static multi-node shell (H2) is not implemented.
5
+ Status: H0 (`acon ui`, local mode) and H1 (`acon serve`, an authenticated resident node). vault-mem and agent-hub register their plugins; a fixture plugin in `test/fixtures/` shows the contract. H2 (`acon build --static`) serves a credential-free shell over independently authenticated H1 nodes.
6
6
 
7
7
  ## Use
8
8
 
@@ -113,7 +113,7 @@ Rules:
113
113
  `acon serve` (H1) is the resident form of the console for a node behind a reverse tunnel or on an internal server. `acon ui` is unchanged.
114
114
 
115
115
  ```sh
116
- acon serve [--host 127.0.0.1] [--port 8787] [--origin https://console.example.com]... [--json]
116
+ acon serve [--host 127.0.0.1] [--port 8787] [--origin https://console.example.com]... [--read-only] [--json]
117
117
  ```
118
118
 
119
119
  - No ticket link, no idle exit and no browser opening. The process stays up until it is stopped. `--port` defaults to `ACON_PORT`, or 8787 when that is unset.
@@ -121,16 +121,47 @@ acon serve [--host 127.0.0.1] [--port 8787] [--origin https://console.example.co
121
121
  - `GET /healthz` answers 204 with no body and no authentication. Every other route, the shell files included, needs authentication.
122
122
  - Authentication is required unless `--host` is a loopback address. Starting on another address without it is refused. There are two modes:
123
123
  - Cloudflare Access: set `ACON_ACCESS_TEAM` (the team domain, `<team>.cloudflareaccess.com`) and `ACON_ACCESS_AUD` (the Access application's AUD tag). Each request's `Cf-Access-Jwt-Assertion` must verify as RS256 against `https://<team>/cdn-cgi/access/certs`. The keys are cached and refetched on an unknown `kid`. The checks are `aud` contains the AUD, `iss` is `https://<team>`, `exp`/`nbf` hold with 60 s skew, and `sub` (or `email`) is present.
124
- - Bearer token: set `ACON_TOKEN_FILE` to a file with mode 0600 holding one random token of at least 32 characters, for example from `openssl rand -hex 32`. Requests send `Authorization: Bearer <token>`. The comparison is constant-time. A file readable by group or others is refused.
124
+ - Bearer token: set `ACON_TOKEN_FILE` to a file with mode 0600 holding one random token of at least 32 characters. `acon token new [--file PATH] [--force]` writes one (32 random bytes, base64url) to `$AGENTHUB_HOME/console/token` by default. It creates the file exclusively with mode 0600, refuses an existing file unless `--force` is given, and prints only the path, never the token. Requests send `Authorization: Bearer <token>`. The comparison is constant-time. A file readable by group or others is refused.
125
125
  - The verified identity (the Access `sub`, or `bearer`) takes the place of the ticket session. Confirm tokens for destructive actions are bound to it, so a preview confirmed by another identity is refused. The page skips the ticket step because `/session` answers `{ok: true, mode: "serve"}`.
126
126
  - CSP, headers, plugins, the confirm path and the 32 KB body limit are the same as under `acon ui`.
127
+ - Read-only node: `--read-only` or `ACON_READ_ONLY=1`. Every `/p/<tool>/action` answers 403 `{ok: false, error: "read-only node"}` before any confirm-token lookup, without calling the plugin and without issuing a confirm token. `/tools` reports `readOnly: true`, and the shell shows a "Read-only" chip and disables verb, answer and confirm buttons. Snapshots, search and assets are unchanged. Authentication rules do not change.
128
+
129
+ ### LaunchAgent (macOS)
130
+
131
+ ```sh
132
+ acon setup launchd [--label acon.serve] [--apply] [--host H] [--port P] [--origin https://host]... [--read-only]
133
+ ```
134
+
135
+ Prints a LaunchAgent plist that runs the absolute `bun` and this checkout's `bin/acon.js serve` with the given flags, with `RunAtLoad`, `KeepAlive` and logs in `$AGENTHUB_HOME/console/logs/<label>.{out,err}.log`. It applies the same refusals as `acon serve` first. Auth is copied as environment names that point to files or settings (`ACON_TOKEN_FILE` as an absolute path, `ACON_ACCESS_TEAM`, `ACON_ACCESS_AUD`, `ACON_ORIGINS`, `ACON_PORT`, `ACON_READ_ONLY`), never a token value. Without `--apply` nothing is written. `--apply` writes `~/Library/LaunchAgents/<label>.plist`, runs `launchctl bootout` then `bootstrap` in `gui/<uid>`, and prints `launchctl print`. A tunnel agent is not part of this.
127
136
 
128
137
  ### Versioned install
129
138
 
130
139
  From a checkout, `bun scripts/install-release.ts [--home DIR] [--json]` copies the committed package files (not uncommitted changes) into `$AGENTHUB_HOME/console/releases/<version>-<commit>/`, then points `console/current` at it by atomic rename and keeps `console/previous`. An existing release is reused, never changed. Start the resident console from `console/current/bin/acon.js`: an install leaves a running process on the release it started from, and a restart switches it. Roll back by pointing `current` at `previous` and restarting.
131
140
 
141
+ ### Static multi-node shell (H2)
142
+
143
+ Build into a new output directory (an existing destination is refused):
144
+
145
+ ```sh
146
+ acon build --static --nodes desk=https://desk.example.com,dgx=https://dgx.example.com --out dist
147
+ ```
148
+
149
+ Each URL must be an exact HTTPS origin, without credentials, paths, queries or fragments. HTTP is allowed only for loopback development. Configure each H1 node with both its public origin and the shell origin, for example `acon serve --origin https://desk.example.com --origin https://console.example.com`, and its own bearer token file or Cloudflare Access application. The node origin also registers its public Host value; the shell origin permits CORS. Keep authentication enabled off loopback.
150
+
151
+ Build one static shell only over equally trusted nodes (such as your own nodes). Each node's panel modules execute with the shell's authority; a compromised node or plugin can use every node's credentials in that tab while it is open. Use separate builds for nodes with different trust.
152
+
153
+ The output contains fixed shell/vendor files, public `config.json`, `_headers`, `vercel.json`, an assets-only `wrangler.jsonc` and `.assetsignore`. It contains no plugin data or credentials. Upload the complete directory to a preview with the supplied header configuration. Main-document CSP permits connections, scripts and images only from self and the configured node origins; plugin styles are fetched with node auth into constructed stylesheets, and attachments use a local sandboxed viewer. The sandboxed visual/attachment documents have separate header policies.
154
+
155
+ In System, each node has its own bearer-token form and Access sign-in/reconnect controls. Bearer tokens stay in that tab's `sessionStorage`; Access uses that node's cookies with credentialed requests. Access CORS/cookie policy must also permit the shell origin. Authenticated nodes issue short-lived GET-only resource grants for panel modules and attachments because native module/image requests cannot carry bearer headers. These runtime grants are node-specific, contain no bearer token and never authorize actions. Keep node access logs private, since runtime resource URLs contain these grants. The "Open attachment" link also includes a GET-only grant and can leave it in browser history for its remaining lifetime (at most 1 hour); keep that history private too.
156
+
157
+ Tools are namespaced `node:tool`; panel `host.node` identifies the owner and `host.tool` keeps its original tool id. All actions, confirmations and read-only restrictions stay on that owner. System distinguishes online, offline, unauthorized and `refused (origin)` nodes. A bounded opaque health probe distinguishes an unreachable node from a reachable node blocked by CORS; persistent failures refresh at most once per tool-list interval, without repeated prompts. Use System to reconnect after fixing a URL, token or `--origin`.
158
+
159
+ Vercel and Workers preview URL/CSP readback is **unverified** until the person supplies logins. No production deployment or Access application configuration is performed by the build command.
160
+
132
161
  ### Docker
133
162
 
163
+ When the repository variable `IMAGE_PUBLISH_ENABLED` is `true`, a tagged release (after npm publish) pushes `ghcr.io/staixbwlb/agent-console:<version>`, plus `latest` for stable versions, for `linux/amd64` and `linux/arm64`, with OCI `source`, `revision` and `version` labels. The release job reads the manifest back for both architectures, checks the revision label and runs `--version` on amd64.
164
+
134
165
  The `Dockerfile` builds a pinned `oven/bun` image that runs `acon serve --host 0.0.0.0` as the non-root `bun` user. `AGENTHUB_HOME=/data` is a volume holding plugins, pets and asset packs, and a `HEALTHCHECK` probes `/healthz`. The image holds no credentials:
135
166
 
136
167
  ```sh
@@ -305,6 +336,8 @@ bun run check # typecheck, boundary, tests, pack dry-run
305
336
 
306
337
  Before UI PRs, run `node scripts/ui-check.mjs`. It finds an existing Playwright install at run time, starts isolated fixture servers and writes screenshots plus `ui-check/report.json`. It checks 375/768/1024/1440 px, both themes and 100%/200% text, including Space with two tools, an explicit synthetic pet/pack and a no-pack fallback. Mini (`#/local/mini`) is captured in every cell, at the 320x440 pop-out size (light and dark, 100% and 200% text, rendered in the page, not a Picture-in-Picture window) and once with no pack and the pet off; it checks the pet sprite, Tab order with visible focus over item and footer buttons, and that a pet-less Mini requests nothing under `/pets/`. It checks namespace keys, roster parity, navigation and unchanged-poll DOM writes. It exits non-zero on browser errors, text clipping (including the search label), overlap or horizontal overflow. It also checks that the skip link is completely offscreen until focused, reachable by keyboard and moves focus to main when activated. Playwright is a development tool supplied by the person running the check, not a package dependency or CI gate.
307
338
 
339
+ The full probe also runs a two-node static-shell matrix at 375/1440 px in both themes and text sizes, checking node ownership, auth, offline and origin-refused states. `--static-only` runs that focused matrix.
340
+
308
341
  Use `PLAYWRIGHT_MODULE` for an absolute Playwright or playwright-core module path, `PLAYWRIGHT_EXECUTABLE_PATH` for an existing Chromium executable, and `BUN_BIN` for Bun when it is not on PATH. `--output <dir>` changes the artifact directory; `--baseline <report.json>` compares 100% text metrics against an earlier run (1 px tolerance). `--force-overflow` is a negative control and must exit non-zero. Use the workspace's remote runner when local browser workloads are deferred.
309
342
 
310
343
  `--space-views-only` runs the Room/3D camera checks at 375 and 1440 px in both themes and 100%/200% text. It verifies lazy vendor requests, preserved focus order, projected overlay centres within 8 px after rotation, model loading, context-loss and absent-WebGL2 fallbacks, and zero additional canvas renders across a settled 10-second window. Depth-ordered scene buttons can occlude each other in projected views; their geometry and equivalent keyboard/roster access are checked separately from the shell's normal strict overlap checks. The full run includes both view and motion checks. For a separate published-art compatibility run, use `--space-views-only --art-root <directory>` with explicit `assets/` and `pets/` subdirectories; the probe never scans a real agent home.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-console",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first web console that composes agent-hub, agent-mem and vault-mem as plugin panels",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,5 @@
1
+ :root { color-scheme: light dark; font: 1rem system-ui, sans-serif; }
2
+ body { margin: 0; }
3
+ img, iframe { display: block; width: 100%; border: 0; }
4
+ iframe { height: 90vh; }
5
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 1rem; }
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Attachment</title>
7
+ <link rel="stylesheet" href="/asset-viewer.css">
8
+ <script src="/asset-viewer.js" defer></script>
9
+ </head>
10
+ <body><main id="asset" role="status">Loading attachment...</main></body>
11
+ </html>
@@ -0,0 +1,23 @@
1
+ // Opaque sandbox: only the parent may supply bounded, allowlisted attachment bytes.
2
+ const types = new Set(['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'text/csv']);
3
+ let objectUrl;
4
+ window.addEventListener('message', (event) => {
5
+ if (event.source !== parent || event.origin !== location.origin || event.data?.kind !== 'acon-asset') return;
6
+ const { mime, bytes } = event.data;
7
+ if (!types.has(mime) || !(bytes instanceof ArrayBuffer) || bytes.byteLength > 32 * 1024 * 1024) return;
8
+ const root = document.getElementById('asset');
9
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
10
+ if (mime.startsWith('text/')) {
11
+ const pre = document.createElement('pre');
12
+ pre.textContent = new TextDecoder().decode(bytes);
13
+ root.replaceChildren(pre);
14
+ } else {
15
+ objectUrl = URL.createObjectURL(new Blob([bytes], { type: mime }));
16
+ const child = document.createElement(mime === 'application/pdf' ? 'iframe' : 'img');
17
+ child.src = objectUrl;
18
+ if (child.tagName === 'IMG') child.alt = 'Attachment';
19
+ else child.title = 'PDF attachment';
20
+ root.replaceChildren(child);
21
+ }
22
+ });
23
+ parent.postMessage({ kind: 'acon-asset-ready' }, location.origin);
package/shell/index.html CHANGED
@@ -20,6 +20,7 @@
20
20
  <header class="top">
21
21
  <div class="crumbs" id="crumbs"></div>
22
22
  <button type="button" class="search-open" id="palette-open" aria-keyshortcuts="Meta+K Control+K" aria-label="Search or run a command"><span class="search-label" aria-hidden="true">Search or run a command</span><kbd id="palette-key">Ctrl K</kbd></button>
23
+ <span class="chip warn read-only" id="read-only" title="Actions are turned off on this console" hidden>Read-only</span>
23
24
  <span class="live" id="live" role="status" aria-live="off"></span>
24
25
  <button type="button" class="pet" id="pet" hidden></button>
25
26
  <button type="button" class="icon-btn" id="mini-open" aria-label="Pop out Mini" title="Pop out Mini"></button>
package/shell/shell.js CHANGED
@@ -710,8 +710,8 @@ function visualView(payload, kit, frames, theme) {
710
710
  // `route` maps a plugin path to the load this page uses (#26), so assets come from the same version as the panel.
711
711
  // `o` holds shell hooks for kit parts that need them (review: openSheet, onError; visual: theme, onTheme).
712
712
  function kitFor(tool, hash, route = (path) => path, o = {}) {
713
- const { openSheet, onError, theme = () => "light", onTheme = () => {} } = o;
714
- const assetUrl = (id) => `${route(`/p/${tool}/asset`)}?id=${encodeURIComponent(id)}`;
713
+ const { openSheet, onError, frameAsset, theme = () => "light", onTheme = () => {} } = o;
714
+ const assetUrl = (id) => route(`/p/${tool}/asset?id=${encodeURIComponent(id)}`);
715
715
  let frames = null;
716
716
  // One theme subscription per kit: diagrams on screen redraw in the new theme.
717
717
  onTheme(() => { if (frames) for (const entry of [...frames.live]) entry.retheme(theme()); });
@@ -725,6 +725,7 @@ function kitFor(tool, hash, route = (path) => path, o = {}) {
725
725
  },
726
726
  frame(o) {
727
727
  const id = assetId(o);
728
+ if (frameAsset) return frameAsset(assetUrl(id), String(o.title ?? "Attachment"));
728
729
  return h("iframe", { class: "ui-frame", src: assetUrl(id), title: String(o.title ?? "Attachment"), referrerpolicy: "no-referrer" });
729
730
  },
730
731
  link(to, text) {
@@ -775,10 +776,17 @@ function store(key, value) {
775
776
 
776
777
  // ---------- network ----------
777
778
  // A page keeps the plugin load it first saw (#26): a version registered later serves pages opened after it.
779
+ let staticTransport = null;
778
780
  const pinnedRefs = new Map();
779
- const pluginPath = (path) => path.replace(/^\/p\/([a-z][a-z0-9-]{1,15})\//, (whole, id) => (pinnedRefs.has(id) ? `/p/${pinnedRefs.get(id)}/` : whole));
781
+ const pluginPath = (path) => staticTransport ? staticTransport.resource(path, pinnedRefs) : path.replace(/^\/p\/([a-z][a-z0-9-]{1,15})\//, (whole, id) => (pinnedRefs.has(id) ? `/p/${pinnedRefs.get(id)}/` : whole));
780
782
 
781
783
  async function post(path, body = {}) {
784
+ if (staticTransport) {
785
+ state.lastRequest = Date.now();
786
+ const response = await staticTransport.post(path, body, pinnedRefs);
787
+ if (response.status === 200) state.lastOk = Date.now();
788
+ return response;
789
+ }
782
790
  path = pluginPath(path);
783
791
  state.lastRequest = Date.now();
784
792
  let res;
@@ -856,6 +864,7 @@ function route() {
856
864
  const prev = state.route;
857
865
  state.route = parseRoute();
858
866
  const r = state.route;
867
+ updateReadOnly();
859
868
  if (prev.tool && prev.tool !== r.tool) { const p = state.panels.get(prev.tool); if (p?.instance) safe(prev.tool, "hide", () => p.instance.hide?.()); }
860
869
  $("view-inbox").hidden = r.area !== "inbox";
861
870
  $("view-system").hidden = r.area !== "system";
@@ -949,6 +958,13 @@ function renderLive() {
949
958
  const el = $("live");
950
959
  const age = Math.round((Date.now() - state.lastOk) / 1000);
951
960
  const kind = state.offline ? "offline" : age > 15 ? "stale" : "live";
961
+ if (staticTransport) {
962
+ const online = staticTransport.nodes.filter((node) => node.status === "online").length;
963
+ el.className = `live ${online ? "live" : "offline"}`;
964
+ el.textContent = `${online}/${staticTransport.nodes.length} nodes online`;
965
+ el.title = "Open System for each node's status and authentication";
966
+ return;
967
+ }
952
968
  const text = kind === "offline" ? "Offline · retrying" : kind === "stale" ? `Stale ${age} s` : `Live · ${age} s ago`;
953
969
  el.className = `live ${kind}`;
954
970
  if (el.dataset.text !== text) { el.dataset.text = text; el.replaceChildren(h("span", { class: "dot", "aria-hidden": "true" }), h("span", { text })); }
@@ -1014,7 +1030,7 @@ function renderInbox(force = false) {
1014
1030
  const view = $("view-inbox");
1015
1031
  const items = sortedItems();
1016
1032
  const failed = [...state.inbox].filter(([, e]) => e.failed).map(([t]) => toolById(t)?.title ?? t);
1017
- const sig = JSON.stringify([items, failed, state.selectedKey, readyTools().length]);
1033
+ const sig = JSON.stringify([items, failed, state.selectedKey, readyTools().length, readOnly()]);
1018
1034
  if (!force && sig === state.inboxSig) return;
1019
1035
  state.inboxSig = sig;
1020
1036
  const focusRow = document.activeElement?.closest?.(".row");
@@ -1035,7 +1051,7 @@ function renderInbox(force = false) {
1035
1051
  if (!group.length) continue;
1036
1052
  kids.push(h("section", { class: "group", "aria-labelledby": `g-${g}` },
1037
1053
  h("h2", { id: `g-${g}` }, h("span", { text: label }), h("span", { class: "badge", text: String(group.length) }),
1038
- g === "result" && group.some((i) => i.seen) ? h("button", { type: "button", class: "btn group-command", onclick: () => markAllRead(group), text: "Mark all read" }) : null),
1054
+ g === "result" && group.some((i) => i.seen) ? h("button", { type: "button", class: "btn group-command", disabled: readOnly(), title: readOnly() ? RO_TITLE : undefined, onclick: () => markAllRead(group), text: "Mark all read" }) : null),
1039
1055
  h("ul", { class: "rows" }, group.map(rowFor))));
1040
1056
  }
1041
1057
  view.replaceChildren(...kids);
@@ -1053,10 +1069,21 @@ function whose(item) {
1053
1069
  function subline(item) {
1054
1070
  return [item.kind, item.project, item.actor ? `from ${item.actor}` : "", toolById(item.tool)?.title, ago(item.at)].filter(Boolean).join(" · ");
1055
1071
  }
1072
+ // #45: a read-only node refuses every action; verbs and confirm stay visible but disabled.
1073
+ const readOnly = (tool = state.route.tool) => state.readOnly === true || Boolean(tool && state.tools.find((t) => t.tool === tool)?.readOnly === true);
1074
+ function updateReadOnly() {
1075
+ const chip = $("read-only");
1076
+ const nodes = staticTransport ? staticTransport.nodes.filter((node) => node.data?.readOnly).map((node) => node.id) : [];
1077
+ const routed = readOnly();
1078
+ chip.hidden = !routed && !nodes.length;
1079
+ chip.textContent = routed ? "Read-only" : `${nodes.length} read-only ${nodes.length === 1 ? "node" : "nodes"}`;
1080
+ chip.title = nodes.length ? `Read-only nodes: ${nodes.join(", ")}. Actions remain available on writable nodes.` : "Actions are turned off on this console";
1081
+ }
1082
+ const RO_TITLE = "This console is read-only";
1056
1083
  function verbButtons(item, max) {
1057
1084
  const verbs = [...item.verbs].sort((a, b) => Number(Boolean(b.primary)) - Number(Boolean(a.primary))).slice(0, max);
1058
1085
  return verbs.map((v, i) => h("button", {
1059
- type: "button", class: `btn${v.primary ? " primary" : ""}${v.destructive ? " danger" : ""}`,
1086
+ type: "button", class: `btn${v.primary ? " primary" : ""}${v.destructive ? " danger" : ""}`, disabled: readOnly(item.tool), title: readOnly(item.tool) ? RO_TITLE : undefined,
1060
1087
  onclick: (e) => { e.stopPropagation(); runVerb(item, v); },
1061
1088
  }, h("span", { text: v.label }), max <= 2 ? h("kbd", { "aria-hidden": "true", text: String(i + 1) }) : null));
1062
1089
  }
@@ -1226,7 +1253,7 @@ function answerForm(item, verb, draft, submit, redraw) {
1226
1253
  str(draft.reason).trim() ? [h("dt", { text: "Reason" }), h("dd", { text: str(draft.reason).trim() })] : []),
1227
1254
  error,
1228
1255
  h("div", { class: "sheet-verbs" },
1229
- h("button", { type: "button", class: "btn primary answer-submit", onclick: () => submit(r.answers), text: `Submit: ${verb.label}` }),
1256
+ h("button", { type: "button", class: "btn primary answer-submit", disabled: readOnly(item.tool), onclick: () => submit(r.answers), text: `Submit: ${verb.label}` }),
1230
1257
  h("button", { type: "button", class: "btn", onclick: () => { draft.step = "edit"; redraw(true); }, text: "Back" })));
1231
1258
  return el;
1232
1259
  }
@@ -1263,7 +1290,7 @@ function answerForm(item, verb, draft, submit, redraw) {
1263
1290
  verb.reason ? h("label", { class: "answer-opt answer-other" }, h("span", { class: "answer-label", text: `Reason${verb.reason === "required" ? " (required)" : " (optional)"}` }),
1264
1291
  h("input", { type: "text", class: "answer-other-text answer-reason", maxlength: "500", autocomplete: "off", value: str(draft.reason), oninput: (e) => { draft.reason = e.target.value; } })) : null,
1265
1292
  error,
1266
- h("div", { class: "sheet-verbs" }, h("button", { type: "submit", class: "btn primary answer-submit", text: form.questions.length > 1 ? "Review answers" : `Submit: ${verb.label}` })));
1293
+ h("div", { class: "sheet-verbs" }, h("button", { type: "submit", class: "btn primary answer-submit", disabled: readOnly(item.tool), text: form.questions.length > 1 ? "Review answers" : `Submit: ${verb.label}` })));
1267
1294
  return el;
1268
1295
  }
1269
1296
  function openAnswer(item, verb) {
@@ -1331,7 +1358,9 @@ function watchSeen(key) {
1331
1358
  }, 250) : null;
1332
1359
  }
1333
1360
  async function sendSeen(key, explicit) {
1361
+ // A read-only node refuses every action, seen receipts included: send nothing, record nothing.
1334
1362
  const item = visibleItems().find((i) => i.key === key);
1363
+ if (readOnly(item?.tool)) return false;
1335
1364
  if (!item?.seen) return false;
1336
1365
  readResults.set(key, item);
1337
1366
  const r = await post(`/p/${item.tool}/action`, { action: item.seen.action, id: item.id, seenAt: new Date().toISOString(), ...(explicit ? { explicit: true } : {}) });
@@ -1340,7 +1369,7 @@ async function sendSeen(key, explicit) {
1340
1369
  return true;
1341
1370
  }
1342
1371
  async function markAllRead(group) {
1343
- const items = group.filter((i) => i.seen);
1372
+ const items = group.filter((i) => i.seen && !readOnly(i.tool));
1344
1373
  const sent = await Promise.all(items.map((i) => { seen.done.add(i.key); return sendSeen(i.key, true); }));
1345
1374
  await pollInbox();
1346
1375
  toast(`Marked ${plural(sent.filter(Boolean).length, "result")} read`);
@@ -1393,7 +1422,7 @@ function renderPeek() {
1393
1422
  const live = sortedItems().find((i) => i.key === key);
1394
1423
  // A result that was just marked seen stays readable in the open peek.
1395
1424
  const item = live ?? readResults.get(key);
1396
- const sig = JSON.stringify([key, item ?? null, Boolean(live)]);
1425
+ const sig = JSON.stringify([key, item ?? null, Boolean(live), readOnly()]);
1397
1426
  if (sig === peekSig && !$("sheet").hidden) return;
1398
1427
  peekSig = sig;
1399
1428
  // Keep keyboard focus on the same control when the data changes under it.
@@ -1438,16 +1467,50 @@ function renderPeek() {
1438
1467
  function safe(tool, where, run) {
1439
1468
  try { return run(); } catch (error) { clientError(tool, where, error); return undefined; }
1440
1469
  }
1470
+ function staticAssetFrame(url, title) {
1471
+ const frame = h("iframe", { class: "ui-frame", src: "/asset-viewer.html", sandbox: "allow-scripts", title, referrerpolicy: "no-referrer" });
1472
+ const status = h("p", { class: "sub", role: "status", text: "Loading attachment..." });
1473
+ const wrapper = h("div", {}, frame, status, h("a", { class: "ui-link", href: url, target: "_blank", rel: "noopener noreferrer", text: "Open attachment" }));
1474
+ const receive = async (event) => {
1475
+ if (event.source !== frame.contentWindow || event.data?.kind !== "acon-asset-ready") return;
1476
+ window.removeEventListener("message", receive);
1477
+ try {
1478
+ const response = await fetch(url, { credentials: "include", signal: AbortSignal.timeout(10000), redirect: "error" });
1479
+ const mime = response.headers.get("content-type")?.split(";")[0];
1480
+ if (!response.ok || !["application/pdf", "image/png", "image/jpeg", "text/plain", "text/csv"].includes(mime)) throw new Error("unavailable attachment");
1481
+ const chunks = [];
1482
+ let length = 0;
1483
+ const reader = response.body.getReader();
1484
+ for (;;) {
1485
+ const { value, done } = await reader.read();
1486
+ if (done) break;
1487
+ length += value.byteLength;
1488
+ if (length > 32 * 1024 * 1024) { await reader.cancel(); throw new Error("oversized attachment"); }
1489
+ chunks.push(value);
1490
+ }
1491
+ const bytes = new Uint8Array(length);
1492
+ let at = 0;
1493
+ for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.byteLength; }
1494
+ frame.contentWindow.postMessage({ kind: "acon-asset", mime, bytes: bytes.buffer }, "*", [bytes.buffer]);
1495
+ status.textContent = "";
1496
+ } catch { status.textContent = "Attachment unavailable; check this node's authentication in System or use Open attachment."; }
1497
+ };
1498
+ window.addEventListener("message", receive);
1499
+ setTimeout(() => window.removeEventListener("message", receive), 15000);
1500
+ frame.addEventListener("load", () => { if (!frame.isConnected) window.removeEventListener("message", receive); });
1501
+ return wrapper;
1502
+ }
1503
+
1441
1504
  function hostFor(tool, p) {
1442
1505
  return {
1443
- tool: tool.tool,
1444
- node: NODE,
1506
+ tool: tool.rawTool ?? tool.tool,
1507
+ node: tool.node ?? NODE,
1445
1508
  mount: p.body,
1446
- ui: kitFor(tool.tool, hashFor, pluginPath, { openSheet: (title) => { peekSig = ""; const b = showSheet(String(title), `panel:${tool.tool}`, true); b.replaceChildren(); return b; }, onError: (error) => clientError(tool.tool, "review comment", error), theme: () => currentTheme().mode, onTheme: (cb) => { p.themeListeners.push(cb); } }),
1509
+ ui: kitFor(tool.tool, hashFor, pluginPath, { frameAsset: staticTransport ? staticAssetFrame : undefined, openSheet: (title) => { peekSig = ""; const b = showSheet(String(title), `panel:${tool.tool}`, true); b.replaceChildren(); return b; }, onError: (error) => clientError(tool.tool, "review comment", error), theme: () => currentTheme().mode, onTheme: (cb) => { p.themeListeners.push(cb); } }),
1447
1510
  get theme() { return currentTheme(); },
1448
1511
  snapshot: async (extra = {}) => (await post(`/p/${tool.tool}/snapshot`, { after: 0, panel: p.panel ?? panelsOf(tool)[0].id, ...extra })).data,
1449
1512
  action: (input) => runAction(tool.tool, input, {}),
1450
- navigate: (t, panel, params) => navigate(t, panel, params),
1513
+ navigate: (t, panel, params) => navigate(tool.node && !t.includes(":") ? `${tool.node}:${t}` : t, panel, params),
1451
1514
  onSnapshot: (cb) => { p.snapListeners.push(cb); },
1452
1515
  onTheme: (cb) => { p.themeListeners.push(cb); },
1453
1516
  toast: (text, options = {}) => toast(String(text), options),
@@ -1463,8 +1526,8 @@ async function showTool(r) {
1463
1526
  p = { tool: tool.tool, mount: h("div", { class: "panel-mount", "data-tool": tool.tool }, banner, body), banner, body, snapListeners: [], themeListeners: [], instance: null, panel: null, cursor: 0, next: 0, busy: false, loading: null, view: "", gen: 0, lastSig: "" };
1464
1527
  state.panels.set(tool.tool, p);
1465
1528
  $("main").append(p.mount);
1466
- document.head.append(h("link", { rel: "stylesheet", href: pluginPath(`/p/${tool.tool}/panel.css`) }));
1467
- p.loading = import(pluginPath(`/p/${tool.tool}/panel.js`)).then((mod) => {
1529
+ const styleReady = staticTransport ? staticTransport.loadStyle(`/p/${tool.tool}/panel.css`, pinnedRefs) : Promise.resolve(document.head.append(h("link", { rel: "stylesheet", href: pluginPath(`/p/${tool.tool}/panel.css`) })));
1530
+ p.loading = styleReady.then(() => import(pluginPath(`/p/${tool.tool}/panel.js`))).then((mod) => {
1468
1531
  if (typeof mod.default !== "function") throw new Error("panel.js has no default export");
1469
1532
  p.instance = mod.default(hostFor(tool, p));
1470
1533
  }).catch((error) => {
@@ -1513,6 +1576,7 @@ async function pollPanel() {
1513
1576
 
1514
1577
  // ---------- actions and confirm ----------
1515
1578
  async function runAction(tool, input, opts) {
1579
+ if (readOnly(tool)) { toast("This console is read-only; actions are turned off.", { tone: "err" }); return { ok: false, readOnly: true }; }
1516
1580
  const r = await post(`/p/${tool}/action`, input);
1517
1581
  const d = r.data;
1518
1582
  if (d?.needsConfirm) {
@@ -1586,7 +1650,7 @@ function showConfirm(tool, d, note, opts) {
1586
1650
  const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
1587
1651
  validity.className = `validity${expired() ? " expired" : ""}`;
1588
1652
  validity.textContent = expired() ? `Preview of version ${shortFp(str(d.fingerprint))} expired.` : `Preview of version ${shortFp(str(d.fingerprint))} · valid ${left} s`;
1589
- ok.disabled = expired() || (typed && typedInput.value !== typed);
1653
+ ok.disabled = readOnly(tool) || expired() || (typed && typedInput.value !== typed);
1590
1654
  refresh.hidden = !expired();
1591
1655
  };
1592
1656
  function finish(choice) {
@@ -1666,7 +1730,7 @@ function commandOptions(q) {
1666
1730
  const add = (title, sub, run, iconName = "go") => { if (matches(q, title, sub)) out.push({ title, sub, run, icon: iconName }); };
1667
1731
  add("Go to Inbox", "Everything waiting for you", () => navigate("inbox"), "inbox");
1668
1732
  for (const t of readyTools()) {
1669
- if (q.tool && q.tool !== t.tool) continue;
1733
+ if ((q.tool && q.tool !== t.tool && q.tool !== t.rawTool) || (q.node && q.node !== (t.node ?? NODE))) continue;
1670
1734
  for (const p of panelsOf(t)) add(`Go to ${t.title} / ${p.title}`, t.tool, () => navigate(t.tool, p.id), toolIcon(t));
1671
1735
  }
1672
1736
  if (!q.tool) {
@@ -1680,15 +1744,15 @@ function commandOptions(q) {
1680
1744
  }
1681
1745
  // Plugin commands run through action(), so a destructive one gets the same confirm dialog as a verb.
1682
1746
  function pluginCommandSections(q) {
1683
- if (q.needsMe || q.id || (q.node && q.node !== NODE)) return [];
1684
- return readyTools().filter((t) => !q.tool || q.tool === t.tool).map((t) => [`${t.title} commands`,
1747
+ if (q.needsMe || q.id || (!staticTransport && q.node && q.node !== NODE)) return [];
1748
+ return readyTools().filter((t) => (!q.tool || q.tool === t.tool || q.tool === t.rawTool) && (!q.node || q.node === (t.node ?? NODE))).map((t) => [`${t.title} commands`,
1685
1749
  (Array.isArray(t.commands) ? t.commands : []).filter((c) => c && str(c.id) && str(c.title) && str(c.action) && matches(q, c.title, c.hint, t.title))
1686
1750
  .map((c) => ({ title: c.title, sub: [t.title, str(c.hint)].filter(Boolean).join(" · "), icon: toolIcon(t),
1687
1751
  run: () => runAction(t.tool, { ...(c.input && typeof c.input === "object" ? c.input : {}), action: c.action }, { label: c.title }) }))]);
1688
1752
  }
1689
1753
  function inboxOptions(q) {
1690
- if (q.node && q.node !== NODE) return [];
1691
- return sortedItems().filter((i) => (!q.tool || q.tool === i.tool) && (!q.needsMe || NEEDS_ME.has(i.group)) && (!q.id || i.id === q.id) && matches(q, i.title, i.kind, i.project, i.actor))
1754
+ if (!staticTransport && q.node && q.node !== NODE) return [];
1755
+ return sortedItems().filter((i) => (!q.node || q.node === (toolById(i.tool)?.node ?? NODE)) && (!q.tool || q.tool === i.tool || q.tool === toolById(i.tool)?.rawTool) && (!q.needsMe || NEEDS_ME.has(i.group)) && (!q.id || i.id === q.id) && matches(q, i.title, i.kind, i.project, i.actor))
1692
1756
  .slice(0, 8).map((i) => ({ title: i.title, sub: subline(i), icon: i.group === "alert" ? "alert" : i.group === "decision" ? "you" : i.group, run: () => { navigate("inbox", undefined, { id: i.key }); }, peek: () => { navigate("inbox"); selectKey(i.key); openPeek(i.key); } }));
1693
1757
  }
1694
1758
  function renderPalette(searchGroups) {
@@ -1724,7 +1788,7 @@ async function searchPalette() {
1724
1788
  const seq = ++palSeq;
1725
1789
  const q = parseQuery($("palette-input").value);
1726
1790
  const text = q.words.join(" ");
1727
- const tools = readyTools().filter((t) => t.capabilities?.search && (!q.tool || q.tool === t.tool) && !q.needsMe && !q.id && (!q.node || q.node === NODE));
1791
+ const tools = readyTools().filter((t) => t.capabilities?.search && (!q.tool || q.tool === t.tool || q.tool === t.rawTool) && !q.needsMe && !q.id && (!q.node || q.node === (t.node ?? NODE)));
1728
1792
  if (text.length < 2 || !tools.length) { renderPalette([]); return; }
1729
1793
  renderPalette([]);
1730
1794
  const groups = await Promise.all(tools.map(async (t) => {
@@ -1864,7 +1928,7 @@ let miniSig = "";
1864
1928
  function renderMini(force = false) {
1865
1929
  if (miniView.hidden && !pip) return;
1866
1930
  const pet = petChoice(), pose = petState(), items = needsMe(), shown = items.slice(0, 5);
1867
- const sig = JSON.stringify([pet?.id, pose, shown, items.length, Boolean(pip)]);
1931
+ const sig = JSON.stringify([pet?.id, pose, shown, items.length, Boolean(pip), readOnly()]);
1868
1932
  if (!force && sig === miniSig) return;
1869
1933
  miniSig = sig;
1870
1934
  const fkey = miniView.contains(miniView.ownerDocument.activeElement) ? miniView.ownerDocument.activeElement.dataset.fid : undefined;
@@ -1878,7 +1942,7 @@ function renderMini(force = false) {
1878
1942
  return h("li", { class: "mini-item" },
1879
1943
  h("span", { class: "row-text" }, h("span", { class: "row-title", text: item.title }), h("span", { class: "row-sub", text: [toolById(item.tool)?.title ?? item.tool, ago(item.at)].join(" · ") })),
1880
1944
  h("span", { class: "mini-verbs" },
1881
- verb ? h("button", { type: "button", class: "btn primary", "data-fid": `v:${item.key}`, onclick: () => runVerb(item, verb, undefined, { mini: true }), text: verb.label }) : null,
1945
+ verb ? h("button", { type: "button", class: "btn primary", "data-fid": `v:${item.key}`, disabled: readOnly(item.tool), onclick: () => runVerb(item, verb, undefined, { mini: true }), text: verb.label }) : null,
1882
1946
  h("button", { type: "button", class: "btn", "data-fid": `o:${item.key}`, "aria-label": `Open ${item.title}`, onclick: () => openInConsole(item), text: "Open" })));
1883
1947
  })) : null,
1884
1948
  items.length > shown.length ? h("p", { class: "sub", text: `${items.length - shown.length} more in the inbox.` }) : null,
@@ -1930,8 +1994,31 @@ async function popOutMini() {
1930
1994
 
1931
1995
  // ---------- system ----------
1932
1996
  let systemSig = "";
1997
+ function staticNodeCards() {
1998
+ return staticTransport.nodes.map((node) => {
1999
+ const input = h("input", { type: "password", autocomplete: "off", "data-fid": `token-${node.id}`, "aria-label": `Bearer token for ${node.id}`, oninput: (event) => { node.draft = event.target.value; } });
2000
+ input.value = node.draft ?? "";
2001
+ const reconnect = async (token) => {
2002
+ staticTransport.setToken(node.id, token);
2003
+ node.draft = "";
2004
+ await loadTools();
2005
+ route();
2006
+ await pollInbox();
2007
+ };
2008
+ return h("section", { class: "card", "data-node": node.id },
2009
+ h("h2", { text: node.id }),
2010
+ h("p", { class: "mono", text: node.url }),
2011
+ h("p", { role: "status", "data-node-status": node.id, text: `${node.status}: ${node.detail}` }),
2012
+ h("form", { class: "ui-form", onsubmit: (event) => { event.preventDefault(); void reconnect(input.value); } },
2013
+ h("label", {}, `Bearer token for ${node.id} (this tab only)`, input),
2014
+ h("button", { type: "submit", class: "btn primary", text: "Connect with token" })),
2015
+ h("p", {}, h("a", { href: node.url, target: "_blank", rel: "noopener noreferrer", text: "Open node to sign in with Access" })),
2016
+ h("button", { type: "button", class: "btn", onclick: () => { void reconnect(""); }, text: "Reconnect with Access cookies" }));
2017
+ });
2018
+ }
2019
+
1933
2020
  function renderSystem(force = false) {
1934
- const sig = JSON.stringify([state.node, state.tools, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off"]);
2021
+ const sig = JSON.stringify([state.node, state.tools, state.serverErrors, state.clientErrors, themeChoice(), state.offline, state.pets, petChoice()?.id ?? "off", staticTransport?.nodes.map((n) => [n.id, n.status, n.detail])]);
1935
2022
  if (!force && sig === systemSig && $("view-system").childElementCount) return;
1936
2023
  systemSig = sig;
1937
2024
  // Keep keyboard focus on the same control across re-renders.
@@ -1945,7 +2032,8 @@ function renderSystem(force = false) {
1945
2032
  h("dt", { text: "Plugins" }), h("dd", { class: "mono", text: str(node.pluginsDir) }));
1946
2033
  $("view-system").replaceChildren(
1947
2034
  h("h1", { class: "lead", id: "system-title", text: "System" }),
1948
- h("p", { class: "sub", text: "The console runs on this computer and stops 5 minutes after the last open tab goes away." }),
2035
+ h("p", { class: "sub", text: staticTransport ? "This static shell connects directly to each node. Credentials stay in this tab; actions run on their owner node." : "The console runs on this computer and stops 5 minutes after the last open tab goes away." }),
2036
+ ...(staticTransport ? staticNodeCards() : []),
1949
2037
  h("div", { class: "cards" },
1950
2038
  h("section", { class: "card" }, h("h2", { text: "Node" }), dl),
1951
2039
  h("section", { class: "card" }, h("h2", { text: "Appearance" }),
@@ -1980,6 +2068,8 @@ async function loadTools() {
1980
2068
  return kept ? { ...kept, update: str(t.version) } : t;
1981
2069
  });
1982
2070
  state.node = r.data.node ?? null;
2071
+ state.readOnly = r.data.readOnly === true;
2072
+ updateReadOnly();
1983
2073
  state.pets = Array.isArray(r.data.pets) ? r.data.pets.filter((p) => p && PET_ID.test(str(p.id)) && str(p.name)) : [];
1984
2074
  state.assetPacks = Array.isArray(r.data.assetPacks) ? r.data.assetPacks.filter((p) => p && PET_ID.test(str(p.id)) && Array.isArray(p.images) && p.images.every((id) => PET_ID.test(str(id))) && Array.isArray(p.tile) && p.tile.length === 2 && p.tile.every((n) => Number.isSafeInteger(n) && n > 0)) : [];
1985
2075
  state.serverErrors = Array.isArray(r.data.errors) ? r.data.errors : [];
@@ -2085,6 +2175,24 @@ function tick() {
2085
2175
 
2086
2176
  async function boot() {
2087
2177
  applyTheme();
2178
+ if (document.documentElement.dataset.static === "true") {
2179
+ try {
2180
+ const response = await fetch("/config.json", { cache: "no-store", credentials: "omit" });
2181
+ if (!response.ok) throw new Error("config.json could not load; rebuild and upload the complete static output.");
2182
+ const { createStaticTransport } = await import("./static.js");
2183
+ staticTransport = createStaticTransport(await response.json(), () => { if (state.route.area === "system") renderSystem(true); });
2184
+ } catch (error) { endSession(String(error.message)); return; }
2185
+ state.serve = true;
2186
+ await loadTools();
2187
+ if (!readyTools().length) location.hash = hashFor("system");
2188
+ route();
2189
+ state.nextTools = Date.now() + TOOLS_MS;
2190
+ state.nextInbox = Date.now() + INBOX_MS;
2191
+ setInterval(tick, 1000);
2192
+ await pollInbox();
2193
+ renderLive();
2194
+ return;
2195
+ }
2088
2196
  const m = /^#([0-9a-f]{64})$/.exec(location.hash);
2089
2197
  if (m) {
2090
2198
  history.replaceState(null, "", "/#/local/inbox");
@@ -0,0 +1,111 @@
1
+ // H2 transport. Credentials live only in this tab; the static host serves no node data.
2
+ const NODE_ID = /^[a-z][a-z0-9-]{0,31}$/;
3
+ const TOOL_ID = /^[a-z][a-z0-9-]{1,15}$/;
4
+ const loopback = (host) => host === 'localhost' || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(host);
5
+
6
+ export function createStaticTransport(config, onStatus = () => {}) {
7
+ if (config?.api !== 1 || !Array.isArray(config.nodes) || !config.nodes.length || config.nodes.length > 32) throw new Error('Invalid static config; rebuild with acon build --static --nodes id=https://node.example');
8
+ const ids = new Set();
9
+ const nodes = config.nodes.map((input) => {
10
+ let url;
11
+ try { url = new URL(input?.url); } catch { throw new Error("Invalid node URL in config.json; rebuild with acon build --static --nodes id=https://node.example"); }
12
+ if (!NODE_ID.test(input.id) || ids.has(input.id) || url.origin !== input.url || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback(url.hostname)))) throw new Error('Invalid node id or origin in config.json; rebuild with unique ids and exact HTTPS origins');
13
+ ids.add(input.id);
14
+ const key = `acon.auth:${input.id}:${url.origin}`;
15
+ let token = '';
16
+ try { token = sessionStorage.getItem(key) ?? ''; } catch { /* this tab may disallow storage */ }
17
+ return { id: input.id, url: url.origin, key, token, status: 'connecting', detail: 'Connecting to node', grant: '', grantUntil: 0, data: null, busy: null };
18
+ });
19
+ const notify = (node, status, detail) => { node.status = status; node.detail = detail; onStatus(); };
20
+ const headersFor = (node, json = true) => ({ ...(json ? { 'Content-Type': 'application/json' } : {}), ...(node.token ? { Authorization: `Bearer ${node.token}` } : {}) });
21
+ const failed = async (node) => {
22
+ // A failed CORS fetch and a stopped node look identical to JavaScript. An opaque,
23
+ // bounded health probe establishes reachability without reading cross-origin data.
24
+ try {
25
+ await fetch(`${node.url}/healthz`, { mode: 'no-cors', credentials: 'omit', cache: 'no-store', signal: AbortSignal.timeout(3000) });
26
+ notify(node, 'refused (origin)', 'Node reachable but browser access refused; add this shell origin to acon serve --origin and check the Access CORS settings.');
27
+ } catch { notify(node, 'offline', 'Node is unreachable; start acon serve and check the node URL and network.'); }
28
+ return { status: 0, data: { ok: false, error: node.detail } };
29
+ };
30
+ const request = async (node, path, body) => {
31
+ const action = body !== undefined && /^\/p\/[^/]+\/action$/.test(path);
32
+ const signal = AbortSignal.timeout(action ? 35000 : 8000);
33
+ const uncertain = () => ({ status: 0, data: { ok: false, uncertain: true, error: 'The action outcome is unknown because its response was interrupted or invalid; check the node System log and current result before repeating the action.' } });
34
+ let response;
35
+ let data = null;
36
+ try {
37
+ response = await fetch(`${node.url}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: headersFor(node, body !== undefined), ...(body === undefined ? {} : { body: JSON.stringify(body) }), credentials: 'include', cache: 'no-store', signal, redirect: 'error' });
38
+ try { data = await response.json(); }
39
+ catch { if (action && response.status !== 401 && response.status !== 403) return uncertain(); }
40
+ } catch { return action ? uncertain() : failed(node); }
41
+ if (response.status === 401 || (response.status === 403 && data?.readOnly !== true)) {
42
+ node.grant = '';
43
+ notify(node, 'unauthorized', 'Authentication refused; enter this node\'s bearer token or sign in to its Access application, then reconnect.');
44
+ } else notify(node, 'online', 'Connected');
45
+ return { status: response.status, data: data ?? { ok: false, error: `Node answered HTTP ${response.status} without JSON; check its System log.` } };
46
+ };
47
+ const connect = async (node) => {
48
+ if (!node.grant || Date.now() >= node.grantUntil) {
49
+ const session = await request(node, '/session', { resources: true });
50
+ if (session.status !== 200 || !/^[0-9a-f]{64}$/.test(session.data.resourceToken ?? '')) return null;
51
+ node.grant = session.data.resourceToken;
52
+ node.grantUntil = Date.now() + Math.min(Number(session.data.expiresIn) || 0, 3600000) - 10000;
53
+ }
54
+ const result = await request(node, '/tools', {});
55
+ node.data = result.status === 200 ? result.data : null;
56
+ return node.data;
57
+ };
58
+ const owner = (key) => nodes.find((node) => key.startsWith(`${node.id}:`));
59
+ const resolve = (path, refs = new Map()) => {
60
+ const match = /^\/p\/([^/]+)\/(.*)$/.exec(path);
61
+ if (!match) return null;
62
+ const node = owner(match[1]);
63
+ if (!node) return null;
64
+ const raw = match[1].slice(node.id.length + 1);
65
+ if (!TOOL_ID.test(raw)) return null;
66
+ const ref = refs.get(match[1]) ?? raw;
67
+ if (!new RegExp(`^${raw}(?:@[0-9a-f]{12})?$`).test(ref)) return null;
68
+ return { node, path: `/p/${ref}/${match[2]}` };
69
+ };
70
+ return {
71
+ nodes,
72
+ async tools() {
73
+ // One bounded refresh per node, with no nested retry loop or repeated auth prompt.
74
+ await Promise.all(nodes.map((node) => node.busy ??= connect(node).finally(() => { node.busy = null; })));
75
+ const tools = nodes.flatMap((node) => (node.data?.tools ?? []).filter((tool) => TOOL_ID.test(tool?.tool)).map((tool) => ({ ...tool, tool: `${node.id}:${tool.tool}`, rawTool: tool.tool, node: node.id, title: `${node.id}: ${tool.title ?? tool.tool}`, readOnly: node.data.readOnly === true, ...(node.status === 'online' ? {} : { status: 'offline', reason: node.detail }) })));
76
+ return { status: 200, data: { tools, pets: [], assetPacks: [], node: { id: 'static', version: 'H2' }, errors: [], readOnly: false } };
77
+ },
78
+ async post(path, body, refs) {
79
+ if (path === '/tools') return this.tools();
80
+ if (path === '/ping') return { status: 200, data: { ok: true } };
81
+ const route = resolve(path, refs);
82
+ if (!route) return { status: 404, data: { ok: false, error: 'Unknown node/tool route; reload the static console.' } };
83
+ if (route.node.status !== "online") return { status: 0, data: { ok: false, error: route.node.detail } };
84
+ return request(route.node, route.path, body);
85
+ },
86
+ resource(path, refs) {
87
+ const route = resolve(path, refs);
88
+ if (!route) return path;
89
+ const url = new URL(route.path, route.node.url);
90
+ url.searchParams.set('acon_resource', route.node.grant);
91
+ return url.href;
92
+ },
93
+ async loadStyle(path, refs) {
94
+ const route = resolve(path, refs);
95
+ if (!route) throw new Error('Unknown plugin stylesheet node; reload the console.');
96
+ const response = await fetch(this.resource(path, refs), { headers: headersFor(route.node, false), credentials: 'include', signal: AbortSignal.timeout(8000), redirect: 'error' });
97
+ if (!response.ok) throw new Error('Plugin stylesheet could not load; check this node\'s authentication in System.');
98
+ // Constructed sheets preserve style-src self: no remote link or inline style element.
99
+ const sheet = new CSSStyleSheet();
100
+ sheet.replaceSync(await response.text());
101
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
102
+ },
103
+ setToken(id, token) {
104
+ const node = nodes.find((item) => item.id === id);
105
+ if (!node) return;
106
+ node.token = token.trim(); node.grant = ''; node.data = null;
107
+ try { if (node.token) sessionStorage.setItem(node.key, node.token); else sessionStorage.removeItem(node.key); } catch { /* memory-only tab */ }
108
+ notify(node, 'connecting', 'Reconnecting');
109
+ },
110
+ };
111
+ }
package/src/cli.ts CHANGED
@@ -1,18 +1,28 @@
1
1
  import pkg from "../package.json" with { type: "json" };
2
2
  import { familyHome, loadPlugins, scanPlugins, type PluginEntry } from "./plugins.ts";
3
3
  import { startServer } from "./server.ts";
4
+ import { buildStatic } from "./static.ts";
4
5
  import { loadPets, petDirs } from "./pets.ts";
5
6
  import { loadAssetPacks } from "./assets.ts";
6
- import { join } from "node:path";
7
7
  import { DEFAULT_INDEX, fetchIndex, installPackage, InstallError, printable } from "./install.ts";
8
- import { serveConfig, type ServeOptions } from "./serve.ts";
8
+ import { serveConfig, type ServeArgs, type ServeOptions } from "./serve.ts";
9
+ import { LABEL, LAUNCHD_ENV, launchdPlist, newTokenFile, tokenPath } from "./setup.ts";
10
+ import { homedir } from "node:os";
11
+ import { isAbsolute, join, resolve } from "node:path";
12
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
9
13
 
10
14
  const USAGE = `acon ${pkg.version}: local console for agent-hub, agent-mem and vault-mem
11
15
 
12
16
  Usage:
13
17
  acon ui [--no-open] [--json] serve the console on 127.0.0.1 and print a one-time link
14
- acon serve [--host H] [--port P] [--origin https://host]... [--json]
18
+ acon serve [--host H] [--port P] [--origin https://host]... [--read-only] [--json]
15
19
  resident console behind Cloudflare Access or a bearer token
20
+ acon build --static --nodes id=https://origin[,id=origin] [--out dist]
21
+ write the static multi-node shell (no credentials or plugin data)
22
+ acon token new [--file PATH] [--force]
23
+ write a 0600 bearer token file (default $AGENTHUB_HOME/console/token); prints the path only
24
+ acon setup launchd [--label ID] [--apply] [serve flags]
25
+ print a LaunchAgent plist for acon serve; --apply installs and loads it
16
26
  acon plugins [--json] list registered plugins and verify their module hashes
17
27
  acon install <name|https URL|dir> [--force]
18
28
  install a pet or asset package
@@ -22,10 +32,12 @@ Usage:
22
32
  Plugins register a manifest under $AGENTHUB_HOME/console/plugins (default ~/.agenthub).
23
33
  Packages install to $AGENTHUB_HOME/console/pets/<id> or console/assets/<id>; ACON_ASSETS_INDEX overrides the index URL.
24
34
  serve auth: ACON_ACCESS_TEAM + ACON_ACCESS_AUD (Cloudflare Access) or ACON_TOKEN_FILE (bearer); required off loopback.
35
+ --read-only or ACON_READ_ONLY=1 refuses every plugin action; snapshots, search and assets keep working.
25
36
  The ui server exits 5 minutes after the last request; serve stays up.`;
26
37
 
27
- const COMMANDS: Record<string, string[]> = { ui: ["--no-open", "--json"], plugins: ["--json"], install: ["--list", "--force", "--json"], serve: ["--json"] };
28
- const VALUED: Record<string, string[]> = { serve: ["--host", "--port", "--origin"] };
38
+ const COMMANDS: Record<string, string[]> = { ui: ["--no-open", "--json"], plugins: ["--json"], install: ["--list", "--force", "--json"], serve: ["--json", "--read-only"], token: ["--force"], setup: ["--apply", "--read-only"], build: ["--static"] };
39
+ const VALUED: Record<string, string[]> = { serve: ["--host", "--port", "--origin"], token: ["--file"], setup: ["--host", "--port", "--origin", "--label"], build: ["--nodes", "--out"] };
40
+ const SUBCOMMANDS: Record<string, string> = { token: "new", setup: "launchd" };
29
41
 
30
42
  function parse(argv: string[]): { command: string; flags: Set<string>; args: string[]; values: Record<string, string[]> } | string {
31
43
  const [command = "help", ...all] = argv;
@@ -43,7 +55,10 @@ function parse(argv: string[]): { command: string; flags: Set<string>; args: str
43
55
  (values[name] ??= []).push(value);
44
56
  } else rest.push(arg);
45
57
  }
46
- for (const name of ["--host", "--port"]) if ((values[name]?.length ?? 0) > 1) return `${name} may be given once`;
58
+ for (const name of ["--host", "--port", "--file", "--label", "--nodes", "--out"]) if ((values[name]?.length ?? 0) > 1) return `${name} may be given once`;
59
+ const sub = SUBCOMMANDS[command];
60
+ if (sub && rest[0] !== sub) return `${command} takes the subcommand ${sub}: acon ${command} ${sub}`;
61
+ if (sub) rest.shift();
47
62
  const args = command === "install" ? rest.filter((arg) => !arg.startsWith("--")) : [];
48
63
  const unknown = rest.find((arg) => !args.includes(arg) && !allowed.includes(arg));
49
64
  if (unknown) return `unknown option for ${command}: ${unknown}`;
@@ -51,6 +66,7 @@ function parse(argv: string[]): { command: string; flags: Set<string>; args: str
51
66
  if (command === "install") {
52
67
  if (flags.has("--list") ? args.length > 0 || flags.has("--force") : args.length !== 1) return "install takes one package name, https URL or directory, or --list";
53
68
  }
69
+ if (command === "build" && (!flags.has("--static") || !values["--nodes"])) return "build needs --static and --nodes id=https://origin; run acon build --static --nodes desk=https://node.example.com";
54
70
  return { command, flags, args, values };
55
71
  }
56
72
 
@@ -89,17 +105,92 @@ async function install(home: string, source: string | undefined, flags: Set<stri
89
105
  }
90
106
  }
91
107
 
108
+ const serveArgs = (values: Record<string, string[]>, flags: Set<string>): ServeArgs => ({ host: values["--host"]?.[0], port: values["--port"]?.[0], origins: values["--origin"] ?? [], readOnly: flags.has("--read-only") });
109
+
110
+ function token(home: string, file: string | undefined, force: boolean): number {
111
+ const path = file ? resolve(file) : tokenPath(home);
112
+ try { newTokenFile(path, force); }
113
+ catch (error) {
114
+ const code = (error as NodeJS.ErrnoException).code;
115
+ console.error(`acon: ${code === "EEXIST" ? `${path} already exists; it was not changed (use --force to replace it)` : `could not write ${path} (${code ?? (error as Error).message})`}`);
116
+ return 1;
117
+ }
118
+ console.log(path);
119
+ return 0;
120
+ }
121
+
122
+ function setupLaunchd(home: string, values: Record<string, string[]>, flags: Set<string>): number {
123
+ const label = values["--label"]?.[0] ?? "acon.serve";
124
+ if (!LABEL.test(label)) return fail(`--label ${label} must be letters, digits, dots and hyphens`);
125
+ // The same refusals as acon serve, checked now rather than in a crash-looping agent.
126
+ const config = serveConfig(serveArgs(values, flags), process.env);
127
+ if (typeof config === "string") return fail(config);
128
+ const env: Record<string, string> = { AGENTHUB_HOME: resolve(home), PATH: "/usr/bin:/bin:/usr/sbin:/sbin" };
129
+ for (const name of LAUNCHD_ENV) {
130
+ const value = process.env[name];
131
+ if (value) env[name] = name === "ACON_TOKEN_FILE" ? resolve(value) : value;
132
+ }
133
+ const args = [
134
+ ...(values["--host"] ? ["--host", values["--host"][0]!] : []), ...(values["--port"] ? ["--port", values["--port"][0]!] : []),
135
+ ...(values["--origin"] ?? []).flatMap((o) => ["--origin", o]), ...(flags.has("--read-only") ? ["--read-only"] : []),
136
+ ];
137
+ const bun = process.execPath;
138
+ if (!isAbsolute(bun)) return fail(`the bun path ${bun} is not absolute`);
139
+ const plist = launchdPlist({ label, bun, script: launchScript(resolve(home)), args, env, logDir: join(resolve(home), "console", "logs") });
140
+ if (!flags.has("--apply")) { process.stdout.write(plist); return 0; }
141
+ if (process.platform !== "darwin") return fail("--apply needs macOS (launchctl)");
142
+ const uid = process.getuid?.();
143
+ if (uid === undefined) return fail("could not read the user id");
144
+ mkdirSync(join(resolve(home), "console", "logs"), { recursive: true, mode: 0o700 });
145
+ const dir = join(homedir(), "Library", "LaunchAgents");
146
+ mkdirSync(dir, { recursive: true });
147
+ const file = join(dir, `${label}.plist`);
148
+ writeFileSync(file, plist, { mode: 0o644 });
149
+ const run = (...argv: string[]) => Bun.spawnSync(["launchctl", ...argv], { stdout: "pipe", stderr: "pipe" });
150
+ run("bootout", `gui/${uid}/${label}`); // not loaded yet is fine
151
+ const boot = run("bootstrap", `gui/${uid}`, file);
152
+ if (boot.exitCode !== 0) return fail(`launchctl bootstrap failed (${boot.exitCode}): ${boot.stderr.toString().trim()}`);
153
+ const print = run("print", `gui/${uid}/${label}`);
154
+ console.log(`acon: wrote ${file} and loaded gui/${uid}/${label}`);
155
+ process.stdout.write(print.stdout.toString());
156
+ return print.exitCode === 0 ? 0 : 1;
157
+ }
158
+
159
+ /**
160
+ * Bun resolves symlinks in import.meta.dir, so a run through the versioned install's `console/current` link reports the
161
+ * release directory. Pin the link itself in that case, so the agent follows `install-release.ts` to the next release.
162
+ */
163
+ export function launchScript(home: string, packageRoot = resolve(import.meta.dir, "..")): string {
164
+ const current = join(home, "console", "current");
165
+ try { if (realpathSync(current) === realpathSync(packageRoot)) return join(current, "bin", "acon.js"); } catch { /* no versioned install */ }
166
+ return join(packageRoot, "bin", "acon.js");
167
+ }
168
+
169
+ const fail = (message: string): number => { console.error(`acon: ${message}`); return 2; };
170
+
92
171
  export async function main(argv: string[]): Promise<number> {
93
172
  const parsed = parse(argv);
94
173
  if (typeof parsed === "string") { console.error(`acon: ${parsed}\n\n${USAGE}`); return 2; }
95
174
  const { command, flags, args, values } = parsed;
96
175
  if (["--version", "-v", "version"].includes(command)) { console.log(pkg.version); return 0; }
97
176
  if (["help", "--help", "-h"].includes(command)) { console.log(USAGE); return 0; }
177
+ if (command === "build") {
178
+ try {
179
+ const result = await buildStatic(values["--nodes"]![0]!, values["--out"]?.[0]);
180
+ console.log(`acon: static shell written to ${result.out}; serve that directory over https and allow its origin on each acon serve node`);
181
+ return 0;
182
+ } catch (error) {
183
+ console.error(`acon: static build failed: ${(error as Error).message}; check --nodes and choose an unused --out directory`);
184
+ return 2;
185
+ }
186
+ }
98
187
  const home = familyHome();
99
188
  if (command === "install") return await install(home, args[0], flags);
189
+ if (command === "token") return token(home, values["--file"]?.[0], flags.has("--force"));
190
+ if (command === "setup") return setupLaunchd(home, values, flags);
100
191
  let serve: ServeOptions | undefined;
101
192
  if (command === "serve") {
102
- const config = serveConfig({ host: values["--host"]?.[0], port: values["--port"]?.[0], origins: values["--origin"] ?? [] }, process.env);
193
+ const config = serveConfig(serveArgs(values, flags), process.env);
103
194
  if (typeof config === "string") { console.error(`acon: ${config}`); return 2; }
104
195
  serve = config;
105
196
  }
@@ -134,10 +225,10 @@ export async function main(argv: string[]): Promise<number> {
134
225
  process.on("SIGINT", shutdown);
135
226
  process.on("SIGTERM", shutdown);
136
227
  if (serve) {
137
- const info = { origin: server.origin, port: server.port, mode: serve.mode, origins: serve.origins, pid: process.pid, plugins: plugins.map(summary) };
228
+ const info = { origin: server.origin, port: server.port, mode: serve.mode, readOnly: Boolean(serve.readOnly), origins: serve.origins, pid: process.pid, plugins: plugins.map(summary) };
138
229
  if (flags.has("--json")) console.log(JSON.stringify(info));
139
230
  else {
140
- console.log(`acon: serving on ${serve.hostname}:${server.port} (${serve.mode === "access" ? "Cloudflare Access" : serve.mode === "bearer" ? "bearer token" : "loopback, no auth"}); allowed origins: ${serve.origins.join(", ")}`);
231
+ console.log(`acon: serving on ${serve.hostname}:${server.port} (${serve.mode === "access" ? "Cloudflare Access" : serve.mode === "bearer" ? "bearer token" : "loopback, no auth"})${serve.readOnly ? ", read-only" : ""}; allowed origins: ${serve.origins.join(", ")}`);
141
232
  if (scan.error) log(scan.error);
142
233
  for (const p of plugins) if (p.status !== "ready") log(`${p.tool} not loaded (${p.status}): ${p.reason}`);
143
234
  }
package/src/serve.ts CHANGED
@@ -10,12 +10,14 @@ export type Authenticate = (req: Request) => Promise<string | null>;
10
10
  export interface ServeOptions {
11
11
  hostname: string;
12
12
  port: number;
13
- /** Exact browser origins allowed for POST; their hosts (plus the bind address) are the allowed Host values. */
13
+ /** Exact browser origins allowed for POST and credentialed CORS; include the node public origin too, since their hosts (plus the bind address) are the allowed Host values. */
14
14
  origins: string[];
15
15
  hosts: string[];
16
16
  /** Returns the verified principal (the session key confirm tokens bind to) or null for 401. */
17
17
  authenticate: Authenticate;
18
18
  mode: "access" | "bearer" | "loopback";
19
+ /** #45: every plugin action is refused without calling the plugin; snapshots, search and assets are unchanged. */
20
+ readOnly?: boolean;
19
21
  }
20
22
 
21
23
  const SKEW_S = 60;
@@ -131,10 +133,17 @@ export function bearerVerifier(file: string): ((header: string | null) => boolea
131
133
  };
132
134
  }
133
135
 
134
- export interface ServeArgs { host?: string; port?: string; origins: string[] }
136
+ export interface ServeArgs { host?: string; port?: string; origins: string[]; readOnly?: boolean }
135
137
 
136
138
  /** Builds the serve options from flags and the environment; a string is a refusal to print. */
137
139
  export function serveConfig(args: ServeArgs, env: Record<string, string | undefined>, certsUrl?: string): ServeOptions | string {
140
+ const raw = env.ACON_READ_ONLY;
141
+ if (raw !== undefined && raw !== "" && raw !== "0" && raw !== "1") return "ACON_READ_ONLY must be 1 (read-only) or 0";
142
+ const config = authConfig(args, env, certsUrl);
143
+ return typeof config === "string" || !(args.readOnly || raw === "1") ? config : { ...config, readOnly: true };
144
+ }
145
+
146
+ function authConfig(args: ServeArgs, env: Record<string, string | undefined>, certsUrl?: string): ServeOptions | string {
138
147
  const hostname = args.host ?? "127.0.0.1";
139
148
  if (!/^[A-Za-z0-9.:[\]-]+$/.test(hostname)) return `--host ${hostname} is not a host name or address`;
140
149
  const port = Number(args.port ?? (env.ACON_PORT || "8787"));
package/src/server.ts CHANGED
@@ -128,6 +128,7 @@ export function startServer(options: ServerOptions) {
128
128
  };
129
129
  const tickets = new Map<string, { expires: number }>();
130
130
  const sessions = new Map<string, { expires: number }>();
131
+ const resources = new Map<string, { expires: number; principal: string; origin: string }>();
131
132
  const confirms = new Map<string, { expires: number; session: string; tool: string; input: string; fingerprint: string }>();
132
133
  const errors: ConsoleError[] = [];
133
134
  let lastSeen = now();
@@ -208,6 +209,8 @@ export function startServer(options: ServerOptions) {
208
209
  }
209
210
 
210
211
  async function action(entry: PluginEntry, session: string, input: Record<string, unknown>, meta: ActionMeta) {
212
+ // #45: first, before any token lookup, so a replayed confirm token is neither honoured nor consumed.
213
+ if (serve?.readOnly) return reject(403, "read-only node", { readOnly: true });
211
214
  const name = input.action;
212
215
  if (typeof name !== "string" || !ACTION_ID.test(name)) return reject(400, "invalid action");
213
216
  const declared = entry.manifest!.capabilities?.actions;
@@ -255,15 +258,10 @@ export function startServer(options: ServerOptions) {
255
258
  return json(result);
256
259
  }
257
260
 
258
- const server = Bun.serve({
259
- hostname: serve ? serve.hostname.replace(/^\[(.*)\]$/, "$1") : "127.0.0.1",
260
- port: serve?.port ?? 0,
261
- maxRequestBodySize: 32 * 1024,
262
- idleTimeout: 60,
263
- async fetch(req): Promise<Response> {
261
+ async function handle(req: Request): Promise<Response> {
264
262
  const url = new URL(req.url);
265
263
  const path = url.pathname;
266
- // H1 health probe: no auth, no body, before every other check so container and tunnel probes work.
264
+ // H1 health probe stays public, including the opaque reachability probe used by H2.
267
265
  if (serve && path === "/healthz" && (req.method === "GET" || req.method === "HEAD")) return new Response(null, { status: 204, headers: HEADERS });
268
266
  lastSeen = now();
269
267
  if (!serve) armIdle();
@@ -271,8 +269,18 @@ export function startServer(options: ServerOptions) {
271
269
  const requestOrigin = req.headers.get("origin");
272
270
  const originOk = requestOrigin !== null && (serve ? serve.origins.includes(requestOrigin) : requestOrigin === origin);
273
271
  if (requestOrigin !== null && !originOk) return reject(403, "forbidden origin");
274
- // Under serve the verified identity is the session: every route needs it, the shell files too.
275
- const principal = serve ? await serve.authenticate(req) : null;
272
+ if (serve && req.method === "OPTIONS") {
273
+ const method = req.headers.get("access-control-request-method");
274
+ const headers = (req.headers.get("access-control-request-headers") ?? "").toLowerCase().split(",").map((h) => h.trim()).filter(Boolean);
275
+ if (!originOk || !["GET", "POST"].includes(method ?? "") || headers.some((h) => !["authorization", "content-type"].includes(h))) return reject(403, "CORS preflight refused; use a configured --origin and GET or POST with authorization/content-type only");
276
+ return new Response(null, { status: 204, headers: { ...HEADERS, "Access-Control-Allow-Methods": "GET, POST", "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Max-Age": "300" } });
277
+ }
278
+ prune(resources);
279
+ const grant = resources.get(url.searchParams.get("acon_resource") ?? "");
280
+ const resourcePath = /^\/(?:p\/[^/]+\/(?:panel\.js|panel\.css|asset)|pets\/[^/]+\/sheet|assets\/[^/]+\/[^/]+|models\/[^/]+\/[^/]+)$/.test(path);
281
+ const resourceGrant = serve && req.method === "GET" && resourcePath && grant && (!requestOrigin || requestOrigin === grant.origin) && serve.origins.includes(grant.origin) ? grant : undefined;
282
+ // A resource grant is GET-only. It cannot authenticate JSON, action or session requests.
283
+ const principal = serve ? (resourceGrant?.principal ?? await serve.authenticate(req)) : null;
276
284
  if (serve && principal === null) return reject(401, "authentication required");
277
285
  prune(tickets);
278
286
  prune(sessions);
@@ -301,7 +309,7 @@ export function startServer(options: ServerOptions) {
301
309
  if (!live) return reject(401, "session expired or missing; run acon ui");
302
310
  // Same-origin fetches and frames only; the SameSite=Strict cookie already keeps cross-site requests out.
303
311
  const site = req.headers.get("sec-fetch-site");
304
- if (site !== null && site !== "same-origin" && site !== "none") return reject(403, "cross-site asset request");
312
+ if (!resourceGrant && !(serve && originOk) && site !== null && site !== "same-origin" && site !== "none") return reject(403, "cross-site asset request");
305
313
  const entry = pluginEntry();
306
314
  if (!entry) return unknownTool();
307
315
  return pluginAsset(entry, url.searchParams.get("id"));
@@ -352,7 +360,16 @@ export function startServer(options: ServerOptions) {
352
360
  // Every accepted body has a canonical form; this refuses numbers like 1e400 that would collide with null.
353
361
  try { canonical(input); } catch { return reject(400, "numbers must be finite"); }
354
362
  if (path === "/session") {
355
- if (serve) return json({ ok: true, mode: "serve" });
363
+ if (serve) {
364
+ if (input.resources === true) {
365
+ if (!originOk) return reject(403, "resource grants need a configured browser --origin; add the static shell origin to acon serve");
366
+ cap(resources);
367
+ const resourceToken = secret();
368
+ resources.set(resourceToken, { expires: now() + SESSION_MS, principal: principal!, origin: requestOrigin! });
369
+ return json({ ok: true, mode: "serve", resourceToken, expiresIn: SESSION_MS });
370
+ }
371
+ return json({ ok: true, mode: "serve" });
372
+ }
356
373
  const ticket = typeof input.ticket === "string" ? input.ticket : "";
357
374
  if (!tickets.delete(ticket)) return reject(401, "this link was already used or has expired; run acon ui");
358
375
  cap(sessions);
@@ -363,15 +380,30 @@ export function startServer(options: ServerOptions) {
363
380
  if (path === "/ping") return json({ ok: true });
364
381
  if (path === "/tools") {
365
382
  await refresh();
366
- return json({ ok: true, node: { id: "local", version: options.version, startedAt, pluginsDir: options.pluginsDir, pluginsError: options.pluginsError }, tools: toolList(), pets: pets.map(({ id, name, description }) => ({ id, name, ...(description ? { description } : {}) })), assetPacks: assetPacks.map(({ id, name, version, tile, images, models }) => ({ id, name, version, tile, images: Object.keys(images), ...(models ? { models: Object.keys(models) } : {}) })), errors });
383
+ return json({ ok: true, ...(serve?.readOnly ? { readOnly: true } : {}), node: { id: "local", version: options.version, startedAt, pluginsDir: options.pluginsDir, pluginsError: options.pluginsError }, tools: toolList(), pets: pets.map(({ id, name, description }) => ({ id, name, ...(description ? { description } : {}) })), assetPacks: assetPacks.map(({ id, name, version, tile, images, models }) => ({ id, name, version, tile, images: Object.keys(images), ...(models ? { models: Object.keys(models) } : {}) })), errors });
367
384
  }
368
385
  const entry = pluginEntry();
369
386
  if (!entry) return unknownTool();
370
387
  return plugin![2] === "snapshot" ? snapshot(entry, input) : action(entry, cookie!, input, { identity: serve ? principal! : "local", node: "local", via: "console" });
371
- },
372
- error(error) {
373
- log(`request failed: ${error.message}`);
374
- return reject(500, "console request failed");
388
+ }
389
+ const server = Bun.serve({
390
+ hostname: serve ? serve.hostname.replace(/^\[(.*)\]$/, "$1") : "127.0.0.1",
391
+ port: serve?.port ?? 0,
392
+ maxRequestBodySize: 32 * 1024,
393
+ idleTimeout: 60,
394
+ async fetch(req): Promise<Response> {
395
+ let response: Response;
396
+ try { response = await handle(req); }
397
+ catch (error) { log(`request failed: ${(error as Error).message}`); response = reject(500, "console request failed"); }
398
+ if (serve) {
399
+ response.headers.set("Vary", "Origin");
400
+ const requestOrigin = req.headers.get("origin");
401
+ if (requestOrigin && serve.origins.includes(requestOrigin)) {
402
+ response.headers.set("Access-Control-Allow-Origin", requestOrigin);
403
+ response.headers.set("Access-Control-Allow-Credentials", "true");
404
+ }
405
+ }
406
+ return response;
375
407
  },
376
408
  });
377
409
  const host = `127.0.0.1:${server.port}`;
@@ -393,6 +425,7 @@ export function startServer(options: ServerOptions) {
393
425
  tickets.clear();
394
426
  sessions.clear();
395
427
  confirms.clear();
428
+ resources.clear();
396
429
  server.stop(true);
397
430
  // Each provider gets 5 s to close; a hanging one never keeps the process alive.
398
431
  await Promise.all([...refs.values()].map((entry) => withTimeout(Promise.resolve().then(() => entry.provider!.close?.()), 5_000, `${entry.tool} close`)
package/src/setup.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, fchmodSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * #45 follow-ups of `acon serve` (H1): a bearer token file and a macOS LaunchAgent.
7
+ * Neither prints or embeds a token value.
8
+ */
9
+
10
+ export const tokenPath = (home: string): string => join(home, "console", "token");
11
+
12
+ /**
13
+ * Writes 32 random bytes as base64url, mode 0600. Without `force` the file is created with O_EXCL, so an existing
14
+ * file (or a symlink) is refused and left as it was; with `force` a new file replaces it by rename.
15
+ */
16
+ export function newTokenFile(path: string, force: boolean): void {
17
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
18
+ const token = `${randomBytes(32).toString("base64url")}\n`;
19
+ const target = force ? `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp` : path;
20
+ const fd = openSync(target, "wx", 0o600);
21
+ try {
22
+ fchmodSync(fd, 0o600); // independent of the umask
23
+ writeSync(fd, token);
24
+ } catch (error) {
25
+ closeSync(fd);
26
+ rmSync(target, { force: true });
27
+ throw error;
28
+ }
29
+ closeSync(fd);
30
+ if (force) {
31
+ try { renameSync(target, path); } catch (error) { rmSync(target, { force: true }); throw error; }
32
+ }
33
+ }
34
+
35
+ export const LABEL = /^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/;
36
+ /** Environment names `setup launchd` copies into the plist. ACON_TOKEN_FILE is a path; no token value is ever copied. */
37
+ export const LAUNCHD_ENV = ["ACON_TOKEN_FILE", "ACON_ACCESS_TEAM", "ACON_ACCESS_AUD", "ACON_ORIGINS", "ACON_PORT", "ACON_READ_ONLY"] as const;
38
+
39
+ const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
40
+ const str = (s: string) => `<string>${xml(s)}</string>`;
41
+
42
+ export interface LaunchdOptions { label: string; bun: string; script: string; args: string[]; env: Record<string, string>; logDir: string }
43
+
44
+ /** A LaunchAgent that keeps `bun bin/acon.js serve ...` running, with logs under `logDir`. */
45
+ export function launchdPlist(o: LaunchdOptions): string {
46
+ const env = Object.entries(o.env).map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t${str(v)}`).join("\n");
47
+ return `<?xml version="1.0" encoding="UTF-8"?>
48
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
49
+ <plist version="1.0">
50
+ <dict>
51
+ \t<key>Label</key>
52
+ \t${str(o.label)}
53
+ \t<key>ProgramArguments</key>
54
+ \t<array>
55
+ ${[o.bun, o.script, "serve", ...o.args].map((a) => `\t\t${str(a)}`).join("\n")}
56
+ \t</array>
57
+ \t<key>EnvironmentVariables</key>
58
+ \t<dict>
59
+ ${env}
60
+ \t</dict>
61
+ \t<key>RunAtLoad</key>
62
+ \t<true/>
63
+ \t<key>KeepAlive</key>
64
+ \t<true/>
65
+ \t<key>ProcessType</key>
66
+ \t<string>Background</string>
67
+ \t<key>StandardOutPath</key>
68
+ \t${str(join(o.logDir, `${o.label}.out.log`))}
69
+ \t<key>StandardErrorPath</key>
70
+ \t${str(join(o.logDir, `${o.label}.err.log`))}
71
+ </dict>
72
+ </plist>
73
+ `;
74
+ }
package/src/static.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { isLoopback } from "./serve.ts";
4
+ import { FRAME_CSP } from "./server.ts";
5
+
6
+ export interface StaticNode { id: string; url: string }
7
+ export interface StaticConfig { api: 1; nodes: StaticNode[] }
8
+ export interface StaticBuild { out: string; config: StaticConfig }
9
+ const NODE_ID = /^[a-z][a-z0-9-]{0,31}$/;
10
+ const MAX_NODES = 32;
11
+
12
+ /** Build configuration contains public origins only, never credentials, paths or plugin records. */
13
+ export function parseStaticNodes(raw: string): StaticConfig {
14
+ if (!raw || raw.length > 8192) throw new Error("--nodes needs 1-32 id=https://origin entries; pass --nodes desk=https://node.example.com");
15
+ const parts = raw.split(",");
16
+ if (parts.length > MAX_NODES) throw new Error("--nodes has more than 32 nodes; split the build into at most 32 nodes");
17
+ const ids = new Set<string>();
18
+ const nodes = parts.map((part): StaticNode => {
19
+ const equal = part.indexOf("=");
20
+ const id = part.slice(0, equal);
21
+ const address = part.slice(equal + 1);
22
+ if (equal < 1 || !NODE_ID.test(id) || ids.has(id)) throw new Error("--nodes needs unique lowercase node ids (1-32 letters, digits or hyphens, starting with a letter); correct the id=origin entries");
23
+ let url: URL;
24
+ try { url = new URL(address); }
25
+ catch { throw new Error("--nodes contains an invalid URL; use an exact https origin such as desk=https://node.example.com"); }
26
+ if (address.length > 1024 || url.username || url.password || url.search || url.hash || url.origin !== address.replace(/\/$/, "") ||
27
+ (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname)))) {
28
+ throw new Error("--nodes accepts exact https origins without credentials, paths, queries or fragments (http only for loopback); correct the node URL before building");
29
+ }
30
+ ids.add(id);
31
+ return { id, url: url.origin };
32
+ });
33
+ return { api: 1, nodes };
34
+ }
35
+
36
+ export function staticCsp(config: StaticConfig): string {
37
+ const origins = [...new Set(config.nodes.map((node) => node.url))].join(" ");
38
+ return `default-src 'none'; script-src 'self' ${origins}; style-src 'self'; connect-src 'self' ${origins}; img-src 'self' data: ${origins}; base-uri 'none'; form-action 'none'; frame-src 'self'; frame-ancestors 'none'`;
39
+ }
40
+
41
+ const FILES: [string, string][] = [
42
+ ...["shell.js", "static.js", "shell.css", "space.js", "space-view.js", "space3d.js", "icon.svg", "asset-viewer.html", "asset-viewer.js", "asset-viewer.css"].map((file): [string, string] => [file, file]),
43
+ ...["three.module.js", "three.core.js", "GLTFLoader.js", "BufferGeometryUtils.js", "SkeletonUtils.js", "NOTICE", "manifest.json"].map((file): [string, string] => [`vendor/${file}`, `vendor/${file}`]),
44
+ ["visual-frame.html", "visual/frame.html"], ["visual-frame.css", "visual/frame.css"], ["visual-frame.js", "visual/frame.js"],
45
+ ["vendor/mermaid/mermaid.js", "visual/mermaid.js"], ["vendor/mermaid/NOTICE", "visual/NOTICE"], ["vendor/mermaid/manifest.json", "visual/manifest.json"],
46
+ ];
47
+ export const VIEWER_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src blob:; frame-src blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
48
+ const BASE_HEADERS = {
49
+ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
50
+ };
51
+ const headerLines = (headers: Record<string, string>) => Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`).join("\n");
52
+
53
+ /** Write a fixed, credential-free shell into a new destination, publishing only a complete staged tree. */
54
+ export async function buildStatic(raw: string, out = "dist"): Promise<StaticBuild> {
55
+ const config = parseStaticNodes(raw);
56
+ const target = resolve(out);
57
+ try { await lstat(target); throw new Error("static output already exists; choose a new --out directory or move the existing output first"); }
58
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
59
+ await mkdir(dirname(target), { recursive: true });
60
+ const parent = await realpath(dirname(target));
61
+ const destination = join(parent, target.slice(dirname(target).length + 1));
62
+ const stage = await mkdtemp(join(parent, ".acon-static-"));
63
+ try {
64
+ const csp = staticCsp(config);
65
+ // frame-ancestors is header-only; browsers report it as ignored in a meta policy.
66
+ const metaCsp = csp.replace("; frame-ancestors 'none'", "");
67
+ const shellRoot = new URL("../shell/", import.meta.url);
68
+ const source = await readFile(new URL("index.html", shellRoot), "utf8");
69
+ const index = source.replace('<html lang="en">', '<html lang="en" data-static="true">').replace('<meta charset="utf-8">', `<meta charset="utf-8">\n<meta http-equiv="Content-Security-Policy" content="${metaCsp}">`);
70
+ await writeFile(join(stage, "index.html"), index);
71
+ for (const [sourceName, destinationName] of FILES) {
72
+ const sourceUrl = new URL(sourceName, shellRoot);
73
+ const info = await lstat(sourceUrl);
74
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("a static shell source is not a regular file; reinstall the console package before building");
75
+ const file = join(stage, destinationName);
76
+ await mkdir(dirname(file), { recursive: true });
77
+ await writeFile(file, await readFile(sourceUrl));
78
+ }
79
+ await writeFile(join(stage, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
80
+ // Keep document policies disjoint: Workers joins duplicate matching header values, so a broad CSP would
81
+ // also restrict the sandboxed frame's separate measuring policy.
82
+ const documents = ["/", "/index.html"];
83
+ const frameDocuments = ["/visual/frame", "/visual/frame.html"];
84
+ const viewerDocuments = ["/asset-viewer", "/asset-viewer.html"];
85
+ const headers = `/*\n${headerLines(BASE_HEADERS)}\n\n${documents.map((path) => `${path}\n${headerLines({ "Content-Security-Policy": csp, "X-Frame-Options": "DENY" })}`).join("\n\n")}\n\n${frameDocuments.map((path) => `${path}\n${headerLines({ "Content-Security-Policy": FRAME_CSP, "X-Frame-Options": "SAMEORIGIN" })}`).join("\n\n")}\n\n${viewerDocuments.map((path) => `${path}\n${headerLines({ "Content-Security-Policy": VIEWER_CSP, "X-Frame-Options": "SAMEORIGIN" })}`).join("\n\n")}\n`;
86
+ await writeFile(join(stage, "_headers"), headers);
87
+ await writeFile(join(stage, "vercel.json"), `${JSON.stringify({ version: 2, cleanUrls: true, headers: [
88
+ { source: "/(.*)", headers: Object.entries(BASE_HEADERS).map(([key, value]) => ({ key, value })) },
89
+ ...documents.map((path) => ({ source: path, headers: [{ key: "Content-Security-Policy", value: csp }, { key: "X-Frame-Options", value: "DENY" }] })),
90
+ ...frameDocuments.map((path) => ({ source: path, headers: [{ key: "Content-Security-Policy", value: FRAME_CSP }, { key: "X-Frame-Options", value: "SAMEORIGIN" }] })),
91
+ ...viewerDocuments.map((path) => ({ source: path, headers: [{ key: "Content-Security-Policy", value: VIEWER_CSP }, { key: "X-Frame-Options", value: "SAMEORIGIN" }] })),
92
+ ] }, null, 2)}\n`);
93
+ await writeFile(join(stage, "wrangler.jsonc"), `${JSON.stringify({ name: "acon-static-shell", compatibility_date: "2026-11-04", assets: { directory: ".", html_handling: "auto-trailing-slash", not_found_handling: "none" } }, null, 2)}\n`);
94
+ await writeFile(join(stage, ".assetsignore"), "vercel.json\nwrangler.jsonc\n.assetsignore\n");
95
+ // rename refuses a nonempty directory, file or symlink that appeared while staging; no existing content is removed.
96
+ await rename(stage, destination);
97
+ return { out: destination, config };
98
+ } catch (error) {
99
+ await rm(stage, { recursive: true, force: true });
100
+ throw error;
101
+ }
102
+ }