feat(webui): enable owner project remote save (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 22:04:07 -06:00
1 parent 92fe6c9303
commit 18df6e049c
17 files changed
+685 -6

No files matched your search

+12 -1
View File
@@ -160,7 +160,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -5425,6 +5425,17 @@ export class Broker {
return { ended: ended.map((h) => ({ thread: h.thread, session: h.session, run: h.holder_run })) };
}
this.#processingGate(s, request.verb);
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#organization) fail('not-configured');
const write = request.verb === 'project.remote.set.console';
keys(args, write ? ['project', 'revision', 'remote'] : ['project'], write ? ['project', 'revision', 'remote'] : ['project']);
try { return this.#organization(s.business, request.verb, args, s.human); }
catch (e) {
const allowed = ['project-remote-unknown', 'project-remote-library', 'project-remote-invalid', 'project-remote-configured', 'project-remote-protected', 'revision-conflict', 'organization-busy', 'human-required'];
fail(allowed.includes(e.code) ? e.code : 'outcome-unknown');
}
}
if (['prd.revoke.console','request.mode.console','request.draft.console'].includes(request.verb)) {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#requests?.catalog) fail('request-not-configured');
+17
View File
@@ -29,6 +29,7 @@ import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { getProjectRemote } from '../../business/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -664,6 +665,22 @@ export async function startBroker({
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busBusiness(current, resolved));
if (!sameFixed(booted.get(id), live) || canonicalJson(live.projects) !== known.get(id)) throw new BusError('project-remote-protected');
if (!Object.hasOwn(live.projects, args.project)) throw new BusError('project-remote-unknown');
if (verb === 'project.remote.get.console') return getProjectRemote(writer, id, args.project);
writer.setProjectRemote(id, args, actor);
const next = structuredClone(live);
next.projects[args.project].remote = args.remote;
broker.reloadBusiness(id, next);
businesses[id] = next;
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
test('only the owner Console reads and writes', t => {
const root = mkdtempSync(join(tmpdir(), 'project-remote-bus-')), store = new Store(root), calls = [];
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'cto', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } }, cto: { definition: 'cto', authority: { withinRole: [], crossRole: [] } } },
projects: { stack: { root } } };
const broker = new Broker({ store, businesses: { demo: business }, organization: (...args) => { calls.push(args); return { project: 'stack', revision: 'a'.repeat(64), remote: null, firstSaveAllowed: true }; } });
const owner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } });
const cli = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = broker.bindReader({ business: 'demo' }), tester = broker.bindTester({ business: 'demo' });
const get = { verb: 'project.remote.get.console', args: { project: 'stack' } };
assert.equal(broker.request(owner, get).project, 'stack');
for (const cap of [cli, reader, tester]) assert.throws(() => broker.request(cap, get), /human-required|read-only|tester-refused/);
assert.throws(() => broker.request(owner, { ...get, args: { project: 'stack', business: 'other' } }), /invalid-request/);
assert.equal(broker.request(owner, { verb: 'project.remote.set.console', args: { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' } }).project, 'stack');
assert.equal(calls.length, 2);
assert.throws(() => broker.bindConsole({ business: 'demo', human: 'other', subject: { provider: 'oidc', iss: 'https://id.test', sub: 'owner' } }), /human-required/);
});
test('socket refuses tester, viewer, collaborator, agent and CLI human for both remote verbs', async t => {
const { serve } = await import('../src/server.mjs');
const { Client } = await import('../src/client.mjs');
const root = mkdtempSync(join(tmpdir(), 'remote-socket-')), store = new Store(root);
const access = { accessVersion: 1, revision: 1, users: [], grants: [
{ id: 'g-1', user: 'viewer', business: 'demo', projects: ['*'], instances: ['pm'], level: 'viewer' },
{ id: 'g-2', user: 'collab', business: 'demo', projects: ['*'], instances: ['pm'], level: 'collaborator' },
] };
const business = { id: 'demo', human: 'jason', vars: { 'bus.tester': true }, arbiters: { technical: 'pm', delivery: 'pm' },
roles: { pm: { definition: 'pm', authority: { withinRole: [], crossRole: [] } } }, projects: { stack: { root } } };
const broker = new Broker({ store, businesses: { demo: business }, access: () => access, organization: () => { throw Error('unexpected writer'); } });
const caps = {
human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: broker.bindTester({ business: 'demo' }),
viewer: broker.bindUser({ business: 'demo', user: 'viewer' }),
collaborator: broker.bindUser({ business: 'demo', user: 'collab' }),
agent: broker.bindLaunch({ business: 'demo', role: 'pm', run: 'remote-test', harness: 'pi' }),
};
const path = join(root, 'bus.sock'), server = await serve({ broker, path });
t.after(async () => { await server.close(); store.close(); rmSync(root, { recursive: true, force: true }); });
for (const [who, cap] of Object.entries(caps)) for (const [verb, args] of [
['project.remote.get.console', { project: 'stack' }],
['project.remote.set.console', { project: 'stack', revision: 'a'.repeat(64), remote: 'https://git.example/a/b' }],
]) await assert.rejects(new Client({ path, cap }).call(verb, args), e => ['human-required', 'read-only', 'tester-refused', 'access-refused'].includes(e.code), `${who} ${verb}`);
});
+1 -1
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
+5
View File
@@ -360,6 +360,11 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Decisions routed to agent roles
remain with those roles, using their real role/run attribution.
- The project remote module can display the configured HTTPS repository URL for
an owner session and save a missing URL when writes are enabled. A configured
URL is not verified and does not publish, set a Git
origin, grant credentials or contact a forge. Project information integration
supplies the authorized chat project to repository-key mapping.
- #797 / Gitea #1839 Task 3, the request intake panel (`src/public/requests.js`,
`src/request-http.mjs`): under each of the signed-in owner's latest three chat
messages that opened a request, the thread shows the request's phase, mode,
+1 -1
View File
@@ -81,7 +81,7 @@ try {
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
// --send. consoleCall refuses any verb that is not a console verb.
// --send. Project remote operations use this same owner-only transport; consoleCall fences the verbs.
if (ssoOn) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
if (ssoOn && sendOn) requestWrite = requestRead;
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
+1
View File
@@ -20,6 +20,7 @@ export const agentCaller = (call, verbs) => async (verb, args) => {
// `send` is the --send flag: without it every write is null. `owner` is the owner's transport for
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
// Project remote operations are deliberately absent: this object is also used by granted users.
const o = {
proxyCall: agentCaller(call, PROXY_VERBS),
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
@@ -0,0 +1,50 @@
// Owner Console transport only. No caller-supplied business, actor or subject.
import { parseGiteaUrl } from '../../business/src/vocabulary.mjs';
export const PROJECT_REMOTE_ROUTE = /^\/api\/project-remotes\/([^/]+)$/;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
const codes = {
'project-remote-unknown': 404, 'project-remote-library': 403, 'project-remote-invalid': 400,
'project-remote-configured': 409, 'project-remote-protected': 409, 'revision-conflict': 409,
'organization-busy': 409, 'human-required': 403,
};
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
export async function projectRemoteHTTP(req, res, { path, read, write, session, live, origin }) {
const refuse = (status, error) => json(res, status, { error });
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
if (!session) return refuse(401, 'sign-in-required');
if (!(req.method === 'GET' ? read : write)) return refuse(403, 'read-only');
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return refuse(403, 'cross-origin');
let project;
try { project = decodeURIComponent(PROJECT_REMOTE_ROUTE.exec(path)?.[1] ?? ''); } catch { return refuse(404, 'project-remote-unknown'); }
if (!ID.test(project)) return refuse(404, 'project-remote-unknown');
let args = { project };
if (req.method === 'POST') {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return refuse(400, 'invalid-request');
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 4096) return refuse(413, 'invalid-request'); chunks.push(chunk); }
let body;
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return refuse(400, 'invalid-request'); }
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).length !== 2 || !Object.hasOwn(body, 'revision') || !Object.hasOwn(body, 'remote') ||
typeof body.revision !== 'string' || !/^[a-f0-9]{64}$/.test(body.revision) || typeof body.remote !== 'string' || body.remote.length > 2048)
return refuse(400, 'invalid-request');
args = { ...args, revision: body.revision, remote: body.remote };
}
if (live() !== session) return refuse(401, 'sign-in-required');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
let result;
try { result = await (req.method === 'GET' ? read : write)(req.method === 'GET' ? 'project.remote.get.console' : 'project.remote.set.console', args, { subject }); }
catch (error) { const code = codes[error.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); }
// A write admitted at spawn may already have committed. Return its receipt rather than
// reporting a sign-out as if the operation had not happened. Reads can still be withheld.
if (req.method === 'GET' && live() !== session) return refuse(401, 'sign-in-required');
if (!result || result.project !== project || !/^[a-f0-9]{64}$/.test(result.revision ?? '') || !(result.remote === null || parseGiteaUrl(result.remote, { repo: true }).url) || typeof result.firstSaveAllowed !== 'boolean' || result.firstSaveAllowed !== (result.remote === null))
return refuse(503, 'outcome-unknown');
return json(res, 200, { project: result.project, remote: result.remote, revision: result.revision, firstSaveAllowed: result.firstSaveAllowed });
}
@@ -0,0 +1,5 @@
.project-remote { border: 1px solid var(--border, #aaa); border-radius: 4px; padding: 1rem; overflow-wrap: anywhere; }
.project-remote form { display: grid; gap: .5rem; max-width: 42rem; }
.project-remote input { box-sizing: border-box; width: 100%; min-height: 44px; }
.project-remote button { min-height: 44px; justify-self: start; }
.project-remote [role="status"] { overflow-wrap: anywhere; }
@@ -0,0 +1,93 @@
/* Owner project information mount. The server rechecks the repository key and owner. */
(() => {
'use strict';
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
let signedOut = false;
document.addEventListener('mosaic-signed-out', () => { signedOut = true; });
function mount({ container, projectId, repositoryKey }) {
if (!(container instanceof Element)) return;
let generation = 0, data = null, pending = false, draft = '', error = '', stale = false, canWrite = false;
const section = document.createElement('section');
section.className = 'project-remote';
container.replaceChildren(section);
document.addEventListener('mosaic-signed-out', () => { generation++; draft = ''; data = null; section.replaceChildren(); }, { once: true });
const element = (tag, content, attrs = {}) => {
const node = document.createElement(tag);
if (content !== null) node.textContent = content;
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
return node;
};
function paint() {
section.replaceChildren(element('h2', 'Repository URL', { tabindex: '-1' }));
if (signedOut) return;
if (!projectId || !ID.test(repositoryKey ?? '')) {
section.append(element('p', 'This project has no linked repository.')); return;
}
if (!data) {
section.append(element('p', error || 'Loading repository setting.', { role: 'status' })); return;
}
if (data.remote !== null) {
section.append(element('p', 'Configured, not verified. Saving does not publish.', { role: 'status', tabindex: '-1' }));
section.append(element('p', data.remote)); return;
}
section.append(element('p', 'Saving sets this repository URL only. Saving does not publish.'));
if (!canWrite || !data.firstSaveAllowed) {
section.append(element('p', 'This setting is read-only.')); return;
}
const form = element('form', null), label = element('label', 'Existing HTTPS repository URL', { for: 'project-remote-url' });
const input = element('input', null, { id: 'project-remote-url', name: 'remote', type: 'url', required: '', maxlength: '2048', 'aria-describedby': 'project-remote-message' });
input.value = draft; input.disabled = pending;
input.addEventListener('input', () => { draft = input.value; });
const button = element('button', pending ? 'Saving' : 'Save', { type: 'submit' });
button.disabled = pending || stale;
const message = element('p', error, { id: 'project-remote-message', role: 'status', 'aria-live': 'polite' });
form.append(label, input, button, message);
if (stale) {
const refresh = element('button', 'Read current setting', { type: 'button' });
refresh.disabled = pending;
refresh.addEventListener('click', () => read(true)); form.append(refresh);
}
form.addEventListener('submit', async event => {
event.preventDefault();
if (pending || stale || signedOut) return;
let parsed;
try { parsed = new URL(input.value); } catch { /* invalid input */ }
if (parsed?.username || parsed?.password) { draft = ''; input.value = ''; error = 'Repository URL must not contain credentials.'; paint(); section.querySelector('input')?.focus(); return; }
if (!parsed || parsed.protocol !== 'https:' || parsed.href !== input.value || parsed.search || parsed.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(parsed.pathname)) {
error = 'Enter a valid HTTPS repository URL without credentials, query or fragment.'; paint(); section.querySelector('input')?.focus(); return;
}
pending = true; error = ''; paint();
const here = ++generation;
try {
const res = await fetch('/api/project-remotes/' + encodeURIComponent(repositoryKey), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ revision: data.revision, remote: draft }), cache: 'no-store' });
const result = await res.json();
if (here !== generation || signedOut) return;
if (!res.ok) {
stale = ['revision-conflict', 'project-remote-configured', 'outcome-unknown'].includes(result.error);
error = result.error === 'outcome-unknown' ? 'Save outcome is unknown. Read the current setting before trying again.' : stale ? 'The setting changed. Read the current setting before deciding what to do.' : result.error === 'project-remote-invalid' ? 'Enter a valid HTTPS repository URL without credentials, query or fragment.' : 'Could not save the repository URL. Your entry is retained.';
} else { data = result; draft = ''; }
} catch { if (here === generation && !signedOut) { stale = true; error = 'Save outcome is unknown. Read the current setting before trying again.'; } }
if (here === generation && !signedOut) { pending = false; paint(); (section.querySelector('button') || section.querySelector('[role=status]'))?.focus(); }
});
section.append(form);
}
async function read(reconcile = false) {
const here = ++generation; pending = true;
try {
const configRes = await fetch('/api/config', { cache: 'no-store' });
const config = await configRes.json();
if (!config.projectRemotes) throw new Error('unavailable');
const res = await fetch('/api/project-remotes/' + encodeURIComponent(repositoryKey), { cache: 'no-store' });
const result = await res.json();
if (!res.ok) throw new Error(result.error);
if (here !== generation || signedOut) return;
data = result; canWrite = !!config.projectRemoteWrites; stale = false;
error = reconcile && data.remote === null ? 'Current setting read. Review your entry before saving.' : '';
} catch { if (here === generation && !signedOut) { data = null; error = 'Repository setting is unavailable.'; } }
if (here === generation && !signedOut) { pending = false; paint(); if (reconcile) (section.querySelector('input') || section.querySelector('h2'))?.focus(); }
}
paint();
if (projectId && ID.test(repositoryKey ?? '') && !signedOut) read();
}
window.MosaicProjectRemote = { mount };
})();
+12 -2
View File
@@ -14,6 +14,7 @@ import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -33,6 +34,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/settings/model-proxy', ['proxy.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
@@ -40,6 +42,7 @@ const files = new Map([
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
['/icons.svg', ['icons.svg', 'image/svg+xml']],
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
@@ -729,7 +732,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path);
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
@@ -740,6 +743,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required');
if (remotePath) return json(res, 401, { error: 'sign-in-required' });
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
@@ -794,6 +798,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: 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: sso.publicOrigin });
}
if (tester && (path === '/project-remote.js' || path === '/project-remote.css')) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
@@ -927,7 +937,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, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && 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);
@@ -0,0 +1,47 @@
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../../business/tests/helpers.mjs';
import { loadBusiness } from '../../../business/src/business.mjs';
import { resolveInstance } from '../../../business/src/resolve.mjs';
import { busBusiness } from '../../../bus/src/business.mjs';
import { startBroker } from '../../../bus/src/runtime.mjs';
import { serve } from '../../../bus/src/server.mjs';
import { Client } from '../../../bus/src/client.mjs';
import { startServer } from '../../src/serve.mjs';
import { close } from '../fixture.mjs';
// Ephemeral organization, real runtime writer, broker socket and HTTP listener. The proof
// verifier and SSO session are fixture authorities; no live service or network is involved.
export async function realRemoteFixture(t, mutate = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'remote-real-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo'); mutate(doc);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const form = () => {
const business = loadBusiness('demo', { dir: root, rolesDir });
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
return busBusiness(business, resolved);
};
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo: form() }, organization: { dir: root, rolesDir, system } });
let runtime = await start();
let socketServer;
const path = join(root, 'fixture.sock');
const attach = async () => {
socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
};
await attach();
const subject = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner' };
const session = { ...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);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: transport, requestWrite: transport, log: () => {} });
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
t.after(async () => { await close(web); await socketServer.close(); await runtime.close(); rmSync(root, { recursive: true, force: true }); });
const restart = async () => { await socketServer.close(); await runtime.close(); runtime = await start(); await attach(); };
return { root, file, base, path, session, subject, restart, signOut: () => { active = false; }, broker: () => runtime.broker };
}
@@ -0,0 +1,43 @@
import { fixture } from '../fixture.mjs';
import { browser } from '../browser.mjs';
// The conversations slice supplies the real mount point at integration. This fixture
// installs one in the actual Console document and stubs only its network boundary.
export async function remoteFixture(t, width) {
const f = await fixture(); t.after(() => f.close());
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = [], network = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
b.on('Fetch.requestPaused', p => {
if (p.request.url.startsWith(f.base + '/')) b.call('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
else { network.push(p.request.url); b.call('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }).catch(() => {}); }
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
window.__remote = { project: 'stack', remote: null, revision: '${'a'.repeat(64)}', firstSaveAllowed: true };
window.__writes = []; window.__mode = 'success'; window.__writeEnabled = true;
window.fetch = async (url, options = {}) => {
const path = String(url), method = options.method || 'GET';
const reply = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
if (path === '/api/config') return reply({ projectRemotes: true, projectRemoteWrites: window.__writeEnabled });
if (path === '/api/project-remotes/stack') {
if (method === 'GET') return reply(window.__remote);
window.__writes.push(JSON.parse(options.body));
if (window.__mode === 'hold') await new Promise(resolve => window.__release = resolve);
if (window.__mode === 'stale') return reply({ error: 'revision-conflict' }, 409);
if (window.__mode === 'unknown') return reply({ error: 'outcome-unknown' }, 503);
if (window.__mode === 'fail') return reply({ error: 'project-remote-invalid' }, 400);
window.__remote = { ...window.__remote, remote: window.__writes.at(-1).remote, revision: '${'b'.repeat(64)}', firstSaveAllowed: false };
return reply(window.__remote);
}
return reply({ error: 'unexpected-api' }, 503);
};
})();` });
await b.viewport(width, 1000);
await b.navigate(f.base + '/#/projects/10000000-0000-4000-8000-000000000001/settings');
await b.evaluate(`new Promise((resolve,reject)=>{const link=document.createElement('link');link.rel='stylesheet';link.href='/project-remote.css';document.head.append(link);const script=document.createElement('script');script.src='/project-remote.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})`);
await b.evaluate(`(()=>{const main=document.querySelector('#main');for(const child of main.children)child.style.display='none';main.hidden=false;const h=document.createElement('h1');h.textContent='Project information';const slot=document.createElement('div');slot.id='remote-fixture';main.append(h,slot);return true})()`);
const mount = async (repositoryKey = 'stack') => b.evaluate(`MosaicProjectRemote.mount({container:document.querySelector('#remote-fixture'),projectId:'10000000-0000-4000-8000-000000000001',repositoryKey:${JSON.stringify(repositoryKey)}}),true`);
const wait = expression => b.evaluate(`(async()=>{const until=Date.now()+10000;while(!(${expression})){if(Date.now()>until)throw Error('timed out');await new Promise(r=>setTimeout(r,20))}return true})()`);
return { b, f, mount, wait, errors, network };
}
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { projectRemoteHTTP } from '../src/project-remote-http.mjs';
const origin = 'https://console.test', revision = 'a'.repeat(64);
const subject = { provider: 'oidc', iss: 'https://id.test', sub: 'owner' };
function setup() {
const session = { ...subject, name: 'owner' }, calls = [];
let current = session;
const transport = async (verb, args, context) => {
calls.push({ verb, args, context });
return { project: args.project, remote: verb.includes('.set.') ? args.remote : null, revision, firstSaveAllowed: !verb.includes('.set.') };
};
async function run({ method = 'GET', body = {}, headers = {}, read = transport, write = transport, live = () => current } = {}) {
const h = { origin: [origin], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
const req = { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield Buffer.from(JSON.stringify(body)); } };
const res = { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(value) { this.value = JSON.parse(value); } };
await projectRemoteHTTP(req, res, { path: '/api/project-remotes/stack', read, write, session, live, origin });
return res;
}
return { calls, run, logout: () => { current = null; } };
}
test('only the trusted session subject and exact route fields reach the Console transport', async () => {
const s = setup();
assert.equal((await s.run()).status, 200);
const result = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' } });
assert.equal(result.status, 200);
assert.deepEqual(s.calls, [
{ verb: 'project.remote.get.console', args: { project: 'stack' }, context: { subject } },
{ verb: 'project.remote.set.console', args: { project: 'stack', revision, remote: 'https://git.example/owner/repo' }, context: { subject } },
]);
});
test('method, origin, payload and expired session fail before transport', async () => {
const s = setup(), body = { revision, remote: 'https://git.example/owner/repo' };
assert.equal((await s.run({ method: 'PUT' })).status, 405);
for (const headers of [{ origin: ['https://other.test'] }, { origin: null }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }, { 'content-type': ['text/plain'] }])
assert.notEqual((await s.run({ method: 'POST', body, headers })).status, 200);
for (const bad of [{ revision }, { remote: body.remote }, { ...body, business: 'other' }, { ...body, projects: {} }, { ...body, credentials: 'secret' }, { ...body, publish: true }, { ...body, revision: 'bad' }])
assert.equal((await s.run({ method: 'POST', body: bad })).status, 400);
s.logout(); assert.equal((await s.run({ method: 'POST', body })).status, 401);
assert.deepEqual(s.calls, []);
});
test('refusals and malformed replies never echo credential-bearing URLs', async () => {
const s = setup(), secret = 'https://name:[email protected]/owner/repo';
const write = async () => { const e = new Error(secret); e.code = 'project-remote-invalid'; throw e; };
const refused = await s.run({ method: 'POST', body: { revision, remote: secret }, write });
assert.equal(refused.status, 400); assert.ok(!JSON.stringify(refused.value).includes('secret'));
const read = async () => ({ project: 'stack', revision, remote: secret, firstSaveAllowed: false });
const invalid = await s.run({ read });
assert.equal(invalid.status, 503); assert.ok(!JSON.stringify(invalid.value).includes('secret'));
});
test('an expiry during the body read prevents the write from starting', async () => {
const s = setup(); let checks = 0;
const out = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' }, live: () => { checks++; return null; } });
assert.equal(out.status, 401); assert.equal(checks, 1); assert.deepEqual(s.calls, []);
});
test('a write admitted before sign-out returns its receipt if the session ends during the call', async () => {
const s = setup();
const write = async (verb, args) => {
s.logout();
return { project: args.project, revision, remote: args.remote, firstSaveAllowed: false };
};
const out = await s.run({ method: 'POST', body: { revision, remote: 'https://git.example/owner/repo' }, write });
assert.equal(out.status, 200); assert.equal(out.value.remote, 'https://git.example/owner/repo');
});
@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { request } from 'node:http';
import childProcess from 'node:child_process';
import https from 'node:https';
import { syncBuiltinESMExports } from 'node:module';
import { Credentials } from '../../bus/src/credentials.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { Client } from '../../bus/src/client.mjs';
import { realRemoteFixture } from './helpers/w15-project-remote-real.mjs';
const url = 'https://git.example/owner/repo.git';
function call(f, method, body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : JSON.stringify(body);
const req = request(f.base + '/api/project-remotes/stack', { method, headers: { ...(data ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const get = f => call(f, 'GET');
const post = (f, body, headers) => call(f, 'POST', body, headers);
test('owner saves over HTTP and the broker socket, then reads after runtime restart', async t => {
const f = await realRemoteFixture(t), [status, before] = await get(f);
assert.equal(status, 200); assert.equal(before.remote, null);
// Fail the save if it attempts a forge call, process/Git command, credential mutation,
// or agent launch. Install only after fixture startup and restore before shutdown.
const effects = { process: 0, forge: 0, credential: 0, launch: 0 };
const originals = [];
const fence = (object, key, kind) => {
originals.push([object, key, object[key]]);
object[key] = () => { effects[kind]++; throw Error('unexpected ' + kind); };
};
for (const key of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) fence(childProcess, key, 'process');
for (const key of ['request', 'get']) fence(https, key, 'forge');
for (const key of ['issue', 'revoke', 'remove', 'unlinkStore', 'writeModel', 'register', 'unregister', 'registerModel']) fence(Credentials.prototype, key, 'credential');
fence(Broker.prototype, 'bindLaunch', 'launch');
fence(globalThis, 'fetch', 'forge'); syncBuiltinESMExports();
let savedStatus, saved;
try { [savedStatus, saved] = await post(f, { revision: before.revision, remote: url }); }
finally { for (const [object, key, original] of originals) object[key] = original; syncBuiltinESMExports(); }
assert.deepEqual(effects, { process: 0, forge: 0, credential: 0, launch: 0 });
assert.equal(savedStatus, 200, JSON.stringify(saved)); assert.equal(saved.remote, url);
assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, url);
await f.restart();
const [reloadStatus, reloaded] = await get(f);
assert.equal(reloadStatus, 200); assert.deepEqual(reloaded, saved);
const [existingStatus, existing] = await post(f, { revision: reloaded.revision, remote: 'https://git.example/other/repo' });
assert.equal(existingStatus, 409); assert.equal(existing.error, 'project-remote-configured');
});
test('sign-out, stale, credentials, extra fields and cross-origin do not write', async t => {
const f = await realRemoteFixture(t), [, before] = await get(f), original = readFileSync(f.file);
const body = { revision: before.revision, remote: url };
for (const [b, headers, expected] of [
[{ ...body, revision: 'a'.repeat(64) }, {}, 'revision-conflict'],
[{ ...body, remote: 'https://user:[email protected]/owner/repo' }, {}, 'project-remote-invalid'],
[{ revision: body.revision }, {}, 'invalid-request'], [{ ...body, publish: true }, {}, 'invalid-request'],
[body, { origin: 'https://other.example' }, 'cross-origin request refused'],
]) {
const [status, result] = await post(f, b, headers);
assert.ok(status >= 400); assert.equal(result.error, expected); assert.ok(!JSON.stringify(result).includes('secret')); assert.deepEqual(readFileSync(f.file), original);
}
f.signOut(); const [status, result] = await post(f, body);
assert.equal(status, 401); assert.equal(result.error, 'sign-in-required'); assert.deepEqual(readFileSync(f.file), original);
});
test('real SSO refuses viewer and collaborator and sign-out prevents the saved write', async t => {
const { fakeOidc } = await import('./helpers/fake-oidc.mjs');
const { createSso, SESSION_COOKIE } = await import('../src/sso.mjs');
const { consoleAccess } = await import('../src/access.mjs');
const { startServer } = await import('../src/serve.mjs');
const { close } = await import('./fixture.mjs');
const op = await fakeOidc(); t.after(() => op.close());
const issuer = op.issuer('a'), origin = 'https://console.example.test';
const access = { accessVersion: 1, revision: 1, users: [
{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] },
{ id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] },
], grants: [
{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'viewer' },
{ 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: () => {} });
t.after(() => sso.close());
const f = await realRemoteFixture(t), original = readFileSync(f.file);
let writes = 0;
const transport = (verb, args, ctx) => new Client({ path: f.path, console: { subject: ctx.subject } }).call(verb, args);
const read = transport;
const write = (verb, args, ctx) => { writes++; return transport(verb, args, ctx); };
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), requestRead: read, requestWrite: write, log: () => {} });
t.after(() => close(web));
const send = (method, path, cookie, body, extraHeaders = {}) => new Promise((resolve, reject) => {
const data = body === undefined ? undefined : JSON.stringify(body);
const req = request({ host: '127.0.0.1', port: web.address().port, method, path, headers: { host: 'console.example.test', ...(cookie ? { cookie } : {}), ...(data ? { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...extraHeaders } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
}); req.on('error', reject); req.setTimeout(10000, () => req.destroy(Error('request timed out'))); req.end(data);
});
const signIn = async sub => {
const start = await send('GET', '/auth/login');
const cookie = start.headers['set-cookie'][0].split(';')[0];
const grant = op.grant(issuer, start.headers.location, { sub });
const q = new URLSearchParams({ code: grant.code, state: new URL(start.headers.location).searchParams.get('state') });
const result = await send('GET', '/auth/callback?' + q, cookie);
return result.headers['set-cookie'].find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0')).split(';')[0];
};
const body = { revision: 'a'.repeat(64), remote: url };
for (const sub of ['alice-sub', 'bob-sub']) {
const cookie = await signIn(sub);
for (const method of ['GET', 'POST']) {
const response = await send(method, '/api/project-remotes/stack', cookie, method === 'POST' ? body : undefined);
assert.equal(response.status, 403); assert.equal(response.body.error, 'owner-only');
}
}
// A CLI human or agent cannot turn an unauthenticated HTTP request into an owner session.
for (const headers of [{ 'x-user': 'jason' }, { 'x-agent': 'pm' }]) for (const method of ['GET', 'POST']) {
const response = await send(method, '/api/project-remotes/stack', null, method === 'POST' ? body : undefined, headers);
assert.equal(response.status, 401); assert.equal(response.body.error, 'sign-in-required');
}
const owner = await signIn('owner-sub');
const readOwner = await send('GET', '/api/project-remotes/stack', owner);
assert.equal(readOwner.status, 200); assert.equal(readOwner.body.remote, null);
assert.equal((await send('POST', '/auth/logout', owner, {})).status, 303);
const after = await send('POST', '/api/project-remotes/stack', owner, { ...body, revision: readOwner.body.revision });
assert.equal(after.status, 401); assert.equal(after.body.error, 'sign-in-required'); assert.equal(writes, 0);
assert.deepEqual(readFileSync(f.file), original);
});
test('tester listener refuses both remote methods', async t => {
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { startServer } = await import('../src/serve.mjs');
const { close } = await import('./fixture.mjs');
const root = mkdtempSync(join(tmpdir(), 'remote-tester-')), socket = join(root, 'tester.sock');
const server = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', log: () => {} });
t.after(async () => { await close(server); rmSync(root, { recursive: true, force: true }); });
for (const method of ['GET', 'POST']) {
const result = await new Promise((resolve, reject) => {
const req = request({ socketPath: socket, path: '/api/project-remotes/stack', method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
req.on('error', reject); req.end();
});
assert.equal(result, 403);
}
});
test('library targets and unrelated project mapping drift are refused', async t => {
const { mappingEntry } = await import('../../library/tests/helpers.mjs');
const { writeFileSync } = await import('node:fs');
const f = await realRemoteFixture(t, doc => { doc.projects.books = mappingEntry(); doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; });
const library = await new Promise((resolve, reject) => {
const req = request(f.base + '/api/project-remotes/books', res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); });
req.on('error', reject); req.end();
});
assert.deepEqual(library, [403, { error: 'project-remote-library' }]);
const [, before] = await get(f), document = JSON.parse(readFileSync(f.file));
document.projects.stack.root += '-other'; writeFileSync(f.file, JSON.stringify(document));
const bytes = readFileSync(f.file), [status, result] = await post(f, { revision: before.revision, remote: url });
assert.equal(status, 409); assert.equal(result.error, 'project-remote-protected'); assert.deepEqual(readFileSync(f.file), bytes);
});
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { remoteFixture } from './helpers/w15-project-remote.mjs';
for (const width of [1440, 390]) test(`project remote fixture at ${width}px`, async t => {
const { b, mount, wait, errors, network } = await remoteFixture(t, width);
const shots = process.env.MOSAIC_UX_SHOTS;
if (shots) mkdirSync(shots, { recursive: true });
async function shot(name) {
if (!shots) return;
await b.screenshot(join(shots, `project-remote-${name}-${width}.png`));
const { contentSize } = await b.call('Page.getLayoutMetrics');
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: contentSize.width, height: contentSize.height, scale: 1 } });
const { writeFile } = await import('node:fs/promises');
await writeFile(join(shots, `project-remote-${name}-${width}-full.png`), Buffer.from(data, 'base64'));
}
async function input(value) {
await b.evaluate(`(()=>{const e=document.querySelector('#project-remote-url');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
}
async function submit() {
const rect = await b.evaluate(`(()=>{const r=document.querySelector('.project-remote button[type=submit]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
}
await mount(); await wait(`document.querySelector('#project-remote-url')`);
for (const mode of ['light', 'dark']) {
await b.evaluate(`(()=>{const e=document.querySelector('#mode');e.value='${mode}';e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`);
assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode);
assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#project-remote-url')).visibility`), 'visible');
}
await b.evaluate(`(()=>{const e=document.querySelector('#mode');e.value='system';e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`);
const ax = await b.call('Accessibility.getFullAXTree');
assert.ok(ax.nodes.some(n => n.role?.value === 'textbox' && n.name?.value === 'Existing HTTPS repository URL'));
assert.ok(ax.nodes.some(n => n.role?.value === 'button' && n.name?.value === 'Save'));
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').getBoundingClientRect().height >= 44`), true);
await b.evaluate(`document.querySelector('#project-remote-url').focus(),true`);
await b.key('Tab'); assert.equal(await b.evaluate(`document.activeElement.textContent`), 'Save');
await b.key('Tab', 'Tab', 8); assert.equal(await b.evaluate(`document.activeElement.id`), 'project-remote-url');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
// Halving the CSS viewport models the reflow available at 200% browser zoom.
await b.viewport(width / 2, 500);
assert.equal(await b.evaluate(`document.querySelector('.project-remote').scrollWidth <= document.querySelector('.project-remote').clientWidth`), true);
await b.viewport(width, 1000);
await shot('remote-missing');
await b.key('Tab'); await b.key('Tab', 'Tab', 8); await b.key('Escape');
await input('https://git.example/owner/repo.git'); await shot('remote-editing');
await b.evaluate(`window.__mode='hold',true`); await submit();
await wait(`window.__writes.length===1 && !!window.__release`);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
await b.evaluate(`document.querySelector('.project-remote form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true})),window.__release(),true`);
await wait(`document.querySelector('.project-remote').textContent.includes('Configured, not verified')`);
assert.equal(await b.evaluate('window.__writes.length'), 1); await shot('remote-saved-not-published');
await b.evaluate(`window.__remote={project:'stack',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true};window.__mode='stale';true`);
await mount(); await wait(`document.querySelector('#project-remote-url')`); await input('https://git.example/owner/repo.git'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('setting changed')`); await shot('remote-stale');
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/repo.git');
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click(),true`);
await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/repo.git');
await input('https://name:[email protected]/owner/repo'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('must not contain credentials')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), ''); await shot('remote-invalid');
await b.evaluate(`window.__mode='fail',true`); await input('https://git.example/owner/invalid'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('Enter a valid HTTPS')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/invalid');
await b.evaluate(`window.__mode='unknown',true`); await input('https://git.example/owner/unknown'); await submit();
await wait(`document.querySelector('.project-remote').textContent.includes('Save outcome is unknown')`);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/unknown');
await b.evaluate(`window.__writeEnabled=false,true`); await mount();
await wait(`document.querySelector('.project-remote').textContent.includes('read-only')`);
assert.equal(await b.evaluate(`document.querySelectorAll('.project-remote form').length`), 0);
await mount(null); await shot('remote-readonly-or-unlinked');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')),true`);
assert.equal(await b.evaluate(`document.querySelector('.project-remote').textContent`), '');
assert.deepEqual(network, []); assert.deepEqual(errors, []);
});
for (const width of [1440, 390]) test(`real fixture saves the project remote at ${width}px`, async t => {
const { realRemoteFixture } = await import('./helpers/w15-project-remote-real.mjs');
const { browser } = await import('./browser.mjs');
const { readFileSync } = await import('node:fs');
const f = await realRemoteFixture(t), b = await browser({ machine: false });
t.after(() => b.close());
const errors = [], blocked = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.text));
b.on('Fetch.requestPaused', p => {
if (p.request.url.startsWith(f.base + '/')) b.call('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
else { blocked.push(p.request.url); b.call('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }).catch(() => {}); }
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
await b.viewport(width, 1000); await b.navigate(f.base + '/#/projects/10000000-0000-4000-8000-000000000001/settings');
await b.evaluate(`new Promise((resolve,reject)=>{const link=document.createElement('link');link.rel='stylesheet';link.href='/project-remote.css';document.head.append(link);const script=document.createElement('script');script.src='/project-remote.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})`);
await b.evaluate(`(()=>{const main=document.querySelector('#main');for(const child of main.children)child.style.display='none';main.hidden=false;const h=document.createElement('h1');h.textContent='Project information';const slot=document.createElement('div');main.append(h,slot);MosaicProjectRemote.mount({container:slot,projectId:'10000000-0000-4000-8000-000000000001',repositoryKey:'stack'});return true})()`);
const wait = async expression => b.evaluate(`(async()=>{const until=Date.now()+10000;while(!(${expression})){if(Date.now()>until)throw Error('timed out');await new Promise(r=>setTimeout(r,20))}return true})()`);
await wait(`document.querySelector('#project-remote-url')`);
await b.evaluate(`(()=>{const e=document.querySelector('#project-remote-url');e.value='https://git.example/owner/repo.git';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
const rect = await b.evaluate(`(()=>{const r=document.querySelector('.project-remote button[type=submit]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
await wait(`document.querySelector('.project-remote').textContent.includes('Configured, not verified')`);
assert.equal(JSON.parse(readFileSync(f.file)).projects.stack.remote, 'https://git.example/owner/repo.git');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
if (process.env.MOSAIC_UX_SHOTS) {
mkdirSync(process.env.MOSAIC_UX_SHOTS, { recursive: true });
await b.screenshot(join(process.env.MOSAIC_UX_SHOTS, `project-remote-real-saved-${width}.png`));
const { contentSize } = await b.call('Page.getLayoutMetrics');
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip: { x: 0, y: 0, width: contentSize.width, height: contentSize.height, scale: 1 } });
const { writeFile } = await import('node:fs/promises');
await writeFile(join(process.env.MOSAIC_UX_SHOTS, `project-remote-real-saved-${width}-full.png`), Buffer.from(data, 'base64'));
}
assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
});