From 85122dd2bc4f674bae52417bea2cd1d2c2ded544 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 14:49:31 -0600 Subject: [PATCH 1/6] feat(webui): separate account sessions from root authority (#1873) --- packages/webui/src/cli.mjs | 3 +- packages/webui/src/console-restart-http.mjs | 4 +- packages/webui/src/dev-owner.mjs | 4 +- packages/webui/src/serve.mjs | 23 +++-- packages/webui/src/sso.mjs | 50 +++++++---- packages/webui/tests/accounts-sso.test.mjs | 85 +++++++++++++++++++ packages/webui/tests/chat-instances.test.mjs | 2 +- packages/webui/tests/decide-sso.test.mjs | 3 +- packages/webui/tests/granted-user.test.mjs | 3 +- .../webui/tests/helpers/owner-accounts.mjs | 5 ++ packages/webui/tests/helpers/sso-session.mjs | 2 + .../tests/helpers/w15-project-remote-real.mjs | 2 +- .../tests/project-remote-transport.test.mjs | 3 +- packages/webui/tests/request-http.test.mjs | 3 +- packages/webui/tests/sso.test.mjs | 10 ++- .../webui/tests/user-option-swap.test.mjs | 3 +- 16 files changed, 163 insertions(+), 42 deletions(-) create mode 100644 packages/webui/tests/accounts-sso.test.mjs create mode 100644 packages/webui/tests/helpers/owner-accounts.mjs diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 8dfb2f95e..714947a9d 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -21,6 +21,7 @@ import { loadRootAgents } from '../../organization/src/agent-levels.mjs'; import { consoleReads, redactor } from './reads.mjs'; import { createDevOwner } from './dev-owner.mjs'; import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs'; +import { loadAccounts } from '../../organization/src/accounts.mjs'; import { createSso, readSlot } from './sso.mjs'; import { readFileSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -151,7 +152,7 @@ try { load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)), business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }), }); - sso = await createSso(system.console.sso, { secrets, users }); + sso = await createSso(system.console.sso, { secrets, users, accounts: { load: () => loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console.sso.providers }) } }); // Each request of a granted user's session gets the bus options over that user's own // user-cli transport. The human transport above serves only the chat's `plugins` read (C6). userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) }); diff --git a/packages/webui/src/console-restart-http.mjs b/packages/webui/src/console-restart-http.mjs index 7c1a719f2..c776f2eaf 100644 --- a/packages/webui/src/console-restart-http.mjs +++ b/packages/webui/src/console-restart-http.mjs @@ -22,9 +22,9 @@ async function emptyBody(req) { return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}'; } -export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) { +export async function consoleRestartHTTP(req, res, { session = null, operator = false, send = false, restart = null, trail = null, boot, log = () => {} }) { if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); - if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' }); + if (!operator || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console requires an instance operator' }); if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' }); const invalid = await emptyBody(req); if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid }); diff --git a/packages/webui/src/dev-owner.mjs b/packages/webui/src/dev-owner.mjs index d62bddf02..ce5dddacf 100644 --- a/packages/webui/src/dev-owner.mjs +++ b/packages/webui/src/dev-owner.mjs @@ -1,8 +1,8 @@ import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs'; -const session = Object.freeze({ ...DEV_OWNER, name: 'Local operator' }); +const session = Object.freeze({ ...DEV_OWNER, name: 'Local operator', rootOwner: true, instanceRole: 'operator' }); export function createDevOwner() { - return Object.freeze({ devOwner: true, session: () => session, formTargets: () => [], + return Object.freeze({ devOwner: true, mode: 'dev', session: () => session, formTargets: () => [], handle: (_req, res) => { res.writeHead(404); res.end(); }, summary: () => [], health: () => ({}), close: () => {} }); } diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 1e111ca83..c3228ecf6 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -790,6 +790,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // names (without a port) a remote browser may use; only the dev owner takes them, and only with // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port'); + if ((sso?.mode === 'accounts') && (sso?.devOwner || devAllowHosts.length)) throw new Error('accounts mode refuses the dev LAN same-origin path'); if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner'); const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0; if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1'); @@ -935,10 +936,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF } if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: localOrigin ?? sso.publicOrigin }; // Row 252: only the owner's session answers a decision. A granted user stops at userRoute below. - if (session && session.user === undefined) ssoSession = session; + if (session?.rootOwner === true) ssoSession = session; // FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport // object is left, so a route past the gate reaches the broker only as this user. - if (session?.user !== undefined) { + const instanceAction = originalPath === '/api/console/restart'; + if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' }); + if (session && !session.rootOwner && session.user === undefined) { + bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = upstream = null; + } + if (session?.user !== undefined && !session.rootOwner && !instanceAction) { if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' }); if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session)); @@ -982,11 +988,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return json(res, 403, { error: 'unavailable', sent: false }); } if (hook) { - if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET'))) return json(res, 403, { error: 'owner-only' }); + const instanceAction = originalPath === '/api/console/restart'; + if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET' && !instanceAction))) return json(res, 403, { error: 'owner-only' }); const origin = localOrigin ?? sso?.publicOrigin; if (req.method !== 'GET' && one(req, 'origin') !== origin) return json(res, 403, { error: 'cross-origin request refused' }); if (req.method !== 'GET' && one(req, 'sec-fetch-site') !== 'same-origin') return json(res, 403, { error: 'cross-site request refused' }); - return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null }); + return hook.handler(req, res, { path: originalPath, search, session: currentSession, orgId, origin, owner: !tester && !granted && named && currentSession?.rootOwner === true, operator: !tester && named && currentSession?.instanceRole === 'operator', send: owner.send !== null }); } const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer; // F3: the owner's routing and agent changes go to the action trail too (who, when, what). @@ -1012,7 +1019,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (decisionPath) { if (tester) return decisionRefuse(res, 'not-found'); const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: localOrigin ?? sso.publicOrigin } : null; - return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log }); + return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, confirmations, log }); } // #797 / #1839 Task 3: the owner's request intake controls. The tester listener never has them, and a // granted user stopped at userRoute above. Reads need the read transport; the writes also --send. @@ -1023,12 +1030,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!(named && sso !== null)) return requestHTTP(req, res, { path, requestRead: null, requestWrite: null, session: null, live: () => null, origin: null, log }); return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null, - session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin, log }); + session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin, log }); } if (remotePath) { if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null, - session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin }); + session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); } // Phase P2 (#1873): the owner's System types read and writes on the selected organization. if (systemTypesPath) { @@ -1185,7 +1192,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // `send`: the page shows the composer only where a send can pass (the public name, --send on). // `edit`: the editor shows Save only where a save can pass, the same test as the route's. // `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged. - if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named }); + if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named }); if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) }); if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } const file = files.get(path); diff --git a/packages/webui/src/sso.mjs b/packages/webui/src/sso.mjs index 32f4037fe..e6b5d7bac 100644 --- a/packages/webui/src/sso.mjs +++ b/packages/webui/src/sso.mjs @@ -19,10 +19,10 @@ export const LOGIN_COOKIE = '__Host-mosaic_login'; const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax'; const LOGIN_MAX_AGE = 600; const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000; -const MAX_SESSIONS = 64, MAX_LOGINS = 32; +const MAX_SESSIONS = 512, MAX_LOGINS = 128, MAX_ACCOUNT_SESSIONS = 8; const FETCH_TIMEOUT = 10000; const RETRY_CAP = 300000; -const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'discovery-failed', 'jwks-failed']); +const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'account-revoked', 'discovery-failed', 'jwks-failed']); const sha256 = value => createHash('sha256').update(value).digest('hex'); const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); @@ -125,7 +125,7 @@ async function discover(p, fetchOptions) { // `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes. // `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the // owner's signs in as the access-file user with a live grant, and each request checks it again. -export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null } = {}) { +export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null, accounts = null } = {}) { checkConsole({ sso: config }); oauth ??= await import('oauth4webapi'); const publicOrigin = config.publicOrigin; @@ -280,18 +280,25 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; } if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); } if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); } - let name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined; - // FED R6: console.sso subjects and access-file subjects are disjoint (R1), so the owner's - // list is asked first and a user is asked only on a miss. - const user = name === undefined && typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null; - if (user !== null) name = user; - if (name === undefined) { + // Account authority and the legacy root grant are independent. A configured + // owner subject alone is never an account and cannot bypass a refused store. + let account = null; + try { account = accounts?.load().users.find(u => u.state === 'active' && u.subjects.some(s => s.provider === pid && s.sub === claims.sub)) ?? null; } catch { /* fail closed for accounts */ } + const user = typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null; + const rootOwner = account?.instanceRole === 'operator' && entry.subjects.has(claims.sub); + if (!account && user === null) { refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`); return failed(res, clear); } - // Step 7: the session, then the return path. + const name = account?.name ?? user; const id = randomBytes(32).toString('base64url'), now = Date.now(); - remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS); + if (account) { + const own = [...sessions].filter(([, s]) => s.account === account.id); + while (own.length >= MAX_ACCOUNT_SESSIONS) sessions.delete(own.shift()[0]); + } + remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, + ...(account ? { account: account.id, instanceRole: account.instanceRole, authGen: account.authGen } : {}), + rootOwner, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS); return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]); } @@ -304,13 +311,20 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo if (!s) return null; const now = Date.now(); if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; } - // FED R6 (design 3.1 D5): a user's grant is read again on every request, so a user revoked, - // removed or expired since sign-in has no session from the next request on. - if (s.user !== undefined && !users?.check(s)) { - sessions.delete(key); - refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`); - return null; + if (s.account !== undefined) { + let account; + try { account = accounts?.load().users.find(u => u.id === s.account); } catch { /* refuse */ } + if (!account || account.state !== 'active' || account.authGen !== s.authGen || account.instanceRole !== s.instanceRole || !account.subjects.some(x => x.provider === s.provider && x.sub === s.sub)) { + refuse('account-revoked', s.provider); + // Never reacquire account authority on this cookie, even after re-enable. + delete s.account; delete s.instanceRole; delete s.authGen; s.rootOwner = false; + } } + if (s.user !== undefined && !users?.check(s)) { + refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`); + delete s.user; + } + if (s.account === undefined && s.user === undefined) { sessions.delete(key); return null; } if (touch) s.seen = now; return s; } @@ -351,7 +365,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo } return { - publicOrigin, publicHost, sessions, logins, handle, session, returnURL, + mode: 'accounts', publicOrigin, publicHost, sessions, logins, handle, session, returnURL, chatGrants: session => users?.chatGrants?.(session) ?? [], health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])), formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))], diff --git a/packages/webui/tests/accounts-sso.test.mjs b/packages/webui/tests/accounts-sso.test.mjs new file mode 100644 index 000000000..4f168b409 --- /dev/null +++ b/packages/webui/tests/accounts-sso.test.mjs @@ -0,0 +1,85 @@ +// M3: real accounts store, real OIDC code flow, isolated port-zero providers. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadAccounts, withAccounts } from '../../organization/src/accounts.mjs'; +import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; +import { startServer } from '../src/serve.mjs'; +import { consoleAccess } from '../src/access.mjs'; +import { fakeOidc } from './helpers/fake-oidc.mjs'; +import { close } from './fixture.mjs'; +const HOST = 'accounts.example.test', ORIGIN = `https://${HOST}`; +function call(port, path, { cookie, method = 'GET', body } = {}) { + return new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}), ...(body ? { origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : {}) } }, res => { + let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text })); + }); req.on('error', reject); req.end(body); + }); +} +async function setup(t, { legacy = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'as-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true })); + const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); + const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }, { sub: 'drift', name: 'Drift' }] }] }; + const ctx = { configDir: dir, providers: config.providers }, file = join(dir, 'accounts.json'); + const row = (id, sub, instanceRole) => ({ id, subjects: [{ provider: 'a', sub }], name: sub, instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }); + await withAccounts(ctx, { init: true, revision: 0, verb: 'init' }, d => { d.users.push(row('u-000000000001', 'owner', 'operator'), row('u-000000000002', 'pat', 'user'), row('u-000000000003', 'op', 'operator')); }); + let access = { users: [{ id: 'legacy', subjects: [{ provider: 'a', sub: 'pat' }] }], grants: legacy ? [{ user: 'legacy', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] : [] }; + const lines = [], sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), accounts: { load: () => loadAccounts(file, ctx) }, users: consoleAccess({ load: () => access, business: () => 'demo' }) }); + t.after(() => sso.close()); let restarts = 0; + const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, bus: { inbox: async () => [] }, send: async () => ({}), restart: () => restarts++, trail: () => {}, userOptions: () => ({ bus: { inbox: async () => [] }, chat: { projects: async () => [] } }), log: () => {} }); t.after(() => close(web)); const port = web.address().port; + async function login(sub = 'pat') { + const begin = await call(port, '/auth/login'); const loc = begin.headers.location; const grant = op.grant(iss, loc, { sub }); + const r = await call(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state: new URL(loc).searchParams.get('state') })}`, { cookie: begin.headers['set-cookie'][0].split(';')[0] }); + const cookie = r.headers['set-cookie']?.find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0'))?.split(';')[0]; return { ...r, cookie }; + } + const read = () => loadAccounts(file, ctx); + const change = (fn, verb = 'user.update') => withAccounts(ctx, { revision: read().revision, verb }, fn); + return { login, sso, lines, file, port, read, change, revoke: () => { access.grants = []; }, restarts: () => restarts }; +} +test('account sign-in records separate authority; unknown and drifted owner subjects refuse', async t => { + const s = await setup(t); const p = await s.login(); assert.equal(p.status, 303); + const session = s.sso.session({ headers: { cookie: p.cookie } }); assert.equal(session.account, 'u-000000000002'); assert.equal(session.authGen, 1); assert.equal(session.instanceRole, 'user'); assert.equal(session.rootOwner, false); assert.equal(session.user, undefined); + for (const sub of ['unknown', 'drift']) { const r = await s.login(sub); assert.equal(r.status, 403); assert.equal(r.cookie, undefined); assert.match(s.lines.at(-1), /subject-unknown/); } + const owner = await s.login('owner'); assert.equal(s.sso.session({ headers: { cookie: owner.cookie } }).rootOwner, true); +}); +test('disabled, removed, signout, changed subject and role generations invalidate without resurrection', async t => { + for (const mutation of ['disable', 'disable-enable', 'role-roundtrip', 'signout', 'subject', 'remove', 'missing', 'invalid']) await t.test(mutation, async t => { + const s = await setup(t), r = await s.login(); assert.ok(r.cookie, 'account sign-in required before revocation'); + if (mutation.startsWith('disable')) { await s.change(d => d.users[1].state = 'disabled'); if (mutation.endsWith('enable')) await s.change(d => d.users[1].state = 'active'); } + if (mutation === 'role-roundtrip') { await s.change(d => d.users[1].instanceRole = 'operator'); await s.change(d => d.users[1].instanceRole = 'user'); } + if (mutation === 'signout') await s.change(d => d.users[1].authGen++, 'user.signout'); + if (mutation === 'subject') await s.change(d => d.users[1].subjects[0].sub = 'changed'); + if (mutation === 'remove') await s.change(d => d.users.splice(1, 1), 'user.remove'); + if (mutation === 'missing') rmSync(s.file); if (mutation === 'invalid') writeFileSync(s.file, '{}'); + assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null); + assert.equal((await call(s.port, '/api/bus/inbox', { cookie: r.cookie })).status, 401); + }); +}); +test('the ninth account session evicts only that account oldest; global caps are 512/128', async t => { + const s = await setup(t), other = await s.login('owner'), cookies = []; + for (let i = 0; i < 9; i++) cookies.push((await s.login()).cookie); + assert.equal(s.sso.session({ headers: { cookie: cookies[0] } }), null); + for (const cookie of [...cookies.slice(1), other.cookie]) assert.ok(s.sso.session({ headers: { cookie } })); + for (let i = 0; i < 129; i++) await call(s.port, '/auth/login'); assert.equal(s.sso.logins.size, 128); +}); +test('root routes refuse account-only sessions and restart checks operator independently of root ownership', async t => { + const s = await setup(t), pat = await s.login(), op = await s.login('op'); + for (const path of ['/api/bus/inbox', '/api/settings', '/api/chat/projects', '/board']) assert.equal((await call(s.port, path, { cookie: pat.cookie })).status, 404, path); + assert.equal((await call(s.port, '/api/console/restart', { cookie: pat.cookie, method: 'POST', body: '{}' })).status, 403); + assert.equal(s.restarts(), 0); + assert.equal((await call(s.port, '/api/console/restart', { cookie: op.cookie, method: 'POST', body: '{}' })).status, 202); + assert.equal(s.restarts(), 1); +}); +test('legacy root grant and account authority revoke independently and never return on an old cookie', async t => { + const s = await setup(t, { legacy: true }), r = await s.login(); + let session = s.sso.session({ headers: { cookie: r.cookie } }); assert.equal(session.user, 'legacy'); assert.equal(session.account, 'u-000000000002'); + await s.change(d => d.users[1].state = 'disabled'); + session = s.sso.session({ headers: { cookie: r.cookie } }); assert.ok(session); assert.equal(session.user, 'legacy'); assert.equal(session.account, undefined); assert.equal(session.instanceRole, undefined); + await s.change(d => d.users[1].state = 'active'); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }).account, undefined); + s.revoke(); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null); + const fresh = await s.login(); assert.ok(fresh.cookie); assert.equal(s.sso.session({ headers: { cookie: fresh.cookie } }).user, undefined); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: fresh.cookie })).status, 404); +}); diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs index 893c38f80..3bb1fd5c2 100644 --- a/packages/webui/tests/chat-instances.test.mjs +++ b/packages/webui/tests/chat-instances.test.mjs @@ -80,7 +80,7 @@ test('C2-09a metadata stays behind the SSO session check and a GET never refresh const headers = { host: sso.publicHost }; assert.equal((await get(server, { headers })).status, 401); assert.equal(reads, 0); - session = { name: 'jason' }; + session = { name: 'jason', rootOwner: true }; assert.equal((await get(server, { headers })).status, 200); assert.equal(reads, 1); assert.deepEqual(touches, [false, false]); diff --git a/packages/webui/tests/decide-sso.test.mjs b/packages/webui/tests/decide-sso.test.mjs index f4c94b167..a9918809d 100644 --- a/packages/webui/tests/decide-sso.test.mjs +++ b/packages/webui/tests/decide-sso.test.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './helpers/owner-accounts.mjs'; // Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed // in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub, // so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the @@ -53,7 +54,7 @@ async function setup(t, server = {}, access = null) { const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] }; const lines = []; const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) }; - const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users }); + const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users }); t.after(() => sso.close()); const calls = []; const decisionWrite = async (verb, args, ctx) => { diff --git a/packages/webui/tests/granted-user.test.mjs b/packages/webui/tests/granted-user.test.mjs index 75ac7a1b3..e345ede3a 100644 --- a/packages/webui/tests/granted-user.test.mjs +++ b/packages/webui/tests/granted-user.test.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './helpers/owner-accounts.mjs'; // FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the // Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a // real provider, the live Console or a real config, slot or access file. The broker is an @@ -92,7 +93,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) { const lines = []; const log = line => lines.push(line); const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] }; - const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) }); + const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) }); t.after(() => sso.close()); const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; }; const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)])); diff --git a/packages/webui/tests/helpers/owner-accounts.mjs b/packages/webui/tests/helpers/owner-accounts.mjs new file mode 100644 index 000000000..25b49eb15 --- /dev/null +++ b/packages/webui/tests/helpers/owner-accounts.mjs @@ -0,0 +1,5 @@ +// Existing OIDC regression fixtures explicitly initialize their configured owners. +// Production never infers account authority from console.sso alone. +export const ownerAccounts = config => ({ load: () => ({ users: config.providers.flatMap((p, i) => p.subjects.map((s, j) => ({ + id: `u-${(i * 64 + j + 1).toString(16).padStart(12, '0')}`, subjects: [{ provider: p.id, sub: s.sub }], name: s.name, state: 'active', instanceRole: 'operator', authGen: 1, +}))) }) }); diff --git a/packages/webui/tests/helpers/sso-session.mjs b/packages/webui/tests/helpers/sso-session.mjs index 36162bfc4..b64eac239 100644 --- a/packages/webui/tests/helpers/sso-session.mjs +++ b/packages/webui/tests/helpers/sso-session.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './owner-accounts.mjs'; // Q284 / #385: real OIDC sessions for writer-route tests. The provider is the existing // in-process fake and is shared only within one test-file process; each test gets its own // createSso instance and completes the real login, code and callback flow. @@ -58,6 +59,7 @@ export async function createSsoSession(t, { subjects: configuredSubjects, }], }, { + accounts: ownerAccounts({ providers: [{ id: issuer.name, subjects: configuredSubjects }] }), secrets: new Map([[issuer.name, issuer.clientSecret]]), fetch: op.fetch, log, diff --git a/packages/webui/tests/helpers/w15-project-remote-real.mjs b/packages/webui/tests/helpers/w15-project-remote-real.mjs index 7afeb31e6..78e66f948 100644 --- a/packages/webui/tests/helpers/w15-project-remote-real.mjs +++ b/packages/webui/tests/helpers/w15-project-remote-real.mjs @@ -34,7 +34,7 @@ export async function realRemoteFixture(t, mutate = () => {}) { }; await attach(); const subject = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner' }; - const session = { ...subject, name: 'jason' }; + const session = { rootOwner: true, instanceRole: 'operator', ...subject, name: 'jason' }; let active = true; const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; const transport = (verb, args, ctx) => new Client({ path, console: { subject: ctx.subject } }).call(verb, args); diff --git a/packages/webui/tests/project-remote-transport.test.mjs b/packages/webui/tests/project-remote-transport.test.mjs index e56ee07cf..bcdb0493e 100644 --- a/packages/webui/tests/project-remote-transport.test.mjs +++ b/packages/webui/tests/project-remote-transport.test.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './helpers/owner-accounts.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -82,7 +83,7 @@ test('real SSO refuses viewer and collaborator and sign-out prevents the saved w { id: 'g-2', user: 'bob', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }, ] }; const config = { publicOrigin: origin, providers: [{ id: 'a', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] }; - const sso = await createSso(config, { secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} }); + const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} }); t.after(() => sso.close()); const f = await realRemoteFixture(t), original = readFileSync(f.file); let writes = 0; diff --git a/packages/webui/tests/request-http.test.mjs b/packages/webui/tests/request-http.test.mjs index fd2dd88f2..a6af35e07 100644 --- a/packages/webui/tests/request-http.test.mjs +++ b/packages/webui/tests/request-http.test.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './helpers/owner-accounts.mjs'; // #797 / #1839 Task 3: the Console's request intake routes. The first half drives requestHTTP with a // fake request, response and bus transport. The second half signs in through the fake OP on loopback // and checks the listener's gates, as decide-sso.test.mjs does for decisions. The bus side is @@ -253,7 +254,7 @@ async function listener(t, server = {}, access = null) { const config = { publicOrigin: LISTEN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] }; const lines = []; const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) }; - const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users }); + const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users }); t.after(() => sso.close()); const reads = [], writes = []; const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return { ...STATE, request: args.request }; }; diff --git a/packages/webui/tests/sso.test.mjs b/packages/webui/tests/sso.test.mjs index a5b0b0551..5a96629bc 100644 --- a/packages/webui/tests/sso.test.mjs +++ b/packages/webui/tests/sso.test.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './helpers/owner-accounts.mjs'; // Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an // OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; // no test reaches a real provider, the live Console or a real config or slot file. The public name @@ -56,7 +57,7 @@ async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = nul const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) }; const lines = []; const log = line => { lines.push(line); ALL_LINES.push(line); }; - const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst }); + const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst }); t.after(() => sso.close()); const sends = []; const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]])); @@ -365,6 +366,7 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, then assert.equal(wide.status, 2); assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m); chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600); + writeFileSync(join(root, 'cfg', 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [providerFor(a)] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 }); // The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it. const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso', '--send'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } }); t.after(() => child.kill('SIGKILL')); @@ -522,11 +524,11 @@ test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid'); }); -test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => { +test('17: 129 started logins leave 128 records, and the first one\'s callback finds none', async t => { const env = await setup(t); const first = await begin(env); - for (let i = 0; i < 32; i++) await begin(env); - assert.equal(env.sso.logins.size, 32); + for (let i = 0; i < 128; i++) await begin(env); + assert.equal(env.sso.logins.size, 128); refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing'); }); diff --git a/packages/webui/tests/user-option-swap.test.mjs b/packages/webui/tests/user-option-swap.test.mjs index 2d447601c..a50da6ee1 100644 --- a/packages/webui/tests/user-option-swap.test.mjs +++ b/packages/webui/tests/user-option-swap.test.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from './helpers/owner-accounts.mjs'; // R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP. // granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the // swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query @@ -87,7 +88,7 @@ async function setup(t) { const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } }); const lines = []; const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] }; - const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) }); + const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) }); t.after(() => sso.close()); // Every owner option is a stub that records its own name. const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; }; -- 2.54.0 From 6db8fb2dced691385ecff181b587a77d46542e17 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 14:54:19 -0600 Subject: [PATCH 2/6] feat(cli): enforce stamped-copy and deployed-instance boundaries (#1873) --- packages/cli/src/cli.mjs | 3 + packages/cli/src/deployment.mjs | 56 ++++++++++++ packages/cli/tests/case2-install.test.mjs | 5 + .../cli/tests/deployment-boundary.test.mjs | 91 +++++++++++++++++++ packages/cli/tests/stack-copy.test.mjs | 3 +- packages/webui/src/cli.mjs | 7 ++ scripts/case2-install.sh | 3 + scripts/stack-copy.sh | 1 + 8 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/deployment.mjs create mode 100644 packages/cli/tests/deployment-boundary.test.mjs diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index b177cebb9..e825364c6 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -8,6 +8,7 @@ // host (src/host.mjs) the systemd unit mosaic-bus@ runs; with // --pm it also launches the business's PM (src/launcher.mjs). +import { assertBuildStart, assertDevInstance } from './deployment.mjs'; import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { createInterface } from "node:readline/promises"; @@ -614,8 +615,10 @@ async function bus(rest, io, deps) { const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] }); if (parsed.positional.length !== 1) throw usage("bus start takes one organization"); if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both"); + assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") }); if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env); const ctx = organizationContext(io, deps); + if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env); const businessId = parsed.positional[0]; if (!parsed.flags.has("--internal-host")) { const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") }); diff --git a/packages/cli/src/deployment.mjs b/packages/cli/src/deployment.mjs new file mode 100644 index 000000000..079320470 --- /dev/null +++ b/packages/cli/src/deployment.mjs @@ -0,0 +1,56 @@ +// M3: executable and instance boundaries. Presence is checked with lstat, so +// broken links and unreadable metadata never turn a deployed path into dev. +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json'))); + +const CODE_ROOT = resolve(import.meta.dirname, '../../..'); +export function present(file) { + try { lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } +} +function metadata(file, label, check) { + let fd; + try { + fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const st = fstatSync(fd); + if (!st.isFile() || st.size > 4096 || !check(st)) throw new Error('metadata refused'); + return JSON.parse(readFileSync(fd, 'utf8')); + } catch { throw new Error(`${label} is missing or invalid`); } + finally { if (fd !== undefined) closeSync(fd); } +} +export function buildStamp(root = CODE_ROOT) { + const file = join(root, '.stack-build.json'); + if (!present(file)) return null; + const doc = metadata(file, 'build stamp', st => !(st.mode & 0o222)); + if (!doc || Object.keys(doc).length !== 1 || !/^[0-9a-f]{40}$/.test(doc.commit) || doc.commit !== basename(root)) throw new Error('build stamp does not name this copy commit'); + return doc; +} +export function assertBuildStart({ serve = false, sso = false, devOwner = false, devBind = null, devNames = [] } = {}) { + const stamp = buildStamp(); + if (stamp && (devOwner || devBind !== null || devNames.length)) throw new Error('stamped build refuses dev-local-owner and LAN flags'); + if (stamp && serve && !sso) throw new Error('stamped build serve requires --sso'); + return stamp; +} +export function assertDevInstance(system, env = process.env) { + if (present(join(configDir(env), 'accounts.json'))) throw new Error('--dev-local-owner refuses accounts.json'); + if (system.console?.sso) throw new Error('--dev-local-owner refuses console.sso'); + if (present(join(system.dataRoot, '.deployed'))) throw new Error('--dev-local-owner refuses .deployed'); +} +const instanceName = dir => /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir))?.[1] ?? 'dev'; +export function assertDeployed(system, dir = configDir()) { + const doc = metadata(join(system.dataRoot, '.deployed'), '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid()); + if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance'); +} +// Both managed host cases install through case2-install.sh. Existing metadata +// must validate; an install never replaces it or follows a destination link. +export function writeDeployment(system, dir = configDir()) { + const file = join(system.dataRoot, '.deployed'); + if (present(file)) { + const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid()); + if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance'); + return; + } + const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o444); + try { writeFileSync(fd, JSON.stringify({ instance: instanceName(dir) }) + '\n'); fchmodSync(fd, 0o444); } finally { closeSync(fd); } +} diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index 63db8cb84..51380506e 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -148,6 +148,8 @@ test("the library install writes the business file, personas and slot dir, makes const x = world(t); const r = install(x); assert.equal(r.code, 0, r.out); + assert.equal(mode(join(x.dataRoot, '.deployed')), 0o444); + assert.deepEqual(JSON.parse(readFileSync(join(x.dataRoot, '.deployed'), 'utf8')), { instance: 'dev' }); assert.equal(mode(x.business), 0o600); const doc = JSON.parse(readFileSync(x.business, "utf8")); for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) { @@ -494,6 +496,9 @@ function dependencyFreeClone(t) { for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) { cpSync(join(REPO, "scripts", file), join(clone, "scripts", file)); } + mkdirSync(join(clone, 'packages/cli/src'), { recursive: true }); + cpSync(join(REPO, 'packages/cli/src/deployment.mjs'), join(clone, 'packages/cli/src/deployment.mjs')); + writeJson(x.config, { dataRoot: x.dataRoot }); // Isolate the persona step; bootstrap and business validation are covered above. script(join(clone, "scripts", "bootstrap.sh"), "exit 0"); script(join(clone, "scripts", "mosaic"), "exit 0"); diff --git a/packages/cli/tests/deployment-boundary.test.mjs b/packages/cli/tests/deployment-boundary.test.mjs new file mode 100644 index 000000000..4e44af756 --- /dev/null +++ b/packages/cli/tests/deployment-boundary.test.mjs @@ -0,0 +1,91 @@ +// M3 executable boundary: copies and instances are fixtures only, no live ports. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { fixture } from './helpers.mjs'; +const REPO = fileURLToPath(new URL('../../../', import.meta.url)); +const SHA = 'a'.repeat(40); +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'db-')); t.after(() => rmSync(root, { recursive: true, force: true })); + const copy = join(root, SHA); mkdirSync(copy); + for (const dir of ['packages', 'scripts', 'roles', 'adapters']) cpSync(join(REPO, dir), join(copy, dir), { recursive: true, filter: p => !p.split('/').some(x => ['tests', 'node_modules'].includes(x)) }); + writeFileSync(join(copy, 'package.json'), '{"type":"module"}'); + const stamp = join(copy, '.stack-build.json'); writeFileSync(stamp, JSON.stringify({ commit: SHA }), { mode: 0o444 }); chmodSync(stamp, 0o444); + const f = fixture(join(root, 'instance')); const home = join(root, 'home'); const dir = join(home, '.config/mosaic-test'); mkdirSync(dir, { recursive: true }); + const config = join(dir, 'config.json'); cpSync(f.env.MOSAIC_CONFIG, config); + writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' })); + const env = { ...process.env, HOME: home, MOSAIC_CONFIG: config }; + const run = (args, from = copy) => spawnSync(process.execPath, [join(from, args[0]), ...args.slice(1)], { env, encoding: 'utf8', timeout: 5000 }); + return { root, copy, stamp, f, env, config, dir, run }; +} +const serve = flags => ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...flags]; +test('stamped serve refuses dev owner, LAN flags, missing --sso and missing .deployed before listening', t => { + const s = setup(t); + for (const flags of [['--dev-local-owner'], ['--dev-insecure-bind','0.0.0.0'], ['--dev-allow-host','test.example'], []]) { + const r = s.run(serve(flags)); assert.equal(r.status, 2); assert.match(r.stderr, /stamped build/); assert.doesNotMatch(r.stdout, /Mosaic Console:/); + } + const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/); + const bus = s.run(['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']); assert.notEqual(bus.status, 0); assert.match(bus.stderr, /stamped build/); +}); +test('writable, linked, malformed, wrong-commit and directory stamps refuse every start', t => { + const s = setup(t); + for (const kind of ['writable','symlink','wrong','malformed','directory']) { + rmSync(s.stamp, { force: true, recursive: true }); + if (kind === 'symlink') { const other = join(s.root, 'stamp'); writeFileSync(other, JSON.stringify({ commit: SHA }), { mode: 0o444 }); symlinkSync(other, s.stamp); } + else if (kind === 'directory') mkdirSync(s.stamp); + else { writeFileSync(s.stamp, kind === 'malformed' ? '{' : JSON.stringify({ commit: kind === 'wrong' ? 'b'.repeat(40) : SHA })); chmodSync(s.stamp, kind === 'writable' ? 0o644 : 0o444); } + for (const args of [serve([]), serve(['--sso']), ['packages/cli/src/cli.mjs','bus','start','acme']]) { const r = s.run(args); assert.notEqual(r.status, 0, kind); assert.match(r.stderr, /build stamp/, `${kind}: ${r.stderr}`); } + } +}); +test('stamped serve requires 0444 metadata naming its instance and a loadable accounts file', t => { + const s = setup(t), file = join(s.f.dataRoot, '.deployed'); + for (const [mode, instance] of [[0o644,'test'], [0o444,'other']]) { + writeFileSync(file, JSON.stringify({ instance })); chmodSync(file, mode); + const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/); + } + chmodSync(file, 0o644); writeFileSync(file, JSON.stringify({ instance: 'test' })); chmodSync(file, 0o444); + const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /accounts/); +}); +test('checkout dev owner refuses accounts, SSO or deployment presence, including dangling links', t => { + const s = setup(t); + for (const name of ['accounts.json', '.deployed']) { + const file = join(name === 'accounts.json' ? s.dir : s.f.dataRoot, name); symlinkSync(join(s.root, 'missing'), file); + for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses/); } + rmSync(file); + } + const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } }; + writeFileSync(s.config, JSON.stringify(config)); const r = s.run(serve(['--dev-local-owner']), REPO); assert.equal(r.status, 2); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); +}); +import { readFileSync as awaitRead } from 'node:fs'; + +test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => { + const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed'); + writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' }); + assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode); + rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file); + writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); +}); +import { statSync } from 'node:fs'; +import { writeDeployment } from '../src/deployment.mjs'; + +test('valid stamped accounts Console starts with --sso and answers health on its own port zero', async t => { + const { spawn } = await import('node:child_process'); + const { once } = await import('node:events'); + const { fakeOidc } = await import('../../webui/tests/helpers/fake-oidc.mjs'); + const s = setup(t), op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); + symlinkSync(join(REPO, 'node_modules'), join(s.copy, 'node_modules')); + const config = JSON.parse(awaitRead(s.config)); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } }; + writeFileSync(s.config, JSON.stringify(config)); mkdirSync(join(s.dir, 'secrets/mosaic-stack'), { recursive: true, mode: 0o700 }); + writeFileSync(join(s.dir, 'secrets/mosaic-stack/a'), iss.clientSecret, { mode: 0o600 }); + writeFileSync(join(s.dir, 'accounts.json'), JSON.stringify({ accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}], memberships:[],invites:[] }), { mode: 0o600 }); + writeDeployment({ dataRoot: s.f.dataRoot }, s.dir); const ca = join(s.root, 'ca.pem'); writeFileSync(ca, op.cert); + const child = spawn(process.execPath, [join(s.copy,'packages/webui/src/cli.mjs'), 'serve','--port','0','--sso','--board','http://127.0.0.1:9'], { env: { ...s.env, NODE_EXTRA_CA_CERTS: ca } }); + let out = '', err = ''; child.stderr.on('data', c => err += c); + t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const done = once(child, 'close'); child.kill(); await done; } }); + await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('start timeout: '+err)), 10000); child.stdout.on('data', c => { out += c; if(out.includes('Ctrl-C')) {clearTimeout(timer);resolve();} }); child.on('exit', () => {clearTimeout(timer);reject(new Error(err));}); }); + const port = Number(out.match(/127\.0\.0\.1:(\d+)\//)[1]); assert.ok(port); assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200); +}); diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index 3e3c75cd6..c2e8019d7 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -58,6 +58,7 @@ test('a copy carries every file the harness modules read from it',async t=>{ test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{ const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr); const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555); + assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0); for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f); const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755); const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm); @@ -158,7 +159,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}} const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c); - try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);} + try{await until(()=>web.exitCode!==null);assert.equal(web.exitCode,2);assert.match(webErr,/stamped build serve requires --sso/);assert.doesNotMatch(webOut,/Mosaic Console:/);} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}} // The production runtime derives cliPath. Only agent ancestry is mocked; // readProcess passes through for tester.mjs (row 110). diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 714947a9d..022cae2e3 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { assertBuildStart, assertDevInstance, assertDeployed } from '../../cli/src/deployment.mjs'; // MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP. import { createChat } from './chat.mjs'; import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs'; @@ -99,6 +100,7 @@ try { } else if (flag === '--dev-allow-host') devNames.push(value); else throw new Error(`unknown option: ${flag}`); } + const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames }); if (selector.given) options.business = selector.value; if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket'); const lan = devLanOptions({ devOwner, bind: devBind, names: devNames }); @@ -119,6 +121,11 @@ try { let processing = null, ownerCall = null; try { system = loadSystem({ instance }); + if (devOwner) assertDevInstance(system); + if (stamp) { + assertDeployed(system); + loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console?.sso?.providers ?? [] }); + } const { dataRoot } = system; const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); // FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs). diff --git a/scripts/case2-install.sh b/scripts/case2-install.sh index 1a6cd63c7..fd7a8b79a 100755 --- a/scripts/case2-install.sh +++ b/scripts/case2-install.sh @@ -203,6 +203,9 @@ if [ ! -e "$CONFIG_DIR" ]; then fi out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)" say "config $CONFIG" +# Case 1 and Case 2 share this installer. Deployment identity is instance-scoped, +# never a switch that can be silently removed to enable the local dev owner. +out="$(cd "$REPO" && node --input-type=module -e 'import { readFileSync } from "node:fs"; import { writeDeployment } from "./packages/cli/src/deployment.mjs"; writeDeployment(JSON.parse(readFileSync(process.argv[1], "utf8")));' "$CONFIG" 2>&1)" || refuse "deployment: $(printf '%s' "$out" | tail -n 1)" # 3. The stack copy row 195's units run from. out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)" diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index 2918e3725..146c10b7d 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -81,6 +81,7 @@ copy_tree() { git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written" [ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run' (cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written' + printf '{"commit":"%s"}\n' "$full" > "$tmp/.stack-build.json" find "$tmp" -type f -exec chmod a-w {} + find "$tmp" -type d -exec chmod 0555 {} + if ! mv -T "$tmp" "$target" 2>/dev/null; then -- 2.54.0 From 554bc5116dbf477057802f6b823e231b9c246771 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 15:01:09 -0600 Subject: [PATCH 3/6] feat(cli): require dedicated host uid for accounts system units (#1873) --- docs/plans/2026-11-10_accounts-design.md | 5 ++-- packages/cli/README.md | 14 ++++++++-- packages/cli/src/deployment.mjs | 6 +++- packages/cli/tests/accounts.test.mjs | 16 +++++++++++ packages/cli/tests/case2-unit.test.mjs | 28 ++++++++++++++++++- .../cli/tests/deployment-boundary.test.mjs | 5 +++- .../cli/tests/request-lifecycle-fixture.mjs | 3 +- packages/cli/tests/stack-copy.test.mjs | 2 +- packages/cli/tests/stack-upgrade.test.mjs | 17 +++++++++-- packages/webui/README.md | 22 +++++++++++++++ packages/webui/src/serve.mjs | 6 ++-- packages/webui/tests/accounts-sso.test.mjs | 9 ++++++ packages/webui/tests/chat.test.mjs | 1 + packages/webui/tests/pinned-copy.test.mjs | 14 +++++++++- packages/webui/tests/service.test.mjs | 18 ++++++++++++ .../webui/tests/system-types-http.test.mjs | 19 +++++++++++-- scripts/case2-unit.sh | 28 +++++++++++++++++-- scripts/webui-service.sh | 14 ++++++++++ 18 files changed, 209 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-11-10_accounts-design.md b/docs/plans/2026-11-10_accounts-design.md index 1e3a79600..f91de36cf 100644 --- a/docs/plans/2026-11-10_accounts-design.md +++ b/docs/plans/2026-11-10_accounts-design.md @@ -1293,8 +1293,9 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off. - The supported rollback is a pinned code and config rollback: install the earlier stack copy with the same unit flags, including `--sso`, through - `webui-service.sh install --copy `, as the unit script - already describes. Leave accounts.json, its receipts and `.deployed` in + `stack-upgrade.sh rollback --instance NAME --to ` for the + dedicated system units. The accounts instance must not use the + `webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in place. BASE code ignores them, and the next roll-forward needs them. - Before the first accounts org exists, that rollback restores today's behavior completely. `console.sso` owners sign in as before, and FED R6 diff --git a/packages/cli/README.md b/packages/cli/README.md index 114c3412b..ba5ef1de4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1304,8 +1304,10 @@ inactive. Project settings changes are append-only and require human proof. `mosaic accounts` administers the instance-level accounts store locally, as the effective uid owning the config directory. It requires a human shell, rejects `--tester`, and takes no business selector or bus connection. It has no organization rights -verbs. Console sign-in, sessions and membership evaluation are later Phase M -slices; these commands alone do not turn accounts mode on. +verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled +account or changed authentication generation loses account authority immediately; +a legacy access.json root grant is checked independently. Organization membership +evaluation follows in later Phase M slices. ```sh mosaic accounts init --name Operator @@ -1327,3 +1329,11 @@ organization (including archived organizations) or any registry entry lacks its owner metadata during creation, and removes its memberships in the same write. List reports rollback rows needing repair; its JSON omits invite bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets. + +Managed accounts instances use the system units from `scripts/case2-unit.sh`. +The selected uid must use a non-login shell, be the only member of its primary +group, and own its config directory and data root. The shared Case 1/Case 2 +installer writes `/.deployed` as mode 0444 naming the instance. +`webui-service.sh` refuses accounts and deployed instances because its user +manager runs under a human or development seat uid. The receipt uid identifies +the invoking process; it does not attribute a human or resist tampering by that uid. diff --git a/packages/cli/src/deployment.mjs b/packages/cli/src/deployment.mjs index 079320470..4b52a2837 100644 --- a/packages/cli/src/deployment.mjs +++ b/packages/cli/src/deployment.mjs @@ -1,6 +1,6 @@ // M3: executable and instance boundaries. Presence is checked with lstat, so // broken links and unreadable metadata never turn a deployed path into dev. -import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync } from 'node:fs'; +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync, mkdirSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json'))); @@ -45,6 +45,10 @@ export function assertDeployed(system, dir = configDir()) { // Both managed host cases install through case2-install.sh. Existing metadata // must validate; an install never replaces it or follows a destination link. export function writeDeployment(system, dir = configDir()) { + // Default-instance bootstrap creates the config before its data directory. + mkdirSync(system.dataRoot, { recursive: true, mode: 0o700 }); + const root = lstatSync(system.dataRoot); + if (!root.isDirectory() || root.uid !== process.geteuid() || (root.mode & 0o022)) throw new Error('.deployed data root must be an owned directory without public write'); const file = join(system.dataRoot, '.deployed'); if (present(file)) { const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid()); diff --git a/packages/cli/tests/accounts.test.mjs b/packages/cli/tests/accounts.test.mjs index 377479129..5bb5cb876 100644 --- a/packages/cli/tests/accounts.test.mjs +++ b/packages/cli/tests/accounts.test.mjs @@ -69,3 +69,19 @@ test('host boundary and receipt use effective uid, not real uid (current design try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); } finally { process.getuid = realUid; } }); + +// The uid identifies this process, not the human who invoked it. The instance +// uid can rewrite its receipt; this is neither human attribution nor tamper evidence. +test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => { + const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs'); + const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root); + const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}}; + writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config)); + const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname; + const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000}); + writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/config directory must belong to uid/); + for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false); + // Different real uid, same effective uid: the effective identity is recorded. + writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr); + const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid()); +}); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 49c09d770..2dc47f800 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -66,7 +66,7 @@ function hub(t, { uid = UID } = {}) { const bin = join(root, "bin"); const log = join(root, "systemctl.log"); script(join(bin, "getent"), `case "$1" in - passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;; + passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;; group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;; *) exit 2 ;; esac`); @@ -415,6 +415,8 @@ test("the read-only library profile explicitly refuses --send before writing a u test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => { const h = hub(t); const node = outsideNode(); + // This metadata-only test uses a root-owned executable as a path stand-in. + writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0'); assert.equal(unit(h, { node }).code, 0); for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]); assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `)); @@ -550,6 +552,7 @@ test("a --node that is relative, not root-owned or under refuses", (t) => test("a re-install with a different --node or different serve flags refuses", (t) => { const h = hub(t); assert.equal(unit(h).code, 0); + writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0'); // A new node moves PATH too, so the installed unit fails its own Environment= check first. refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/); const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f)); @@ -1320,3 +1323,26 @@ test('row334 proxy installed path rejects symlink and non-regular file', t => { mkdirSync(proxyFile(h)); refuses(proxyUnit(h), /is not a regular file/); }); + +// M3: the managed uid is a dedicated non-login service identity. These are +// database fixtures only: no real passwd/group or instance directories change. +test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => { + for (const [kind, shell, extraPasswd, members] of [ + ['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''], + ['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''], + ['supplementary primary-group member','/usr/sbin/nologin','','human'], + ]) { + const h = hub(t); + script(join(h.bin,'getent'), `case "$1" in + passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';; + group) echo 'mosaic:x:${GID}:${members}';; + esac`); + const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false); + } + const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); } +}); +test('M3 host config and data directories belong to the selected uid and both units use it', t => { + const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err); + for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); } + const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/); +}); diff --git a/packages/cli/tests/deployment-boundary.test.mjs b/packages/cli/tests/deployment-boundary.test.mjs index 4e44af756..72a70730e 100644 --- a/packages/cli/tests/deployment-boundary.test.mjs +++ b/packages/cli/tests/deployment-boundary.test.mjs @@ -58,13 +58,16 @@ test('checkout dev owner refuses accounts, SSO or deployment presence, including rmSync(file); } const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } }; - writeFileSync(s.config, JSON.stringify(config)); const r = s.run(serve(['--dev-local-owner']), REPO); assert.equal(r.status, 2); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); + writeFileSync(s.config, JSON.stringify(config)); + for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); } }); import { readFileSync as awaitRead } from 'node:fs'; test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => { const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed'); + rmSync(system.dataRoot, { recursive: true, force: true }); writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' }); + assert.equal(statSync(system.dataRoot).mode & 0o777, 0o700); assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode); rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file); writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); diff --git a/packages/cli/tests/request-lifecycle-fixture.mjs b/packages/cli/tests/request-lifecycle-fixture.mjs index 89cb79727..8175c2a5a 100644 --- a/packages/cli/tests/request-lifecycle-fixture.mjs +++ b/packages/cli/tests/request-lifecycle-fixture.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs'; import assert from 'node:assert/strict'; import { fork } from 'node:child_process'; import { once } from 'node:events'; @@ -119,7 +120,7 @@ export async function lifecycleFixture(t) { const issuer = op.issuer('fixture'); sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] }, - { secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); + { accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); const transport = async (verb, args, ctx) => { try { return await rpc('console', { verb, args, subject: ctx.subject }); } catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); } diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index c2e8019d7..63bef58d8 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -159,7 +159,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}} const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c); - try{await until(()=>web.exitCode!==null);assert.equal(web.exitCode,2);assert.match(webErr,/stamped build serve requires --sso/);assert.doesNotMatch(webOut,/Mosaic Console:/);} + try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}} // The production runtime derives cliPath. Only agent ancestry is mocked; // readProcess passes through for tester.mjs (row 110). diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index 34a6f171f..941f1b66a 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -718,12 +718,25 @@ const baseCase2=()=>{ // read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir. function case2Host(e,dir){ const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700}); + copyFileSync(e.cfg,join(home,'.config','mosaic-dev','config.json')); const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600); const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true}); const uid=process.getuid(),gid=process.getgid(); - writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755}); + writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture"|"passwd ") echo "fixture:x:${uid}:${gid}::${home}:/usr/sbin/nologin" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755}); writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755}); - const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')}; + // M3: the current renderer queries the config under the selected uid for every + // profile. These copies deliberately stub the CLI; give that query its own + // fixture loader and sudo rather than invoking the machine's sudo. + for(const id of [B,C1,C]){ + const d=join(COPIES,id),packages=join(d,'packages'),cli=join(packages,'cli'),src=join(cli,'src'); + if(!existsSync(join(src,'config.mjs'))){ + chmodSync(d,0o755);chmodSync(packages,0o755);mkdirSync(src,{recursive:true}); + writeFileSync(join(src,'config.mjs'),`import fs from 'node:fs';import os from 'node:os';import path from 'node:path';export const loadSystem=()=>JSON.parse(fs.readFileSync(path.join(os.homedir(),'.config/mosaic-dev/config.json'),'utf8'));`); + chmodSync(join(src,'config.mjs'),0o444);chmodSync(src,0o555);chmodSync(cli,0o555);chmodSync(packages,0o555);chmodSync(d,0o555); + } + } + writeFileSync(join(bin,'sudo'),'#!/bin/sh\n[ "$1" = -u ] && [ "$2" = fixture ] && [ "$3" = -H ] || exit 97\nshift 3\nexec "$@"\n',{mode:0o755}); + const env={PATH:`${bin}:${e.env.PATH}`,CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')}; const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof]; const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',RENDER_NODE,...extra,'--',...serve]; const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}}; diff --git a/packages/webui/README.md b/packages/webui/README.md index 5055d2e80..397d044c2 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -226,6 +226,28 @@ design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send` requires `--sso`. Header-preview mode cannot enable the human writer. +Phase M3 sign-in requires an active account for account authority. Run +`mosaic accounts init` before the first accounts sign-in; configured SSO subjects +without accounts are refused. Legacy access.json users can still reach the root +business under their live grants. A session records account, legacy user and root +ownership separately. Only an active operator whose subject is configured as a +root owner gets the root human transport; an account alone gets 404 on root data +routes. Restarting the Console requires the instance operator role. + +Authentication generations are checked on every request: disable/re-enable, +role or subject changes and `user signout` invalidate earlier account sessions. +Legacy root grants remain independent. There are at most 512 sessions and 128 +pending logins, with eight sessions per account; a ninth evicts that account's +oldest. Sign-out removes the current session entirely. + +Every stack copy carries a read-only `.stack-build.json` naming its commit. +A stamped Console requires `--sso`, a valid mode-0444 `.deployed` naming its +instance, and a loadable accounts file. A malformed stamp refuses every start. +Stamped Console and bus starts refuse dev flags. Checkout development still +supports the named local dev owner, but refuses it if accounts.json, console.sso +or `.deployed` is present. Accounts instances use `case2-unit.sh` system units; +`webui-service.sh` user units remain a checkout development path. + - The client secret is read once at start from `/secrets/mosaic-stack/`. The start refuses a slot that is a symbolic link, not a regular file, owned by another user, has any group or diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index c3228ecf6..be55a66da 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -790,6 +790,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // names (without a port) a remote browser may use; only the dev owner takes them, and only with // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port'); + // The CLI also refuses SSO/dev-owner combinations and LAN flags without the + // dev owner. Keep this guard for callers that construct a session source here. if ((sso?.mode === 'accounts') && (sso?.devOwner || devAllowHosts.length)) throw new Error('accounts mode refuses the dev LAN same-origin path'); if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner'); const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0; @@ -942,7 +944,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF const instanceAction = originalPath === '/api/console/restart'; if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' }); if (session && !session.rootOwner && session.user === undefined) { - bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = upstream = null; + bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = upstream = null; } if (session?.user !== undefined && !session.rootOwner && !instanceAction) { if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); @@ -1041,7 +1043,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (systemTypesPath) { if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null, - session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin }); + session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); } if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' }); // Row 194 (#1692, D4.1): the tester also launches and terminates. diff --git a/packages/webui/tests/accounts-sso.test.mjs b/packages/webui/tests/accounts-sso.test.mjs index 4f168b409..6611d8b36 100644 --- a/packages/webui/tests/accounts-sso.test.mjs +++ b/packages/webui/tests/accounts-sso.test.mjs @@ -76,10 +76,19 @@ test('root routes refuse account-only sessions and restart checks operator indep test('legacy root grant and account authority revoke independently and never return on an old cookie', async t => { const s = await setup(t, { legacy: true }), r = await s.login(); let session = s.sso.session({ headers: { cookie: r.cookie } }); assert.equal(session.user, 'legacy'); assert.equal(session.account, 'u-000000000002'); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200); await s.change(d => d.users[1].state = 'disabled'); session = s.sso.session({ headers: { cookie: r.cookie } }); assert.ok(session); assert.equal(session.user, 'legacy'); assert.equal(session.account, undefined); assert.equal(session.instanceRole, undefined); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200); await s.change(d => d.users[1].state = 'active'); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }).account, undefined); s.revoke(); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 401); const fresh = await s.login(); assert.ok(fresh.cookie); assert.equal(s.sso.session({ headers: { cookie: fresh.cookie } }).user, undefined); assert.equal((await call(s.port, '/api/chat/projects', { cookie: fresh.cookie })).status, 404); }); + +test('accounts mode refuses any start that could reach the dev LAN same-origin fake', async () => { + const { createDevOwner } = await import('../src/dev-owner.mjs'); + await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' }, devAllowHosts: ['accounts.test'] }), /accounts mode refuses the dev LAN/); + await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' } }), /accounts mode refuses the dev LAN/); +}); diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs index 64bc958e8..1a9db0107 100644 --- a/packages/webui/tests/chat.test.mjs +++ b/packages/webui/tests/chat.test.mjs @@ -309,6 +309,7 @@ test('configured Console process injects chat through its existing human transpo clientSecret: { slot: issuer.name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }], }] } }; writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(config)); + writeFileSync(join(dirname(env.MOSAIC_CONFIG), 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: issuer.name, sub: 'owner-sub' }], name: 'jason', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 }); const secrets = join(dirname(env.MOSAIC_CONFIG), 'secrets', 'mosaic-stack'); mkdirSync(secrets, { recursive: true }); const secret = join(secrets, issuer.name); writeFileSync(secret, `${issuer.clientSecret}\n`, { mode: 0o600 }); chmodSync(secret, 0o600); diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs index 6ed2d7a36..6bf7da990 100644 --- a/packages/webui/tests/pinned-copy.test.mjs +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -75,7 +75,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm')); assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind'); - assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']); + assert.deepEqual(readdirSync(target).sort(), ['.stack-build.json', 'RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']); assert.deepEqual(readdirSync(join(target, 'scripts')), ['case2-persona-import.mjs', 'mosaic', 'mosaic-config.mjs']); assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation="); assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only'); @@ -240,6 +240,18 @@ async function serve(t, cli, args, env) { return { out, get }; } +test('a stamped copy refuses the former unauthenticated Console start', async t => { + const { dir, script, head } = fixtureRepo(t); + const copies = join(dir, 'copies'); + assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0); + const cli = join(copies, head, 'packages/webui/src/cli.mjs'); + const { env } = fixture(join(dir, 'home')); + const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--business', 'acme'], { env }); + let err = ''; child.stderr.on('data', c => { err += c; }); + assert.equal((await once(child, 'close'))[0], 2); + assert.match(err, /stamped build serve requires --sso/); +}); + test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => { const { dir, script, head } = fixtureRepo(t); const copies = join(dir, 'copies'); diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index ee65630f0..1b25839b8 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -867,3 +867,21 @@ test('row196 socket flag renders only for supporting targets and refuses mixed t const both=await run(dir,['render','--tester-socket','--tester-port','7333']); assert.equal(both.status,4); }); + +// M3 follow-up 1: this user-manager path cannot represent the dedicated +// instance uid required by accounts deployments. No systemctl start is used. +test('M3 user-unit path refuses accounts and deployment metadata while checkout development render works', async t => { + for (const kind of ['accounts', 'deployed', 'sso', 'stamp']) { + const dir = tmp(t), configDir = join(dir,'.config/mosaic-dev'), dataRoot = join(dir,'data'); mkdirSync(configDir,{recursive:true}); mkdirSync(dataRoot); + const config = {configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'test'}}; + if(kind==='sso') config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}}; + writeFileSync(join(configDir,'config.json'),JSON.stringify(config)); + if(kind==='accounts') writeFileSync(join(configDir,'accounts.json'),'{}'); + if(kind==='deployed') writeFileSync(join(dataRoot,'.deployed'),'{"instance":"dev"}',{mode:0o444}); + const args = ['render','--port','17890']; + if(kind==='stamp') { const copy=fakeCopy(dir); writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:COPY_ID}),{mode:0o444}); args.push('--copy',COPY_ID); } + const r=await run(dir,args); assert.notEqual(r.status,0,kind); assert.match(r.stderr,/system units.*case2-unit|accounts.*user.unit/,kind); assert.equal(r.stdout,''); + } + const dir=tmp(t); const dev=await run(dir,['render','--port','17890']); assert.equal(dev.status,0,dev.stderr); + for(const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) {const r=await run(dir,['render',flag,'127.0.0.1']);assert.notEqual(r.status,0,flag);assert.equal(r.stdout,'');} +}); diff --git a/packages/webui/tests/system-types-http.test.mjs b/packages/webui/tests/system-types-http.test.mjs index 90ced42b9..9a8ba3fc2 100644 --- a/packages/webui/tests/system-types-http.test.mjs +++ b/packages/webui/tests/system-types-http.test.mjs @@ -46,7 +46,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef // afterRead and beforeWrite let a test act between the route's read and its write. const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; }; const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); }; - const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' }; + const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true }; let active = true; const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; const organizations = { @@ -61,7 +61,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id; const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type']; const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args }); - return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } }; + return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; } function call(f, path, method = 'GET', body, headers = {}) { @@ -147,6 +147,21 @@ test('a sign-out while the read runs dispatches no write on either route', async assert.deepEqual(f.onDisk(), ['coding']); }); +test('root authority revoked during a read cannot write through a surviving user session', async t => { + for (const projectRoute of [false, true]) { + let revokeOnRead = false; + const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } }); + const [, { revision }] = await call(f, ORG); + revokeOnRead = true; + const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG; + const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] }; + const [status, result] = await call(f, path, 'POST', body); + assert.deepEqual([status, result.error], [401, 'sign-in-required'], path); + assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0); + assert.deepEqual(f.onDisk(), ['coding']); + } +}); + test('the enabled set and its revision come from one read, so a change in between is not undone', async t => { let raced = false; const f = await fixture(t, { beforeWrite: async verb => { diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index 1151a774d..acfd9ce70 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -136,12 +136,19 @@ fi # The user, its uid, primary group and home, from the passwd database. [[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME" PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database" -IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW" +IFS=: read -r PW_NAME _ USER_UID USER_GID _ PW_HOME PW_SHELL <<<"$PW" [[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME" +[ "$PW_NAME" = "$USER_NAME" ] || die "passwd name does not match --user" +case "$PW_SHELL" in /usr/sbin/nologin|/sbin/nologin|/bin/false|/usr/bin/false) ;; *) die "--user requires a non-login shell" ;; esac abs_plain "$HOME_DIR" '--home' [ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)" GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database" -GROUP="${GR%%:*}" +IFS=: read -r GROUP _ GROUP_GID GROUP_MEMBERS <<<"$GR" +[ "$GROUP_GID" = "$USER_GID" ] || die "primary group id mismatch" +IFS=, read -r -a PRIMARY_MEMBERS <<<"$GROUP_MEMBERS" +for member in "${PRIMARY_MEMBERS[@]}"; do [ -z "$member" ] || [ "$member" = "$USER_NAME" ] || die "primary group must be sole-member"; done +PASSWD_ALL="$("$GETENT" passwd)" || die "cannot enumerate primary group members" +printf '%s\n' "$PASSWD_ALL" | awk -F: -v gid="$USER_GID" -v user="$USER_NAME" '$4 == gid && $1 != user { bad=1 } $4 == gid && $1 == user { seen++ } END { exit bad || seen != 1 }' || die "primary group must be sole-member" [[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP" case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac @@ -479,6 +486,23 @@ UNIT_PATH="$NODE_DIR" for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done FLAGS="$(serve_flags)" || exit 1 +# The config query runs after dropping uid, through the bounded supervisor. +# Root reads only checked facts and stats the two owned directories. +HOST_FACTS="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "host config query $(tester_capture_reason "$?")" +tester_facts "$HOST_FACTS" || die "host config query has invalid facts" +owned_directory() { + local dir="$1" what="$2" real st + abs_plain "$dir" "$what" + [ -d "$dir" ] && [ ! -L "$dir" ] || die "$what directory must exist without a symlink" + real="$(realpath -e -- "$dir")" || die "cannot resolve $what directory" + [ "$real" = "$dir" ] || die "$what directory contains a symlink" + st="$(stat -c '%u %a' -- "$dir")" || die "cannot stat $what directory" + [ "${st% *}" = "$USER_UID" ] || die "$what directory must be owned by $USER_NAME uid $USER_UID" + (( (8#${st#* } & 8#022) == 0 )) || die "$what directory must not be group or other writable" +} +owned_directory "$HOME_DIR/.config/mosaic-dev" config +owned_directory "$TS_DATA" data-root + HARDENING="ProtectSystem=strict ReadWritePaths=$HOME_DIR ProtectHome=yes diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 7a8585a11..eb95e274d 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -318,6 +318,20 @@ render() { case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac local root root="$(unit_root)" || exit 1 + # Accounts instances use dedicated system services, never a human/seat's + # systemd user manager. The check runs before rendering or installing. + node --input-type=module -e ' + import { lstatSync, readFileSync } from "node:fs"; + import { join } from "node:path"; + const [root, dir, file] = process.argv.slice(1); + const present = p => { try { lstatSync(p); return true; } catch(e) { if(e.code === "ENOENT") return false; throw e; } }; + let accounts = present(join(root, ".stack-build.json")) || present(join(dir, "accounts.json")); + if (present(file)) { + const s = JSON.parse(readFileSync(file, "utf8")); + accounts ||= Boolean(s.console?.sso) || present(join(s.dataRoot, ".deployed")); + } + if (accounts) { console.error("webui-service: accounts user-unit path refused; use system units from case2-unit.sh"); process.exit(1); } + ' "$root" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" || exit 1 if [ "$TESTER_SOCKET" = 1 ]; then grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support" fi -- 2.54.0 From db822c5b9d1a1a749092c4f435233b4f6ef4335e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 18:06:44 -0600 Subject: [PATCH 4/6] feat(cli): M3 reconciliation: accounts units, stamped renders, rehearsal (#1873) stack-release refuses a user-unit activate, rollback or pm-on when the instance shows any accounts sign (stamped target, accounts.json, console.sso, .deployed), before anything stops. Root renders the system units with case2-unit.sh, and the op runs with --units external. case2-unit renders a stamped copy's units only with --sso, console.sso, an accounts.json that loads and a valid .deployed, and names each missing step in run-sheet order before any write. Per Jason's ruling of 2026-11-10, a stamped library unit takes --sso and refuses proxy-preview admission and its proof. The unstamped and dev paths are unchanged. rehearse runs a stamped copy against rehearsal.invalid with scratch slot placeholders, a copy of the live accounts.json and the copy's own .deployed. It refuses sso-not-prepared and accounts-not-initialized before any rehearsal directory exists, and sso-isolated before the host starts. The tests that turned SSO on through the user unit now pin the refusal and reach the same end state through the system path. The copy tests start the Console on real scratch prerequisites and pin each refusal. The run sheet, TOOLS.md and the accounts design record the ruling and the migration order. --- docs/TOOLS.md | 37 +- docs/guides/host-run-sheet.md | 56 ++- docs/plans/2026-11-10_accounts-design.md | 18 +- packages/cli/tests/case2-install.test.mjs | 11 + packages/cli/tests/case2-unit.test.mjs | 172 +++++++- packages/cli/tests/stack-copy.test.mjs | 23 +- packages/cli/tests/stack-release.test.mjs | 512 ++++++++++++++++------ packages/webui/tests/pinned-copy.test.mjs | 100 ++++- packages/webui/tests/sso.test.mjs | 18 +- scripts/case2-install.sh | 8 +- scripts/case2-unit.sh | 72 ++- scripts/stack-release.sh | 96 +++- 12 files changed, 939 insertions(+), 184 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b1e623e62..c0062fdb9 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -807,13 +807,31 @@ the port and the backup's `SHA256SUMS`. Then it makes Roles come from the target copy's `roles/`, as the live bus reads them. - `steps.log` holds the same PASS and FAIL lines the run prints, with times; `host.log`, `console.log` and, after a migration, `migrate.log` sit beside it. +- When the live config has `console.sso` (M3, #1873), the scratch copy keeps + its provider ids, client ids, algorithms and subjects, and moves the public + origin and every issuer to `rehearsal.invalid`, a name that never resolves. + Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/` + holding `rehearsal-placeholder-secret`; the live secret is never read. + +A stamped target copy (one with `.stack-build.json`) serves only with `--sso`, +so its rehearsal needs the live instance's human-made SSO steps. Without a live +`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json` +beside the config it refuses `accounts-not-initialized`. Both name the run-sheet +step. Otherwise it copies the live `accounts.json` into `config/`, writes +`data/.deployed` with the target copy's own `writeDeployment`, prints +`PASS deployment`, and starts the Console with `--sso`. The scratch Console +runs every startup check, finds no provider at `rehearsal.invalid`, and starts +degraded. The live `accounts.json` joins the end check `live-config-unchanged`. +The rehearsal adds no subject and no account. A guard then loads the scratch config with the target's code and refuses with a named check: `data-root-live` when the scratch data root resolves to the live one, `sessions-live` when a `sessions.json` there names a running pid (the run never deletes the file), `data-root-scratch`, `notify-off`, -`tracker-configured` when `bootConfig` still sets a tracker, and -`credential-ref-live` for an env reference or a file outside `placeholders/`. +`tracker-configured` when `bootConfig` still sets a tracker, +`credential-ref-live` for an env reference or a file outside `placeholders/`, +and `sso-isolated` when a `console.sso` origin or issuer is not on +`rehearsal.invalid` or a slot is not its scratch placeholder. When the backup's schema differs from the target's, the run checks that the target refuses the store, migrates it, opens it, migrates it again as a no-op, and that the pointer's copy refuses the migrated store. @@ -834,6 +852,21 @@ Usage exits 4. Nothing prunes rehearsal directories. starts no thread ticker, and leaves an earlier host's `sessions.json` and its runs alone. No unit passes it, and `--pm` with it is a usage error. +An accounts instance runs system units (M3, #1873). Without `--units external`, +`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before +anything stops and before `--plan` prints, when the target copy holds a +`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the +config, when the config holds `console.sso`, or when `/.deployed` +exists. A config that cannot be read as JSON refuses as well. The message +names the first sign found and says root renders the units with +`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes +`--units external`. For `pm-on` it says the PM is a flag in root's bus unit. +So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot` +and `--no-sso` text above describes the pointers that pre-M3 hosts left, and +the Console's own start checks the slot (`readSlot` in +`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer +runs once `console.sso` is gone and no other sign is present. + #### Root-owned units (`--units external`, row 232) On a host whose bus and Console are root's system units, the stack user runs diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 7ffa4dcb3..505ab9047 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops. -For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair -but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh` -explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory` -profile and its private tester socket are unchanged. +A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles +(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its +private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console +stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`, +`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not +apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old +proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy +that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in +order. A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh` required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's @@ -418,7 +423,46 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' -- lead with the command's output. Removing only the args line does not help: the run then refuses `console-unit-legacy`. -The Console stays read-only after the upgrade, until #412 (#385). +The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO +accounts, as the migration below sets up. + +### Migrating a host to SSO accounts (M3, #1873) + +A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory` +(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the +stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and +`accounts.json` comes only from the human verb `mosaic accounts init`. + +1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6) + writes `/.deployed` naming the instance at its step 2. It never replaces an existing one, and + it refuses one that is invalid or names another instance. Check it read-only: + `stat -c '%U %a' /.deployed` prints `mosaic 444`. +2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its + providers, their subjects and its client-secret slots, per + `docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9). +3. Run `mosaic accounts init --name ` as `mosaic`. This is a human verb, run from a human shell and + never by an agent. It creates the one operator account from the configured `console.sso` subjects and + refuses if `accounts.json` exists. +4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member + of its primary group, and owner of its config directory and data root. An accounts instance never runs + under a human or development seat uid, and never under the `webui-service.sh` user units. +5. Re-pin. Put `--sso` after `--` in the args file (`factory`: `--sso --send`). For `library`, also remove + the proxy-preview trio and the proof pair. Then run `case2-unit.sh` or `stack-upgrade.sh upgrade`. + +If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps, +numbered in this order, for example: + +```text +case2-unit: refusing the units for : a stamped copy's Console starts only with SSO accounts; missing steps, in order: + 1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes /.deployed + 2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots + 3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json + 4. add --sso to the serve flags, then re-pin +``` + +Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO +has not configured yet. The next render after step 2 checks it. Rollback keeps `--sso`, accounts.json and +`.deployed` in place (`docs/plans/2026-11-10_accounts-design.md`, section 7). Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these rules, before it stops a unit. @@ -450,7 +494,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `` is the host's bus | Path | File | Mode | Owner | Profile | Precheck line | |---|---|---|---|---|---| | `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | ` sets ZAI_API_KEY` | -| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. | +| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. | | `/srv/mosaic/.config/mosaic-dev/secrets//` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business --library-status`: `library-last-error library-credential-missing` until it's placed, then `library last fetched ...` | | `/srv/mosaic/.config/mosaic-dev/secrets/agents///` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. | diff --git a/docs/plans/2026-11-10_accounts-design.md b/docs/plans/2026-11-10_accounts-design.md index f91de36cf..029d76866 100644 --- a/docs/plans/2026-11-10_accounts-design.md +++ b/docs/plans/2026-11-10_accounts-design.md @@ -108,7 +108,10 @@ configured subject (`packages/cli/src/config.mjs:141`), and `packages/webui/src/cli.mjs serve` from that copy. Its flag set is `--port`, `--business`, `--board`, `--sso`, `--send` and the header preview set (`webui-service.sh:98`). It has no dev-owner flag. - `scripts/case2-unit.sh` renders Case 2 units the same way. + `scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its + library profile takes the preview set and refuses `--sso`. Under the + owner ruling of 2026-11-10 a stamped library copy signs in through SSO + accounts instead (section 6, item 5). ### Organizations and who may read them @@ -1231,7 +1234,13 @@ The executable boundary: this instance, and accounts.json loads. The Case 1 and Case 2 installers write `.deployed`. 5. **The unit renderers have no dev flag.** `webui-service.sh` and - `case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds + `case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a + stamped copy, `case2-unit.sh` renders either profile, factory or library, + only with `--sso`, `console.sso`, a loading accounts.json and a valid + `.deployed`, and refuses before any write, naming each missing step. A + stamped library unit refuses proxy-preview admission and its proof flags: + proxy-preview does not apply to a stamped copy. The dev and unstamped + paths keep the preview set. M3 adds a test that pins it. The instance boundary, which also covers a checkout started by mistake on a @@ -1451,6 +1460,9 @@ Acceptance tests, against a fake OIDC provider on port 0: - `--dev-local-owner` from a checkout refuses with accounts.json present, with `console.sso`, and with `.deployed`. - `webui-service.sh render` and `case2-unit.sh` refuse a dev flag. +- For a stamped copy, `case2-unit.sh` renders a library or factory unit + only with `--sso` and the prerequisites, names each missing step before + any write, and refuses the proxy-preview and proof flags. - `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only when its shell is non-login, its primary group has no other members, and it owns the config directory and data root. Fixtures for a human login and @@ -1667,6 +1679,8 @@ and draft 3 applies them. No question from draft 2 remains open. | May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. | | May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. | +The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy. + ### Choices made under the rulings These are decided in this draft, not open. Each one is a single reviewed diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index 51380506e..030352908 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -570,3 +570,14 @@ test('row334 install Next tells root to select proxy only when configured', t => assert.equal(r.code, 0, r.out); assert.match(r.out, /Next: root runs case2-unit.sh[^\n]+add --proxy when the config has a proxy section/); }); + +test('M3 install Next names the SSO, accounts-init and dedicated-uid steps before root re-pins with --sso', t => { + const x = world(t), r = install(x); + assert.equal(r.code, 0, r.out); + const done = r.out.split('\n').findIndex(l => l.startsWith('case2-install: done. .deployed names this instance')); + assert.ok(done >= 0, r.out); + assert.match(r.out.split('\n')[done], /in order \(run sheet section 7\): prepare console\.sso and its client secret slots in .+; a human runs `mosaic accounts init` as [^;]+; [^ ]+ stays a dedicated uid with a non-login shell, the only member of its primary group\.$/); + assert.match(r.out.split('\n')[done + 1], /^case2-install: Next: root runs case2-unit\.sh --copy [0-9a-f]{40} --business \S+ with --sso in the serve flags \(factory: --sso --send;/); + // The installer names the human step; it never runs it. + assert.equal(existsSync(join(x.configDir, 'accounts.json')), false); +}); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 2dc47f800..28e502a27 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -708,7 +708,9 @@ test("#415 factory --sso refuses before any unit write when the stack user's con noUnits(h); }); -test("#416 library still refuses --sso, alone and with --send, before writing a unit", (t) => { +// M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The +// stamped copies below sign in through SSO accounts. +test("#416 an unstamped library copy still refuses --sso, alone and with --send, before writing a unit", (t) => { const h = hub(t); ssoConfig(h); refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/); @@ -716,6 +718,174 @@ test("#416 library still refuses --sso, alone and with --send, before writing a noUnits(h); }); +// ---- M3 (#1873): stamped copies ---- +// A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso, +// an accounts.json that loads and a .deployed naming the default instance. The render asks the +// stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and +// organization accounts.mjs, and refuses before any write, naming every missing step in order. +// No real host, config or credential: the issuer is on .invalid and nothing connects to it. +const ISSUER = "https://idp.invalid/a"; +const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] }; +const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json"); +const deployedFile = (h) => join(h.dataRoot, ".deployed"); +function stamp(h, id = COPY_A) { + const dir = copyDir(h, id); + cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs")); + for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg)); + writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n"); + chmodSync(join(dir, ".stack-build.json"), 0o444); +} +const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } }); +function stampAccounts(h, doc = STAMP_ACCOUNTS) { + writeFileSync(accountsFile(h), JSON.stringify(doc)); + chmodSync(accountsFile(h), 0o600); +} +function stampDeployed(h, instance = "dev") { + writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n"); + chmodSync(deployedFile(h), 0o444); +} +function stamped(t, { sso = true, accounts = true, deployed = true } = {}) { + const h = hub(t); + stamp(h); + if (sso) stampSso(h); + if (accounts) stampAccounts(h); + if (deployed) stampDeployed(h); + return h; +} +const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"]; +const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"]; +const STEP = { + deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`, + deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`, + sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`, + accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`, + accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`, + library: () => "add --sso to the serve flags, then re-pin", + factory: () => "add --sso --send to the serve flags, then re-pin", +}; +// The exact refusal: one header line, then the steps numbered in the run sheet's order. +function refusesSteps(h, r, steps) { + assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`); + const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n"; + assert.equal(r.err, want); +} +// Nothing the render must not write: no unit, no systemctl, and none of the prerequisites. +function wroteNothing(h, { accounts = false, deployed = false } = {}) { + noUnits(h); + assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json"); + assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed"); +} + +test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => { + const h = stamped(t); + const r = unit(h, { flags: STAMP_LIBRARY }); + assert.equal(r.code, 0, r.out + r.err); + const webui = read(webuiFile(h)); + assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --sso`]); + assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/); + assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]); + // The prerequisites were asked as the stack user, through the copy's own modules. + const sudo = read(h.sudoLog); + assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m")); + assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/); + assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`)); + assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/); + // The render read them and left each as it was. + assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS); + assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444); +}); + +test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => { + const h = stamped(t); + for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) { + const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] }); + assert.equal(r.code, 1, flag); + assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`); + } + // The whole old preview set refuses at its first flag, with or without --sso. + refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/); + refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/); + wroteNothing(h, { accounts: true, deployed: true }); + assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query"); +}); + +test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => { + const none = stamped(t, { sso: false, accounts: false, deployed: false }); + refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]); + wroteNothing(none); + refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]); + wroteNothing(none); + // Each prerequisite alone. + for (const [name, opts, steps] of [ + ["no .deployed", { deployed: false }, [STEP.deployed]], + ["no console.sso", { sso: false }, [STEP.sso]], + ["no accounts.json", { accounts: false }, [STEP.accounts]], + ]) { + const h = stamped(t, opts); + refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps); + wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false }); + } + const flagless = stamped(t); + refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]); + wroteNothing(flagless, { accounts: true, deployed: true }); +}); + +test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => { + const cases = [ + [".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]], + [".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]], + [".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]], + ["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]], + ["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]], + ["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]], + ["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]], + ]; + for (const [name, change, steps] of cases) { + for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) { + const h = stamped(t); + change(h); + refusesSteps(h, unit(h, { profile, flags }), steps); + noUnits(h); + } + } +}); + +test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => { + const h = stamped(t); + const r = unit(h, { profile: "factory", flags: STAMP_FACTORY }); + assert.equal(r.code, 0, r.out + r.err); + assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]); + const none = stamped(t, { sso: false, accounts: false, deployed: false }); + refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]); + wroteNothing(none); + refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]); + wroteNothing(none); + // A stamped copy without the flag pair names it as the last step, with the prerequisites present. + const flagless = stamped(t); + refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]); + refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]); + wroteNothing(flagless, { accounts: true, deployed: true }); + // The pair stays a pair: --sso alone still refuses, as for an unstamped copy. + refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/); + noUnits(flagless); +}); + +test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => { + // The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit. + for (const [body, pattern] of [ + ['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/], + ["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/], + ]) { + const h = stamped(t); + const org = join(copyDir(h), "packages/organization"); + rmSync(org); + mkdirSync(join(org, "src"), { recursive: true }); + writeFileSync(join(org, "src/accounts.mjs"), body); + refuses(unit(h, { flags: STAMP_LIBRARY }), pattern); + noUnits(h); + } +}); + test("row253 factory refuses retired TCP and a value after the socket marker", (t) => { const h = hub(t); refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/); diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index 63bef58d8..6457cdbf5 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -12,6 +12,8 @@ import {createOwnedRunner} from './fixtures/owned-child.mjs'; import {pathToFileURL} from 'node:url'; import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs'; import {reserveOrg,activateOrg} from '../../business/src/org-registry.mjs'; +import {writeDeployment} from '../src/deployment.mjs'; +import {fakeOidc} from '../../webui/tests/helpers/fake-oidc.mjs'; const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,''); const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim(); export function setup(t, realPin=false){ @@ -140,7 +142,7 @@ async function childResult(command,args,env,input){ const timer=setTimeout(()=>p.kill('SIGKILL'),15000); try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)} } -test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{ +test('real pinned install loads bus and Console; the Console starts only with --sso and its deployed prerequisites; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{ const s=setup(t,true); const copied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(copied.status,0,copied.stderr); const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href; @@ -157,7 +159,24 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c); try{await until(()=>hostOut.includes('\"org\":\"acme\"')||host.exitCode!==null);assert.match(hostOut,/\"org\":\"acme\"/,hostErr);} finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}} - const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; + // The copy is stamped (M3): its Console starts only with --sso, console.sso and its slot, .deployed and accounts.json. + const serveArgs=sso=>[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9',...(sso?['--sso']:[])]; + const op=await fakeOidc();t.after(()=>op.close());const iss=op.issuer('a'); + const ca=join(s.root,'ca.pem');writeFileSync(ca,op.cert);const webEnv={...env,NODE_EXTRA_CA_CERTS:ca}; + const startRefused=async(sso,why)=>{const r=await childResult(process.execPath,serveArgs(sso),webEnv,'');assert.equal(r.status,2,r.stderr);assert.match(r.stderr,why);assert.doesNotMatch(r.stdout,/Mosaic Console:/);}; + await startRefused(false,/stamped build serve requires --sso/); + await startRefused(true,/\.deployed is missing or invalid/); + writeDeployment({dataRoot:f.dataRoot},configDir); + await startRefused(true,/accounts file not found/); + writeFileSync(join(configDir,'accounts.json'),JSON.stringify({accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}),{mode:0o600}); + await startRefused(true,/must be a configured provider/); + const config=JSON.parse(readFileSync(env.MOSAIC_CONFIG,'utf8')); + config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}}; + writeFileSync(env.MOSAIC_CONFIG,JSON.stringify(config)); + await startRefused(true,/client secret slot is missing/); + mkdirSync(join(configDir,'secrets','mosaic-stack'),{recursive:true,mode:0o700}); + const slot=join(configDir,'secrets','mosaic-stack','a');writeFileSync(slot,iss.clientSecret,{mode:0o600});chmodSync(slot,0o600); + const web=spawn(process.execPath,serveArgs(true),{env:webEnv,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c); try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}} diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 22f4c404f..cdc44f89c 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -1555,51 +1555,88 @@ function setEdit(set,name,from,to,sums=true){ if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))} } +// M3 (#1873): console.sso marks an accounts instance, which runs system units, so a user-unit run +// with it refuses accounts-user-unit before anything stops. An --sso pointer now comes two ways: +// - a pre-M3 host's user-unit run. LEGACY is the fixture repo with the M3 base's stack-release.sh, +// webui-service.sh and stack-copy.sh, so legacy(e) builds the state such a host left behind; +// - the system path: root stops the units, stack-release stages with --units external, root +// installs the units and starts them, and confirm pins the pointer to root's Console flags. +const M3_BASE='c3c192677defa518eabeb324aac82f0757e398c3'; +const LEGACY=join(base,'legacy-repo'); +cpSync(repo,LEGACY,{recursive:true}); +for(const f of ['scripts/stack-release.sh','scripts/webui-service.sh','scripts/stack-copy.sh'])writeFileSync(join(LEGACY,f),execFileSync('git',['-C',ROOT,'show',`${M3_BASE}:${f}`]),{mode:0o755}); +const legacy=(e)=>({...e,sr:join(LEGACY,'scripts','stack-release.sh')}); +const X=(e)=>({...e,flags:[...e.flags,'--units','external']}); +const AUU=(e,op)=>`${e.cfg} holds console.sso; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`; +// Root's part. rootUp installs the bus unit for COPY and writes the Console unit case2-unit.sh +// renders for COPY with FLAGS (webui-service.sh refuses an accounts instance), then starts both. +const rootDown=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)}; +function rootUp(e,copy,flags){ + execFileSync('bash',[join(repo,'scripts','bus-service.sh'),'install',...e.flags,'--copy',copy,'--copies',COPIES,'--claude',e.claude],{env:e.env,stdio:'ignore'}); + const f=join(e.units,e.con),s=readFileSync(f,'utf8'),node=/^ExecStart=(\S+) /m.exec(s)[1],dir=join(COPIES,copy); + writeFileSync(f,s.replace(/^WorkingDirectory=.*$/m,`WorkingDirectory=${dir}`).replace(/^Documentation=.*$/m,`Documentation=file://${dir}/packages/webui/README.md`) + .replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`)); + execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con); +} +// One system-path run: OP (activate TO, or rollback --to TO) staged, root's units for TO with FLAGS, confirm. +async function viaSystem(e,op,to,flags,...more){ + const x=X(e);rootDown(e); + let r=await prun(x,...(op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to]),...more);assert.equal(r.code,0,r.out+r.err); + const act=logLines(e).at(-1).activation; + rootUp(e,to,flags); + r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err); + assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`); + const l=logLines(e).at(-1);assert.deepEqual([l.op,l.to,l.result,l.units,l.activation],[op,to,'ok','external',act]); + return act; +} + +// --plan with an accounts sign: exit 3 with the refusal alone; nothing written, started or stopped, and no log line. +async function planRefused(e,args,code,msg){ + const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length; + const r=await prun(e,...args,'--plan');assert.equal(r.code,3,r.out+r.err);assert.equal(r.out,''); + assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`); + assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); +} +// The user-unit run and its --plan both refuse accounts-user-unit, before anything stops. +async function auu(e,args,op,msg=AUU(e,op)){await refused(e,args,'accounts-user-unit',msg);await planRefused(e,args,'accounts-user-unit',msg)} +const rootStart=(e)=>{execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con)}; +const shown=(e,act,show=SHOW)=>readFileSync(join(setOf(e,act),show),'utf8'); + test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{ - await t.test('item 1: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',async(t)=>{ + await t.test('item 1 (M3): a pre-SSO activation drops legacy send; with console.sso a user-unit --sso refuses accounts-user-unit, and the system path pins root\'s --sso Console',async(t)=>{ const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base); let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err); assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--tester-port','7333']); await ssoConfig(e); + await auu(e,['activate',C1,'acme','--sso'],'activate'); + // Root renders the stamped Console with --sso and no preview or proof flags (case2-unit.test.mjs pins that render). const want=['--port',e.port,'--business','acme','--sso','--tester-port','7333']; - const out=await planOnly(e,0,'activate',C1,'acme','--sso'); - assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: off', - `sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --tester-port 7333`]); - assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`); - r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + const act=await viaSystem(e,'activate',C1,want); assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want); assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e)); r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); - const steps=stepText(e); - assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps); - assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps); + // The activation's set holds the proxy-preview Console it replaced, pair and tester port included. + assert.ok(shown(e,act).includes(` ${[...pre,...pair(f),'--tester-port','7333'].join(' ')}`),shown(e,act)); await noLeak(e); }); - await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{ - const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e); - let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); - let out=await planOnly(e,0,'activate',C1,'acme','--sso'); - assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]); - r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); - const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']); - assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']); - assert.doesNotMatch(stepText(e),/proof/); - await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`); - await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`); - r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); - assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']); - out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']); - r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err); - // Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso). - assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m); - assert.match(stepText(e),/^serve: .* --sso --send$/m); - r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); - assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']); - r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + await t.test('item 1 (M3): while console.sso is set every user-unit activate refuses accounts-user-unit; the system path carries root\'s --sso --send Console to another copy',async(t)=>{ + const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e); + for(const args of [['activate',C1,'acme'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--sso','--no-send'],['activate',C,'acme','--migrate']])await auu(e,args,'activate'); + const sso=['--port',e.port,'--business','acme','--sso','--send']; + await viaSystem(e,'activate',C1,sso); + assert.deepEqual(pointer(e).console,sso); + await auu(e,['activate',C1,'acme','--no-send'],'activate'); + await auu(e,['rollback','acme'],'rollback'); + const act=await viaSystem(e,'activate',C,sso,'--migrate'); + assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,sso); + assert.ok(conExec(e).endsWith(` --checkout ${repo} ${sso.join(' ')}`),conExec(e)); + assert.match(shown(e,act),/ --sso --send /); + let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + // Root's unit without --send: the pointer pins what root rendered. const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g); - r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err); + await viaSystem(g,'activate',C1,['--port',g.port,'--business','acme','--sso']); assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']); - assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n')); + r=await prun(g,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); for(const x of [e,g])await noLeak(x); }); await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{ @@ -1613,53 +1650,49 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); await noLeak(e); }); - await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{ - const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false}); - const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)}; - put(); + await t.test('item 2 (M3): without console.sso, a target without --sso and the missing key refuse before any stop; with console.sso, valid or not and whatever its slot, the user-unit run refuses accounts-user-unit first',async(t)=>{ + const e=await mk(t);await seedPreview(e); await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`); - setKey(e,undefined); // N4: from another copy, the refusal names the re-pin without --sso as the first step. await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`); let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`); - setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}}); - r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err); - await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`); - setKey(e,SSO_KEY); - const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`; - rmSync(slot); - await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing')); - const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot); - await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link')); - rmSync(slot);mkdirSync(slot,{mode:0o700}); - await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file')); - for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))} - put(0o600,''); - await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty')); - // The slot directory resolving into the repository: the file is no link, but its real path is. - const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true})); - writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600); - const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets); - await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies')); // --plan lists the sso refusal with the rest and writes nothing. const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80'); - assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]); - rmSync(secrets);mkdirSync(secrets,{mode:0o700});put(); - r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-config: ${C}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C} without --sso first, then write console.sso, then run activate ${C} --sso`]); + // With console.sso the slot is the Console start's check (packages/webui/tests/sso.test.mjs, readSlot); + // no slot state and no bad key gets a user-unit run past accounts-user-unit. + await ssoConfig(e,{slot:false}); + const slot=slotPath(e),secrets=dirname(slot),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)}; + const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true})); + writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600); + const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600}); + const states=[()=>{},()=>symlinkSync(elsewhere,slot),()=>{rmSync(slot);mkdirSync(slot,{mode:0o700})},()=>put(0o644),()=>put(0o640),()=>put(0o604),()=>put(0o600,''), + ()=>{rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets)},()=>{rmSync(secrets);mkdirSync(secrets,{mode:0o700});put()}]; + // A bad tester port is a usage error outside --plan (row 180), so that case runs --plan only: + // accounts-user-unit stops the plan before it lists tester-port-invalid. + for(const s of states){s();for(const args of [['activate',C,'acme','--sso'],['activate',B,'acme','--sso']])await auu(e,args,'activate');await planRefused(e,['activate',C,'acme','--sso','--tester-port','80'],'accounts-user-unit',AUU(e,'activate'))} + setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}}); + await auu(e,['activate',C,'acme','--sso'],'activate'); + setKey(e,undefined); + r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err); await noLeak(e); }); - await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{ - const e=await mk(t);await seedPreview(e);await ssoConfig(e); + await t.test('item 3 (M3): a config whose console key the target loader refuses stops an external activate or rollback before any stop, naming the first step; a user-unit run refuses accounts-user-unit first',async(t)=>{ + const e=await mk(t);await seedPreview(e); let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); - const why='mosaic-config: unsupported configuration key: "console"'; - await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why)); - await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why)); - const out=await planOnly(e,3,'rollback','acme'); + await ssoConfig(e); + await auu(e,['activate',B,'acme'],'activate');await auu(e,['rollback','acme'],'rollback'); + // Root stopped the units; the staged run checks the config before it writes anything. + rootDown(e); + const x=X(e),why='mosaic-config: unsupported configuration key: "console"'; + await refused(x,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why)); + await refused(x,['rollback','acme','--to',B],'config-console-key',KEY_MSG(e,B,'rollback',why)); + const out=await planOnly(x,3,'rollback','acme','--to',B); assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]); setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}}); - await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`)); - setKey(e,undefined); + await refused(x,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`)); + setKey(e,undefined);rootStart(e); r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B); await noLeak(e); }); @@ -1676,17 +1709,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B); await noLeak(e); }); - await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{ - const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e); + await t.test('item 3 (M3): from a pre-M3 --sso pointer the user-unit rollback refuses accounts-user-unit; without console.sso it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{ + const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']); let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); const set=arrivalSet(e,C1); - r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); - r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); - r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); + // A pre-M3 host turned --sso on in place, then re-pinned twice. + await ssoConfig(e); + for(const f of ['--sso','--send','--no-send']){r=await prun(legacy(e),'activate',C1,'acme',f);assert.equal(r.code,0,r.out+r.err)} // The latest set holds the --sso Console; the arrival set holds B's. - assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /); + assert.match(shown(e,pointer(e).activation),/ --sso /); assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /); - await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"')); + await auu(e,['rollback','acme'],'rollback'); setKey(e,undefined); const want=[...noSend(base.slice(0,-2)),'--tester-port','7333']; const out=await planOnly(e,0,'rollback','acme'); @@ -1701,15 +1734,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g); r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err); await ssoConfig(g); - r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + r=await prun(legacy(g),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + await auu(g,['rollback','acme'],'rollback'); setKey(g,undefined); r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err); assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]); for(const x of [e,g])await noLeak(x); }); - await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{ + await t.test('item 3 (M3): from a pre-M3 --sso pointer, a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{ const e=await mk(t);await seedPreview(e);await ssoConfig(e); - let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + await auu(e,['rollback','acme'],'rollback'); setKey(e,undefined); const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8'); setEdit(set,SHOW,' --public-host ',' --sso --public-host '); @@ -1731,17 +1766,18 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B); await noLeak(e); }); - await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{ + await t.test('round 2 (webby-0028 N1, M3): run through a symlinked repository path, a proof file inside the repository still refuses, and console.sso with a slot inside the repository refuses accounts-user-unit',async(t)=>{ const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link); const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e); const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true})); await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`); - await ssoConfig(e,{slot:false}); let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + await ssoConfig(e,{slot:false}); const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true})); writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600); const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets); - await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`); + // The Console's start refuses the slot (sso.test.mjs: inside the repository or a stack copy). + await auu(e,['activate',C1,'acme','--sso'],'activate'); await noLeak(e); }); await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{ @@ -1758,24 +1794,21 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); await noLeak(e); }); - await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{ + await t.test('round 3 (sage-1556, M3): a named instance refuses a user-unit --sso with its own config, turns --sso on through the system path on its own Console unit, and rolls back from its own set',async(t)=>{ const e=await mk(t),a=await named(e,'alpha');await seed(a); const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart=')); assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a)); - await ssoConfig(a,{slot:false}); - await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`); - await proofFile(a,SLOT,0o600,dirname(slotPath(a))); + await ssoConfig(a); + await auu(a,['activate',C1,'acme','--sso'],'activate'); const want=['--port',a.port,'--business','acme','--sso']; - const out=await planOnly(a,0,'activate',C1,'acme','--sso'); - assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`); - let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + const act=await viaSystem(a,'activate',C1,want); assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want); assert.ok(unit().endsWith(` ${want.join(' ')}`),unit()); assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`)); assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written'); - r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + let r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); setKey(a,undefined); - const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show); + const set=setOf(a,act);assert.equal(arrivalSet(a,C1),set);assert.ok(existsSync(join(set,show)),show); assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]); r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err); assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1)); @@ -1794,44 +1827,35 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac // Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and // activate --no-sso turns --sso off with the Console flags from the set of the activation that turned -// it on, dropping --send. The tester follows the pointer. +// it on, dropping --send. The tester follows the pointer. M3 (#1873): with console.sso these are +// accounts-user-unit refusals; a pre-M3 --sso pointer comes from legacy(e), and --no-sso runs once +// console.sso is gone. const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)"; const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off"; const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`; const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`; const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`; test('T58 row 273: activate --send and --no-sso',async(t)=>{ - await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{ + await t.test('T1 (M3): a user-unit --sso --send refuses accounts-user-unit; the system path pins root\'s --sso --send before the tester port',async(t)=>{ const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e); + await auu(e,['activate',C1,'acme','--sso','--send'],'activate'); const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']; - const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send'); - assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]); - assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`); - let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err); + await viaSystem(e,'activate',C1,want); assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want); assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e)); - const steps=stepText(e); - assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps); - r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); - // The same copy with send on again is already active. - await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`); + const r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + await auu(e,['activate',C1,'acme','--send'],'activate'); await noLeak(e); }); - await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{ + await t.test('T1 (M3): beside a pre-M3 carried --sso, a user-unit --send refuses accounts-user-unit; the system path carries root\'s --sso --send to another copy',async(t)=>{ const e=await mk(t);await seedPreview(e);await ssoConfig(e); - let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']); - const out=await planOnly(e,0,'activate',C1,'acme','--send'); - assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']); - assert.ok(out.includes(NOTE(C1)),out.join('\n')); - r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); - const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']); + await auu(e,['activate',C1,'acme','--send'],'activate'); + await auu(e,['activate',C,'acme','--send','--migrate'],'activate'); const want=['--port',e.port,'--business','acme','--sso','--send']; - assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e)); - assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m); - await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`); - // Another copy carries both. - assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']); + await viaSystem(e,'activate',C,want,'--migrate'); + assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e)); r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); await noLeak(e); }); @@ -1848,11 +1872,12 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`); for(const x of [e,g])await noLeak(x); }); - await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{ + await t.test('T2 (sage-2315 B1, M3): from a pre-M3 --sso --send pointer the user-unit rollback refuses accounts-user-unit; without console.sso the rollback to a Console without --sso ends with neither flag',async(t)=>{ const e=await mk(t),base=await seedPreview(e);await ssoConfig(e); - let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err); assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']); const set=arrivalSet(e,C1); + await auu(e,['rollback','acme'],'rollback'); setKey(e,undefined); const out=await planOnly(e,0,'rollback','acme'); assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]); @@ -1866,7 +1891,7 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ }); await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{ const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e); - let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err); const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e); for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'], ['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'], @@ -1879,15 +1904,18 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs); await noLeak(e); }); - await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{ - const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e); + await t.test('T3 (M3): --no-sso refuses accounts-user-unit while console.sso is set; without it, on the same copy it returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{ + const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']); let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); const pre=conExec(e),want=noSend(base); assert.ok(pre.endsWith(` ${want.join(' ')}`),pre); - r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + await ssoConfig(e); + r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); const set=setOf(e,pointer(e).activation); - r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); + r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']); + await auu(e,['activate',C1,'acme','--no-sso'],'activate'); + setKey(e,undefined); const out=await planOnly(e,0,'activate',C1,'acme','--no-sso'); assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]); assert.ok(out.includes(NOTE(C1)),out.join('\n')); @@ -1904,14 +1932,16 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG); await noLeak(e); }); - await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{ + await t.test('T3 (M3): from pre-M3 re-pins, the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{ const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e); // The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ. - let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err); const set=setOf(e,pointer(e).activation); - r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); - assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /); + r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); + assert.match(shown(e,pointer(e).activation),/ --sso /); assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']); + await auu(e,['activate',C1,'acme','--no-sso'],'activate'); + setKey(e,undefined); const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334']; const out=await planOnly(e,0,'activate',C1,'acme','--no-sso'); assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]); @@ -1921,17 +1951,20 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); await noLeak(e); }); - await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{ - const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e); + await t.test('T3 (sage-2305 a, M3): after a pre-M3 --sso and two re-pins that carry it, --no-sso without console.sso restores the flags from before --sso byte for byte',async(t)=>{ + const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']); let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre); - r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + await ssoConfig(e); + r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); const set=setOf(e,pointer(e).activation); // Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it. - r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); - r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); + r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err); + r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2); - for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /); + for(const a of acts)assert.match(shown(e,a),/ --sso /); + await auu(e,['activate',C1,'acme','--no-sso'],'activate'); + setKey(e,undefined); const out=await planOnly(e,0,'activate',C1,'acme','--no-sso'); assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]); r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err); @@ -1939,27 +1972,31 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); await noLeak(e); }); - await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{ + await t.test('T4 (sage-2305 b, M3): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{ const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e); - let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); const first=setOf(e,pointer(e).activation); - r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); - assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /); + r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err); + assert.match(shown(e,pointer(e).activation),/ --sso /); // The set of the activation that turned --sso on now claims its Console ran with it too, sums intact. setEdit(first,SHOW,' --public-host ',' --sso --public-host '); + await auu(e,['activate',C1,'acme','--no-sso'],'activate'); + setKey(e,undefined); const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`); await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why); assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]); await noLeak(e); }); - await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{ + await t.test('T4 (M3): --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{ const e=await mk(t);await seedPreview(e,undefined,['--send']); await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG); await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG); assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]); - // One set, from the activation that turned --sso on. + // One set, from the pre-M3 activation that turned --sso on. await ssoConfig(e); - let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err); + await auu(e,['activate',C1,'acme','--no-sso'],'activate'); + setKey(e,undefined); const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8'); // An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above. const logText=readFileSync(e.log,'utf8'); @@ -1985,6 +2022,62 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{ }); }); +// M3 (#1873) item 2: each accounts sign refuses a user-unit activate, rollback or pm-on before +// anything stops; without them the user path runs, and with them the system path runs. +const AUU_TAIL=(op)=>`; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`; +test('M3 accounts-user-unit: each accounts sign refuses a user-unit run before any stop; the user path without them and the system path with them both run',async(t)=>{ + await t.test('a stamped target, accounts.json, console.sso and .deployed each refuse activate and rollback, run and --plan',async(t)=>{ + const e=await mk(t);await seedPreview(e); + let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + const accounts=join(dirname(e.cfg),'accounts.json'),deployed=join(e.data,'.deployed'); + const stamp=(copy)=>{const d=join(COPIES,copy),f=join(d,'.stack-build.json'),m=statSync(d).mode&0o777;chmodSync(d,0o755);writeFileSync(f,'{}\n');chmodSync(d,m); + return ()=>{chmodSync(d,0o755);rmSync(f);chmodSync(d,m)}}; + const signs=[ + [()=>stamp(C),()=>stamp(B),`${join(COPIES,C)} is a stamped copy`,`${join(COPIES,B)} is a stamped copy`], + [()=>{writeFileSync(accounts,'{}\n',{mode:0o600});return ()=>rmSync(accounts)},null,`${accounts} is present`], + [()=>{setKey(e,SSO_KEY);return ()=>setKey(e,undefined)},null,`${e.cfg} holds console.sso`], + [()=>{writeFileSync(deployed,'{}\n');return ()=>rmSync(deployed)},null,`${deployed} is present`], + ]; + for(const [on,onRb,why,whyRb] of signs){ + let off=on();t.after(()=>{try{off()}catch{}}); + await auu(e,['activate',C,'acme','--migrate'],'activate',why+AUU_TAIL('activate')); + if(onRb){off();off=onRb()} + await auu(e,['rollback','acme'],'rollback',(whyRb??why)+AUU_TAIL('rollback')); + off(); + } + // No sign left: the user path runs both ways. + r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B); + r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,C1); + r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + await noLeak(e); + }); + await t.test('pm-on on an accounts instance refuses before it captures or stops anything; without the sign it runs',async(t)=>{ + const e=await mk(t);await pending(e);biz(e,HARNESS); + const accounts=join(dirname(e.cfg),'accounts.json');writeFileSync(accounts,'{}\n',{mode:0o600}); + // The harness is set, so only the accounts sign stands between pm-on and the user bus unit. + await refused(e,['pm-on','acme'],'accounts-user-unit',`${accounts} is present; an accounts instance runs system units, so root renders them with case2-unit.sh, and the PM is a flag in root's bus unit`); + rmSync(accounts); + const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err); + assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out); + }); + await t.test('with accounts.json and .deployed the system path activates, confirms and rolls back, and the user path still refuses',async(t)=>{ + const e=await mk(t),base=await seedPreview(e); + writeFileSync(join(dirname(e.cfg),'accounts.json'),'{}\n',{mode:0o600});writeFileSync(join(e.data,'.deployed'),'{}\n'); + await ssoConfig(e); + const sso=['--port',e.port,'--business','acme','--sso','--send']; + await viaSystem(e,'activate',C1,sso); + assert.deepEqual([pointer(e).copy,pointer(e).console],[C1,sso]); + let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + // B's loader refuses the console key, so a rollback onto it drops console.sso first; the other signs stay. + setKey(e,undefined); + await auu(e,['rollback','acme'],'rollback',`${join(dirname(e.cfg),'accounts.json')} is present`+AUU_TAIL('rollback')); + await viaSystem(e,'rollback',B,base); + assert.deepEqual([pointer(e).copy,pointer(e).console],[B,base]); + r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/); + await noLeak(e); + }); +}); + // --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") --- // Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and // mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and @@ -2079,6 +2172,16 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']], for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\nexport function requestArchived() { return false; }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)} execFileSync('chmod',['-R','a-w',d]); } +// M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json +// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, and +// the deployment.mjs whose writeDeployment the rehearsal runs. +const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES); +{ + const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]); + for(const p of ['packages/webui/src/sso-config.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p)); + writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444}); + execFileSync('chmod',['-R','a-w',d]); +} const TESTER='7499'; // Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which @@ -2121,7 +2224,7 @@ async function rmk(t,{copy=B,store='3g'}={}){ const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]); async function rehearse(e,id,...args){ - const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']}); + const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',e.copies??RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']}); let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d}); e.during?.(ch); const timer=setTimeout(()=>ch.kill('SIGKILL'),120000); @@ -2137,7 +2240,7 @@ function ownScript(e,from,to){ const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to)); } const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[]; -const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live']; +const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live','PASS sso-isolated']; const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged']; test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{ @@ -2374,6 +2477,133 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t } }); +// --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) --- +// A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its +// instance. The rehearsal gives its scratch Console all four: the live console.sso with origin and +// issuers on rehearsal.invalid, a placeholder per slot, a copy of the live accounts.json, and a +// .deployed from the copy's writeDeployment. The live slot has a read watch: it is never opened. +const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}; +async function smk(t,{sso=true,accounts=true}={}){ + const e=await rmk(t);e.copies=SCOPIES; + if(sso)await ssoConfig(e); + e.accounts=join(dirname(e.cfg),'accounts.json'); + if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600}); + return e; +} +const SPRE=[...PRE.slice(0,4),'PASS deployment',...PRE.slice(4)]; +// A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one. +function stampedCopy(t){ + const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true})); + const copy=join(root,sha40);mkdirSync(copy); + for(const dir of ['packages','scripts','roles','adapters'])cpSync(join(ROOT,dir),join(copy,dir),{recursive:true,filter:(q)=>!q.split('/').some((x)=>['tests','node_modules'].includes(x))}); + writeFileSync(join(copy,'package.json'),'{"type":"module"}'); + writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:sha40}),{mode:0o444});chmodSync(join(copy,'.stack-build.json'),0o444); + symlinkSync(join(ROOT,'node_modules'),join(copy,'node_modules')); + return copy; +} +// Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with +// {code,out,err} on exit, or with the bound port once it prints its address line. +async function realServe(t,copy,e,dir){ + const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json')},stdio:['ignore','pipe','pipe']}); + t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}}); + let out='',err='';ch.stderr.on('data',(d)=>{err+=d}); + return new Promise((res)=>{ + const timer=setTimeout(()=>{ch.kill('SIGKILL');res({code:'timeout',out,err})},20000); + ch.stdout.on('data',(d)=>{out+=d;const m=/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(out);if(m&&out.includes('Ctrl-C')){clearTimeout(timer);res({port:Number(m[1]),out,err,ch})}}); + ch.on('close',(code)=>{clearTimeout(timer);res({code,out,err})}); + }); +} + +test('M3 R16 a stamped copy rehearses with --sso against rehearsal.invalid, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{ + const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]}; + const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(e)),port=await freePort(); + const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10'); + assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session',...POST]); + const R=r.dir; + assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out); + assert.ok(r.out.includes('PASS sso-isolated: 1 provider(s), each at rehearsal.invalid with a placeholder secret\n'),r.out); + // The Console started with --sso, and only on the scratch config. + assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]); + // The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id, + // algorithms, slot name and subjects as they were. No subject is added. + const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')); + const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer='https://rehearsal.invalid/a'; + assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects); + // The slot is a scratch placeholder, 0600 in a 0700 directory. + const ph=join(R,'config','secrets','mosaic-stack',SLOT); + assert.equal(readFileSync(ph,'utf8'),'rehearsal-placeholder-secret\n');assert.equal(statSync(ph).mode&0o777,0o600); + assert.equal(statSync(dirname(ph)).mode&0o777,0o700);assert.deepEqual(readdirSync(dirname(ph)),[SLOT]); + // accounts.json is the live file's bytes; .deployed names the instance the scratch config dir gives. + assert.equal(readFileSync(join(R,'config','accounts.json'),'utf8'),readFileSync(e.accounts,'utf8')); + const dep=join(R,'data','.deployed'); + assert.deepEqual(JSON.parse(readFileSync(dep,'utf8')),{instance:'dev'});assert.equal(statSync(dep).mode&0o777,0o444); + // The live store, config, business, pointer, backup, accounts and slot are as they were; the run + // never opened the live slot and its sentinel is nowhere under the data root. + assert.equal(untouched(e),before);assert.equal(sha(e.accounts),accounts);assert.deepEqual(st(slotPath(e)),slot); + assert.ok(readFileSync(join(R,'live.before'),'utf8').includes(e.accounts),'the end check sums the live accounts.json'); + await noLeak(e); +}); + +test('M3 R17 the real stamped Console accepts the rehearsal\'s scratch prerequisites and starts degraded; without .deployed or accounts.json it refuses',async(t)=>{ + const e=await smk(t); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10'); + assert.equal(r.code,0,r.out+r.err); + const copy=stampedCopy(t); + // Every startup check passes: stamp, --sso, .deployed, accounts, console.sso and its slot. The + // provider at rehearsal.invalid is unreachable, so the Console serves degraded and /healthz answers. + const up=await realServe(t,copy,e,r.dir); + assert.ok(up.port,`${up.code} ${up.out} ${up.err}`); + assert.equal((await fetch(`http://127.0.0.1:${up.port}/healthz`)).status,200); + const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done; + // The same start without each prerequisite refuses before it listens. + const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep); + rmSync(dep); + let q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/\.deployed is missing or invalid/);assert.doesNotMatch(q.out,/Mosaic Console:/); + writeFileSync(dep,keep,{mode:0o444});rmSync(acc); + q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/accounts/);assert.doesNotMatch(q.out,/Mosaic Console:/); +}); + +test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso and accounts-not-initialized without a live accounts.json, before any rehearsal directory',async(t)=>{ + for(const [opts,code,msg] of [ + [{sso:false},'sso-not-prepared',(e)=>`${B} is a stamped copy, which serves only with --sso, and ${e.cfg} has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it`], + [{accounts:false},'accounts-not-initialized',(e)=>`${B} is a stamped copy, which needs an accounts file, and there is no ${e.accounts}; a human runs mosaic accounts init (run sheet) before re-pinning to it`], + ]){ + const e=await smk(t,opts),before=untouched(e); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3'); + assert.equal(r.code,1,code+r.out+r.err); + assert.ok(r.out.includes(`FAIL ${code}: ${msg(e)}\nREHEARSAL_FAIL\n`),r.out); + assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${code}`]); + assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false); + assert.equal(existsSync(e.accounts),code!=='accounts-not-initialized'); + assert.equal(untouched(e),before); + } + // An unstamped copy needs neither: B from RCOPIES rehearses without console.sso or accounts.json, + // and its Console gets no --sso. + const u=await rmk(t),port=await freePort(); + const r=await rehearse(u,B,'--backup',u.act,'--port',port,'--timeout','10'); + assert.equal(r.code,0,r.out+r.err);assert.ok(!r.names.includes('PASS deployment')); + assert.ok(r.out.includes('PASS sso-isolated: no console.sso\n'),r.out); + assert.deepEqual(lines(rh(u,'console-args.log')),[`serve --port ${port} --business acme`]); + assert.equal(existsSync(join(r.dir,'data','.deployed')),false); +}); + +test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{ + for(const [from,to,why] of [ + ['sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'], + ['p.issuer=`https://rehearsal.invalid/${encodeURIComponent(String(p.id))}`','0','console.sso provider a issuer is https://op.example.test/, not on rehearsal.invalid'], + ['"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'], + ]){ + const e=await smk(t);ownScript(e,from,to); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3'); + assert.equal(r.code,1,why+r.out+r.err); + assert.ok(r.out.includes(`FAIL sso-isolated: ${why}\n`),r.out); + assert.deepEqual(r.names.slice(-3),['FAIL sso-isolated','PASS backup-unchanged','PASS live-config-unchanged']); + assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false); + await noLeak(e); + } +}); + // --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) --- // The log's lines, as the runs write them, with only the fields from_log reads set. @@ -2601,7 +2831,8 @@ test('row196 explicit socket conversion refuses inherited TCP before stop and ro assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]); }); -// Row196 merge guard: without --send, SSO insertion must still precede the socket flag. +// Row196 merge guard, M3 (#1873): a user-unit --sso --tester-socket refuses accounts-user-unit; root's +// unit for the copy carries --sso before the socket flag, and the pointer and status pin that order. test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{ const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e); const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8'); @@ -2611,8 +2842,9 @@ test('row196 SSO socket conversion pins pointer and unit argv order without send chmodSync(dirname(module),0o755); writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555); t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);}); - const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err); + await auu(e,['activate',C1,'acme','--sso','--tester-socket'],'activate'); const want=['--port',e.port,'--business','acme','--sso','--tester-socket']; + await viaSystem(e,'activate',C1,want); assert.deepEqual(pointer(e).console,want); assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e)); assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/); diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs index 6bf7da990..93e717b8b 100644 --- a/packages/webui/tests/pinned-copy.test.mjs +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -2,11 +2,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { once } from 'node:events'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { fixture } from '../../cli/tests/helpers.mjs'; +import { writeDeployment } from '../../cli/src/deployment.mjs'; +import { fakeOidc } from './helpers/fake-oidc.mjs'; +import { request as httpRequest } from 'node:http'; import { consoleReads } from '../src/reads.mjs'; // Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and @@ -227,6 +230,8 @@ test('a copy that meets an empty target directory replaces it by rename, not by // serve from a copy: /healthz, the config check, roles and RELEASE from the // copy, and the queue read from --checkout or, without it, from the copy. +// A copy is stamped, so it starts only with --sso and the deployed +// prerequisites (M3): console.sso and its slot, .deployed and accounts.json. async function serve(t, cli, args, env) { const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(() => child.kill()); @@ -236,8 +241,22 @@ async function serve(t, cli, args, env) { child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); }); child.on('exit', (code) => reject(new Error(`exited ${code}: ${out}${err}`))); }); - const get = async (path) => { const res = await fetch(`http://127.0.0.1:${port}${path}`); return { status: res.status, body: await res.json() }; }; - return { out, get }; + return { out, port }; +} + +// A start that refuses: exit 2, the refusal on stderr, and no listener line. +// A start that listens instead is killed at its listener line (or after 60s), +// so a removed check fails here rather than holding the file open. +async function refusedStart(cli, args, env) { + const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = '', err = ''; + const timer = setTimeout(() => child.kill('SIGKILL'), 60_000); + child.stdout.on('data', (c) => { out += c; if (/Mosaic Console:/.test(out)) child.kill('SIGKILL'); }); + child.stderr.on('data', (c) => { err += c; }); + const [code] = await once(child, 'close'); + clearTimeout(timer); + assert.doesNotMatch(out, /Mosaic Console:/); + return { code, err }; } test('a stamped copy refuses the former unauthenticated Console start', async t => { @@ -246,29 +265,84 @@ test('a stamped copy refuses the former unauthenticated Console start', async t assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0); const cli = join(copies, head, 'packages/webui/src/cli.mjs'); const { env } = fixture(join(dir, 'home')); - const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--business', 'acme'], { env }); - let err = ''; child.stderr.on('data', c => { err += c; }); - assert.equal((await once(child, 'close'))[0], 2); + const { code, err } = await refusedStart(cli, ['--business', 'acme'], env); + assert.equal(code, 2); assert.match(err, /stamped build serve requires --sso/); }); +const PUBLIC = 'console.test'; +function request(port, path, headers = {}) { + return new Promise((resolve, reject) => { + const req = httpRequest({ host: '127.0.0.1', port, path, headers: { host: PUBLIC, ...headers } }, (res) => { + let text = ''; + res.on('data', (c) => { text += c; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text ? JSON.parse(text) : null })); + }); + req.on('error', reject); req.end(); + }); +} + +// The owner signs in through the fake provider; reads carry the session on the public Host. +async function signIn(port, op, iss) { + const login = await request(port, '/auth/login'); + assert.equal(login.status, 303); + const cookie = [login.headers['set-cookie']].flat()[0].split(';')[0]; + const grant = op.grant(iss, login.headers.location, { sub: 'owner' }); + const state = new URL(login.headers.location).searchParams.get('state'); + const callback = await request(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`, { cookie }); + assert.equal(callback.status, 303); + const session = [callback.headers['set-cookie']].flat().find((v) => v.startsWith('__Host-mosaic_session=') && !v.includes('Max-Age=0')).split(';')[0]; + return async (path) => { const r = await request(port, path, { cookie: session }); return { status: r.status, body: r.body }; }; +} + test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => { const { dir, script, head } = fixtureRepo(t); const copies = join(dir, 'copies'); assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0); const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs'); + // A deployed copy gets node_modules from npm ci; the fixture links this tree's (the SSO client needs it). + const mode = statSync(join(copies, head)).mode & 0o777; chmodSync(join(copies, head), mode | 0o200); symlinkSync(join(REPO, 'node_modules'), join(copies, head, 'node_modules')); chmodSync(join(copies, head), mode); // A checkout whose queue read says where it ran. const checkout = join(dir, 'checkout'); mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true }); writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`); - const { env } = fixture(join(dir, 'home')); + const f = fixture(join(dir, 'home')); + const env = { ...f.env }; delete env.MOSAIC_ROLES_DIR; - const { out, get } = await serve(t, cli, ['--business', 'acme', '--checkout', checkout], env); + const args = ['--business', 'acme', '--sso', '--checkout', checkout]; + // Each prerequisite missing in turn refuses the --sso start before it listens. + const op = await fakeOidc(); t.after(() => op.close()); + const iss = op.issuer('a'); + const ca = join(dir, 'ca.pem'); writeFileSync(ca, op.cert); + env.NODE_EXTRA_CA_CERTS = ca; + const cfgDir = dirname(f.config); + let r = await refusedStart(cli, args, env); + assert.equal(r.code, 2, r.err); assert.match(r.err, /\.deployed is missing or invalid/); + writeDeployment({ dataRoot: f.dataRoot }, cfgDir); + r = await refusedStart(cli, args, env); + assert.equal(r.code, 2, r.err); assert.match(r.err, /accounts file not found/); + writeFileSync(join(cfgDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: 'a', sub: 'owner' }], name: 'Owner', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 }); + r = await refusedStart(cli, args, env); + // Without console.sso no provider is configured, so the accounts file's subject has none. + assert.equal(r.code, 2, r.err); assert.match(r.err, /must be a configured provider/); + const config = JSON.parse(readFileSync(f.config, 'utf8')); + config.console = { sso: { publicOrigin: `https://${PUBLIC}`, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }] }] } }; + writeFileSync(f.config, JSON.stringify(config)); + r = await refusedStart(cli, args, env); + assert.equal(r.code, 2, r.err); assert.match(r.err, /client secret slot is missing/); + mkdirSync(join(cfgDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 }); + const slot = join(cfgDir, 'secrets', 'mosaic-stack', 'a'); writeFileSync(slot, iss.clientSecret, { mode: 0o600 }); chmodSync(slot, 0o600); + const { out, port } = await serve(t, cli, args, env); assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy'); assert.match(out, /^Queue: read from the --checkout directory$/m); - assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } }); + const health = await fetch(`http://127.0.0.1:${port}/healthz`); + assert.equal(health.status, 200); + // Under --sso the body adds each provider's state beside ok (serve.mjs /healthz). + const hb = await health.json(); + assert.equal(hb.ok, true); assert.deepEqual(Object.keys(hb), ['ok', 'sso']); assert.deepEqual(Object.keys(hb.sso), ['a']); + const get = await signIn(port, op, iss); const settings = await get('/api/settings'); - assert.equal(settings.status, 200); + assert.equal(settings.status, 200, JSON.stringify(settings.body)); assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy'); const business = await get('/api/business'); assert.equal(business.status, 200, JSON.stringify(business.body)); @@ -282,10 +356,10 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE assert.deepEqual(fields.body, JSON.parse(readFileSync(join(REPO, 'packages', 'tasks', 'fields.json'), 'utf8'))); // Without --checkout the copy's own queue read runs, and the copy holds // no packages/queue, so it fails closed. - const plain = await serve(t, cli, ['--business', 'acme'], env); + const plain = await serve(t, cli, ['--business', 'acme', '--sso'], env); assert.doesNotMatch(plain.out, /^Queue:/m); - assert.equal((await plain.get('/healthz')).status, 200); - const none = await plain.get('/api/queue'); + assert.equal((await fetch(`http://127.0.0.1:${plain.port}/healthz`)).status, 200); + const none = await (await signIn(plain.port, op, iss))('/api/queue'); assert.equal(none.status, 503); assert.equal(none.body.error, 'queue-refused'); }); diff --git a/packages/webui/tests/sso.test.mjs b/packages/webui/tests/sso.test.mjs index 5a96629bc..29565c451 100644 --- a/packages/webui/tests/sso.test.mjs +++ b/packages/webui/tests/sso.test.mjs @@ -298,7 +298,7 @@ test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/); }); -test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => { +test('11: slot files: a symlink, a directory, mode 0644, 0640 or 0604 and a file under the repository are refused, and no message holds the value', t => { const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack'); @@ -310,24 +310,34 @@ test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repos assert.equal(readSlot(dir, provider('good'), opts), value); writeFileSync(join(root, 'target'), value, { mode: 0o600 }); symlinkSync(join(root, 'target'), join(slots, 'link')); - for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); } + for (const [slot, mode] of [['wide', 0o644], ['group', 0o640], ['other', 0o604]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); } + mkdirSync(join(slots, 'dir'), { mode: 0o700 }); + // M3 (#1873): a config whose secrets directory is a link into the repository, as stack-release's T57 once pinned. + const linked = join(root, 'linked'), inRepo = join(opts.repo, 'secrets'); + mkdirSync(join(inRepo, 'mosaic-stack'), { recursive: true }); + mkdirSync(linked); + writeFileSync(join(inRepo, 'mosaic-stack', 'good'), `${value}\n`, { mode: 0o600 }); + symlinkSync(inRepo, join(linked, 'secrets')); writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 }); writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 }); const cases = [ ['missing', /slot is missing$/, opts], ['link', /must not be a symbolic link$/, opts], + ['dir', /must be a regular file$/, opts], ['wide', /must have no group or other permission bits$/, opts], ['group', /must have no group or other permission bits$/, opts], + ['other', /must have no group or other permission bits$/, opts], ['two', /must hold one non-empty line$/, opts], ['empty', /must hold one non-empty line$/, opts], // The config dir itself under the repository, and under a --checkout. ['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }], ['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }], ['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }], + ['good', /must not be inside the repository or a stack copy$/, opts, linked], ]; - for (const [slot, why, o] of cases) { + for (const [slot, why, o, cfg = dir] of cases) { let err = null; - try { readSlot(dir, provider(slot), o); } catch (e) { err = e; } + try { readSlot(cfg, provider(slot), o); } catch (e) { err = e; } assert.ok(err, `${slot} was read`); assert.match(err.message, /^console\.sso provider a client secret slot /); assert.match(err.message, why, slot); diff --git a/scripts/case2-install.sh b/scripts/case2-install.sh index fd7a8b79a..a8d57183c 100755 --- a/scripts/case2-install.sh +++ b/scripts/case2-install.sh @@ -253,8 +253,12 @@ if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then fi say "business $BUSINESS validates" -# 8. What comes next. Root's steps run from root's own checkout. -say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3." +# 8. What comes next. Root's steps run from root's own checkout. The copy is stamped and .deployed +# names this instance (step 2), so its Console starts only with SSO accounts (M3, #1873). The steps +# before root's are the run sheet's (section 7), and nothing here does them: SSO is the operator's +# config, accounts.json comes only from the human verb, and case2-unit.sh refuses until both exist. +say "done. .deployed names this instance and the copy is stamped, so its Console starts only with SSO accounts. Before the units, in order (run sheet section 7): prepare console.sso and its client secret slots in $CONFIG; a human runs \`mosaic accounts init\` as $(id -un); $(id -un) stays a dedicated uid with a non-login shell, the only member of its primary group." +say "Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS with --sso in the serve flags (factory: --sso --send; add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3. It refuses before any write and names each step still missing." if [ "$PROFILE" = library ]; then say "Then Jason places the library token in $SLOT (step 4), and runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)." fi diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index acfd9ce70..d24e6a1a0 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -36,6 +36,10 @@ # --proof-header NAME --proof-file PATH # It is proxy-preview read-only until Case 2 supports --sso (#412); --send # and --sso are explicitly refused. The proof still protects proxy preview reads. +# That holds for an unstamped copy only. A stamped library copy (M3, #1873; +# Jason's ruling 2026-11-10) signs in through SSO accounts like every +# deployed Console: it takes --port, --business and a bare --sso, refuses +# every proxy-preview and proof flag, and still refuses --send. # The factory profile takes --port N (1..1023) and --business ID, and an # optional bare --tester-socket marker. It also takes the paired bare # --sso --send (#415): the Console signs its owner in from console.sso, which @@ -43,6 +47,15 @@ # preview and proof flags still refuse. Retired tester TCP refuses. Only # factory takes --pm and --claude. # +# A stamped copy (.stack-build.json, from stack-copy.sh) starts only with +# --sso, console.sso, a loadable accounts.json and a .deployed naming the +# default instance (packages/webui/src/cli.mjs, M3 #1873). For a stamped copy, +# in either profile, the render asks the stack user's own copy for those +# facts and refuses before any write, naming every missing step in the run +# sheet's order (docs/guides/host-run-sheet.md section 7). It writes none of +# them and picks no subject: .deployed comes from case2-install.sh, and +# accounts.json from the human verb `mosaic accounts init`. +# # On a re-install the installed units must equal the render in everything # but the copy id, and all selected units must name one copy, so a re-pin or rollback # re-renders them together. A unit that was already running stays on its @@ -161,6 +174,9 @@ COPIES="$HOME_DIR/.local/share/mosaic-stack/copies" COPY="$COPIES/$COPY_ID" [ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY" [ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing" +# M3: presence alone decides, as the Console's buildStamp does (lstat), so a broken link counts. +STAMPED=0 +if [ -e "$COPY/.stack-build.json" ] || [ -L "$COPY/.stack-build.json" ]; then STAMPED=1; fi # Owned by root and writable by no one else, the file and every directory above it. root_only() { @@ -389,11 +405,13 @@ serve_flags() { --tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;; --checkout) die "refusing --checkout: Case 2 has no queue panel" ;; --board) die "refusing --board: Case 2 runs no control board" ;; - --sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;; - --send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;; + --sso) [ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;; + --send) [ "$PROFILE" = factory ] || [ "$STAMPED" = 0 ] || die "the library profile is read-only; refusing --send (a stamped library Console signs in with --sso and stays read-only)" + [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;; --port|--business|--org) ;; --public-host|--auth-header|--allow-user|--proof-header|--proof-file) - [ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;; + [ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" + [ "$STAMPED" = 0 ] || die "refusing $name: $COPY_ID is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso" ;; *) die "unknown serve flag: $name" ;; esac [ -z "${seen[$name]:-}" ] || die "serve flag $name given twice" @@ -434,7 +452,7 @@ serve_flags() { i=$((i + 2)) done local required=(--port --business) - [ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file) + [ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file) for name in "${required[@]}"; do [ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required" done @@ -443,8 +461,10 @@ serve_flags() { # proof file is inferred. The Console refuses --sso without console.sso # at start; this asks the stack user's config first, before any write. [ "$sso" = 0 ] || [ -n "${seen[--send]:-}" ] || die "refusing --sso without --send in the factory profile" - [ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || die "the factory profile refuses --send without --sso" - if [ "$sso" = 1 ]; then + # A stamped copy without --sso is a missing step, which stamped_checks names with the others. + [ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || [ "$STAMPED" = 1 ] || die "the factory profile refuses --send without --sso" + # A stamped copy's console.sso is one of stamped_checks' steps, asked in the same query. + if [ "$sso" = 1 ] && [ "$STAMPED" = 0 ]; then local answer answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$SSO_QUERY" "$COPY/packages/cli/src/config.mjs")" || die "sso config query $(tester_capture_reason "$?")" [ "$answer" != '{"sso":false}' ] || die "--sso needs console.sso in $USER_NAME's system config" @@ -459,6 +479,8 @@ serve_flags() { fi echo "${flags# }"; return 0 fi + # A stamped library copy has no proxy-preview flag left to check; stamped_checks follows. + if [ "$STAMPED" = 1 ]; then echo "${flags# }"; return 0; fi # Lead decision 101: the uid header carries the Authentik uid. if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user" @@ -485,6 +507,8 @@ NODE_DIR="$(dirname -- "$NODE")" UNIT_PATH="$NODE_DIR" for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done FLAGS="$(serve_flags)" || exit 1 +SSO_FLAG=0 +case " $FLAGS " in *" --sso "*) SSO_FLAG=1 ;; esac # The config query runs after dropping uid, through the bounded supervisor. # Root reads only checked facts and stats the two owned directories. @@ -503,6 +527,42 @@ owned_directory() { owned_directory "$HOME_DIR/.config/mosaic-dev" config owned_directory "$TS_DATA" data-root +# M3 (#1873): a stamped copy's Console starts only with --sso, console.sso, accounts.json that +# loads and a .deployed naming the default instance (packages/webui/src/cli.mjs). The stack user's +# own copy answers, through the same bounded supervisor, with its loadSystem, loadAccounts and +# assertDeployed, so the render asks exactly what the start will. Root reads three checked facts. +# Every missing step is named, in the run sheet's order, before any write. Without console.sso an +# accounts.json is "unchecked": its subjects name providers SSO has not configured yet, so the SSO +# step comes first and the next render checks it. Nothing here writes .deployed or accounts.json, +# or picks a subject. +STAMP_QUERY='import {join} from "node:path";import {homedir} from "node:os";import {pathToFileURL} from "node:url";const u=(f)=>import(pathToFileURL(f).href);const [c,d,a]=await Promise.all(process.argv.slice(1,4).map(u));const s=c.loadSystem();const dir=join(homedir(),".config/mosaic-dev");const fact=(file,check)=>{if(!d.present(file))return "missing";try{check();return "ok"}catch{return "invalid"}};const accounts=!s.console?.sso&&d.present(join(dir,"accounts.json"))?"unchecked":fact(join(dir,"accounts.json"),()=>a.loadAccounts(join(dir,"accounts.json"),{providers:s.console?.sso?.providers??[]}));const deployed=fact(join(s.dataRoot,".deployed"),()=>d.assertDeployed(s,dir));console.log(JSON.stringify({sso:Boolean(s.console&&s.console.sso),accounts,deployed}));' +stamped_checks() { + [ "$STAMPED" = 1 ] || return 0 + local answer pattern='^\{"sso":(true|false),"accounts":"(ok|missing|invalid|unchecked)","deployed":"(ok|missing|invalid)"\}$' + local -a steps=() + answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$STAMP_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/cli/src/deployment.mjs" "$COPY/packages/organization/src/accounts.mjs")" || die "stamped copy query $(tester_capture_reason "$?")" + [[ "$answer" =~ $pattern ]] || die "stamped copy query has invalid facts" + local sso="${BASH_REMATCH[1]}" accounts="${BASH_REMATCH[2]}" deployed="${BASH_REMATCH[3]}" + case "$deployed" in + missing) steps+=("check or create .deployed: $USER_NAME runs case2-install.sh, whose deployment step writes $TS_DATA/.deployed") ;; + invalid) steps+=("check .deployed: $TS_DATA/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it") ;; + esac + [ "$sso" = true ] || steps+=("prepare SSO: console.sso in $USER_NAME's system config $HOME_DIR/.config/mosaic-dev/config.json, with its client secret slots") + case "$accounts" in + missing) steps+=("run \`mosaic accounts init\` as $USER_NAME, a human verb: no $HOME_DIR/.config/mosaic-dev/accounts.json") ;; + invalid) steps+=("fix $HOME_DIR/.config/mosaic-dev/accounts.json: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs") ;; + esac + if [ "$SSO_FLAG" = 0 ]; then + if [ "$PROFILE" = factory ]; then steps+=("add --sso --send to the serve flags, then re-pin") + else steps+=("add --sso to the serve flags, then re-pin"); fi + fi + [ "${#steps[@]}" -gt 0 ] || return 0 + local text="refusing the units for $COPY_ID: a stamped copy's Console starts only with SSO accounts; missing steps, in order:" i=1 step + for step in "${steps[@]}"; do text+=$'\n'" $i. $step"; i=$((i + 1)); done + die "$text" +} +stamped_checks + HARDENING="ProtectSystem=strict ReadWritePaths=$HOME_DIR ProtectHome=yes diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index babc193f6..90ba1e4bd 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -354,6 +354,25 @@ check_target() { refuse already-active "$TO is the pointer's copy" } +# M3 (#1873): an accounts instance runs dedicated system units, never the operator's systemd user +# manager. webui-service.sh install refuses the same four signs, but only after the run stopped the +# units; this refuses before anything stops. The run goes through --units external: root renders +# the units with case2-unit.sh, and activate, confirm, revert and rollback stage and gate them. +# pm-on has no external form, so it passes its own ending ($1). TO names the copy to check. +user_units_allowed() { + local why + why="$(node -e ' + const fs=require("fs"),p=require("path"),[root,dir,file]=process.argv.slice(1); + const present=(f)=>{try{fs.lstatSync(f);return true}catch(e){if(e.code==="ENOENT")return false;throw e}}; + if(present(p.join(root,".stack-build.json"))){console.log(root+" is a stamped copy");process.exit(1)} + if(present(p.join(dir,"accounts.json"))){console.log(p.join(dir,"accounts.json")+" is present");process.exit(1)} + if(present(file)){let s;try{s=JSON.parse(fs.readFileSync(file,"utf8"))}catch(e){console.log(file+" cannot be read as JSON");process.exit(1)} + if(s.console?.sso){console.log(file+" holds console.sso");process.exit(1)} + if(typeof s.dataRoot==="string"&&present(p.join(s.dataRoot,".deployed"))){console.log(p.join(s.dataRoot,".deployed")+" is present");process.exit(1)}} + ' -- "$COPIES/$TO" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" 2>&1)" || + refuse accounts-user-unit "${why:-the accounts signs cannot be read}; an accounts instance runs system units, so root renders them with case2-unit.sh${1:- and the $OP runs with --units external}" +} + # --- Plug-in kinds (row 169) --- # Unlike copy_schema, these import the copy's own code, as a business-check does: its kinds.mjs @@ -1552,6 +1571,7 @@ do_activate() { run_external migrate return fi + user_units_allowed if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi precheck plugin_checks @@ -1583,6 +1603,7 @@ do_rollback() { run_external restore return fi + user_units_allowed if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi precheck plugin_checks @@ -1935,6 +1956,8 @@ do_pm_on() { out="$(plugins harness "$P_COPY" 2>&1)" || refuse harness-unset "$P_COPY's loader refuses the business file: $out" [ "$out" != none ] || refuse harness-unset "the business file sets no harness; Jason sets it with plugins set first" node -e 'const h=JSON.parse(process.argv[1]);process.exit(Array.isArray(h)&&h.length>0?0:1)' -- "$out" 2>/dev/null || refuse harness-unset "the business file's harness is $out, not a non-empty list" + TO="$P_COPY" + user_units_allowed ", and the PM is a flag in root's bus unit" instance_busy drop_in_override read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT" @@ -2045,7 +2068,7 @@ r_end() { r_stop if [ -n "$R" ]; then if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi - if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi + if [ "$(sha256sum "${LIVE_FILES[@]}" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "${LIVE_FILES[*]} differs from $R/live.before"; fi fi if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi [ -z "$R" ] || echo "rehearsal directory: $R" @@ -2056,10 +2079,38 @@ r_port_free() { node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1" } -# The scratch config: the live one with dataRoot set to the scratch data root. +# The scratch config: the live one with dataRoot set to the scratch data root. M3 (#1873): a +# console.sso keeps its provider ids, client ids, algorithms and subjects, but the public origin and +# every issuer move to rehearsal.invalid, a name that never resolves (RFC 6761). The scratch Console +# reaches no real provider: discovery fails as unreachable and the Console starts degraded, after +# every other startup check. No subject is added; the live config's own are the only ones. R_CONFIG_JS=' const fs=require("node:fs");const [file,data]=process.argv.slice(1); -const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");' +const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data; +const sso=c.console?.sso; +if(sso&&typeof sso==="object"){ + sso.publicOrigin="https://rehearsal.invalid"; + for(const p of Array.isArray(sso.providers)?sso.providers:[])if(p&&typeof p==="object")p.issuer=`https://rehearsal.invalid/${encodeURIComponent(String(p.id))}`; +} +process.stdout.write(JSON.stringify(c,null,2)+"\n");' + +# M3 (#1873): one 0600 placeholder per console.sso client secret slot, in the scratch config +# directory where readSlot looks. Never the live slot: the rehearsal reads no secret. +R_SLOTS_JS=' +const fs=require("node:fs"),path=require("node:path");const [file]=process.argv.slice(1); +const dir=path.join(path.dirname(file),"secrets","mosaic-stack"); +fs.mkdirSync(dir,{recursive:true,mode:0o700}); +for(const p of JSON.parse(fs.readFileSync(file,"utf8")).console.sso.providers){ + const slot=p?.clientSecret?.slot; + if(typeof slot!=="string"||!/^[a-z0-9-]{1,64}$/.test(slot))throw new Error("a console.sso provider has no valid clientSecret.slot"); + try{fs.writeFileSync(path.join(dir,slot),"rehearsal-placeholder-secret\n",{mode:0o600,flag:"wx"})}catch(e){if(e.code!=="EEXIST")throw e} +}' + +# M3 (#1873): the scratch .deployed, written by the new copy's own writeDeployment, so it names +# the instance that copy derives from the scratch config directory. +R_DEPLOYED_JS=' +const {pathToFileURL}=require("node:url");const [copy,data,dir]=process.argv.slice(1); +import(pathToFileURL(`${copy}/packages/cli/src/deployment.mjs`).href).then((d)=>d.writeDeployment({dataRoot:data},dir));' # The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables, # business and role, and with every credential reference, the roles and tracker.sync, moved to its @@ -2094,6 +2145,8 @@ fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});' # notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962) # tracker-configured bootConfig sets no trackers (mos-0962) # credential-ref-live every credential reference is a file under placeholders/ (mos-0963) +# sso-isolated every console.sso origin and issuer is rehearsal.invalid and every client +# secret slot is a scratch placeholder (M3, #1873) # Exit 0 when all pass, 1 on the first FAIL. R_GUARD_JS=' const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url"); @@ -2136,6 +2189,17 @@ const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)}; if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`); } pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`); + const sso=system.console?.sso; + if(!sso){pass("sso-isolated","no console.sso");return} + const host=(u)=>{try{return new URL(u).hostname}catch{return null}}; + if(host(sso.publicOrigin)!=="rehearsal.invalid")fail("sso-isolated",`console.sso.publicOrigin is ${sso.publicOrigin}, not https://rehearsal.invalid`); + const sd=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack")); + for(const p of sso.providers){ + if(host(p.issuer)!=="rehearsal.invalid")fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not on rehearsal.invalid`); + const f=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack",p.clientSecret.slot)); + if(f===null||sd===null||path.dirname(f)!==sd||fs.readFileSync(f,"utf8")!=="rehearsal-placeholder-secret\n")fail("sso-isolated",`console.sso provider ${p.id} has no scratch placeholder secret`); + } + pass("sso-isolated",`${sso.providers.length} provider(s), each at rehearsal.invalid with a placeholder secret`); })().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));' # Opens a data root's store with a copy's Store, as the bus does at start, with no migration @@ -2258,9 +2322,22 @@ do_rehearse() { LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")" LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json" [ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ" + # M3 (#1873): a stamped copy serves only with --sso, console.sso, a loadable accounts.json and + # a .deployed naming its instance. The rehearsal gives its Console all four in scratch. The + # live instance's console.sso and accounts.json are a human's steps; without them the stamped + # copy cannot start live either, so the rehearsal refuses and names the step. + LIVE_FILES=("$LIVE_CFG" "$LIVE_BIZ") R_STAMPED="" + if [ -e "$COPIES/$TO/.stack-build.json" ] || [ -L "$COPIES/$TO/.stack-build.json" ]; then + R_STAMPED=1 + node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$LIVE_CFG" 2>/dev/null || + r_abort sso-not-prepared "$TO is a stamped copy, which serves only with --sso, and $LIVE_CFG has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it" + [ -f "$(dirname "$LIVE_CFG")/accounts.json" ] || + r_abort accounts-not-initialized "$TO is a stamped copy, which needs an accounts file, and there is no $(dirname "$LIVE_CFG")/accounts.json; a human runs mosaic accounts init (run sheet) before re-pinning to it" + LIVE_FILES+=("$(dirname "$LIVE_CFG")/accounts.json") + fi local stamp before live - before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files" + before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "${LIVE_FILES[@]}" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files" stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}" (umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)" R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log" @@ -2272,13 +2349,19 @@ do_rehearse() { cp "$R_BK"/bus/* "$R/data/bus/" && node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" && node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" && - printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json" + printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json" && + { ! node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$R/config/config.json" || + node -e "$R_SLOTS_JS" -- "$R/config/config.json"; } && + { [ -z "$R_STAMPED" ] || { + cp "$(dirname "$LIVE_CFG")/accounts.json" "$R/config/accounts.json" && + r_env node -e "$R_DEPLOYED_JS" -- "$COPIES/$TO" "$R/data" "$R/config"; }; } ) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS" local rc=0 diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \ <(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$? [ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff" r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them" + [ -z "$R_STAMPED" ] || r_pass deployment "$TO is stamped: $R/data/.deployed from its writeDeployment, the live accounts.json copied to $R/config, the Console starts with --sso" local out rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$? @@ -2295,6 +2378,7 @@ do_rehearse() { R_HOST_PID=$! local -a tester_rehearsal=() if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$COPIES/$TO/packages/webui/src/cli.mjs"; then tester_rehearsal=(--tester-socket); fi + [ -z "$R_STAMPED" ] || tester_rehearsal+=(--sso) env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 & R_CONSOLE_PID=$! if r_gate; then r_no_session; fi @@ -2335,7 +2419,7 @@ FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED="" P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=() BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET="" -R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ="" +R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ="" LIVE_FILES=() R_STAMPED="" O_ACT="" FLOOR=0 case "$OP" in adopt) do_adopt ;; -- 2.54.0 From 9acc3785554532f0da94379da930365970b642aa Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 20:17:54 -0600 Subject: [PATCH 5/6] fix(cli): M3 review round: re-pin migration, rehearsal provider (#1873) An installed unit must equal the render but for the copy id, so a re-pin that adds --sso refused render-refused. case2-unit gains --check (every render check, nothing written) and --check-new (a first install's checks). stack-upgrade runs both checks before anything stops, and --next-args reads root's .args.next: equal before -- but for --copy, different after it. After the stage it moves the installed unit files into a new .replaced.XXXXXX directory and renders a first install. A failure moves them back unchanged and restarts the old copy, and confirm swaps the args file. The run sheet gives the commands, the rollback past the migration included. A stamped rehearsal now starts an isolated provider on 127.0.0.1 port 0 with a certificate and keys made for the run. It serves discovery and a JWKS for the live provider ids and answers 403 to authorize and token. Only the scratch Console trusts its certificate. The gate requires each provider ready (sso-ready); provider-discovery requires discovery and JWKS fetches and no code or token request. The provider stops with the run, and by itself once the run's shell is gone. The system-path test helper asserts the log's from copy again. The pinned-copy sign-in requests are bounded, and cleanup waits for each Console to exit. --- docs/TOOLS.md | 70 +++++-- docs/guides/host-run-sheet.md | 40 +++- docs/plans/2026-11-10_accounts-design.md | 10 +- packages/cli/tests/case2-unit.test.mjs | 61 +++++- packages/cli/tests/stack-release.test.mjs | 227 ++++++++++++++++++---- packages/cli/tests/stack-upgrade.test.mjs | 117 ++++++++++- packages/webui/tests/pinned-copy.test.mjs | 28 ++- scripts/case2-unit.sh | 25 ++- scripts/stack-release.sh | 158 +++++++++++++-- scripts/stack-upgrade.sh | 146 ++++++++++++-- 10 files changed, 766 insertions(+), 116 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c0062fdb9..f7e7c4ff1 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -808,9 +808,10 @@ the port and the backup's `SHA256SUMS`. Then it makes - `steps.log` holds the same PASS and FAIL lines the run prints, with times; `host.log`, `console.log` and, after a migration, `migrate.log` sit beside it. - When the live config has `console.sso` (M3, #1873), the scratch copy keeps - its provider ids, client ids, algorithms and subjects, and moves the public - origin and every issuer to `rehearsal.invalid`, a name that never resolves. - Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/` + its provider ids, client ids, algorithms and subjects and moves the public + origin to `rehearsal.invalid`, a name that never resolves. For a stamped + target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below; + otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/` holding `rehearsal-placeholder-secret`; the live secret is never read. A stamped target copy (one with `.stack-build.json`) serves only with `--sso`, @@ -819,10 +820,27 @@ so its rehearsal needs the live instance's human-made SSO steps. Without a live beside the config it refuses `accounts-not-initialized`. Both name the run-sheet step. Otherwise it copies the live `accounts.json` into `config/`, writes `data/.deployed` with the target copy's own `writeDeployment`, prints -`PASS deployment`, and starts the Console with `--sso`. The scratch Console -runs every startup check, finds no provider at `rehearsal.invalid`, and starts -degraded. The live `accounts.json` joins the end check `live-config-unchanged`. -The rehearsal adds no subject and no account. +`PASS deployment`, and starts the Console with `--sso`. + +Before that, a stamped run starts a rehearsal provider and prints +`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves +HTTPS with a certificate made for this run, under `provider/` (mode 0700). For +each live provider id it serves discovery and a JWKS whose public key it made +in memory for the first algorithm it can make from that provider's list. Its +authorize and token routes answer 403: it issues no code and no token, and +it reads no live secret. It logs each request to `provider/requests.log`, +deletes its TLS key file once it has read it, and exits on SIGTERM or when +the rehearsal's process is gone. `provider-start` fails when it exits early, +writes no origin within the timeout, writes an origin that is not +`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets +`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not. + +The scratch Console runs every startup check against that provider. The gate +adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate, +`provider-discovery` requires a 200 discovery and JWKS request per provider and +no authorize or token request. The end stops the provider with the Console and +host and prints `provider-stopped`. The live `accounts.json` joins the end check +`live-config-unchanged`. The rehearsal adds no subject and no account. A guard then loads the scratch config with the target's code and refuses with a named check: `data-root-live` when the scratch data root resolves to @@ -830,8 +848,10 @@ the live one, `sessions-live` when a `sessions.json` there names a running pid (the run never deletes the file), `data-root-scratch`, `notify-off`, `tracker-configured` when `bootConfig` still sets a tracker, `credential-ref-live` for an env reference or a file outside `placeholders/`, -and `sso-isolated` when a `console.sso` origin or issuer is not on -`rehearsal.invalid` or a slot is not its scratch placeholder. +and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`, +an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on +`rehearsal.invalid` for an unstamped target), or a slot is not its scratch +placeholder. When the backup's schema differs from the target's, the run checks that the target refuses the store, migrates it, opens it, migrates it again as a no-op, and that the pointer's copy refuses the migrated store. @@ -840,7 +860,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's `serve --port N --business BUSINESS` from the target copy, without `--send`. The gate needs the host's `launches disabled` line, a live `bus status` host with this run's pid and a boot from this start, the Console's address line, -`/healthz` 200, and both command lines on the target copy. Then it checks +`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines +on the target copy. Then it checks that the host wrote no `sessions.json` and no `launches`, stops the Console and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that the host released `writer.lock`, and compares the backup and the live config @@ -927,8 +948,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under ``` scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] - [--rehearse-port N] [--timeout S] -scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S] + [--rehearse-port N] [--next-args] [--timeout S] +scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args] + [--timeout S] ``` Root runs it from a root-owned checkout of the reviewed commit, never from a @@ -968,7 +990,13 @@ user's clone `/src/mosaic-stack`: (`args-differ`), and `activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0 (`plan-refused`, which covers `open-activation`, `migrate-required` and - `pointer-differs`). + `pointer-differs`). Then the render checks (#1873 M3): + `case2-unit.sh --check` renders OLD from the args file and compares the + installed units with it, so a rollback could render them, then checks + SHA's render the same way. Either refusal is `render-refused`. A re-pin + that changes the serve flags fails the second check, because an + installed unit must equal the render but for the copy id (row 195); it + takes `--next-args`, below. 4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339` (`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`; @@ -983,6 +1011,22 @@ user's clone `/src/mosaic-stack`: still names the old one, and root moves the new file (the result line names it) into place by hand. +`--next-args` changes the serve flags, as the SSO migration does +(`docs/guides/host-run-sheet.md` section 7). The run reads +`/etc/mosaic-stack/dev.args.next` as it reads the args file +(`args-unreadable`). It must equal the args file before `--` but for +`--copy`'s value (`next-args-differ`) and differ from it after `--` +(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a +first install's checks from the next args, without the installed units'. +After A3, root moves the installed unit files into a new 0700 directory +`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders +as a first install. A failure from there moves them back unchanged +(`units-back`), runs `daemon-reload` and starts the old units with no render. +After A5 the old args file is copied into the replaced directory, the next +args file is removed (step `next-args`), and the new args file, made from +the next args, moves into place. The result line names the replaced +directory. Nothing removes it. + A failure in A2 or a stage that exits 3 starts the old units again and gates them (A2f). A later failure stops both units, runs `revert`, renders the old copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 505ab9047..ca45d1487 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -446,8 +446,36 @@ stack picks or invents a subject: the subjects come from `console.sso`, which a 4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member of its primary group, and owner of its config directory and data root. An accounts instance never runs under a human or development seat uid, and never under the `webui-service.sh` user units. -5. Re-pin. Put `--sso` after `--` in the args file (`factory`: `--sso --send`). For `library`, also remove - the proxy-preview trio and the proof pair. Then run `case2-unit.sh` or `stack-upgrade.sh upgrade`. +5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to + `/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the + serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the + proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair + (`--proof-header`, `--proof-file`). Then run + `stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`. + + A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for + the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against + `dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when + the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after + it. After the stage it moves the installed unit files into a new 0700 directory + `/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from + there moves them back unchanged and starts the old copy again. After `confirm` passes, + `dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old + `dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no + rollback needs it. + + To roll back past the migration, put the replaced args back as the next args and roll back with + `--next-args`: + + ```sh + install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next + stack-upgrade.sh rollback --instance dev --to --next-args + ``` + + For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for + an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview + admission included, as before the migration. A `factory` rollback may instead keep `--sso --send` + with a plain `rollback`. If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps, numbered in this order, for example: @@ -461,11 +489,11 @@ case2-unit: refusing the units for : a stamped copy's Console starts only w ``` Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO -has not configured yet. The next render after step 2 checks it. Rollback keeps `--sso`, accounts.json and -`.deployed` in place (`docs/plans/2026-11-10_accounts-design.md`, section 7). +has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json, +its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7). -Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these -rules, before it stops a unit. +Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`, +`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit. ## 8. Enrollment, once per host diff --git a/docs/plans/2026-11-10_accounts-design.md b/docs/plans/2026-11-10_accounts-design.md index 029d76866..1c5c89e9d 100644 --- a/docs/plans/2026-11-10_accounts-design.md +++ b/docs/plans/2026-11-10_accounts-design.md @@ -1298,7 +1298,8 @@ business file. ### Rollback -Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off. +Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off, +except the library rollback past the migration below. - The supported rollback is a pinned code and config rollback: install the earlier stack copy with the same unit flags, including `--sso`, through @@ -1306,6 +1307,13 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off. dedicated system units. The accounts instance must not use the `webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in place. BASE code ignores them, and the next roll-forward needs them. +- A library host rolled back to a copy older than M3 is the exception. + `case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so + that rollback renders the old copy from the args file the migration + replaced, through `stack-upgrade.sh rollback ... --next-args` + (`docs/guides/host-run-sheet.md` section 7). The old copy comes back with + its old flags, proxy-preview admission included, as before the migration. + accounts.json and `.deployed` stay in place. - Before the first accounts org exists, that rollback restores today's behavior completely. `console.sso` owners sign in as before, and FED R6 users keep their root grants. Account-only users and pending invites stop diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 28e502a27..cc0a9a1ea 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -364,7 +364,7 @@ test("the unit takes --org or --business on either side of --, renders --busines test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => { const h = hub(t); const serve = ["--port", "443", "--business", "acme"]; - const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n"; + const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n"; const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"]; for (const [label, argv, code, err] of [ ["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"], @@ -886,6 +886,65 @@ test("M3 a stamped copy's query refuses on noise or a failed query, before any w } }); +// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's +// checks, the installed units' included, and writes nothing; --check-new skips only the installed +// units' checks, for --next-args, which moves them aside before its render. +const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) => + [mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--", + ...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))]; +const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null)); +const logNow = (h) => (existsSync(h.log) ? read(h.log) : ""); + +test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => { + const h = hub(t); + const empty = unit(h, { argv: checkArgv(h) }); + assert.equal(empty.code, 0, empty.out + empty.err); + assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`); + noUnits(h); + assert.equal(unit(h).code, 0); + const units = unitsNow(h), log = logNow(h); + for (const copy of [COPY_A, COPY_B]) { + const r = unit(h, { argv: checkArgv(h, { copy }) }); + assert.equal(r.code, 0, r.out + r.err); + assert.deepEqual(unitsNow(h), units, copy); + assert.equal(logNow(h), log, `${copy}: no systemctl call`); + } + // The installed-unit match stays: an edit, or other serve flags, refuses as the render would. + writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited")); + refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/); + writeFileSync(busFile(h), units[0]); + const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f)); + refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/); + assert.deepEqual(unitsNow(h), units); + assert.equal(logNow(h), log); +}); + +for (const profile of ["library", "factory"]) { + test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => { + const h = hub(t); + assert.equal(unit(h, { profile }).code, 0); + stamp(h, COPY_B); + stampSso(h); + stampAccounts(h); + stampDeployed(h); + const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY; + const units = unitsNow(h), log = logNow(h); + refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/); + refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/); + const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }); + assert.equal(r.code, 0, r.out + r.err); + assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`); + // --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse. + refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/); + rmSync(accountsFile(h)); + refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/); + assert.deepEqual(unitsNow(h), units); + assert.equal(logNow(h), log, "no systemctl call"); + assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json"); + assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444); + }); +} + test("row253 factory refuses retired TCP and a value after the socket marker", (t) => { const h = hub(t); refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index cdc44f89c..ed4bf2fb7 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -1578,15 +1578,37 @@ function rootUp(e,copy,flags){ .replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`)); execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con); } -// One system-path run: OP (activate TO, or rollback --to TO) staged, root's units for TO with FLAGS, confirm. +// One system-path run: OP (activate TO, or rollback --to TO) planned and staged from the pointer's +// copy, root's units for TO with FLAGS, confirm. The plan, the staged set and both log lines are this +// script's output; the unit flags are root's render (case2-unit.test.mjs pins that render), so the +// pointer pinning them is confirm's read of root's unit, not a conversion of this script's. async function viaSystem(e,op,to,flags,...more){ - const x=X(e);rootDown(e); - let r=await prun(x,...(op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to]),...more);assert.equal(r.code,0,r.out+r.err); - const act=logLines(e).at(-1).activation; + const x=X(e),from=pointer(e).copy,p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,e.wrapper)); + const args=op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to],inst=e.flags.length?`${e.flags.join(' ')} `:''; + rootDown(e); + const con0=sha(join(e.units,e.con)); + const out=await planOnly(x,0,...args,...more),plan=out.join('\n'); + assert.deepEqual([out.find((l)=>l.startsWith('plan: ')),out.find((l)=>l.startsWith('from: ')),out.find((l)=>l.startsWith('to: '))],[`plan: ${op} for acme`,`from: ${from}`,`to: ${to}`],plan); + assert.ok(out.includes(`units: external; root installs the units for ${to} and starts them, then confirm gates them`),plan); + assert.ok(out.includes(`CLI: scripts/stack-copy.sh wrapper ${to} ${inst}--copies ${COPIES} --bin ${e.bin}`),plan); + // Root renders the units, so the plan names no unit, Console flag or refusal of this script's. + assert.deepEqual(out.filter((l)=>/^(bus|pm|proof|send|sso|Console):|^(drop|refuses) /.test(l)),[],plan); + let r=await prun(x,...args,...more);assert.equal(r.code,0,r.out+r.err); + const st=logLines(e).at(-1),act=st.activation,rs=setOf(e,act); + assert.deepEqual([st.op,st.from,st.to,st.result,st.units],[op,from,to,'started','external']); + assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} staged ${to} for acme as ${act}; root installs and starts the units, then runs confirm ${to} acme --units external`); + // Staged, not live: the pointer stays; the set holds the pointer, the wrapper and the Console unit root stopped. + assert.equal(readFileSync(e.pointer,'utf8'),p0); + assert.equal(readFileSync(join(rs,'AWAITING-UNITS'),'utf8'),`${to}\n`); + assert.equal(readFileSync(join(rs,'pointer'),'utf8'),p0);assert.equal(sha(join(rs,'wrapper')),w0); + assert.ok(readFileSync(join(rs,'UNITS.sha256'),'utf8').includes(`${con0} ${join(e.units,e.con)}\n`)); + assert.ok(readFileSync(join(e.bin,e.wrapper),'utf8').includes(join(COPIES,to)),'the wrapper names the target copy'); rootUp(e,to,flags); r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err); assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`); - const l=logLines(e).at(-1);assert.deepEqual([l.op,l.to,l.result,l.units,l.activation],[op,to,'ok','external',act]); + const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.units,l.activation],[op,from,to,'ok','external',act]); + assert.ok(!existsSync(join(rs,'AWAITING-UNITS'))); + assert.deepEqual([pointer(e).copy,pointer(e).console],[to,flags]); return act; } @@ -2102,6 +2124,7 @@ function rHost(){ } if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)} fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`); + fs.appendFileSync(path.join(home,'host-ca.log'),`${process.env.NODE_EXTRA_CA_CERTS??'none'}\n`); if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)} const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString(); fs.writeFileSync(lock,String(process.pid),{flag:'wx'}); @@ -2122,14 +2145,35 @@ function rHost(){ } // The Console: serve on the given loopback port; /healthz answers 200 unless healthz- is set. -function rConsole(){ +// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the +// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults: +// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider +// ready without asking any; console-asks-token asks provider a's token endpoint once. +async function rConsole(){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http'); const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2); const fault=(n)=>fs.existsSync(path.join(home,'faults',n)); + const c=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')); fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`); - fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n'); + fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:c.dataRoot,ca:process.env.NODE_EXTRA_CA_CERTS??null})+'\n'); if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)} - const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()}); + while(fault('console-hold')&&!fs.existsSync(path.join(home,'release')))await new Promise((r)=>setTimeout(r,50)); + let health=null; + if(a.includes('--sso')){ + const ps=c.console.sso.providers; + if(fault('console-no-sso'))health=()=>Object.fromEntries(ps.map((p)=>[p.id,'ready'])); + else{ + const {createSso}=await import(path.join(import.meta.dirname,'sso.mjs')); + const secrets=new Map(ps.map((p)=>[p.id,fs.readFileSync(path.join(path.dirname(process.env.MOSAIC_CONFIG),'secrets','mosaic-stack',p.clientSecret.slot),'utf8')])); + let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)} + health=()=>sso.health(); + } + if(fault('console-asks-token'))await fetch(`${ps[0].issuer}token`).catch(()=>{}); + } + const s=http.createServer((q,r)=>{ + const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'}); + r.end(ok&&health?JSON.stringify({ok:true,sso:health()}):''); + }); s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`)); process.on('SIGTERM',()=>process.exit(0)); } @@ -2173,15 +2217,23 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']], execFileSync('chmod',['-R','a-w',d]); } // M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json -// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, and -// the deployment.mjs whose writeDeployment the rehearsal runs. +// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, its +// sso.mjs the stand-in Console runs, and the deployment.mjs whose writeDeployment the rehearsal runs. const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES); { const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]); - for(const p of ['packages/webui/src/sso-config.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p)); + for(const p of ['packages/webui/src/sso-config.mjs','packages/webui/src/sso.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p)); + // sso.mjs imports oauth4webapi. + symlinkSync(join(ROOT,'node_modules'),join(d,'node_modules')); writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444}); execFileSync('chmod',['-R','a-w',d]); } +// UCOPIES holds B unstamped but able to load a console key: SCOPIES's B without .stack-build.json. +const UCOPIES=join(base,'ucopies');mkdirSync(UCOPIES); +{ + const d=join(UCOPIES,B);cpSync(join(SCOPIES,B),d,{recursive:true,verbatimSymlinks:true});execFileSync('chmod',['-R','u+w',d]); + rmSync(join(d,'.stack-build.json'));execFileSync('chmod',['-R','a-w',d]); +} const TESTER='7499'; // Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which @@ -2194,12 +2246,13 @@ const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync // A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the // Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one. -// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test. +// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG or a rehearsal path in their command +// line (the rehearsal provider), are killed after the test. async function rmk(t,{copy=B,store='3g'}={}){ const e=await mk(t); t.after(()=>{ for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n))) - try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{} + try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`)||readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(`${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{} }); const db=new DatabaseSync(e.store); db.exec('PRAGMA journal_mode=WAL'); @@ -2479,9 +2532,10 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t // --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) --- // A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its -// instance. The rehearsal gives its scratch Console all four: the live console.sso with origin and -// issuers on rehearsal.invalid, a placeholder per slot, a copy of the live accounts.json, and a -// .deployed from the copy's writeDeployment. The live slot has a read watch: it is never opened. +// instance. The rehearsal gives its scratch Console all four: the live console.sso with its origin +// on rehearsal.invalid and each issuer at the rehearsal's own provider, a placeholder per slot, a +// copy of the live accounts.json, and a .deployed from the copy's writeDeployment. The live slot +// has a read watch: it is never opened. const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}; async function smk(t,{sso=true,accounts=true}={}){ const e=await rmk(t);e.copies=SCOPIES; @@ -2490,7 +2544,10 @@ async function smk(t,{sso=true,accounts=true}={}){ if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600}); return e; } -const SPRE=[...PRE.slice(0,4),'PASS deployment',...PRE.slice(4)]; +const SPRE=[...PRE.slice(0,3),'PASS provider-start',PRE[3],'PASS deployment',...PRE.slice(4)]; +const SPOST=['PASS console-stopped','PASS host-stopped','PASS provider-stopped',...POST.slice(2)]; +// The rehearsal provider's origin and pid, from its provider-start line. +const provOf=(out)=>{const m=/^PASS provider-start: pid (\d+) at (https:\/\/127\.0\.0\.1:\d+): /m.exec(out);return m?{pid:Number(m[1]),origin:m[2]}:null}; // A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one. function stampedCopy(t){ const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true})); @@ -2503,8 +2560,8 @@ function stampedCopy(t){ } // Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with // {code,out,err} on exit, or with the bound port once it prints its address line. -async function realServe(t,copy,e,dir){ - const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json')},stdio:['ignore','pipe','pipe']}); +async function realServe(t,copy,e,dir,more={}){ + const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json'),...more},stdio:['ignore','pipe','pipe']}); t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}}); let out='',err='';ch.stderr.on('data',(d)=>{err+=d}); return new Promise((res)=>{ @@ -2514,21 +2571,32 @@ async function realServe(t,copy,e,dir){ }); } -test('M3 R16 a stamped copy rehearses with --sso against rehearsal.invalid, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{ +test('M3 R16 a stamped copy rehearses with --sso against its own provider, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{ const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]}; const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(e)),port=await freePort(); const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10'); assert.equal(r.code,0,r.out+r.err); - assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session',...POST]); - const R=r.dir; + assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','PASS provider-discovery',...SPOST]); + const R=r.dir,prov=provOf(r.out);assert.ok(prov,r.out); + assert.ok(r.out.includes(`PASS provider-start: pid ${prov.pid} at ${prov.origin}: discovery and a JWKS from keys made for this run, no code and no token; its certificate ${join(R,'provider','cert.pem')} is the trust the scratch Console alone gets\n`),r.out); assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out); - assert.ok(r.out.includes('PASS sso-isolated: 1 provider(s), each at rehearsal.invalid with a placeholder secret\n'),r.out); - // The Console started with --sso, and only on the scratch config. + assert.ok(r.out.includes(`PASS sso-isolated: 1 provider(s), each at the rehearsal provider ${prov.origin} with a placeholder secret\n`),r.out); + assert.ok(r.out.includes(`PASS gate: host-up, bus-status, console-up, healthz, sso-ready and copy-match on ${B}\n`),r.out); + assert.ok(r.out.includes(`PASS provider-discovery: the Console fetched each provider's discovery and JWKS from ${prov.origin} and asked for no code or token\n`),r.out); + // The Console started with --sso, only on the scratch config, and alone trusts the provider's + // certificate; the host gets no added trust. assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]); + assert.deepEqual(lines(rh(e,'console-context.jsonl')).map((l)=>JSON.parse(l).ca),[join(R,'provider','cert.pem')]); + assert.deepEqual(lines(rh(e,'host-ca.log')),['none']); + // The provider served discovery and the JWKS, got no other request, kept no TLS key and is gone. + assert.deepEqual(lines(join(R,'provider','requests.log')).sort(),['GET /a/.well-known/openid-configuration 200','GET /a/jwks 200']); + assert.deepEqual(readdirSync(join(R,'provider')).sort(),['cert.pem','origin','provider.log','requests.log']); + assert.equal(statSync(join(R,'provider')).mode&0o777,0o700); + assert.equal(existsSync(`/proc/${prov.pid}`),false,`provider pid ${prov.pid} is still there`); // The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id, // algorithms, slot name and subjects as they were. No subject is added. const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')); - const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer='https://rehearsal.invalid/a'; + const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer=`${prov.origin}/a/`; assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects); // The slot is a scratch placeholder, 0600 in a 0700 directory. const ph=join(R,'config','secrets','mosaic-stack',SLOT); @@ -2545,16 +2613,30 @@ test('M3 R16 a stamped copy rehearses with --sso against rehearsal.invalid, a sc await noLeak(e); }); -test('M3 R17 the real stamped Console accepts the rehearsal\'s scratch prerequisites and starts degraded; without .deployed or accounts.json it refuses',async(t)=>{ - const e=await smk(t); - const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10'); - assert.equal(r.code,0,r.out+r.err); +test('M3 R17 the real stamped Console finds the rehearsal provider ready with the scratch prerequisites, starts degraded once it is gone, and refuses without .deployed or accounts.json',async(t)=>{ + const e=await smk(t);rfault(e,'console-hold'); const copy=stampedCopy(t); - // Every startup check passes: stamp, --sso, .deployed, accounts, console.sso and its slot. The - // provider at rehearsal.invalid is unreachable, so the Console serves degraded and /healthz answers. + // While the stand-in Console holds, the rehearsal provider is up: the real Console of a stamped + // copy passes every startup check (stamp, --sso, .deployed, accounts, console.sso and its slot) + // and, trusting only the provider's certificate, finds provider a ready. + let during=null; + e.during=async()=>{ + try{ + for(let i=0;i<400&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50)); + const dir=join(e.data,'rehearsals',rDirs(e)[0]); + const up=await realServe(t,copy,e,dir,{NODE_EXTRA_CA_CERTS:join(dir,'provider','cert.pem')}); + during={up:up.port?await (await fetch(`http://127.0.0.1:${up.port}/healthz`)).json():null,why:`${up.code} ${up.out} ${up.err}`}; + if(up.ch){const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done} + }finally{writeFileSync(rh(e,'release'),'')} + }; + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30'); + assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(during?.up,{ok:true,sso:{a:'ready'}},during?.why); + // After the rehearsal its provider is gone: the same Console serves degraded and /healthz says so. const up=await realServe(t,copy,e,r.dir); assert.ok(up.port,`${up.code} ${up.out} ${up.err}`); - assert.equal((await fetch(`http://127.0.0.1:${up.port}/healthz`)).status,200); + const h=await fetch(`http://127.0.0.1:${up.port}/healthz`); + assert.equal(h.status,200);assert.deepEqual(await h.json(),{ok:true,sso:{a:'unreachable'}}); const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done; // The same start without each prerequisite refuses before it listens. const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep); @@ -2589,19 +2671,86 @@ test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso }); test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{ + // stamped: the issuer must be the rehearsal provider's; unstamped (no provider): on rehearsal.invalid. + for(const [stamped,from,to,why] of [ + [true,'sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'], + [true,'p.issuer=`${op||','p.issuer=p.issuer||`${op||','console.sso provider a issuer is https://op.example.test/, not ORIGIN/a/ at the rehearsal provider'], + [false,'p.issuer=`${op||"https://rehearsal.invalid"}','p.issuer=`${op||"https://op.example.test"}','console.sso provider a issuer is https://op.example.test/a, not on rehearsal.invalid'], + [true,'"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'], + ]){ + let e;if(stamped)e=await smk(t);else{e=await rmk(t);e.copies=UCOPIES;await ssoConfig(e)} + ownScript(e,from,to); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3'); + assert.equal(r.code,1,why+r.out+r.err); + const prov=provOf(r.out);assert.equal(!!prov,stamped,r.out); + assert.ok(r.out.includes(`FAIL sso-isolated: ${why.replace('ORIGIN',prov?.origin)}\n`),r.out); + assert.deepEqual(r.names.slice(stamped?-4:-3),['FAIL sso-isolated',...(stamped?['PASS provider-stopped']:[]),'PASS backup-unchanged','PASS live-config-unchanged']); + assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false); + if(prov)assert.equal(existsSync(`/proc/${prov.pid}`),false); + await noLeak(e); + } +}); + +test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no discovery, a token request, a signal and a SIGKILL each fail and stop it',async(t)=>{ + const ends=['PASS backup-unchanged','PASS live-config-unchanged']; + // The provider cannot start, leaves its TLS key, offers a non-loopback origin or never writes one: + // provider-start fails before the scratch build, and no host or Console starts. for(const [from,to,why] of [ - ['sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'], - ['p.issuer=`https://rehearsal.invalid/${encodeURIComponent(String(p.id))}`','0','console.sso provider a issuer is https://op.example.test/, not on rehearsal.invalid'], - ['"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'], + ['spawnSync("openssl"','spawnSync("openssl-missing-for-r20"','the rehearsal provider exited: rehearsal provider: cannot make the certificate: openssl exit ENOENT'], + ['finally{fs.rmSync(tls,{force:true})}','finally{}','the rehearsal provider left its TLS key at RDIR/provider/tls-key.pem'], + ['origin=`https://127.0.0.1:${','origin=`https://localhost:${','the rehearsal provider\'s origin https://localhost:PORT is not https on 127.0.0.1'], + ['fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));','','the rehearsal provider wrote no origin within 3s'], ]){ const e=await smk(t);ownScript(e,from,to); const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3'); - assert.equal(r.code,1,why+r.out+r.err); - assert.ok(r.out.includes(`FAIL sso-isolated: ${why}\n`),r.out); - assert.deepEqual(r.names.slice(-3),['FAIL sso-isolated','PASS backup-unchanged','PASS live-config-unchanged']); + assert.equal(r.code,1,why+r.out+r.err);assert.ok(r.dir,r.out+r.err); + const line=r.out.split('\n').find((l)=>l.startsWith('FAIL provider-start: '))??''; + const re=new RegExp(`^FAIL provider-start: ${why.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace('RDIR',r.dir.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).replace('PORT','\\d+')}$`); + assert.match(line,re,r.out); + assert.deepEqual(r.names,[...PRE.slice(0,3),'FAIL provider-start',...(why.includes('exited')?[]:['PASS provider-stopped']),...ends],r.out); + assert.equal(existsSync(join(r.dir,'config')),false); assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false); - await noLeak(e); } + // The Console cannot reach the provider (its discovery drops the connection, or the Console has + // no trust for its certificate): it starts degraded and the gate fails sso-ready. + for(const [from,to] of [ + [' send(200,{issuer,authorization_endpoint',' return q.socket.destroy();send(200,{issuer,authorization_endpoint'], + ['r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")','r_ca=()'], + ]){ + const e=await smk(t);ownScript(e,from,to); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','5'); + assert.equal(r.code,1,from+r.out+r.err); + assert.ok(r.out.includes('FAIL sso-ready: provider a is unreachable, not ready\n'),r.out); + assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL sso-ready',...SPOST],r.out); + assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false); + } + // A Console that reports ready without asking the provider, or asks it for a token: the gate + // passes and provider-discovery fails. + for(const [f,why] of [ + ['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'], + ['console-asks-token','a request for GET /a/token 403'], + ]){ + const e=await smk(t);rfault(e,f); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10'); + assert.equal(r.code,1,f+r.out+r.err); + assert.ok(r.out.includes(`FAIL provider-discovery: ${why}; see ${join(r.dir,'provider','requests.log')}\n`),r.out); + assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','FAIL provider-discovery',...SPOST],r.out); + } + // A signal while the gate waits stops the Console, host and provider. + const e=await smk(t);rfault(e,`healthz-${B}`); + e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGTERM')}; + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30'); + assert.equal(r.code,1,r.out+r.err); + assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL signal',...SPOST],r.out); + assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false); + // SIGKILL leaves no trap to run: the provider sees its parent gone and exits by itself. + const k=await smk(t);rfault(k,`healthz-${B}`); + k.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(k,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGKILL')}; + const rk=await rehearse(k,B,'--backup',k.act,'--port',await freePort(),'--timeout','30'); + assert.equal(rk.code,null,rk.out+rk.err); + const pk=provOf(rk.out);assert.ok(pk,rk.out); + for(let i=0;i<60&&existsSync(`/proc/${pk.pid}`);i++)await new Promise((r)=>setTimeout(r,100)); + assert.equal(existsSync(`/proc/${pk.pid}`),false,`provider pid ${pk.pid} outlived the killed rehearsal`); }); // --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) --- diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index 941f1b66a..2df0580fc 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -47,12 +47,19 @@ function fakeSudo(K){ // case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its // WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console, -// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails- exits 1 first; faults/render-probe- (one-shot) first runs +// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. A first +// --check or --check-new (#1873) logs its argv and exits 1 under faults/check-fails-, else 0, +// writing nothing. faults/render-fails- exits 1 first; faults/render-probe- (one-shot) first runs // probe.sh, a second driver, and records its exit and stderr. function fakeRender(K){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'); const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n); fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n'); + if(a[0]==='--check'||a[0]==='--check-new'){ + const c=a[a.indexOf('--copy')+1]; + if(fs.existsSync(f(`check-fails-${c}`))){console.error(`fixture render: ${a[0]} ${c} fails`);process.exit(1)} + process.exit(0); + } const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1]; const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies'); if(fs.existsSync(f(`render-probe-${copy}`))){ @@ -147,7 +154,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text); // The step log: every line `