diff --git a/agents/mos/work/quick-setup/BUILD.md b/agents/mos/work/quick-setup/BUILD.md new file mode 100644 index 000000000..4f5dc0dae --- /dev/null +++ b/agents/mos/work/quick-setup/BUILD.md @@ -0,0 +1,13 @@ +# Quick Setup implementation + +Baseline: `bc728e592`. The brief is `/mnt/storage/scratch/mosaic-lead/uidev/org-release/QUICK-SETUP-BRIEF.md`. + +The optional `orgPresets()` export uses the existing harness and model-provider kinds. This avoids introducing a kind and keeps each preset next to the module it selects, at the cost of discovering both kinds for setup. Duplicate names and malformed descriptions refuse rather than choosing one. The legacy `--fixture local` alias is retained. Claude availability uses the Console process environment and returns a generic refusal without paths or values. + +Quick Setup explicitly asks for local-only loss-risk acceptance, because organization checkout setup already requires it even when the project's remote is supplied. The flow does not automatically retry a refused or uncertain step; an owner can open the organization and inspect its receipts. Screenshots use the actual page and organization service with a deliberately refusing bus double. A separate live browser test used a bootstrapped scratch home, the real loopback fixture, the Console, bus, worker and browser: Quick Setup completed all four steps and the conversation received PINEAPPLE. The fixture endpoint ran under a long-lived parent. A short organization ID was needed to fit the Unix socket path limit. + +Follow-ups outside this brief: purpose-based role questions, proxy initialization, organization retirement, and theming. + +The opt-in live test `packages/webui/tests/org-quick-live.test.mjs` reads the Console address from the scratch home's `orgdev-qconsole.log`; it skips in the ordinary package suite when that log is absent. The live test was run with the brief's clean Node 26 environment and `HOME=/mnt/storage/scratch/tmp/oLGvYu`. + +Verification, Node 26 clean environment: business 346 pass/0 fail, plugins 155/0, WebUI 1012 pass/8 fail/1 skip. All eight WebUI failures are `pinned-copy.test.mjs` `ERR_FS_EISDIR` from the brief-required `packages/webui/node_modules` symlink; the skipped case is the separately passing live browser test. Live test: 1 pass/0 fail. The browser test also checks that an organization can be opened after setup, including its repository and the fixture conversation. Screenshots remain untracked. diff --git a/packages/business/src/org-service.mjs b/packages/business/src/org-service.mjs index c25c4c48b..848fb9b00 100644 --- a/packages/business/src/org-service.mjs +++ b/packages/business/src/org-service.mjs @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { validateBusinessDocument, httpsUrl } from './business.mjs'; import { ID_PATTERN } from './vocabulary.mjs'; import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry } from './org-registry.mjs'; -import { FIXTURE_TEMPLATE } from '../../plugins/model-provider/fixture/index.mjs'; +import { loadInstalled } from '../../plugins/src/loader.mjs'; import { BINDING_NAME } from '../../discord/src/binding.mjs'; const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles'); const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' }); @@ -49,15 +49,58 @@ function normalizeDoc(ctx, id, input, existing) { if (existing) for (const [p, entry] of Object.entries(existing.projects)) if (entry.git && !doc.projects?.[p]) fail('org-invalid', 'initialized project cannot be removed', `$.projects.${p}`); return doc; } -export async function templateRender(id, { fixture } = {}) { - if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture'); checkId(id); +// Presets are supplied by installed modules; reject malformed or duplicate names +// rather than silently choosing a different module's configuration. +async function discoverPresets() { + const presets = new Map(); + for (const kind of ['harness', 'model-provider']) { + const { modules, refused } = await loadInstalled(kind); + if (refused.length) fail('org-invalid', 'installed plugin could not be loaded'); + for (const { module } of modules) { + if (module.orgPresets === undefined) continue; + if (typeof module.orgPresets !== 'function') fail('org-invalid', 'invalid preset provider'); + const list = await module.orgPresets(); + if (!Array.isArray(list)) fail('org-invalid', 'invalid preset provider'); + for (const p of list) { + if (!p || typeof p.name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(p.name) || presets.has(p.name) || + typeof p.label !== 'string' || !p.label || typeof p.description !== 'string' || !p.description || + !Array.isArray(p.thinking?.levels) || !p.thinking.levels.length || + p.thinking.levels.some(v => typeof v !== 'string' || !/^[a-z][a-z0-9-]*$/.test(v)) || + new Set(p.thinking.levels).size !== p.thinking.levels.length || !p.thinking.levels.includes(p.thinking.default) || + typeof p.available !== 'function' || typeof p.overlay !== 'function') fail('org-invalid', 'invalid or duplicate preset'); + presets.set(p.name, p); + } + } + } + return presets; +} +export async function orgPresets({ env = process.env } = {}) { + const presets = await discoverPresets(); + return Promise.all([...presets.values()].map(async p => { + const availability = await p.available({ env }); + if (!availability || typeof availability.ok !== 'boolean' || (!availability.ok && (typeof availability.reason !== 'string' || !availability.reason))) fail('org-invalid', 'invalid preset availability'); + return { name: p.name, label: p.label, description: p.description, thinking: structuredClone(p.thinking), available: availability.ok ? { ok: true } : { ok: false, reason: availability.reason } }; + })); +} +export async function templateRender(id, { fixture, preset, thinking } = {}) { + if (fixture !== undefined && fixture !== 'local') fail('org-invalid', 'unknown fixture'); + if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset'); + if (fixture === 'local') preset = 'fixture'; + checkId(id); const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8')); doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`; - if (fixture === 'local') { - const fixtureDoc = structuredClone(FIXTURE_TEMPLATE); - doc.plugins = fixtureDoc.plugins; doc.launch = fixtureDoc.launch; - doc.roles.assistant.vars = fixtureDoc.assistantVars; doc.vars = fixtureDoc.vars; - } + if (preset !== undefined) { + if (typeof preset !== 'string') fail('org-invalid', 'unknown preset'); + const p = (await discoverPresets()).get(preset); + if (!p) fail('org-invalid', 'unknown preset'); + const level = thinking === undefined ? p.thinking.default : thinking; + if (!p.thinking.levels.includes(level)) fail('org-invalid', 'unknown thinking level'); + const overlay = await p.overlay({ thinking: level }); + if (!overlay || typeof overlay !== 'object' || !overlay.plugins || !overlay.launch || !overlay.assistantVars) fail('org-invalid', 'invalid preset overlay'); + doc.plugins = structuredClone(overlay.plugins); doc.launch = structuredClone(overlay.launch); + doc.roles.assistant.vars = structuredClone(overlay.assistantVars); + if (overlay.vars !== undefined) doc.vars = structuredClone(overlay.vars); + } else if (thinking !== undefined) fail('org-invalid', 'thinking requires a preset'); return doc; } export async function validateOrg(doc) { diff --git a/packages/business/tests/org-presets.test.mjs b/packages/business/tests/org-presets.test.mjs new file mode 100644 index 000000000..7b24bbbd6 --- /dev/null +++ b/packages/business/tests/org-presets.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as service from '../src/org-service.mjs'; +const { templateRender, validateOrg } = service; + +test('installed modules supply presets and report availability without credentials', async () => { + const presets = await service.orgPresets({ env: { PATH: '/usr/bin:/bin' } }); + const fixture = presets.find(p => p.name === 'fixture'); + const claude = presets.find(p => p.name === 'claude-code'); + assert.equal(fixture.available.ok, true); + assert.equal(claude.available.ok, false); + assert.ok(claude.available.reason); + assert.deepEqual(claude.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); +}); + +test('preset overlays produce valid documents and the legacy alias remains identical', async () => { + const doc = await templateRender('alpha', { preset: 'fixture' }); + assert.deepEqual(doc, await templateRender('alpha', { fixture: 'local' })); + assert.deepEqual(await validateOrg(doc), { ok: true, errors: [] }); + assert.equal(doc.vars['bus.tester'], true); + const claude = await templateRender('alpha', { preset: 'claude-code', thinking: 'medium' }); + assert.deepEqual(claude.roles.assistant.vars, { harness: 'claude-code', model: 'opus', thinking: 'medium' }); + assert.equal(claude.launch.models.opus.model, 'claude-opus-5-5'); + assert.equal(claude.vars, undefined); + assert.deepEqual(await validateOrg(claude), { ok: true, errors: [] }); +}); + +test('unknown presets and thinking levels refuse', async () => { + await assert.rejects(templateRender('alpha', { preset: 'no-such-preset' }), /unknown preset/); + await assert.rejects(templateRender('alpha', { preset: 'claude-code', thinking: 'off' }), /thinking/); + await assert.rejects(templateRender('alpha', { thinking: 'low' }), /thinking/); +}); diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 3185bd95a..c2cf6c031 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -48,6 +48,20 @@ hold booleans. The harness receives it as data. Modules return named refusals fo they never return credentials, commands or endpoints. The host rejects an unknown or ambiguous legacy mapping and validates transport data before use. +### Organization presets + +A `harness` or `model-provider` module may export `orgPresets()`, returning an +array of preset descriptions. Each has a globally unique stable `name`, a +`label`, a one-line `description`, `thinking: { levels, default }`, an +`available({ env })` function returning `{ ok: true }` or `{ ok: false, +reason }`, and `overlay({ thinking })` returning `{ plugins, launch, +assistantVars, vars? }`. The loader discovers installed modules; the +organization service rejects duplicate names, invalid metadata, unknown +presets and unsupported thinking levels. Availability reasons are displayed +in the Console and must never contain credential values or private paths. +The fixture provider supplies the legacy `--fixture local` alias; the +Claude Code harness checks its preflight using the Console's environment. + A `search-source` answers the broker's `web.search` (REQ-HARN-2, REQ-PLUG-2, decision 0b81b0c5): `search(settings, query, deps, { signal })` resolves `{ query, total, results: [{ title, url, snippet }] }` or throws a `WebRefusal`; `signal` is an `AbortSignal` the broker fires at its deadline, and a module must stop its request when it does. The kind switches on one module, and the module's name is the source: `searxng-url` queries the operator's SearXNG at the business var `web.searxng`; `searxng-bundled` queries the SearXNG the Stack installs, at a fixed loopback address (the install is REQ-INST-1's first component); `harness-builtin` is served by the harness itself: a granted claude-code role gets WebSearch and WebFetch, the broker serves no web verbs, and `search` only refuses. The choice and every change to it are the `plugins` setting, logged in `changes.jsonl` by the plug-in setter. Adding a kind, or changing a kind's exports, interface version or switch, diff --git a/packages/plugins/harness/claude-code/index.mjs b/packages/plugins/harness/claude-code/index.mjs index 211f5a0f7..e65f38c57 100644 --- a/packages/plugins/harness/claude-code/index.mjs +++ b/packages/plugins/harness/claude-code/index.mjs @@ -69,6 +69,19 @@ export function preflight({ env, proxy = null }) { return null; } +export function orgPresets() { + return [{ name: 'claude-code', label: "Claude via this machine's sign-in", + description: 'Use Claude Code with the credentials available to this Console.', + thinking: { levels: [...LEVELS], default: 'low' }, + available: ({ env }) => { + const problem = preflight({ env }); + return problem ? { ok: false, reason: problem.code === 'no-session-credential' ? 'No session credential is available to the Console.' : 'Claude Code is not available to the Console.' } : { ok: true }; + }, + overlay: ({ thinking }) => ({ plugins: { channel: ['cli', 'webui'], harness: ['claude-code'] }, + launch: { by: 'chief', instances: ['assistant'], models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } } }, + assistantVars: { harness: 'claude-code', model: 'opus', thinking } }) }]; +} + // `claude --version` from the binary sessions run, or null when it doesn't answer. export function version({ env }) { const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env }); diff --git a/packages/plugins/model-provider/fixture/index.mjs b/packages/plugins/model-provider/fixture/index.mjs index 48f0b45e2..07ea977d9 100644 --- a/packages/plugins/model-provider/fixture/index.mjs +++ b/packages/plugins/model-provider/fixture/index.mjs @@ -16,6 +16,12 @@ export const FIXTURE_TEMPLATE = Object.freeze({ assistantVars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' }, vars: { 'bus.tester': true }, }); +// Organization setup is supplied by this module, alongside its model route. +export function orgPresets() { + return [{ name: 'fixture', label: 'Test model', description: 'Canned replies, no cost.', + thinking: { levels: ['off'], default: 'off' }, available: () => ({ ok: true }), + overlay: ({ thinking }) => ({ ...structuredClone(FIXTURE_TEMPLATE), assistantVars: { ...FIXTURE_TEMPLATE.assistantVars, thinking } }) }]; +} const HARNESSES = Object.freeze(['pi']); const TRANSPORT = Object.freeze({ routeId: 'fixture', endpoint: 'root', api: 'anthropic-messages', piProvider: 'mosaic-fixture', compat: null }); diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index 7ce18beb2..f03cd34a1 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -291,7 +291,7 @@ export function createOrgAdmin({ service, registry = null, bus = null, context } // the page's own origin for the write fence. export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) { const org = path.match(ROUTE); - const top = ['/api/org-template', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST'); + const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST'); if (!org && !top) return false; if (!owner || !session) { json(res, 403, { error: 'owner-only', message: 'organization setup is the owner\'s' }); return true; } if (!admin) { json(res, 503, { error: 'not-configured' }); return true; } @@ -380,12 +380,20 @@ const gitOptions = (body, res, step) => { }; async function route(req, res, { path, search, org, admin, principal, write, log }) { + if (path === '/api/org-presets' && !write) { + if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' }); + const service = await need(admin, 'service', res, 'presets'); if (!service) return; + try { return json(res, 200, { presets: await service.orgPresets() }); } + catch (e) { return refusal(res, e, { step: 'presets' }); } + } if (path === '/api/org-template' && !write) { - const id = new URLSearchParams(search).get('id') || 'new-org'; + const params = new URLSearchParams(search); + for (const [key] of params) if (!['id', 'preset', 'thinking'].includes(key) || params.getAll(key).length !== 1) return json(res, 422, { error: 'invalid', message: 'invalid template query' }); + const id = params.has('id') ? params.get('id') : 'new-org'; if (!ID.test(id)) return json(res, 422, { error: 'invalid', errors: [{ path: '$.id', field: 'id', message: 'invalid organization id' }] }); const service = await need(admin, 'service', res, 'template'); if (!service) return; let doc; - try { doc = await service.templateRender(id, { fixture: 'local' }); } catch (e) { return refusal(res, e, { step: 'template' }); } + try { doc = await service.templateRender(id, params.has('preset') || params.has('thinking') ? { ...(params.has('preset') ? { preset: params.get('preset') } : {}), ...(params.has('thinking') ? { thinking: params.get('thinking') } : {}) } : { fixture: 'local' }); } catch (e) { return refusal(res, e, { step: 'template' }); } const shown = forBrowser(doc); return json(res, 200, { id, doc: shown.doc, held: shown.held, verb: verbFor('create', id) }); } diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js index f80bbd02a..760562943 100644 --- a/packages/webui/src/public/display.js +++ b/packages/webui/src/public/display.js @@ -22,7 +22,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date( } const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/'; window.fetch = (input, init) => { - if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input)) + if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input)) input = `/api/orgs/${id}/${input.slice('/api/'.length)}`; return nativeFetch(input, init); }; diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css index 7b2cb3dcf..96c849930 100644 --- a/packages/webui/src/public/org-create.css +++ b/packages/webui/src/public/org-create.css @@ -74,3 +74,5 @@ .org-step .settings-actions{margin-top:8px} @media(max-width:959px){.org-columns{grid-template-columns:minmax(0,1fr)}.org-json{position:static}.settings-content .org-json textarea{min-height:280px}} @media(max-width:759px){.org-start-row{grid-template-columns:minmax(0,1fr);gap:16px}.org-row{grid-template-columns:minmax(0,1fr) auto}.org-row .org-field:nth-child(2){grid-column:1/2}.org-row .org-remove{grid-column:2;grid-row:1}.settings-content .org-admin .settings-actions button{flex:1}} + +.org-quick-risk{display:flex;align-items:flex-start;gap:8px;margin:12px 0;line-height:1.5}.org-quick-risk input{margin-top:4px}.settings-content .org-path-actions button{display:inline-flex;align-items:center;justify-content:center} diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js index ffd202e98..00bd6a687 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -25,6 +25,21 @@
+ +