@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 +8 -0
- package/README.md +36 -3
- package/package.json +1 -1
- package/shell/asset-viewer.css +5 -0
- package/shell/asset-viewer.html +11 -0
- package/shell/asset-viewer.js +23 -0
- package/shell/index.html +1 -0
- package/shell/shell.js +135 -27
- package/shell/static.js +111 -0
- package/src/cli.ts +100 -9
- package/src/serve.ts +11 -2
- package/src/server.ts +49 -16
- package/src/setup.ts +74 -0
- package/src/static.ts +102 -0
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.
|
|
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
|
|
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
|
@@ -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) =>
|
|
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");
|
package/shell/static.js
ADDED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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)
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|