diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aa266fb2..0ab54fbd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Follow-up `shell-dot-path`: the studio shell is served from an install path holding a dot-directory +- **`GET /`, `GET /index.html` and the SPA fallback answer the shell again when the server runs from a path with a dot-directory in it** (a fix; `server/index.mjs`; no `tools/lib`, golden or output change). `sendShell` answered the unbranded shell with `res.sendFile` and the absolute path of `studio/index.html`, no `root`, and `send` (1.2.1, under Express 5) applies its default `dotfiles: 'ignore'` to every segment of an absolute path: under `~/.nvm/versions/node/…/lib/node_modules/observogram`, `~/.local/…` or a `.claude/worktrees/…` checkout every shell path answered 404 `NotFoundError` (`server/test-smoke.mjs` failed at its `GET /` there and passed from a dot-free path). It now names `index.html` relative to `root: STUDIO_DIR`, as the static mount names its files, so `send` reads only that name; the bytes and the headers (ETag, Last-Modified, `Cache-Control: public, max-age=0`, `Accept-Ranges: bytes`) are the ones it answered before. The branded shell is a rendering held in memory and never went through `send`; the static mount and `/lib` pass a root already, and no other `res.sendFile` or `send` in `server/` names a file by its absolute path. One more test in `server/test-brand-shell.mjs` (Suite total 1486 → 1487): DOT PATH boots a child from `/.install/observogram`, a link to the checkout (a junction on Windows), with `--preserve-symlinks`, so the server's own paths hold the dot segment on any checkout (the test first checks that the server module resolves through the link); every shell path answers the shell byte for byte with the static file headers, and `/app.css` and `/lib/brand.mjs` are served. At the parent commit it fails (404). `serve()` in `server/fixtures/serve-child.mjs` takes `index`, the server module to boot by path; DEFAULT and DOT PATH share the shell-file assertions. + ### Follow-up `journey-scope-through-handle`: on Windows a journey's org scope reads a file id through a handle - **On Node 22.16.x a studio-run journey's org scope places a local path in a workspace named through a share again** (a fix; `tools/lib/journey.mjs`; Linux and macOS compare exactly as before). While the workspace is named through a share this host serves (`OBSERVOGRAM_WORKSPACE=\\myhost\ws`, or a drive mapped to `\\localhost\C$\ws`), `crawlScopeRefusal` places a path on a local drive outside it by its nearest directory with the workspace's file id, the volume serial's low 32 bits and the file's id, which it read with `statSync`. libuv answers a stat of a local path by its name (`GetFileInformationByName`, Windows 11 24H2 and Server 2025), and 1.49.0 to 1.50.x (Node 22.16.x ships 1.49.2) report there the upper half of the file's 128-bit id as the device, 0 on NTFS, while the workspace through its share is opened and reports the serial: no local directory was the workspace's own, so acme's journey read beta's `C:\ws\orgs\beta\packs\b1.pack.yaml` as a file outside the workspace, nobody's part, and loaded it (`server/store/paths.mjs` had the same read, fixed in `paths-volume-through-handle`). On Windows a file id is now read through a handle opened on the path (`openSync`, `fstatSync`, `closeSync`; libuv opens a directory too), which reports the serial on every libuv, every handle closed before the comparison goes on; a path that does not open is passed over as one that did not stat was. `crawlScopeRefusal` and `crawlScopeTest` take `openSync`, `fstatSync` and `closeSync` in their host in place of `statSync`. One more test in `tools/test-journey-scope.mjs` (Suite total 1485 → 1486): on libuv 1.51.0 and 1.49.2, with the workspace named `\\myhost\ws` or `Z:\` (mapped to `\\localhost\C$\ws`), beta's file in every local spelling refused to acme, acme's own and `C:\repo\…` read, and every handle closed; at the parent commit it fails on 1.49.2 alone (beta's file read). Every scope the suite builds injects the fixture's handles. diff --git a/docs/UPDATE_JOURNEY.md b/docs/UPDATE_JOURNEY.md index 6d05a1f9..2e57ada2 100644 --- a/docs/UPDATE_JOURNEY.md +++ b/docs/UPDATE_JOURNEY.md @@ -2014,3 +2014,18 @@ serial on every libuv; a file or a link under the root lies on its directory's volume. A volume mounted in a folder under the root, or on the root, is refused as before. Tests: 1518 → 1519 (one more in `server/test-store-ops.mjs`). + +### Follow-up: the studio shell under a dot-directory + +The unbranded shell was answered by `res.sendFile` with the absolute path of +`studio/index.html`, and `send` applies its default dotfiles rule (ignore, a +404) to every segment of an absolute path: run from an nvm global install +(`~/.nvm/versions/node/…/lib/node_modules/observogram`), from `~/.local/…` or +from a `.claude/worktrees` checkout, the studio answered 404 at `/`, +`/index.html` and every SPA path, while the assets the static mount serves +under its root loaded. The shell is now named relative to the studio +directory, as the mount names its files, so only `index.html` is read for a +dot segment; the bytes and the headers are the ones it answered before. The +test boots a child from a link under `/.install/` with +`--preserve-symlinks`, so the server's own paths hold the dot segment on any +checkout. Tests: 1486 → 1487 (one more in `server/test-brand-shell.mjs`). diff --git a/server/fixtures/serve-child.mjs b/server/fixtures/serve-child.mjs index 8141fe62..83b84e7e 100644 --- a/server/fixtures/serve-child.mjs +++ b/server/fixtures/serve-child.mjs @@ -77,10 +77,11 @@ export function dropInheritedOrgVars(env = process.env) { // The import is inside the try: initAuth() runs at import and refuses a // malformed identity contract there (the reverse-proxy mode's ACK, OIDC // beside it), so that refusal prints REFUSED too — its message raw, no -// nothingMoved (nothing ran that could write). -export const BOOT_CODE = ` +// nothingMoved (nothing ran that could write). bootCode(url) imports the +// server module at that URL; BOOT_CODE, the one beside this fixture. +const bootCode = (indexUrl) => ` try { - const { start } = await import(${JSON.stringify(INDEX_URL)}); + const { start } = await import(${JSON.stringify(indexUrl)}); const srv = await start({ port: Number(process.env.BOOT_PORT || 0), host: process.env.BOOT_HOST, silent: process.env.BOOT_SILENT === '1', legacyLivePack: process.env.BOOT_LEGACY_LIVE_PACK || undefined }); process.stdout.write('LISTENING ' + srv.address().port + '\\n'); if (process.env.BOOT_KEEP !== '1') { srv.close(); process.exit(0); } @@ -89,6 +90,7 @@ try { process.exit(3); } `; +export const BOOT_CODE = bootCode(INDEX_URL); export function parseBoot(stdout) { const listening = /^LISTENING (\d+)$/m.exec(stdout); @@ -148,9 +150,11 @@ export function bootAsync(ws, opts) { // stops — the server has no access log, so a suite proving a value never // reached the server's output scans this. `verbose: true` boots without // BOOT_SILENT, so the start lines are in it too. `execArgv`: node flags -// before the script (the permission model's, say). -export async function serve(ws, { host = '127.0.0.1', env = {}, verbose = false, execArgv = [] } = {}) { - const proc = spawn(process.execPath, [...execArgv, '--input-type=module', '-e', BOOT_CODE], { +// before the script (the permission model's, say). `index`: the server +// module to boot, by path, when not the one beside this fixture (the repo +// reached through a link, say). +export async function serve(ws, { host = '127.0.0.1', env = {}, verbose = false, execArgv = [], index = null } = {}) { + const proc = spawn(process.execPath, [...execArgv, '--input-type=module', '-e', index ? bootCode(pathToFileURL(index).href) : BOOT_CODE], { env: childEnv(ws, { ...env, BOOT_HOST: host, BOOT_SILENT: verbose ? '0' : '1', BOOT_KEEP: '1' }), stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; diff --git a/server/index.mjs b/server/index.mjs index 37bf7eee..7f086aa3 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -2307,6 +2307,11 @@ app.use('/lib', express.static(resolve(ROOT, 'tools/lib'), { // brandShellHtml's rendering, held in memory, no-cache. The static mount // serves no index and tries no extension, so `/index` cannot reach the // on-disk shell around this handler (it falls to the SPA route instead). +// The file is named relative to `root`, as the static mount names its own: +// `send`'s dotfiles rule ('ignore', a 404) reads every segment of the path it +// is given, so an absolute one refused the shell wherever the install path +// holds a dot-directory (~/.nvm/…/node_modules/observogram, ~/.local/…, a +// .claude/worktrees checkout); with a root it reads only 'index.html'. const SHELL_FILE = resolve(STUDIO_DIR, 'index.html'); let brandedShell = null; // The brand start() loaded (DEFAULT_BRAND until then): the audit report's @@ -2314,7 +2319,7 @@ let brandedShell = null; let brand = DEFAULT_BRAND; function sendShell(req, res) { if (brandedShell !== null) return res.type('html').set('Cache-Control', 'no-cache').send(brandedShell); - res.sendFile(SHELL_FILE); + res.sendFile('index.html', { root: STUDIO_DIR }); } app.get('/', authorize('GET /'), sendShell); app.get('/index.html', authorize('GET /index.html'), sendShell); diff --git a/server/test-brand-shell.mjs b/server/test-brand-shell.mjs index 2b8e5a1b..447b6d93 100644 --- a/server/test-brand-shell.mjs +++ b/server/test-brand-shell.mjs @@ -8,7 +8,11 @@ // byte with the static file headers; GET /index (the old // `extensions: ['html']` path) answers the same shell, not a // static file; /lib/brand.mjs is served. -// BRANDED — OBSERVOGRAM_BRAND_FILE=tools/fixtures/brand/acme.json: every +// DOT PATH — the same shell, headers and mounts with the server run from a +// path holding a dot-directory (~/.nvm/…, ~/.local/…, a +// .claude/worktrees checkout), which `send` refused in an +// absolute path: the repo through a link under /.install/. +// BRANDED —OBSERVOGRAM_BRAND_FILE=tools/fixtures/brand/acme.json: every // shell path and every auth page (sign-in, change password, the // reverse-proxy explainer) names the brand and carries no // "Observogram"; the config JSON and the tokens are injected. @@ -25,11 +29,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { get as httpGet } from 'node:http'; +import { spawnSync } from 'node:child_process'; import { boot, serve, signIn } from './fixtures/serve-child.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -44,21 +49,25 @@ const get = async (base, path, headers = {}) => { const r = await fetch(`${base}${path}`, { headers, redirect: 'manual' }); return { status: r.status, headers: r.headers, body: await r.text() }; }; +// Every shell path answers studio/index.html byte for byte with the static file headers. +async function assertShellFile(base) { + for (const p of SHELL_PATHS) { + const r = await get(base, p); + assert.equal(r.status, 200, p); + assert.equal(r.body, SHELL, `${p}: the shell as on disk`); + assert.match(r.headers.get('content-type'), /^text\/html; charset=utf-8/i, p); + assert.ok(r.headers.get('etag'), `${p}: ETag (res.sendFile — the pipeline the static mount used)`); + assert.ok(r.headers.get('last-modified'), `${p}: Last-Modified`); + assert.equal(r.headers.get('cache-control'), 'public, max-age=0', p); + assert.equal(r.headers.get('accept-ranges'), 'bytes', p); + } +} test('DEFAULT: every shell path answers studio/index.html byte for byte with the static file headers; /index too; /lib/brand.mjs is served', async () => { const ws = workspace(); const s = await serve(ws, { env: { OBSERVOGRAM_AUTH: 'off' } }); try { - for (const p of SHELL_PATHS) { - const r = await get(s.base, p); - assert.equal(r.status, 200, p); - assert.equal(r.body, SHELL, `${p}: the shell as on disk`); - assert.match(r.headers.get('content-type'), /^text\/html; charset=utf-8/i, p); - assert.ok(r.headers.get('etag'), `${p}: ETag (res.sendFile — the pipeline the static mount used)`); - assert.ok(r.headers.get('last-modified'), `${p}: Last-Modified`); - assert.equal(r.headers.get('cache-control'), 'public, max-age=0', p); - assert.equal(r.headers.get('accept-ranges'), 'bytes', p); - } + await assertShellFile(s.base); const root = await get(s.base, '/'); const named = await get(s.base, '/index.html'); assert.equal(root.headers.get('etag'), named.headers.get('etag'), 'one file, one ETag'); @@ -83,6 +92,39 @@ test('DEFAULT: every shell path answers studio/index.html byte for byte with the } }); +test('DOT PATH: run from a path holding a dot-directory, every shell path still answers studio/index.html byte for byte with the static file headers; the static mount and /lib too', async () => { + // The repo reached through /.install/observogram, a link to it (a junction needs no privilege on Windows; a + // symlink elsewhere), booted with --preserve-symlinks: the child's import.meta.url keeps the link's path, so its + // STUDIO_DIR holds the dot segment whatever path this checkout has. + const ws = workspace(); + const outer = mkdtempSync(join(tmpdir(), 'observogram-dot-path-')); + const install = join(outer, '.install', 'observogram'); + mkdirSync(dirname(install)); + symlinkSync(ROOT, install, 'junction'); + const index = join(install, 'server', 'index.mjs'); + let s = null; + try { + // Under that flag the server module resolves to the link's path, not this checkout's: the child runs from the dot path. + const url = pathToFileURL(index).href; + const resolved = spawnSync(process.execPath, ['--preserve-symlinks', '--input-type=module', '-e', `process.stdout.write(import.meta.resolve(${JSON.stringify(url)}))`], { encoding: 'utf8' }); + assert.equal(resolved.stdout, url, `the server module through the link: ${resolved.stderr}`); + s = await serve(ws, { env: { OBSERVOGRAM_AUTH: 'off' }, execArgv: ['--preserve-symlinks'], index }); + await assertShellFile(s.base); + const css = await get(s.base, '/app.css'); + assert.equal(css.status, 200, '/app.css: the static mount'); + assert.match(css.headers.get('content-type'), /^text\/css/); + const lib = await get(s.base, '/lib/brand.mjs'); + assert.equal(lib.status, 200, '/lib/brand.mjs: the /lib mount'); + assert.ok(lib.body.includes('export function normalizeBrand')); + } finally { + await s?.stop(); + // The link first, by itself: unlink takes the link (a junction too), never the checkout it leads to. + unlinkSync(install); + rmSync(outer, { recursive: true, force: true }); + rmSync(ws, { recursive: true, force: true }); + } +}); + test('BRANDED (acme file): every shell path and every auth page names the brand and carries no Observogram', async () => { const ws = workspace(); const s = await serve(ws, { env: { OBSERVOGRAM_BRAND_FILE: ACME_FILE } });