geneseed 4.17.0 → 4.17.2
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 +20 -0
- package/SHIPPED.md +1 -1
- package/docs/extending.md +1 -1
- package/docs/guides/upgrade.md +17 -1
- package/docs/web-ui.md +17 -3
- package/harness.config.json +1 -1
- package/js/README.md +1 -1
- package/js/web/actions.mjs +61 -16
- package/js/web/api.mjs +34 -9
- package/js/web/catalog.mjs +3 -0
- package/js/web/handler.mjs +102 -39
- package/js/web/jobs.mjs +70 -13
- package/js/web/server.mjs +6 -0
- package/js/web/update-check.mjs +106 -10
- package/js/web/user-files.mjs +24 -7
- package/package.json +1 -1
- package/web/dist/assets/{Activity-DMgUkz1y.js → Activity-BvbzB-H1.js} +1 -1
- package/web/dist/assets/{ActivityDetail-0NQav1Ct.js → ActivityDetail-CSwA3UmY.js} +1 -1
- package/web/dist/assets/{Dashboard-BrtpmSc7.js → Dashboard-DAN3juLM.js} +2 -2
- package/web/dist/assets/{Docs-BgSOOv3c.js → Docs-BQ2pEV8F.js} +1 -1
- package/web/dist/assets/{GreenhouseView-BCpp3_J7.js → GreenhouseView-BoJyzeuf.js} +1 -1
- package/web/dist/assets/{Installs-CJuwb-R_.js → Installs-3fAay1NE.js} +3 -3
- package/web/dist/assets/{JournalView-DoSufCu0.js → JournalView-Ck5c_3Md.js} +1 -1
- package/web/dist/assets/{KitApply-Csb2NKOJ.js → KitApply-CsnEjQi6.js} +1 -1
- package/web/dist/assets/{Kits-Dtq7aDW7.js → Kits-Bos4VAWe.js} +1 -1
- package/web/dist/assets/{Laws-CV584w6U.js → Laws-r1vX_zU5.js} +1 -1
- package/web/dist/assets/{Library-DfXSs4so.js → Library-DmFLcNYl.js} +1 -1
- package/web/dist/assets/{Loops-_D1DPJVK.js → Loops-DhRt563u.js} +1 -1
- package/web/dist/assets/{Markdown-zaXXXHz9.js → Markdown-unTJNwOC.js} +3 -3
- package/web/dist/assets/{OperatorHudView-Dh5gW8Bt.js → OperatorHudView-DIhkL4qf.js} +1 -1
- package/web/dist/assets/Personal-BxtZyYl_.js +1 -0
- package/web/dist/assets/{Tabs-CjSgf4wz.js → Tabs-CFVpjPm7.js} +1 -1
- package/web/dist/assets/{drawable-DlKtGexn.js → drawable-D2dpNTE8.js} +1 -1
- package/web/dist/assets/index-D-cBvfU2.js +2 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/Personal-Bk6kfmgc.js +0 -1
- package/web/dist/assets/index-DbwBkTjy.js +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,26 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [4.17.2] — 2026-10-24
|
|
12
|
+
|
|
13
|
+
Patch: the web console closes its security and data-loss holes from the 2026-10-24 audit.
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
- **Restore acts only on what the diff offers.** `POST /api/actions/restore` trusted the client's file list: one call could reset `user-rules.md`, `PROFILE.md` or a memory file to its seed, or delete a file the diff never listed. It now restores only the paths `/api/diff` offers, computed on the server. It never touches the files written once and then left to you (`user-rules.md`, `PROFILE.md`, `memory/`, `notebook/`). An added file is moved to `archived-restore/<stamp>/` in the install instead of being deleted.
|
|
17
|
+
- **The web console cannot be framed or driven from another site.** Every response carries a Content-Security-Policy with `frame-ancestors 'none'` and a per-response nonce on the token script, plus `X-Frame-Options: DENY`, `nosniff` and `Referrer-Policy: no-referrer`. One request guard checks the exact Host and port, refuses a cross-site `/api` request (`Sec-Fetch-Site`, foreign `Origin`), and compares the token in constant time. A repeated `GET /api/doctor?fresh=1` reuses a run less than 3 s old. The server sets request and header timeouts.
|
|
18
|
+
- **The markdown sanitizer drops SVG animation elements, `template`, raw-text elements and `style` attributes.** These closed a `javascript:` link set through `<set>`/`<animate>`, a full-page overlay drawn with `style`, and a mutation XSS through `xmp`, `noembed`, `noframes`, `noscript` or `plaintext`.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **Settings › Maintenance no longer offers an Uninstall.** It ran `geneseed uninstall --yes` with no target, so it always removed the OpenCode global install, whichever install you were viewing. Each install keeps its own Uninstall in Installs › Hosts.
|
|
22
|
+
- **Editing a rule keeps its scope.** A rule written `scope: global` was saved back as `project` on every Edit or Graduate. A scope the editor does not list is now kept as written. A rule body with a line that reads as a rule heading (`## …`) is refused with a 400, because it could create a second rule with a forged id. So is a `|` in the scope or source.
|
|
23
|
+
|
|
24
|
+
## [4.17.1] — 2026-10-23
|
|
25
|
+
|
|
26
|
+
Patch: the web console's Update button works on a global npm install and re-checks for updates while the page is open.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **The web console's Update button follows the install.** On a clone it runs `upgrade`, as before. On a global npm install it now runs `npm install -g geneseed@latest` and then the new package's `rebuild-all`, where it used to fail with "not a git checkout". On any other copy (an npx cache, a project's `node_modules`, an unzipped folder) it shows the command to run instead of a button. A clone also says whether there is anything to pull ("3 commits to pull", "up to date") from a daily background `git fetch`. An Overview page left open asks again every hour and when its tab comes back into view, so it shows a new release within a day.
|
|
30
|
+
|
|
11
31
|
## [4.17.0] — 2026-10-23
|
|
12
32
|
|
|
13
33
|
Minor: doctrine comms 5 Mark the Ask; a version bump on main now releases itself; two Bob fixes.
|
package/SHIPPED.md
CHANGED
|
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
|
|
|
20
20
|
> **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
|
|
21
21
|
> human-readable label only. The canonical identity of an installed harness is the
|
|
22
22
|
> **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
|
|
23
|
-
> string. Current label: **4.17.
|
|
23
|
+
> string. Current label: **4.17.2**.
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
package/docs/extending.md
CHANGED
|
@@ -125,7 +125,7 @@ Notes that matter:
|
|
|
125
125
|
|
|
126
126
|
### The gates CI does not run
|
|
127
127
|
|
|
128
|
-
- **The mutation matrix itself.** `tests/mutate.mjs` —
|
|
128
|
+
- **The mutation matrix itself.** `tests/mutate.mjs` — 34 rows, each a one-string edit to a
|
|
129
129
|
product file with a written argument for which gate should catch it. The full run is **in no
|
|
130
130
|
workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
|
|
131
131
|
Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
|
package/docs/guides/upgrade.md
CHANGED
|
@@ -34,7 +34,23 @@ geneseed upgrade imperial # the same, switching the theme
|
|
|
34
34
|
|
|
35
35
|
A dirty working tree, or a folder that is not a git clone, is reported rather than force-updated. `update` and `sync-self` are aliases of `upgrade`. `bootstrap` does the same and then opens the setup wizard (`--no-setup` skips the wizard).
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
## The Update button in the web console
|
|
38
|
+
|
|
39
|
+
The **Update** button on the Overview picks its route from how this copy of Geneseed is installed:
|
|
40
|
+
|
|
41
|
+
| Install | What **Update** runs | When the button shows |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| A clone (or a worktree) | `geneseed upgrade` | Always, because `main` moves between releases. |
|
|
44
|
+
| A global npm install (`npm root -g`/geneseed) | `npm install -g geneseed@latest`, then `geneseed rebuild-all` from the new package | When npm has a newer release. |
|
|
45
|
+
| Anything else (an npx cache, a project's `node_modules`, an unzipped copy) | Nothing. The Overview shows the command to run instead. | Never. |
|
|
46
|
+
|
|
47
|
+
On a clone, the Overview also says whether there is anything to pull. Once a day the console runs `git fetch` in the background and counts the commits upstream has that the clone does not: "3 commits to pull", or "up to date". The fetch has the same timeout as `upgrade` (`GENESEED_NET_TIMEOUT`, 120 s by default). If the fetch fails, the Overview shows the release check instead, and the button stays. `GENESEED_NO_UPDATE_CHECK` turns off both the npm check and the fetch.
|
|
48
|
+
|
|
49
|
+
The console asks npm for the latest release once a day. An Overview page that stays open asks the console again every hour, and each time you come back to its tab, so it shows a new release within a day.
|
|
50
|
+
|
|
51
|
+
The npm route never uses `sudo` and never retries. If npm refuses (no permission on the global folder, a proxy, a private registry), the job log shows npm's own output. Fix the cause, or run the two commands yourself.
|
|
52
|
+
|
|
53
|
+
When the update is done, the console restarts and the page reloads. If you ran `git pull` yourself, the Overview shows a banner that the console runs old code. Click **Rebuild** on an install that is behind: the console restarts after the rebuild and reloads the page. If no install is behind, click **Restart** in the banner.
|
|
38
54
|
|
|
39
55
|
## What an upgrade keeps
|
|
40
56
|
|
package/docs/web-ui.md
CHANGED
|
@@ -44,6 +44,15 @@ so other sites can't trigger builds, deletes, or shutdown. Mutating actions (bui
|
|
|
44
44
|
update, export, restore, memory-delete, uninstall) run in the background and report back
|
|
45
45
|
as toasts (fire-and-notify). Read-only browsing needs no token.
|
|
46
46
|
|
|
47
|
+
Every request passes one guard first: the `Host` must be `127.0.0.1`, `localhost` or `[::1]`
|
|
48
|
+
**at the server's own port** (DNS rebinding, other local servers), `/api/*` refuses a
|
|
49
|
+
`Sec-Fetch-Site` of `cross-site`/`same-site` or a foreign `Origin` (so another site cannot read
|
|
50
|
+
the API either), and every write compares the token in constant time. Every response carries
|
|
51
|
+
`X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` and a
|
|
52
|
+
strict CSP (`frame-ancestors 'none'`, same-origin only, the one inline token script admitted by
|
|
53
|
+
a per-response nonce). `?fresh=1` on the doctor re-runs at most once per 3 s, and the server
|
|
54
|
+
drops a request whose headers take over 10 s or whose whole request takes over 30 s.
|
|
55
|
+
|
|
47
56
|
## The rail
|
|
48
57
|
|
|
49
58
|
The left rail mirrors the harness's own shape:
|
|
@@ -89,7 +98,9 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
89
98
|
|
|
90
99
|
- **Rules** (`#/rules`) — your own standing rules, read from and written to the
|
|
91
100
|
`user-rules.md` beside the deployed AGENT.md (the seed-once file no update ever
|
|
92
|
-
touches). List with scope (user/project
|
|
101
|
+
touches). List with scope (user/project, or any other scope written by hand, which a
|
|
102
|
+
save keeps as written) and trial chips, add/edit/retire forms (a body line starting
|
|
103
|
+
`## ` is refused: the file would read it as a new rule heading), a
|
|
93
104
|
**Graduate** action that adopts a trial rule for good, and a budget meter that turns
|
|
94
105
|
amber as the always-loaded set gets heavy. Every write carries the fingerprint of the
|
|
95
106
|
content the page last read — if an agent session edited the file meanwhile, the save
|
|
@@ -169,6 +180,9 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
169
180
|
- **Changes** (`#/diff`) — the diff between the deployed harness and the source: edited /
|
|
170
181
|
added / missing files with per-file expansion and coloured unified-diff lines. Select
|
|
171
182
|
files to **export an `improvements.md`** back-port, or **restore** them to source.
|
|
183
|
+
Restore acts only on files the diff lists, and never on your own files (`user-rules.md`,
|
|
184
|
+
`PROFILE.md`, `memory/`, `notebook/`). An added file is moved into
|
|
185
|
+
`archived-restore/<date-time>/` in the install, not deleted.
|
|
172
186
|
- **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
|
|
173
187
|
theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
|
|
174
188
|
the server's last run — warmed at start, redone after a job, an install toggle or a view
|
|
@@ -207,8 +221,8 @@ streams the output of background jobs and keeps their history across reloads.
|
|
|
207
221
|
console: Cultivar, Operator, Matrix or Atlas) with a **Dashboard layout** control (Auto follows each theme's designed Status
|
|
208
222
|
lens; Cultivar / Greenhouse / Operator / Journal force one regardless of skin), the
|
|
209
223
|
install snapshot (deployed/target/theme/version/memory store), a Maintenance card (PATH
|
|
210
|
-
link/unlink
|
|
211
|
-
**About** colophon — project + creator credits and the source link, folded in from the
|
|
224
|
+
link/unlink and Update; each install is uninstalled from its own panel in Hosts), an
|
|
225
|
+
offline package download, server controls (Stop), and the **About** colophon — project + creator credits and the source link, folded in from the
|
|
212
226
|
retired `#/about` tab (which still resolves here). Build, update, and MCP wiring live in
|
|
213
227
|
the Harness tab.
|
|
214
228
|
|
package/harness.config.json
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
"_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
|
|
3
3
|
"theme": "neutral",
|
|
4
4
|
"product": "Geneseed",
|
|
5
|
-
"version": "4.17.
|
|
5
|
+
"version": "4.17.2"
|
|
6
6
|
}
|
package/js/README.md
CHANGED
|
@@ -235,7 +235,7 @@ the deployed harness as JSON.
|
|
|
235
235
|
|---|---|
|
|
236
236
|
| `server.mjs` | `serve()`/`cmdWeb`: UI-build precheck, socket bind, token mint, daemon record wiring |
|
|
237
237
|
| `routes.mjs` | The route tables — `STATE_ROUTES`, `PREFIX_ROUTES`, `POST_ROUTES` (+ its 409 column) — and the inline lists |
|
|
238
|
-
| `handler.mjs` | One function per request: dispatch, gzip, Host/CSRF
|
|
238
|
+
| `handler.mjs` | One function per request: dispatch, gzip, `requestGuard` (Host+port, Origin/Sec-Fetch-Site on `/api`, constant-time CSRF token) and body cap (before the body is read), `securityHeaders` (CSP nonce, framing), static SPA fallback |
|
|
239
239
|
| `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
|
|
240
240
|
| `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
|
|
241
241
|
| `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
|
package/js/web/actions.mjs
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { spawn } from 'node:child_process';
|
|
29
29
|
import {
|
|
30
|
-
accessSync, constants, copyFileSync, mkdirSync, mkdtempSync, realpathSync,
|
|
30
|
+
accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, realpathSync,
|
|
31
|
+
renameSync, rmSync,
|
|
31
32
|
} from 'node:fs';
|
|
32
33
|
import os from 'node:os';
|
|
33
34
|
import path from 'node:path';
|
|
@@ -36,7 +37,8 @@ import { emitGlobalInto } from '../build/driver.mjs';
|
|
|
36
37
|
import {
|
|
37
38
|
ROOT, discoverNames, knownRuleIds, loadKits, PACK_ORDER,
|
|
38
39
|
} from '../build/source.mjs';
|
|
39
|
-
import { withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
40
|
+
import { diffCollect, drifted, withStdoutSwallowed } from '../inspect/diff.mjs';
|
|
41
|
+
import { SESSION_FILES } from '../hosts/hooks-context.mjs';
|
|
40
42
|
import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
|
|
41
43
|
import { installProfile, setupBuildArgs } from '../build/generate.mjs';
|
|
42
44
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
@@ -503,13 +505,30 @@ export function globalEmitHostFor(emit) {
|
|
|
503
505
|
return HOSTS.some((h) => h.host === host) ? host : 'opencode';
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
/**
|
|
509
|
+
* The session files' names and their top folders (`memory/`, `notebook/`, …): seed-once, the
|
|
510
|
+
* user's after the first build. Derived from `SESSION_FILES`, the one list of them.
|
|
511
|
+
*/
|
|
512
|
+
const SEED_FILES = new Set(SESSION_FILES.filter((r) => !r.includes('/')));
|
|
513
|
+
const SEED_DIRS = new Set(SESSION_FILES.filter((r) => r.includes('/')).map((r) => r.split('/')[0]));
|
|
514
|
+
const isSeedOnce = (rel) => SEED_FILES.has(rel) || SEED_DIRS.has(rel.split('/')[0]);
|
|
515
|
+
|
|
506
516
|
/**
|
|
507
517
|
* Restore selected drifted files from the SOURCE render.
|
|
508
518
|
*
|
|
509
519
|
* Source wins and local edits are discarded (the inverse, keeping them, is Export
|
|
510
|
-
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel
|
|
511
|
-
*
|
|
512
|
-
*
|
|
520
|
+
* improvements). Renders the expected copy exactly as `diffCollect` does, then per rel, BY ITS
|
|
521
|
+
* DIFF STATUS: edited/missing -> overwrite/create the deployed copy; added -> move it into
|
|
522
|
+
* `archived-restore/<stamp>/` inside the install; anything else -> an error and nothing touched.
|
|
523
|
+
*
|
|
524
|
+
* THE CLIENT'S LIST IS A SELECTION, NEVER AN AUTHORITY. Only a rel the drift list offers is
|
|
525
|
+
* acted on, and that list is `diffCollect` with the arguments `apiDiff` passes, so the panel and
|
|
526
|
+
* this verb cannot disagree. Trusting the POST made it a write-or-delete primitive over the whole
|
|
527
|
+
* install (audit SE-1, 2026-10): one request reset `user-rules.md` and the memory index to their
|
|
528
|
+
* stubs, a second unlinked a memory fact and an unrelated secret file. The seed-once files are
|
|
529
|
+
* refused even when a manifest claims them. An added file is ARCHIVED, never unlinked, in the
|
|
530
|
+
* `archived-<name>/<stamp>` shape `archiveStore` gives uninstall; nothing owns the archive, so
|
|
531
|
+
* the next diff does not list it.
|
|
513
532
|
*
|
|
514
533
|
* SYNCHRONOUS, AND NOT A JOB. One render, the same cost as a diff GET, and it returns a
|
|
515
534
|
* structured result rather than a job id — which is why the dispatcher answers it before it
|
|
@@ -534,7 +553,11 @@ export function apiRestore(state, files) {
|
|
|
534
553
|
const restored = [];
|
|
535
554
|
const deleted = [];
|
|
536
555
|
const errors = [];
|
|
556
|
+
let archive = null;
|
|
537
557
|
const target = resolvePath(state.target);
|
|
558
|
+
const offered = new Map(drifted(diffCollect({
|
|
559
|
+
target: state.target, theme: state.theme, emit: state.emit }).files ?? [])
|
|
560
|
+
.map((f) => [f.rel, f.status]));
|
|
538
561
|
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-restore-'));
|
|
539
562
|
try {
|
|
540
563
|
const expected = resolvePath(path.join(tmp, 'expected'));
|
|
@@ -566,26 +589,48 @@ export function apiRestore(state, files) {
|
|
|
566
589
|
const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
|
|
567
590
|
const dst = resolvePath(path.join(target, rel));
|
|
568
591
|
const src = resolvePath(path.join(expected, rel));
|
|
592
|
+
const status = offered.get(rel);
|
|
569
593
|
if (!rel || !within(dst, target) || !within(src, expected)) {
|
|
570
594
|
errors.push(`${rel}: outside the deployed tree`);
|
|
571
|
-
} else if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
595
|
+
} else if (!status || isSeedOnce(rel)) {
|
|
596
|
+
errors.push(`${rel}: not a drifted file restore may touch`);
|
|
597
|
+
} else if (status !== 'added' && !isFile(src)) {
|
|
598
|
+
errors.push(`${rel}: not in the source render`);
|
|
599
|
+
} else if (status !== 'added') {
|
|
600
|
+
// Caught per file, so one EISDIR/EBUSY cannot lose the report of the files before it.
|
|
601
|
+
try {
|
|
602
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
603
|
+
// A BYTE copy, so the restored file keeps the render's own line endings rather than
|
|
604
|
+
// the platform's (the `writeText`/`copy2` rule, one file over).
|
|
605
|
+
copyFileSync(src, dst);
|
|
606
|
+
restored.push(rel);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
errors.push(`${rel}: could not restore (${e.code || e.message})`);
|
|
609
|
+
}
|
|
580
610
|
} else {
|
|
581
|
-
|
|
611
|
+
// `archiveStore`'s local `YYYYMMDD-HHMMSS` stamp, one per call; a taken name gets `-2`, …
|
|
612
|
+
archive ??= path.join(target, 'archived-restore', new Date().toLocaleString('sv-SE')
|
|
613
|
+
.replace(/\D/g, '').replace(/^(\d{8})/, '$1-'));
|
|
614
|
+
let dest = resolvePath(path.join(archive, rel));
|
|
615
|
+
for (let n = 2; existsSync(dest); n += 1) dest = resolvePath(path.join(archive, `${rel}-${n}`));
|
|
616
|
+
if (!within(dest, archive)) {
|
|
617
|
+
errors.push(`${rel}: outside the archive`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
622
|
+
renameSync(dst, dest);
|
|
623
|
+
deleted.push(rel);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
errors.push(`${rel}: could not archive (${e.code || e.message})`);
|
|
626
|
+
}
|
|
582
627
|
}
|
|
583
628
|
}
|
|
584
629
|
} finally {
|
|
585
630
|
rmSync(tmp, { recursive: true, force: true });
|
|
586
631
|
}
|
|
587
632
|
state.refresh();
|
|
588
|
-
return { restored, deleted, errors };
|
|
633
|
+
return { restored, deleted, errors, archive };
|
|
589
634
|
}
|
|
590
635
|
|
|
591
636
|
/** (host, scope) -> the emit name that installs it. */
|
package/js/web/api.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* bind paths to these functions are `routes.mjs`. This file imports none of its route siblings,
|
|
9
9
|
* which is what keeps the old `api ↔ actions` and `api ↔ activity` cycles from coming back.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
import { CONFIG, CREDIT_RE, ROOT, SRC, THEMES, discoverNames, loadKits, makeCfg, upstreamOf } from '../build/source.mjs';
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
|
|
36
36
|
} from './catalog.mjs';
|
|
37
37
|
import { stampMinute, viewCfg } from './state.mjs';
|
|
38
|
-
import { updateStatus } from './update-check.mjs';
|
|
38
|
+
import { MANUAL_UPDATE, gitBehind, installKind, updateStatus } from './update-check.mjs';
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
|
|
@@ -80,13 +80,24 @@ export function apiSetup(state) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Short: a user who fixes a problem and clicks Re-run must not be shown the old verdict for long.
|
|
84
|
+
const DOCTOR_COOLDOWN_MS = 3_000;
|
|
85
|
+
|
|
83
86
|
/**
|
|
84
87
|
* The same engine as the `doctor` verb, grouped per check — served from the state's cached run
|
|
85
88
|
* (a lens visit or a page mount no longer pays ~3.6 s), and re-run only when `fresh` is asked
|
|
86
89
|
* for: the Doctor page's Re-run button, as `GET /api/doctor?fresh=1`.
|
|
90
|
+
*
|
|
91
|
+
* A fresh ask within `DOCTOR_COOLDOWN_MS` of the last one THIS PATH ran reuses that run, so a
|
|
92
|
+
* page looping on `?fresh=1` cannot pin the event loop. `_doctorFreshAt` is written here only: a
|
|
93
|
+
* verdict planted any other way still re-runs. No single-flight promise — `runDoctor` is
|
|
94
|
+
* synchronous, so two fresh runs cannot overlap (revisit if it ever goes async).
|
|
87
95
|
*/
|
|
88
96
|
export function apiDoctor(state, fresh = false) {
|
|
89
|
-
if (fresh) state.
|
|
97
|
+
if (fresh && Date.now() - (state._doctorFreshAt ?? 0) >= DOCTOR_COOLDOWN_MS) {
|
|
98
|
+
state.runDoctor();
|
|
99
|
+
state._doctorFreshAt = Date.now();
|
|
100
|
+
}
|
|
90
101
|
const { ok, problems, checked_at } = state.doctor;
|
|
91
102
|
return { ...state._doctorRun, ok, problems, checked_at };
|
|
92
103
|
}
|
|
@@ -499,12 +510,26 @@ export function apiLoopsActive() {
|
|
|
499
510
|
return { loops: activeLoops() };
|
|
500
511
|
}
|
|
501
512
|
|
|
502
|
-
/**
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Is a newer release on npm than the source this console serves, and how would this copy take
|
|
515
|
+
* it? See update-check.mjs.
|
|
516
|
+
*
|
|
517
|
+
* `install` picks the Update route (`git`, `npm-global`, `other`); `git` is kept beside it for a
|
|
518
|
+
* console bundle older than the field. `behind` is a clone's own "up to date": commits upstream
|
|
519
|
+
* holds that it does not, null when unknown — a clone can always pull, since `main` moves between
|
|
520
|
+
* releases and a work proxy can leave the release check unanswered. `command` is what to run by
|
|
521
|
+
* hand when the console cannot update this copy itself.
|
|
522
|
+
*/
|
|
523
|
+
export function apiUpdate(state) {
|
|
524
|
+
const install = installKind();
|
|
525
|
+
return {
|
|
526
|
+
...updateStatus(state, releaseLabel()),
|
|
527
|
+
install,
|
|
528
|
+
git: install === 'git',
|
|
529
|
+
behind: install === 'git' ? gitBehind(state, { busy: Boolean(state.jobBusy?.()) }) : null,
|
|
530
|
+
command: install === 'other' ? MANUAL_UPDATE : null,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
508
533
|
|
|
509
534
|
/** The source's release label, or null when it cannot be read. */
|
|
510
535
|
function releaseLabel() {
|
package/js/web/catalog.mjs
CHANGED
|
@@ -29,6 +29,9 @@ import { loadKits } from '../build/source.mjs';
|
|
|
29
29
|
/** A requested catalog section or item that does not exist. */
|
|
30
30
|
export class NotFound extends Error {}
|
|
31
31
|
|
|
32
|
+
/** A request body the route refuses on its content (a 400, not a 500): the caller can fix it. */
|
|
33
|
+
export class BadRequest extends Error {}
|
|
34
|
+
|
|
32
35
|
/** `??` would also swallow an explicit `null` the file declared; this does not. */
|
|
33
36
|
export const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
34
37
|
|
package/js/web/handler.mjs
CHANGED
|
@@ -6,29 +6,32 @@
|
|
|
6
6
|
* surface testable at all. Keep the arity.
|
|
7
7
|
*
|
|
8
8
|
* What it actually uses of HTTP is small and deliberate — a whole-buffer `gzipSync` over a few
|
|
9
|
-
* content types, two Cache-Control policies,
|
|
10
|
-
*
|
|
9
|
+
* content types, two Cache-Control policies, one set of security headers (CSP with a per-response
|
|
10
|
+
* nonce) on every response, and one request guard (`requestGuard`) in front of every route. No
|
|
11
|
+
* streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
|
|
11
12
|
* declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
|
|
12
13
|
*/
|
|
13
14
|
import { resolvePath } from '../hosts/hosts.mjs';
|
|
14
15
|
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
15
16
|
import { parseIntStrict, percentDecode } from '../lib/text.mjs';
|
|
16
|
-
import { preflight } from '../maintain/update.mjs';
|
|
17
17
|
import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildActionCmds, mcpTargetPaths } from './actions.mjs';
|
|
18
|
-
import { NotFound } from './catalog.mjs';
|
|
18
|
+
import { BadRequest, NotFound } from './catalog.mjs';
|
|
19
19
|
import { openUrl, requestRestart } from './daemon.mjs';
|
|
20
20
|
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
21
|
-
import { actionCommands } from './jobs.mjs';
|
|
21
|
+
import { actionCommands, updatePlan } from './jobs.mjs';
|
|
22
22
|
import { POST_ROUTES, PREFIX_ROUTES, STATE_ROUTES, readJsonBody } from './routes.mjs';
|
|
23
|
+
import { installKind } from './update-check.mjs';
|
|
23
24
|
import { isFile } from '../lib/fs.mjs';
|
|
25
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
24
26
|
import { readFileSync, statSync } from 'node:fs';
|
|
25
27
|
import { basename, dirname, extname, join, sep } from 'node:path';
|
|
26
28
|
import { gzipSync } from 'node:zlib';
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
* DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST
|
|
30
|
-
* `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
|
|
31
|
-
* prefix check above it is for.
|
|
31
|
+
* DNS-rebinding guard, HOSTNAME ONLY. `h.split(":", 1)[0]` keeps everything before the FIRST
|
|
32
|
+
* colon, so `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
|
|
33
|
+
* prefix check above it is for. The server itself no longer calls it: `requestGuard` matches the
|
|
34
|
+
* exact `name:port` instead, which this looser predicate cannot express.
|
|
32
35
|
*/
|
|
33
36
|
export function localHost(host) {
|
|
34
37
|
const h = (host || '').trim().toLowerCase();
|
|
@@ -36,6 +39,62 @@ export function localHost(host) {
|
|
|
36
39
|
return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Sent on EVERY response, merged last so no caller's `extra` can weaken one. `frame-ancestors` +
|
|
44
|
+
* `X-Frame-Options` stop a foreign page from framing the console and clickjacking its buttons; the
|
|
45
|
+
* CSP admits the one inline script (the token) by `nonce`, so an injected `<script>` cannot run
|
|
46
|
+
* and read the token. `style-src 'unsafe-inline'` because the React UI sets `style=` attributes.
|
|
47
|
+
*/
|
|
48
|
+
export function securityHeaders(nonce) {
|
|
49
|
+
return {
|
|
50
|
+
'X-Frame-Options': 'DENY',
|
|
51
|
+
'Content-Security-Policy': "frame-ancestors 'none'; default-src 'self'; "
|
|
52
|
+
+ `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; `
|
|
53
|
+
+ "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; "
|
|
54
|
+
+ "manifest-src 'self'; base-uri 'none'; form-action 'none'",
|
|
55
|
+
'X-Content-Type-Options': 'nosniff',
|
|
56
|
+
'Referrer-Policy': 'no-referrer',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* THE one request guard, answered before dispatch (and before a POST body is read). `null` =
|
|
62
|
+
* pass, else `{ code, body }`.
|
|
63
|
+
*
|
|
64
|
+
* - Host must be an allowed hostname AT THIS SOCKET'S PORT, exactly: the hostname stops DNS
|
|
65
|
+
* rebinding, the port stops another local server's page from posing as this origin.
|
|
66
|
+
* - `/api/*`: a cross-site or same-site fetch, or a foreign `Origin`, is refused even on GET —
|
|
67
|
+
* the reads expose paths and memories. A request with neither header (curl, Node) passes.
|
|
68
|
+
* - Any non-GET: the CSRF token, compared in constant time so its bytes cannot be timed out.
|
|
69
|
+
*/
|
|
70
|
+
export function requestGuard(req, token) {
|
|
71
|
+
const host = req.headers.host || '';
|
|
72
|
+
// Exact `name:port`, not `localHost` + a suffix test: that pair let `[::1]x.evil:4747` and
|
|
73
|
+
// `localhost:1:4747` through. Lower-cased the way browsers send it.
|
|
74
|
+
const want = `:${req.socket.localPort}`;
|
|
75
|
+
const h = host.trim().toLowerCase();
|
|
76
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(h.endsWith(want) ? h.slice(0, -want.length) : '')) {
|
|
77
|
+
return { code: 403, body: { error: 'forbidden host' } };
|
|
78
|
+
}
|
|
79
|
+
const path = (req.url || '').split('?', 1)[0];
|
|
80
|
+
if (path.startsWith('/api/')) {
|
|
81
|
+
const site = req.headers['sec-fetch-site'];
|
|
82
|
+
const origin = req.headers.origin;
|
|
83
|
+
if (site === 'cross-site' || site === 'same-site'
|
|
84
|
+
|| (origin !== undefined && origin !== `http://${host}`)) {
|
|
85
|
+
return { code: 403, body: { error: 'forbidden origin' } };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (req.method !== 'GET') {
|
|
89
|
+
const got = Buffer.from(String(req.headers['x-geneseed-token'] ?? ''), 'utf-8');
|
|
90
|
+
const want = Buffer.from(token, 'utf-8');
|
|
91
|
+
if (got.length !== want.length || !timingSafeEqual(got, want)) {
|
|
92
|
+
return { code: 403, body: { error: 'forbidden' } };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
39
98
|
const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
|
|
40
99
|
'application/manifest+json', 'application/javascript'];
|
|
41
100
|
const GZIP_MIN = 1024;
|
|
@@ -74,7 +133,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
74
133
|
// page stayed blank with nothing logged on either side.
|
|
75
134
|
const distReal = resolvePath(dist);
|
|
76
135
|
|
|
77
|
-
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null
|
|
136
|
+
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null,
|
|
137
|
+
nonce = randomBytes(16).toString('base64')) {
|
|
78
138
|
const headers = { ...(extra || {}) };
|
|
79
139
|
if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
|
|
80
140
|
&& (acceptEncoding || '').includes('gzip')) {
|
|
@@ -86,6 +146,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
86
146
|
'Content-Type': ctype,
|
|
87
147
|
'Content-Length': String(body.length),
|
|
88
148
|
...headers,
|
|
149
|
+
...securityHeaders(nonce),
|
|
89
150
|
});
|
|
90
151
|
res.end(body);
|
|
91
152
|
}
|
|
@@ -114,8 +175,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
114
175
|
}
|
|
115
176
|
let data = entry.data;
|
|
116
177
|
let extra = null;
|
|
178
|
+
// Fresh per response; for index.html the same value goes in the token script and its CSP.
|
|
179
|
+
const nonce = randomBytes(16).toString('base64');
|
|
117
180
|
if (basename(fp) === 'index.html') {
|
|
118
|
-
const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
181
|
+
const inject = Buffer.from(`<script nonce="${nonce}">window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
119
182
|
data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
|
|
120
183
|
extra = { 'Cache-Control': 'no-store' };
|
|
121
184
|
// The injected bytes differ from `entry.data`, so the gz cache must not serve them.
|
|
@@ -124,7 +187,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
124
187
|
extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
125
188
|
}
|
|
126
189
|
return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
|
|
127
|
-
200, extra, acceptEncoding, entry);
|
|
190
|
+
200, extra, acceptEncoding, entry, nonce);
|
|
128
191
|
}
|
|
129
192
|
|
|
130
193
|
function doGet(req, res, path, ae) {
|
|
@@ -156,19 +219,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
156
219
|
return serveStatic(res, path, ae);
|
|
157
220
|
}
|
|
158
221
|
|
|
159
|
-
/** The Host and token guards, answered BEFORE a byte of the body is read. `true` = refused. */
|
|
160
|
-
function refusePost(req, res, ae) {
|
|
161
|
-
if (!localHost(req.headers.host)) {
|
|
162
|
-
sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
if (req.headers['x-geneseed-token'] !== token) {
|
|
166
|
-
sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
222
|
function doPost(req, res, path, ae, body) {
|
|
173
223
|
if (path === '/api/shutdown') {
|
|
174
224
|
if (jm.busy) return sendJson(res, BUSY, 409, ae);
|
|
@@ -287,16 +337,26 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
287
337
|
return startJob(res, 'deploy', [plan.cmd], ae);
|
|
288
338
|
}
|
|
289
339
|
if (action === 'update') {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
340
|
+
// The route follows the install (`updatePlan`); a refusal is the same friendly 422.
|
|
341
|
+
// The daily background `git fetch` still running would hold `.git` locks the update's own
|
|
342
|
+
// fetch needs, and fail it as `fetch_failed`; it ends within GENESEED_NET_TIMEOUT.
|
|
343
|
+
if (state._behind?.inflight) {
|
|
344
|
+
return sendJson(res, { precondition: 'checking', kind: 'info',
|
|
345
|
+
message: 'The console is checking upstream right now. Try Update again in a moment.' },
|
|
346
|
+
422, ae);
|
|
294
347
|
}
|
|
348
|
+
const plan = updatePlan(installKind());
|
|
349
|
+
if (plan.refuse) return sendJson(res, plan.refuse, 422, ae);
|
|
295
350
|
// `requestRestart` declines on a foreground server, which then shows its stale banner.
|
|
296
|
-
const jid = jm.start('update',
|
|
351
|
+
const jid = jm.start('update', plan.cmds, (rc) => {
|
|
297
352
|
state.refresh();
|
|
298
|
-
if (rc === 0)
|
|
299
|
-
|
|
353
|
+
if (rc === 0) {
|
|
354
|
+
// The old answers ("3 commits to pull") are about the code just replaced.
|
|
355
|
+
delete state._behind;
|
|
356
|
+
delete state._update;
|
|
357
|
+
requestRestart(state.theme);
|
|
358
|
+
}
|
|
359
|
+
}, plan.opts);
|
|
300
360
|
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
301
361
|
return sendJson(res, { job_id: jid }, 202, ae);
|
|
302
362
|
}
|
|
@@ -336,10 +396,13 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
336
396
|
const ae = req.headers['accept-encoding'] || '';
|
|
337
397
|
const path = (req.url || '').split('?', 1)[0];
|
|
338
398
|
try {
|
|
399
|
+
// GUARD FIRST, BODY SECOND: a refused request is DISCARDED (`resume`, nothing buffered).
|
|
400
|
+
const refused = requestGuard(req, token);
|
|
401
|
+
if (refused) {
|
|
402
|
+
sendJson(res, refused.body, refused.code, ae);
|
|
403
|
+
return req.resume();
|
|
404
|
+
}
|
|
339
405
|
if (req.method === 'GET') {
|
|
340
|
-
if (!localHost(req.headers.host)) {
|
|
341
|
-
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
342
|
-
}
|
|
343
406
|
try {
|
|
344
407
|
return doGet(req, res, path, ae);
|
|
345
408
|
} catch (e) {
|
|
@@ -353,13 +416,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
353
416
|
}
|
|
354
417
|
}
|
|
355
418
|
if (req.method === 'POST') {
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
// collected. A body over `MAX_BODY` is refused on its declared length, and the socket is
|
|
359
|
-
// closed rather than drained. Otherwise the body is buffered before routing regardless
|
|
419
|
+
// The guard above needed no body, so it answered before one was read. A body over
|
|
420
|
+
// `MAX_BODY` is refused on its declared length, and the socket is closed rather than drained. Otherwise the body is buffered before routing regardless
|
|
360
421
|
// of what the route needs it for: Node's parser owns the message boundary, so unread
|
|
361
422
|
// bytes are discarded when the response ends rather than mis-parsed as the next request.
|
|
362
|
-
if (refusePost(req, res, ae)) return req.resume();
|
|
363
423
|
const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
364
424
|
if (length > MAX_BODY) {
|
|
365
425
|
res.setHeader('Connection', 'close');
|
|
@@ -377,6 +437,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
377
437
|
if (e instanceof NotFound) {
|
|
378
438
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
379
439
|
}
|
|
440
|
+
// A body the route refused on its content (a rule body with a `## ` heading) is
|
|
441
|
+
// the caller's to fix: 400, not the 500 of a server fault.
|
|
442
|
+
if (e instanceof BadRequest) return sendJson(res, { error: e.message }, 400, ae);
|
|
380
443
|
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
381
444
|
}
|
|
382
445
|
});
|