Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tmp>/.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.

Expand Down
15 changes: 15 additions & 0 deletions docs/UPDATE_JOURNEY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tmp>/.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`).
16 changes: 10 additions & 6 deletions server/fixtures/serve-child.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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);
Expand Down Expand Up @@ -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 = '';
Expand Down
7 changes: 6 additions & 1 deletion server/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@
try {
const out = compileArtifact(overlaid, { group: g.id, flavor: fl.id, artifact: 'all' });
files.push({ name: `artefacts/${g.id}/${out.filename}`, data: out.content });
} catch (_) { /* a flavor that can't compile for this pack — skip it */ }

Check warning on line 724 in server/index.mjs

View workflow job for this annotation

GitHub Actions / validate

'_' is defined but never used
}
}
if (meta.uploaded) {
Expand Down Expand Up @@ -1186,7 +1186,7 @@
const packA = sourceFor(metaA);
let packB;
let canonicalB = null;
try { canonicalB = loadPackCanonical(metaB); } catch (_) {}

Check warning on line 1189 in server/index.mjs

View workflow job for this annotation

GitHub Actions / validate

'_' is defined but never used
const bAnn = canonicalB?.metadata?.annotations || {};
// A snapshot is an inventory read at one time (its mcp.url is an origin
// only): it is saved as the file it is, never re-fetched as a draft.
Expand Down Expand Up @@ -1286,7 +1286,7 @@
res.json({
specVersion: SPEC_VERSION,
docs: `${SPEC_DIR}/docs/maturity-model.md`,
clauses: RUBRIC.map(({ evaluate, ...rest }) => rest),

Check warning on line 1289 in server/index.mjs

View workflow job for this annotation

GitHub Actions / validate

'evaluate' is defined but never used. Allowed unused args must match /^_/u
});
});

Expand Down Expand Up @@ -2307,14 +2307,19 @@
// 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
// chrome and tokens read it through the closure above.
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);
Expand Down
66 changes: 54 additions & 12 deletions server/test-brand-shell.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tmp>/.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.
Expand All @@ -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));
Expand All @@ -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');
Expand All @@ -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 <tmp>/.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 } });
Expand Down
Loading