From f5df06730420232e33b16a6425b07b781c5cb153 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 19:18:57 -0600 Subject: [PATCH 1/5] feat(org): project remote set and checked, repository editable on Settings (#1873 P1-6, P1-7) Remote mode sets the project checkout's origin and checks it once with git ls-remote, with no credential, no prompt, a protocol allowlist of https and ssh and a 15 second limit. The receipt reports reachable, needs-credential or unreachable with git's error summarized. Nothing is pushed. URLs are https or ssh only; ext:: and other helpers, file://, local paths, option-like values and credential-bearing URLs are refused. `mosaic org project repo set` changes a project's repository later, both ways between remote and local only, through the same rules and check, with a revision guard and an org.project.repo.set receipt. The Console's organization project Settings tab edits it through /api/orgs/ID/repo/project/PROJECT; Quick Setup accepts ssh and shows the check. --- packages/cli/README.md | 8 +- packages/cli/src/org.mjs | 7 +- packages/cli/tests/org-repo-set.test.mjs | 48 ++++ packages/organization/README.md | 29 ++- packages/organization/src/org-service.mjs | 58 ++++- .../organization/src/organization-config.mjs | 6 +- packages/organization/src/repo-remote.mjs | 97 ++++++++ packages/organization/src/vocabulary.mjs | 56 +++++ .../organization/tests/repo-remote.test.mjs | 217 ++++++++++++++++++ packages/webui/README.md | 3 +- packages/webui/src/org-admin.mjs | 45 +++- packages/webui/src/public/chat.js | 5 +- packages/webui/src/public/org-create.js | 14 +- packages/webui/src/public/project-remote.css | 13 ++ packages/webui/src/public/project-remote.js | 135 +++++++++++ packages/webui/src/serve.mjs | 2 +- packages/webui/tests/org-admin.test.mjs | 91 +++++++- .../webui/tests/project-repo-browser.test.mjs | 191 +++++++++++++++ 18 files changed, 997 insertions(+), 28 deletions(-) create mode 100644 packages/cli/tests/org-repo-set.test.mjs create mode 100644 packages/organization/src/repo-remote.mjs create mode 100644 packages/organization/tests/repo-remote.test.mjs create mode 100644 packages/webui/tests/project-repo-browser.test.mjs diff --git a/packages/cli/README.md b/packages/cli/README.md index d55e60335..06605aace 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1279,8 +1279,12 @@ its checkout; `mosaic org project repo init ID PROJECT --local-only URL` selects remote mode instead. These commands never push or overwrite an existing repository. Local-only acceptance records the risk of losing the only copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project -repo show ID PROJECT [--json]`. See `packages/organization/README.md` for the -layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`; +repo show ID PROJECT [--json]`. `mosaic org project repo set ID PROJECT +(--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json]` +changes a project's repository later. A remote is https or ssh; its checkout's +`origin` is set and checked once with `git ls-remote`, without credentials, and +the receipt says `reachable`, `needs-credential` or `unreachable`. See +`packages/organization/README.md` for the URL rules, layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`; if both are supplied they must name the same ID. `mosaic org delete ID --confirm ID --if-revision N [--json]` stops the diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index 2e6489235..20dad706b 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { configDir } from '../../organization/src/organization-config.mjs'; import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs'; -import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs'; +import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs'; import { installedSystemTypes } from '../../plugins/src/system-types.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { loadSystem } from './config.mjs'; @@ -12,7 +12,7 @@ import { consoleBusRestart } from './org-console.mjs'; import { refuseInsideAgent } from './transport.mjs'; import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs'; import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs'; -export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]'; +export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]'; function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; } // A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916). const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable'; @@ -80,6 +80,9 @@ export async function orgCommand(args, io, deps = {}) { const project = words[0] === 'project', op = words[project ? 2 : 1], id = words[project ? 3 : 2], name = project ? words[4] : null; if (op === 'show') { allowed(['--json'], []); result = project ? await projectRepoShow(ctx, id, name) : await queueShow(ctx, id); } else if (op === 'init') { allowed(['--local-only', '--accept-local-loss-risk'], ['--remote']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const opts = { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk') }; result = project ? await projectRepoInit(ctx, id, name, opts) : await queueInit(ctx, id, opts); } + // #1873 P1-7: change the project's repository (remote URL, or remote to local-only and back). + // The same URL rules and check as init; origin follows; nothing is pushed. + else if (op === 'set' && project) { allowed(['--json', '--local-only', '--accept-local-loss-risk'], ['--remote', '--if-revision']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const ifRevision = revision(p.values['--if-revision'], true); result = await projectRepoSet(ctx, id, name, { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk'), ifRevision }); } else throw new CliError(ORG_USAGE, 4); } else throw new CliError(ORG_USAGE, 4); io.stdout.write(JSON.stringify(result, null, 2) + '\n'); diff --git a/packages/cli/tests/org-repo-set.test.mjs b/packages/cli/tests/org-repo-set.test.mjs new file mode 100644 index 000000000..4c778cee5 --- /dev/null +++ b/packages/cli/tests/org-repo-set.test.mjs @@ -0,0 +1,48 @@ +// #1873 P1-6 and P1-7: `org project repo init --remote` sets and checks origin; `org project repo +// set` changes the repository both ways. The remotes point at 127.0.0.1 port 1, which refuses at +// once, so the check runs for real and answers "unreachable" without leaving the host. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync, spawnSync } from 'node:child_process'; +const repo = resolve(import.meta.dirname, '../../..'); +const run = (env, ...args) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' }); +const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { env: { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, encoding: 'utf8' }).trim(); + +test('org project repo init --remote and repo set: origin follows, the check is reported, refusals are plain', () => { + const home = mkdtempSync(join(tmpdir(), 'org-repo-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-orgtest/config.json') }; + const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'orgtest'], { cwd: repo, env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr); + const ok = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); }; + const file = join(home, 'alpha.json'); + run(env, 'org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); ok('org', 'create', 'alpha', '--from', file); + + const ssh = 'ssh://git@127.0.0.1:1/team/work.git'; + const init = ok('org', 'project', 'repo', 'init', 'alpha', 'work', '--remote', ssh); + assert.equal(init.origin, ssh); assert.equal(init.check.result, 'unreachable'); assert.match(init.check.message, /port 1|refused/i); + assert.equal(git(init.root, 'remote', '-v'), `origin\t${ssh} (fetch)\norigin\t${ssh} (push)`); + + // Usage errors come first and change nothing. + for (const args of [['--local-only', '--accept-local-loss-risk'], ['--remote', ssh, '--local-only', '--if-revision', '9'], ['--if-revision', '9'], ['--remote', ssh, '--if-revision', '0']]) { + const r = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', ...args); assert.equal(r.status, 4, `${args}: ${r.stderr}`); + } + assert.equal(run(env, 'org', 'queue', 'set', 'alpha', '--local-only', '--accept-local-loss-risk', '--if-revision', '1').status, 4); + let rev = ok('org', 'show', 'alpha', '--json').revision; + for (const bad of ['ext::sh -c id', 'file:///etc', '/srv/repo', 'https://user:token@127.0.0.1:1/o/r', 'http://127.0.0.1:1/o/r']) { + const r = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', bad, '--if-revision', String(rev)); + assert.equal(r.status, 2, bad); assert.match(r.stderr, /org-invalid/, bad); + } + const stale = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--local-only', '--accept-local-loss-risk', '--if-revision', String(rev + 5)); + assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/); + assert.equal(git(init.root, 'remote', 'get-url', 'origin'), ssh); + + const local = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--local-only', '--accept-local-loss-risk', '--if-revision', String(rev)); + assert.equal(local.op, 'org.project.repo.set'); assert.equal(local.revision, rev + 1); assert.equal(local.origin, null); assert.equal(git(init.root, 'remote', '-v'), ''); + rev = local.revision; + const https = 'https://127.0.0.1:1/team/work.git'; + const back = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', https, '--if-revision', String(rev), '--json'); + assert.equal(back.revision, rev + 1); assert.equal(back.check.result, 'unreachable'); assert.match(back.check.message, /Failed to connect|Could not connect/); + assert.equal(git(init.root, 'remote', 'get-url', 'origin'), https); + assert.deepEqual(ok('org', 'project', 'repo', 'show', 'alpha', 'work', '--json').current, { mode: 'remote', remote: https, origin: https, revision: back.revision, check: back.check, changedAt: back.at }); +}); diff --git a/packages/organization/README.md b/packages/organization/README.md index b08f0ee97..e64daea02 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -569,10 +569,31 @@ Initialize an organization checkout with `mosaic org queue init ID --local-only --accept-local-loss-risk` and a project repository with `mosaic org project repo init ID PROJECT --local-only --accept-local-loss-risk`. The explicit acceptance records that this machine may hold the only copy. Alternatively use `--remote -https://HOST/OWNER/REPO` in place of both local-only flags. Both modes create a -local repository and genesis commit; remote mode records the URL without a -push. Existing paths are never overwritten. `mosaic org queue show ID --json` -and `mosaic org project repo show ID PROJECT --json` read the receipts back. +URL` in place of both local-only flags. Both modes create a local repository and +genesis commit. Existing paths are never overwritten. `mosaic org queue show ID +--json` and `mosaic org project repo show ID PROJECT --json` read the receipts +back; the project form also reports the binding as it is now (`current`). + +Remote mode (#1873 P1-6) accepts `https://HOST/OWNER/REPO` and ssh +(`ssh://[USER@]HOST[:PORT]/PATH` or `USER@HOST:PATH`) only. It refuses +`ext::` and other transport helpers, `file://`, local paths, values that begin +with `-`, and any URL that carries a user password or token +(`src/vocabulary.mjs` `parseRepoRemote`). The checkout's `origin` is set to the +URL and checked once with `git ls-remote` (`src/repo-remote.mjs`): no +credential helper, no askpass, no terminal prompt, no redirects, +`GIT_ALLOW_PROTOCOL=https:ssh`, no git or ssh config files, no ssh agent or key, +and a 15 second limit. The receipt's `check.result` is `reachable`, +`needs-credential` (the credential binding will supply it) or `unreachable` +with git's error summarized. The check never refuses the setup, and nothing is +pushed. Tests reach a local bare repository only through `repoRemoteTestSeam`, +which no source module may import. + +`mosaic org project repo set ID PROJECT (--local-only --accept-local-loss-risk | +--remote URL) --if-revision N [--json]` (#1873 P1-7) changes an initialized +project's repository: a new URL, or remote to local only and back. It runs the +same URL rules and check, sets or removes `origin`, records the binding in the +organization file's `projects.PROJECT.git`, and writes an +`org.project.repo.set` receipt. A stale `--if-revision` is `revision-conflict`. Queue review transports and transitions are not provided by this setup. The local development owner is accepted only through the existing named-instance diff --git a/packages/organization/src/org-service.mjs b/packages/organization/src/org-service.mjs index a8d3b223c..9b8a08a37 100644 --- a/packages/organization/src/org-service.mjs +++ b/packages/organization/src/org-service.mjs @@ -4,7 +4,8 @@ import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { validateBusinessDocument, httpsUrl } from './organization-config.mjs'; +import { validateBusinessDocument } from './organization-config.mjs'; +import { checkRemote, parseRepoRemote } from './repo-remote.mjs'; import { ID_PATTERN } from './vocabulary.mjs'; import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs'; import { loadInstalled } from '../../plugins/src/loader.mjs'; @@ -198,7 +199,8 @@ export async function updateOrg(ctx, id, input, { ifRevision } = {}) { } function options({ mode, remote, acceptLocalLossRisk }) { if (mode === 'local-only') { if (acceptLocalLossRisk !== true || remote !== undefined) fail('org-risk', 'local-only requires explicit loss-risk acceptance and no remote'); return { mode, riskAccepted: true }; } - if (mode === 'remote') { if (acceptLocalLossRisk || typeof remote !== 'string') fail('org-invalid', 'remote mode requires a remote URL'); try { httpsUrl(remote, 'remote', { repo: true }); } catch (e) { fail('org-invalid', e.message); } return { mode, remote }; } + // #1873 P1-6: https or ssh, and nothing that git could read as an option, a helper or a path. + if (mode === 'remote') { if (acceptLocalLossRisk || typeof remote !== 'string') fail('org-invalid', 'remote mode requires a remote URL'); const parsed = parseRepoRemote(remote); if (!parsed.ok) fail('org-invalid', parsed.message, '$.remote'); return { mode, remote: parsed.url }; } fail('org-invalid', 'select local-only or remote'); } async function git(root, args) { @@ -206,8 +208,21 @@ async function git(root, args) { return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', 'user.email=mosaic@localhost.invalid', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git ${args[0]} failed (exit ${code})`))); }); } async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); } +// The checkout's origin, or null when it has none. +async function originOf(root) { try { return await git(root, ['config', '--get', 'remote.origin.url']); } catch (e) { if (e?.code === 'org-git') return null; throw e; } } +// #1873 P1-6: point origin at the remote (or remove it for local-only). Never pushes or fetches. +async function setOrigin(root, selection) { + const origin = await originOf(root); + if (selection.mode === 'remote') { if (origin === null) await git(root, ['remote', 'add', 'origin', selection.remote]); else if (origin !== selection.remote) await git(root, ['remote', 'set-url', 'origin', selection.remote]); } + else if (origin !== null) await git(root, ['remote', 'remove', 'origin']); + return originOf(root); +} +const checkFields = (check) => check ? { check: { result: check.result, message: check.message } } : {}; async function setup(ctx, id, project, opts) { authorize(ctx); const selection = options(opts); if (project !== null) checkId(project); + // The check talks to the network, so it runs before any lock is taken. Its result is reported, + // never a refusal: a repository that needs a credential is the usual case before binding. + const check = selection.mode === 'remote' ? await checkRemote(selection.remote) : null; return withOrgLifecycle(ctx, id, async () => { const current = await withRegistry(ctx, async () => { const current = await readCurrentUnlocked(ctx, id); if (project !== null && !Object.hasOwn(current.doc.projects, project)) fail('org-unknown', 'unknown project'); @@ -217,7 +232,7 @@ async function setup(ctx, id, project, opts) { }, { write: false }); const parent = project === null ? current.root : join(current.root, 'projects'); if (project !== null) await ensurePrivateDir(parent); const root = project === null ? join(current.root, 'checkout') : projectPath(current.root, project); - const commit = await initGit(root); const fields = { ...selection, root, commit, ...(project === null ? {} : { project }) }; + const commit = await initGit(root); const origin = await setOrigin(root, selection); const fields = { ...selection, root, commit, ...(project === null ? {} : { project }), origin, ...checkFields(check) }; return withRegistry(ctx, async registry => { const latest = await readCurrentUnlocked(ctx, id); if (latest.revision !== current.revision) fail('revision-conflict', 'organization changed during setup; inspect the initialized repository before retrying'); @@ -236,7 +251,42 @@ async function showSetup(ctx, id, project) { if (!item || item.root !== root || !same(item.subject, current.principal)) fail('org-unavailable', 'setup receipt unavailable'); if (project !== null) { const parent = await fs.lstat(join(current.root, 'projects')); if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== process.getuid() || (parent.mode & 0o077)) fail('org-mismatch', 'unsafe projects directory'); } const st = await fs.lstat(root); const dotgit = await fs.lstat(join(root, '.git')); if (!st.isDirectory() || st.isSymbolicLink() || !dotgit.isDirectory() || dotgit.isSymbolicLink()) fail('org-mismatch', 'unsafe git repository'); - await git(root, ['rev-parse', '--verify', 'HEAD']); return item; + await git(root, ['rev-parse', '--verify', 'HEAD']); + if (project === null) return item; + // #1873 P1-7: the binding as it is now, which a later repo set may have changed since init. + const binding = current.doc.projects[project].git ?? { mode: item.mode, ...(item.remote ? { remote: item.remote } : {}) }; + const last = records.findLast(r => r.op === 'org.project.repo.set' && r.project === project && same(r.subject, current.principal)) ?? null; + return { ...item, current: { mode: binding.mode, ...(binding.mode === 'remote' ? { remote: binding.remote } : {}), origin: await originOf(root), revision: current.revision, ...((last ?? item).check ? { check: (last ?? item).check } : {}), ...(last ? { changedAt: last.at } : {}) } }; +} +// #1873 P1-7: change an initialized project's repository between remote and local-only, or to a +// new URL. The same URL rules and check as init; origin follows; nothing is pushed. A receipt +// records it. Setting the same URL again re-runs the check and repairs origin. +export async function projectRepoSet(ctx, id, project, opts = {}) { + authorize(ctx); checkId(id); checkId(project); const selection = options(opts); const { ifRevision } = opts; + if (!Number.isSafeInteger(ifRevision)) fail('org-invalid', 'repo set needs --if-revision'); + // Fail fast on the cheap checks before the network check, then again under the lock. + await projectRepoShow(ctx, id, project).then(r => { if (r.current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed'); }); + const check = selection.mode === 'remote' ? await checkRemote(selection.remote) : null; + return withOrgLifecycle(ctx, id, () => withRegistry(ctx, async registry => { + const latest = await readCurrentUnlocked(ctx, id); if (!Object.hasOwn(latest.doc.projects, project)) fail('org-unknown', 'unknown project'); + if (latest.revision !== ifRevision) fail('revision-conflict', 'organization revision changed'); + const root = projectPath(latest.root, project); + const init = (await receipts(latest.root)).findLast(r => r.op === 'org.project.repo.init' && r.project === project); + if (!init || init.root !== root || !same(init.subject, latest.principal)) fail('org-unavailable', 'setup receipt unavailable'); + const st = await fs.lstat(root); const dotgit = await fs.lstat(join(root, '.git')); if (!st.isDirectory() || st.isSymbolicLink() || !dotgit.isDirectory() || dotgit.isSymbolicLink()) fail('org-mismatch', 'unsafe git repository'); + const previous = latest.doc.projects[project].git ?? { mode: init.mode, ...(init.remote ? { remote: init.remote } : {}) }; + const next = selection.mode === 'remote' ? { mode: 'remote', remote: selection.remote } : { mode: 'local-only' }; + // origin first: a failure after it leaves the recorded binding as it was, and repeating the + // same set repairs origin. + const origin = await setOrigin(root, selection); + let revision = latest.revision; + if (canonicalJson(previous) !== canonicalJson(next)) { + if (!Number.isSafeInteger(revision + 1)) fail('org-mismatch', 'organization revision cannot be incremented'); + latest.doc.projects[project].git = next; await checked(latest.doc); const raw = encode(latest.doc); + await atomic(join(ctx.configDir, 'businesses', `${id}.json`), raw); registry.orgs[id].digest = digest(raw); registry.orgs[id].revision = revision = latest.revision + 1; + } + return receipt(latest.root, 'org.project.repo.set', id, ctx.principal, { ...selection, root, project, previous, revision, origin, ...checkFields(check) }); + })); } export async function queueShow(ctx, id) { return showSetup(ctx, id, null); } export async function projectRepoShow(ctx, id, project) { return showSetup(ctx, id, project); } diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index d2ab239b8..b76204271 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -12,7 +12,7 @@ import { checkVars } from "./vars.mjs"; import { parseCredentialRef } from "./credentials.mjs"; import { loadRole } from "./role.mjs"; import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs"; -import { END_PROTECTED } from "./vocabulary.mjs"; +import { END_PROTECTED, parseRepoRemote } from "./vocabulary.mjs"; import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs"; import { hasModule } from "../../plugins/src/loader.mjs"; import { readPluginLock } from "../../plugins/src/pinned.mjs"; @@ -320,7 +320,9 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo requireObject(entry.git, `${file} projects.${id}.git`); rejectUnknownKeys(entry.git, ["mode", "remote"], `${file} projects.${id}.git`); if (!['local-only', 'remote'].includes(entry.git.mode)) refuse(`${file} projects.${id}.git.mode must be local-only or remote`); - if (entry.git.mode === 'remote') httpsUrl(entry.git.remote, `${file} projects.${id}.git.remote`, { repo: true }); + // #1873 P1-6: the checkout's origin may be https or ssh (repo-remote.mjs's rules). The Row 199 + // upstream, projects.

.remote, stays https only. + if (entry.git.mode === 'remote') { const remote = parseRepoRemote(entry.git.remote); if (!remote.ok) refuse(`${file} projects.${id}.git.remote: ${remote.message}`); } else if (Object.hasOwn(entry.git, 'remote')) refuse(`${file} projects.${id}.git.remote is not allowed for local-only`); } projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}), diff --git a/packages/organization/src/repo-remote.mjs b/packages/organization/src/repo-remote.mjs new file mode 100644 index 000000000..4135c9a26 --- /dev/null +++ b/packages/organization/src/repo-remote.mjs @@ -0,0 +1,97 @@ +// #1873 P1-6 and P1-7: the project repository's remote URL, and the one check it gets. +// +// parseRepoRemote decides which URLs may become a checkout's origin: https (the Gitea form the +// loader already uses) and ssh, nothing else. checkRemote runs `git ls-remote` against that URL with +// no credential of any kind and a hard timeout, and says plainly whether the repository answered, +// wants a credential, or could not be reached. Nothing here pushes, fetches objects or writes. +import { spawn } from 'node:child_process'; +import { parseRepoRemote } from './vocabulary.mjs'; + +export { parseRepoRemote }; + +// The ssh client for the check: no user or system config file, no agent, no key, no password, no +// proxy or jump host, no multiplexing, no forwarding, no local command, and an unknown host key is +// accepted for this one connection without being remembered (nothing secret is sent to it). +export const CHECK_SSH_COMMAND = [ + 'ssh', '-F', '/dev/null', + ...['BatchMode=yes', 'ConnectTimeout=5', 'StrictHostKeyChecking=no', 'UserKnownHostsFile=/dev/null', + 'GlobalKnownHostsFile=/dev/null', 'LogLevel=ERROR', 'IdentityFile=none', 'IdentitiesOnly=yes', + 'IdentityAgent=none', 'PasswordAuthentication=no', 'KbdInteractiveAuthentication=no', + 'HostbasedAuthentication=no', 'GSSAPIAuthentication=no', 'ProxyCommand=none', 'ProxyJump=none', + 'ControlMaster=no', 'ControlPath=none', 'PermitLocalCommand=no', 'ForwardAgent=no', + 'ClearAllForwardings=yes'].flatMap((o) => ['-o', o]), +].join(' '); + +const PRODUCTION = Object.freeze({ protocols: 'https:ssh', timeoutMs: 15000 }); +const OUTPUT_CAP = 64 * 1024; + +// git's own message for "this needs a credential": a prompt it could not show, a 401 or 403, or +// ssh refusing every (absent) key. +const NEEDS_CREDENTIAL = [ + /terminal prompts disabled/i, /could not read (username|password)/i, /authentication failed/i, + /returned error: 40[13]\b/, /permission denied \(/i, +]; + +// One plain line from git's stderr: the first line that says what went wrong, without "fatal: ", +// control characters or more than 200 characters. +export function summarizeGitError(stderr) { + const lines = String(stderr).split('\n').map((l) => l.replace(/[\0-\x1f\x7f]/g, '').trim()).filter(Boolean); + const line = lines.find((l) => /^(fatal|ssh|error):/i.test(l)) ?? lines[0] ?? ''; + const text = line.replace(/^(fatal|error):\s*/i, ''); + return text.length > 200 ? `${text.slice(0, 199)}…` : text || 'git gave no reason'; +} + +export function classifyCheck({ code, timedOut, spawnError, stderr }) { + if (spawnError) return { result: 'unreachable', message: 'git could not be started' }; + if (timedOut) return { result: 'unreachable', message: 'no answer before the check timed out' }; + if (code === 0) return { result: 'reachable', message: 'the repository answered' }; + if (NEEDS_CREDENTIAL.some((p) => p.test(stderr))) return { result: 'needs-credential', message: 'the repository needs a credential; the credential binding will supply it' }; + return { result: 'unreachable', message: summarizeGitError(stderr) }; +} + +function lsRemote(url, { protocols, timeoutMs }) { + const env = { + PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', LC_ALL: 'C', + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CEILING_DIRECTORIES: '/', + GIT_TERMINAL_PROMPT: '0', GIT_ALLOW_PROTOCOL: protocols, + GIT_SSH_COMMAND: CHECK_SSH_COMMAND, GIT_SSH_VARIANT: 'ssh', GIT_PROTOCOL_FROM_USER: '0', + }; + const args = [ + '-c', 'credential.helper=', '-c', 'core.askPass=', '-c', 'http.followRedirects=false', + '-c', 'protocol.allow=never', ...protocols.split(':').flatMap((p) => ['-c', `protocol.${p}.allow=always`]), + 'ls-remote', '--', url, 'HEAD', + ]; + return new Promise((resolve) => { + let child; + try { child = spawn('git', args, { cwd: '/', env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }); } + catch { resolve(classifyCheck({ spawnError: true })); return; } + let stderr = '', timedOut = false, done = false; + const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ } }; + const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs); + child.stdout.on('data', () => {}); + child.stderr.on('data', (d) => { if (stderr.length < OUTPUT_CAP) stderr += d; }); + const finish = (outcome) => { if (done) return; done = true; clearTimeout(timer); kill(); resolve(classifyCheck({ ...outcome, timedOut, stderr })); }; + child.on('error', () => finish({ spawnError: true })); + child.on('close', (code) => finish({ code })); + }); +} + +let override = null; + +// The check for a URL parseRepoRemote accepted: https and ssh only, 15 second limit. Returns +// {result: 'reachable'|'needs-credential'|'unreachable', message, url}. A URL that does not parse +// is refused by the caller first; here it is reported unreachable without running git. +export async function checkRemote(url) { + const parsed = parseRepoRemote(url); + if (!parsed.ok) return { result: 'unreachable', message: parsed.message, url }; + const outcome = override ? await override(parsed.url) : await lsRemote(parsed.url, PRODUCTION); + return { result: outcome.result, message: outcome.message, url: parsed.url }; +} + +// TEST SEAM. Tests reach a local bare repository through this and nothing else: lsRemote with a +// widened protocol list, and a stand-in for checkRemote's git call. No module under packages/*/src +// may import or name it; tests/repo-remote.test.mjs enforces that by scanning the sources. +export const repoRemoteTestSeam = Object.freeze({ + lsRemote: (url, { protocols, timeoutMs = 5000 }) => lsRemote(url, { protocols, timeoutMs }), + useCheck(fn) { override = typeof fn === 'function' ? fn : null; }, +}); diff --git a/packages/organization/src/vocabulary.mjs b/packages/organization/src/vocabulary.mjs index 993f37d74..51986b39a 100644 --- a/packages/organization/src/vocabulary.mjs +++ b/packages/organization/src/vocabulary.mjs @@ -236,6 +236,62 @@ export function parseGiteaUrl(value, { repo = false } = {}) { return { url, owner: segments[0], repo: name }; } +// #1873 P1-6: a project repository's remote, https or ssh. It lives here, beside parseGiteaUrl, +// because the loader validates projects.

.git.remote with it and this file imports nothing. +const REPO_REMOTE_MAX = 512; +// A host name or IPv4 address: labels of letters, digits and "-", starting with a letter or digit. +const REMOTE_HOST = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*'; +// A login name. It never starts with "-" so ssh can't read it as an option. +const REMOTE_USER = '[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}'; +// Path segments: letters, digits, "_", ".", "-" and "~", never starting with "-", never "." or "..". +const REMOTE_SEGMENT = '[A-Za-z0-9_.~][A-Za-z0-9_.~-]{0,99}'; +const REMOTE_SSH_URL = new RegExp(`^ssh://(?:(${REMOTE_USER})@)?(${REMOTE_HOST})(?::([0-9]{1,5}))?(/${REMOTE_SEGMENT}(?:/${REMOTE_SEGMENT})*)$`); +const REMOTE_SCP_LIKE = new RegExp(`^(${REMOTE_USER})@(${REMOTE_HOST}):(/?${REMOTE_SEGMENT}(?:/${REMOTE_SEGMENT})*)$`); + +const REMOTE_PROBLEMS = Object.freeze({ + form: 'the repository URL must be an https or ssh URL', + option: 'the repository URL must not start with "-"', + transport: 'the repository URL must not name a git transport helper (such as ext::)', + local: 'the repository URL must not be a local path or a file:// URL', + scheme: 'the repository URL must use https or ssh', + credentials: 'the repository URL must not carry a user password or token; credentials come from the credential binding', + https: 'an https repository URL must be normalized and name a repository, https://HOST/OWNER/REPO', + ssh: 'an ssh repository URL must be ssh://[USER@]HOST[:PORT]/PATH or USER@HOST:PATH, with no "." or ".." segment', +}); + +// Returns {ok: true, kind: 'https'|'ssh', url} or {ok: false, problem, message}. The URL that is +// checked and stored is exactly the value given; nothing is rewritten. +export function parseRepoRemote(value) { + const no = (problem) => ({ ok: false, problem, message: REMOTE_PROBLEMS[problem] }); + if (typeof value !== 'string' || value.length === 0 || value.length > REPO_REMOTE_MAX) return no('form'); + if (value.startsWith('-')) return no('option'); + if (value.includes('::')) return no('transport'); + if (/[\s\0-\x1f\x7f]/.test(value)) return no('form'); + if (/^file:/i.test(value) || /^[/.~\\]/.test(value)) return no('local'); + const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(value)?.[1]?.toLowerCase(); + if (scheme === 'https') { + let url = null; + try { url = new URL(value); } catch { return no('https'); } + if (url.username || url.password) return no('credentials'); + return parseGiteaUrl(value, { repo: true }).url ? { ok: true, kind: 'https', url: value } : no('https'); + } + if (scheme === 'ssh') { + if (/^ssh:\/\/[^/@]*:[^/@]*@/.test(value)) return no('credentials'); + const m = REMOTE_SSH_URL.exec(value); + if (!m || remoteDotSegment(m[4]) || (m[3] !== undefined && !(Number(m[3]) >= 1 && Number(m[3]) <= 65535 && !m[3].startsWith('0')))) return no('ssh'); + return { ok: true, kind: 'ssh', url: value }; + } + if (scheme !== undefined) return no('scheme'); + // No "scheme://": git reads HOST:PATH as ssh only when the colon comes before any slash. + const colon = value.indexOf(':'), slash = value.indexOf('/'); + if (colon < 0 || (slash >= 0 && slash < colon)) return no('local'); + if (/^[^@]*:[^@]*@/.test(value)) return no('credentials'); + const m = REMOTE_SCP_LIKE.exec(value); + if (!m || remoteDotSegment(m[3])) return no('ssh'); + return { ok: true, kind: 'ssh', url: value }; +} +const remoteDotSegment = (path) => path.split('/').some((s) => s === '.' || s === '..'); + // Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]//REPO, or null for an // upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it. // URL.host drops a default port, so 443 counts the same on both sides. diff --git a/packages/organization/tests/repo-remote.test.mjs b/packages/organization/tests/repo-remote.test.mjs new file mode 100644 index 000000000..6debe7878 --- /dev/null +++ b/packages/organization/tests/repo-remote.test.mjs @@ -0,0 +1,217 @@ +// #1873 P1-6 and P1-7: the remote URL rules, the ls-remote check, and repo set. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { createServer as createTcpServer } from 'node:net'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { parseRepoRemote, checkRemote, classifyCheck, summarizeGitError, CHECK_SSH_COMMAND, repoRemoteTestSeam } from '../src/repo-remote.mjs'; +import { createOrg, templateRender, projectRepoInit, projectRepoShow, projectRepoSet, queueInit, showOrg, updateOrg } from '../src/org-service.mjs'; +import { loadBusiness } from '../src/organization-config.mjs'; +import { REPO_ROLES } from './helpers.mjs'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const seam = repoRemoteTestSeam; +const gitEnv = { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', LC_ALL: 'C' }; +const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { env: gitEnv, encoding: 'utf8' }).trim(); +function bare() { const dir = mkdtempSync(join(tmpdir(), 'bare-')); git(dir, 'init', '--quiet', '--bare', '--template='); return dir; } +function context() { const root = mkdtempSync(join(tmpdir(), 'repo-remote-')); const configDir = join(root, 'config'), dataRoot = join(root, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 }); return { configDir, dataRoot, instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; } +const listen = (server) => new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port))); + +test('remote URL rules: https and ssh only, nothing git could read as an option, helper, path or credential', () => { + for (const ok of ['https://git.example.com/owner/repo', 'https://git.example.com/owner/repo.git', 'https://git.example.com:8443/o/r', + 'ssh://git@git.example.com/owner/repo.git', 'ssh://git.example.com:2222/srv/git/repo.git', 'git@git.example.com:owner/repo.git', 'git@10.0.0.5:/srv/repo']) { + assert.equal(parseRepoRemote(ok).ok, true, ok); + } + const refused = { + 'ext::sh -c touch% /tmp/pwned': 'transport', 'fd::17': 'transport', 'file:///srv/repo.git': 'local', 'FILE:///srv/r': 'local', + '/srv/repo.git': 'local', './repo': 'local', '../repo': 'local', '~/repo': 'local', 'repo/sub:x': 'local', 'plainword': 'local', + '--upload-pack=touch /tmp/x': 'option', '-oProxyCommand=x': 'option', + 'https://user:token@git.example.com/o/r': 'credentials', 'https://token@git.example.com/o/r': 'credentials', + 'ssh://git:pw@git.example.com/o/r': 'credentials', 'git:pw@git.example.com:o/r': 'credentials', + 'http://git.example.com/o/r': 'scheme', 'git://git.example.com/o/r': 'scheme', 'ftp://x/o/r': 'scheme', 'ssh+git://x/o/r': 'scheme', + 'https://git.example.com/o/r?x=1': 'https', 'https://git.example.com/o/../r': 'https', 'https://git.example.com/o': 'https', 'HTTPS://git.example.com/o/r': 'https', + 'ssh://-oProxyCommand=x/o/r': 'ssh', 'ssh://git@-host/o/r': 'ssh', 'ssh://-user@host/o/r': 'ssh', 'ssh://host/../etc': 'ssh', + 'ssh://host:0/r': 'ssh', 'ssh://host:65536/r': 'ssh', 'ssh://host/-r': 'ssh', 'git@host:-r': 'ssh', 'git@host:a/../b': 'ssh', 'host:repo': 'ssh', + 'ssh://host/a b': 'form', 'https://x/o/r\n': 'form', '': 'form', + }; + for (const [value, problem] of Object.entries(refused)) { + const result = parseRepoRemote(value); + assert.equal(result.ok, false, value); assert.equal(result.problem, problem, value); assert.ok(result.message.length > 0); + } + assert.equal(parseRepoRemote(42).ok, false); assert.equal(parseRepoRemote('x'.repeat(600)).ok, false); +}); + +test('check outcomes are classified from git\'s own messages', () => { + assert.equal(classifyCheck({ code: 0, stderr: '' }).result, 'reachable'); + assert.equal(classifyCheck({ code: 128, stderr: "fatal: could not read Username for 'https://git.example.com': terminal prompts disabled\n" }).result, 'needs-credential'); + assert.equal(classifyCheck({ code: 128, stderr: 'git@127.0.0.1: Permission denied (publickey,password).\nfatal: Could not read from remote repository.\n' }).result, 'needs-credential'); + assert.equal(classifyCheck({ code: 128, stderr: "fatal: unable to access 'https://h/o/r/': The requested URL returned error: 403\n" }).result, 'needs-credential'); + const dns = classifyCheck({ code: 128, stderr: "fatal: unable to access 'https://git.example.invalid/o/r.git/': Could not resolve host: git.example.invalid\n" }); + assert.deepEqual(dns, { result: 'unreachable', message: "unable to access 'https://git.example.invalid/o/r.git/': Could not resolve host: git.example.invalid" }); + const ssh = classifyCheck({ code: 128, stderr: 'ssh: Could not resolve hostname git.example.invalid: Name or service not known\r\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\n' }); + assert.equal(ssh.message, 'ssh: Could not resolve hostname git.example.invalid: Name or service not known'); + assert.equal(classifyCheck({ timedOut: true, stderr: '' }).message, 'no answer before the check timed out'); + assert.equal(classifyCheck({ spawnError: true }).result, 'unreachable'); + assert.equal(summarizeGitError(`fatal: ${'x'.repeat(400)}\u001b[31m`).length, 200); + assert.equal(summarizeGitError(''), 'git gave no reason'); +}); + +test('the ssh client for the check reads no config, key, agent or proxy', () => { + for (const option of ['-F /dev/null', 'BatchMode=yes', 'IdentityFile=none', 'IdentitiesOnly=yes', 'IdentityAgent=none', 'PasswordAuthentication=no', + 'KbdInteractiveAuthentication=no', 'ProxyCommand=none', 'ProxyJump=none', 'ControlPath=none', 'PermitLocalCommand=no', 'ForwardAgent=no', 'UserKnownHostsFile=/dev/null']) { + assert.ok(CHECK_SSH_COMMAND.includes(option), option); + } +}); + +test('production check refuses local and helper URLs without running git, and file is not an allowed transport', async () => { + const repo = bare(); + for (const url of [`file://${repo}`, repo, 'ext::sh -c id', '--upload-pack=id']) assert.equal((await checkRemote(url)).result, 'unreachable', url); + // The production protocol list, through the seam: git itself refuses the file transport. + const refused = await seam.lsRemote(`file://${repo}`, { protocols: 'https:ssh' }); + assert.equal(refused.result, 'unreachable'); assert.match(refused.message, /transport 'file' not allowed/); + assert.equal((await seam.lsRemote(`file://${repo}`, { protocols: 'file' })).result, 'reachable'); +}); + +test('the check ignores the caller\'s git config and environment', async () => { + const repo = bare(); const home = mkdtempSync(join(tmpdir(), 'home-')); + // A user config that would rewrite the URL to the local repository, and a credential helper. + writeFileSync(join(home, '.gitconfig'), `[url "file://${repo}"]\n\tinsteadOf = https://git.example.invalid/o/r\n[credential]\n\thelper = !echo username=x; echo password=y\n`); + const saved = { HOME: process.env.HOME, GIT_CONFIG_PARAMETERS: process.env.GIT_CONFIG_PARAMETERS, GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL }; + process.env.HOME = home; process.env.GIT_CONFIG_GLOBAL = join(home, '.gitconfig'); process.env.GIT_CONFIG_PARAMETERS = `'url.file://${repo}.insteadof'='https://git.example.invalid/o/r'`; + try { + const result = await seam.lsRemote('https://git.example.invalid/o/r', { protocols: 'https:ssh:file', timeoutMs: 10000 }); + assert.equal(result.result, 'unreachable'); assert.match(result.message, /git\.example\.invalid/); + } finally { for (const [k, v] of Object.entries(saved)) if (v === undefined) delete process.env[k]; else process.env[k] = v; } +}); + +test('a 401 is needs-credential and no credential is sent', async () => { + const seen = []; + const server = createServer((req, res) => { seen.push(req.headers.authorization ?? null); res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="git"' }); res.end(); }); + const port = await listen(server); + try { + const result = await seam.lsRemote(`http://127.0.0.1:${port}/o/r.git`, { protocols: 'http' }); + assert.equal(result.result, 'needs-credential'); assert.match(result.message, /credential binding/); + assert.ok(seen.length >= 1); assert.deepEqual(seen.filter(Boolean), []); + } finally { server.close(); } +}); + +test('a redirect is not followed, so the check reaches only the named URL', async () => { + let elsewhere = 0; + const other = createServer((req, res) => { elsewhere++; res.writeHead(404); res.end(); }); + const otherPort = await listen(other); + const server = createServer((req, res) => { res.writeHead(302, { Location: `http://127.0.0.1:${otherPort}${req.url}` }); res.end(); }); + const port = await listen(server); + try { + const result = await seam.lsRemote(`http://127.0.0.1:${port}/o/r.git`, { protocols: 'http' }); + assert.equal(result.result, 'unreachable'); assert.match(result.message, /302/); assert.equal(elsewhere, 0); + } finally { server.close(); other.close(); } +}); + +test('a silent server times out and the check reports it', async () => { + const sockets = []; const server = createTcpServer((s) => sockets.push(s)); const port = await listen(server); + try { + const started = Date.now(); + const result = await seam.lsRemote(`http://127.0.0.1:${port}/o/r.git`, { protocols: 'http', timeoutMs: 600 }); + assert.deepEqual(result, { result: 'unreachable', message: 'no answer before the check timed out' }); assert.ok(Date.now() - started < 5000); + } finally { for (const s of sockets) s.destroy(); server.close(); } +}); + +test('no production source reaches the test seam', () => { + const offenders = []; + for (const pkg of readdirSync(join(repoRoot, 'packages'))) { + const walk = (dir) => { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const p = join(dir, e.name); if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); } else if (/\.(m?js|cjs)$/.test(e.name) && readFileSync(p, 'utf8').includes('repoRemoteTestSeam') && !p.endsWith(join('organization', 'src', 'repo-remote.mjs'))) offenders.push(p); } }; + walk(join(repoRoot, 'packages', pkg, 'src')); + } + assert.deepEqual(offenders, []); +}); + +async function orgWithRepo(c, opts = { mode: 'local-only', acceptLocalLossRisk: true }) { + await createOrg(c, 'alpha', await templateRender('alpha')); return projectRepoInit(c, 'alpha', 'work', opts); +} + +test('remote init sets origin and records the check; nothing is pushed', async () => { + const c = context(); const repo = bare(); const url = 'https://git.example.com/team/work.git'; + seam.useCheck((u) => { assert.equal(u, url); return seam.lsRemote(`file://${repo}`, { protocols: 'file' }); }); + try { + const r = await orgWithRepo(c, { mode: 'remote', remote: url }); + assert.equal(r.origin, url); assert.deepEqual(r.check, { result: 'reachable', message: 'the repository answered' }); + assert.equal(git(r.root, 'remote', '-v'), `origin\t${url} (fetch)\norigin\t${url} (push)`); + assert.equal(git(repo, 'for-each-ref').length, 0, 'nothing pushed'); + const shown = await projectRepoShow(c, 'alpha', 'work'); + assert.deepEqual(shown.current, { mode: 'remote', remote: url, origin: url, revision: shown.current.revision, check: r.check }); + } finally { seam.useCheck(null); } +}); + +test('repo set switches remote to local-only and back, with receipts and the revision fence', async () => { + const c = context(); const r = await orgWithRepo(c); + assert.equal(git(r.root, 'remote', '-v'), ''); + let rev = (await showOrg(c, 'alpha')).revision; + seam.useCheck(async () => ({ result: 'needs-credential', message: 'the repository needs a credential; the credential binding will supply it' })); + try { + const ssh = 'git@git.example.com:team/work.git'; + await assert.rejects(projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: ssh, ifRevision: rev + 1 }), { code: 'revision-conflict' }); + await assert.rejects(projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: ssh }), { code: 'org-invalid' }); + const set = await projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: ssh, ifRevision: rev }); + assert.equal(set.op, 'org.project.repo.set'); assert.equal(set.revision, rev + 1); assert.deepEqual(set.previous, { mode: 'local-only' }); + assert.equal(set.check.result, 'needs-credential'); assert.equal(git(r.root, 'remote', 'get-url', 'origin'), ssh); + assert.deepEqual(loadBusiness('alpha', { dir: c.configDir, rolesDir: REPO_ROLES }).projects.work.git, { mode: 'remote', remote: ssh }); + rev = set.revision; + const https = 'https://git.example.com/team/work'; + const moved = await projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: https, ifRevision: rev }); + assert.equal(git(r.root, 'remote', 'get-url', 'origin'), https); assert.equal(moved.revision, rev + 1); rev = moved.revision; + // The same URL again re-checks and repairs origin without a new revision. + git(r.root, 'remote', 'set-url', 'origin', 'https://elsewhere.example/x/y'); + const again = await projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: https, ifRevision: rev }); + assert.equal(again.revision, rev); assert.equal(git(r.root, 'remote', 'get-url', 'origin'), https); + await assert.rejects(projectRepoSet(c, 'alpha', 'work', { mode: 'local-only', ifRevision: rev }), { code: 'org-risk' }); + const local = await projectRepoSet(c, 'alpha', 'work', { mode: 'local-only', acceptLocalLossRisk: true, ifRevision: rev }); + assert.equal(local.revision, rev + 1); assert.equal(local.check, undefined); assert.equal(git(r.root, 'remote', '-v'), ''); + const shown = await projectRepoShow(c, 'alpha', 'work'); + assert.deepEqual(shown.current, { mode: 'local-only', origin: null, revision: local.revision, changedAt: local.at }); + const ops = readFileSync(join(r.root, '../../receipts.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l).op); + assert.deepEqual(ops.filter((o) => o.startsWith('org.project.repo')), ['org.project.repo.init', 'org.project.repo.set', 'org.project.repo.set', 'org.project.repo.set', 'org.project.repo.set']); + // A whole-document update still can't change the binding. + const current = await showOrg(c, 'alpha'); const doc = structuredClone(current.doc); doc.projects.work.git = { mode: 'remote', remote: https }; + await assert.rejects(updateOrg(c, 'alpha', doc, { ifRevision: current.revision }), { code: 'org-invalid' }); + } finally { seam.useCheck(null); } +}); + +test('repo set refuses bad URLs before any check, and needs an initialized repository', async () => { + const c = context(); await createOrg(c, 'alpha', await templateRender('alpha')); + let checks = 0; seam.useCheck(async () => { checks++; return { result: 'reachable', message: 'x' }; }); + try { + const rev = (await showOrg(c, 'alpha')).revision; + await assert.rejects(projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote: 'https://git.example.com/o/r', ifRevision: rev }), { code: 'org-unavailable' }); + await projectRepoInit(c, 'alpha', 'work', { mode: 'local-only', acceptLocalLossRisk: true }); + const rev2 = (await showOrg(c, 'alpha')).revision; + for (const remote of ['ext::sh -c id', `file://${bare()}`, '/srv/x', '--upload-pack=id', 'https://u:p@git.example.com/o/r', 'http://git.example.com/o/r']) { + await assert.rejects(projectRepoSet(c, 'alpha', 'work', { mode: 'remote', remote, ifRevision: rev2 }), { code: 'org-invalid' }, remote); + await assert.rejects(projectRepoInit(c, 'alpha', 'other', { mode: 'remote', remote }), { code: 'org-invalid' }, remote); + } + assert.equal(checks, 0); + } finally { seam.useCheck(null); } +}); + +test('queue init with a remote also sets origin', async () => { + const c = context(); await createOrg(c, 'alpha', await templateRender('alpha')); + seam.useCheck(async () => ({ result: 'unreachable', message: 'Could not resolve host: git.example.invalid' })); + try { + const q = await queueInit(c, 'alpha', { mode: 'remote', remote: 'ssh://git@git.example.invalid/team/queue.git' }); + assert.equal(git(q.root, 'remote', 'get-url', 'origin'), 'ssh://git@git.example.invalid/team/queue.git'); assert.equal(q.check.result, 'unreachable'); + } finally { seam.useCheck(null); } +}); + +test('the loader accepts an ssh git remote and refuses a helper URL', async () => { + const c = context(); await createOrg(c, 'alpha', await templateRender('alpha')); + const file = join(c.configDir, 'businesses', 'alpha.json'); + const doc = JSON.parse(readFileSync(file, 'utf8')); + const { validateOrg } = await import('../src/org-service.mjs'); + doc.projects.work.git = { mode: 'remote', remote: 'git@git.example.com:team/work.git' }; + assert.deepEqual(await validateOrg(doc), { ok: true, errors: [] }); + doc.projects.work.git = { mode: 'remote', remote: 'ext::sh -c id' }; + assert.equal((await validateOrg(doc)).ok, false); +}); diff --git a/packages/webui/README.md b/packages/webui/README.md index cdfa1d5a3..d5c226449 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -16,7 +16,8 @@ baseline CLI. | Create organization | `mosaic org create --from ` | S6 integration hook | | Save organization or theme | `mosaic org update --from --if-revision ` | S6 integration hook | | Initialize organization checkout | `mosaic org queue init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook | -| Initialize project Git | `mosaic org project repo init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook | +| Initialize project Git | `mosaic org project repo init --local-only --accept-local-loss-risk` or `--remote ` | S6 integration hook; remote mode sets and checks `origin` (#1873 P1-6) | +| Change a project's repository | `mosaic org project repo set (--local-only --accept-local-loss-risk \| --remote ) --if-revision ` | Enabled (#1873 P1-7): the owner's Repository section on an organization project's Settings tab, `/api/orgs//repo/project/`; needs send mode | | Recover a stopped bus with stale host state | `mosaic bus recover ` | CLI only | | Inspect or clear a dead lifecycle lock | `mosaic org lock show ` / `mosaic org lock clear ` | CLI only | | Start, stop or restart a bus | `mosaic bus start ` / `mosaic bus stop ` / `mosaic bus restart ` | S6 integration hook | diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index ff8aea0f3..f84597ab2 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -15,6 +15,8 @@ // GET /api/orgs/ID/setup read-back of every setup step (resume) // POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init // POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init +// GET /api/orgs/ID/repo/project/P C2 projectRepoShow mosaic org project repo show (#1873 P1-7) +// POST /api/orgs/ID/repo/project/P C2 projectRepoSet mosaic org project repo set --if-revision // POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach // POST /api/orgs/ID/restore C2 restoreOrg, then C3 busStart (E1: the answer's `bus`) // POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped; @@ -32,12 +34,13 @@ import { join } from 'node:path'; import { actor } from './action-trail.mjs'; import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs'; import { revivePending } from './bus-revive.mjs'; +import { parseRepoRemote } from '../../organization/src/vocabulary.mjs'; const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/; const MAX_BODY = 64 * 1024; -const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/; +const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|repo|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/; // The org-template placeholder (packages/organization/src/org-service.mjs templateRender). export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`; // Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is @@ -46,11 +49,12 @@ const SERVER_PROJECT_KEYS = ['root', 'repo', 'git', 'agents']; // The CLI verb each step equals (r4 section 2). export function verbFor(step, id, opts = {}) { - const tail = opts.mode === 'remote' ? `--remote ${opts.remote ?? ''}` : '--local-only --accept-local-loss-risk'; + const tail = opts.mode === 'remote' ? `--remote ${opts.remote ?? ''}` : '--local-only --accept-local-loss-risk'; if (step === 'create') return `mosaic org create ${id} --from `; if (step === 'update') return `mosaic org update ${id} --from --if-revision ${opts.revision ?? ''}`; if (step === 'queue') return `mosaic org queue init ${id} ${tail}`; if (step === 'project') return `mosaic org project repo init ${id} ${opts.project} ${tail}`; + if (step === 'repo') return `mosaic org project repo set ${id} ${opts.project} ${tail} --if-revision ${opts.revision ?? ''}`; if (step === 'archive' || step === 'restore') return `mosaic org ${step} ${id} --if-revision ${opts.revision}`; if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`; if (step === 'records') return `mosaic project init ${id} ${opts.project}`; @@ -385,15 +389,17 @@ function unreadable(res, error, step, id, log) { return json(res, 409, { error: 'readback-failed', step, message: `the ${step === 'queue' ? 'checkout' : 'repository'} could not be read back${error?.code ? ` (${error.code})` : ''}, so it was not retried. Inspect it with the show verb.`, readBack: `/api/orgs/${id}/setup` }); } -const gitOptions = (body, res, step) => { - strictKeys(body, ['mode', 'remote', 'acceptLocalLossRisk']); +const gitOptions = (body, res, step, extra = []) => { + strictKeys(body, ['mode', 'remote', 'acceptLocalLossRisk', ...extra]); if (body.mode === 'local-only') { if (body.acceptLocalLossRisk !== true) { json(res, 422, { error: 'risk-not-accepted', step, errors: [{ path: '$.acceptLocalLossRisk', field: 'acceptLocalLossRisk', message: 'tick the box to accept that a local-only checkout has no copy anywhere else' }] }); return null; } if (body.remote !== undefined) { json(res, 422, { error: 'invalid', step, errors: [{ path: '$.remote', field: 'remote', message: 'local only takes no remote' }] }); return null; } return { mode: 'local-only', acceptLocalLossRisk: true }; } if (body.mode === 'remote') { - if (typeof body.remote !== 'string' || body.remote.length > 2048 || !/^https:\/\/[^\s/]+\/\S+$/.test(body.remote) || isShellText(body.remote)) { json(res, 422, { error: 'invalid', step, errors: [{ path: '$.remote', field: 'remote', message: 'must be an https repository URL, such as https://git.example/owner/repo' }] }); return null; } + // #1873 P1-6: the service's own URL rules (https or ssh; no option, helper, path or credential). + const parsed = parseRepoRemote(body.remote); + if (!parsed.ok) { json(res, 422, { error: 'invalid', step, errors: [{ path: '$.remote', field: 'remote', message: parsed.message }] }); return null; } if (body.acceptLocalLossRisk !== undefined) { json(res, 422, { error: 'invalid', step, errors: [{ path: '$.acceptLocalLossRisk', field: 'acceptLocalLossRisk', message: 'applies to local only' }] }); return null; } return { mode: 'remote', remote: body.remote }; } @@ -526,6 +532,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log if (kind === 'setup' && !sub && !write) { try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); } } + if (kind === 'repo' && sub?.startsWith('project/')) return repoSet(req, res, { project, write, id, ctx, service, log }); if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }); if (kind === 'stop') return stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }); if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' }); @@ -614,6 +621,34 @@ async function restoreBus({ id, ctx, admin, log, trail, by }) { return { outcome: 'done', healthy, receipt: forReceipt(ctx, receipt), verb }; } +// #1873 P1-7: the project Settings tab reads and changes the project's repository. GET answers the +// binding as it is now; POST is `mosaic org project repo set`: the same URL rules and check as setup, +// origin follows, nothing is pushed, a receipt records it. A project not set up yet answers missing. +async function repoSet(req, res, { project, write, id, ctx, service, log }) { + let name; + try { name = decodeURIComponent(project); } catch { return json(res, 404, { error: 'not found' }); } + if (!ID.test(name)) return json(res, 404, { error: 'not found' }); + let body = null, opts = null; + if (write) { body = await readBody(req); opts = gitOptions(body, res, 'repo', ['ifRevision']); if (!opts) return; } + if (write && (!Number.isSafeInteger(body.ifRevision) || body.ifRevision < 1)) return json(res, 422, { error: 'invalid', step: 'repo', errors: [{ path: '$.ifRevision', field: 'ifRevision', message: 'reload the project; the revision you opened is missing' }] }); + let shown; + try { shown = await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step: 'repo', id }); } + if (!Object.hasOwn(shown.doc?.projects ?? {}, name) || shown.doc.projects[name]?.kind === 'library') return json(res, 404, { error: 'project-unknown', step: 'repo', message: `${id} declares no project ${name}` }); + let existing; + try { existing = await receiptOf(() => service.projectRepoShow(ctx, id, name)); } catch (e) { return unreadable(res, e, 'project', id, log); } + // No new refusal code: a write to a project not set up yet is the existing 'invalid'. + if (!existing) return json(res, write ? 422 : 200, { state: 'missing', step: 'repo', project: name, revision: shown.revision, ...(write ? { error: 'invalid' } : {}), message: 'this project has no repository yet; set it up first', verb: verbFor('project', id, { project: name }) }); + if (!write) { + const current = existing.current ?? { mode: existing.mode, ...(existing.remote ? { remote: existing.remote } : {}), revision: shown.revision }; + return json(res, 200, { state: 'done', step: 'repo', project: name, current: forReceipt(ctx, current), receipt: forReceipt(ctx, existing), verb: verbFor('repo', id, { ...current, project: name, revision: current.revision }) }); + } + if (typeof service.projectRepoSet !== 'function') return json(res, 503, { error: 'not-configured', step: 'repo', message: 'changing a project repository is not available on this Console yet' }); + try { + const receipt = await service.projectRepoSet(ctx, id, name, { ...opts, ifRevision: body.ifRevision }); + return json(res, 200, { outcome: 'done', step: 'repo', project: name, receipt: forReceipt(ctx, receipt), check: receipt?.check ?? null, verb: verbFor('repo', id, { ...opts, project: name, revision: body.ifRevision }) }); + } catch (e) { return refusal(res, e, { step: 'repo', id, unknownOk: true }); } +} + // F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A // running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner); // a stopped one starts as setup starts it. The trail line is written before anything changes, and diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index c81e37124..c99bc0533 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -683,7 +683,10 @@ refreshRequestData(c, false); const remote = c.container.querySelector('[data-chat-remote]'); const project = c.projects.find(p => p.id === c.id); - if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); } + // #1873 P1-7: an organization project's repository is editable through the organization's own route, + // which names the project as the organization file does: the chat project's repo key. + if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived }); + else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); } // Phase P2 (#1873): the project's System types selection, the owner's, mounted like the remote. const types = c.container.querySelector('[data-chat-system-types]'); if (types && roomOwner() && typeof window.MosaicSystemTypes?.mount === 'function') window.MosaicSystemTypes.mount({ container: types, projectId: c.id, archived: !!c.projectArchived }); diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js index 0c88178fc..fdb00aa07 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -4,6 +4,9 @@ (() => { const $ = id => document.getElementById(id); const txt = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + // #1873 P1-6: the remote check a repository receipt records, in one plain line. + const CHECKED = { reachable: 'reachable', 'needs-credential': 'needs a credential', unreachable: 'unreachable' }; + const checkLine = r => `Remote check: ${CHECKED[r.check.result] ?? 'unknown'}, ${r.check.message}. Origin: ${r.origin ?? 'none'}.`; const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/; const markup = `

@@ -34,7 +37,7 @@