openvibers/openvibe.codes Commits
The repository field's own dropdown: sources in the owner's order, and a repository made from it (#392)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++-- phase: expand++-- Where the repository field looks (ask 348, the owner 2026-11-10): Codes' own dropdown asks several sources and merges++-- their answers in this table's order. The owner sets which are on, their order, each one's limit and time, and any++-- custom source of their own. A custom source is an https URL template with {q}; Codes fetches it through the egress++-- rule for user-chosen URLs, never directly.++--++-- id 'recent', 'git', 'github' for the three built-ins, or 'src_<ULID>' for a custom source++-- kind recent (the person's tasks), git (OpenVibe Git), github, or url (the custom template)++-- url_template the https URL with {q}, for kind = url only++-- position the order the sections appear in (lowest first)++-- limit_n the most rows this source may add to one answer (1–20)++-- timeout_ms how long Codes waits for it (100–5000; a source over it is left out, not reported)++CREATE TABLE IF NOT EXISTS repo_picker_sources (++ id text COLLATE "C" PRIMARY KEY,++ kind text COLLATE "C" NOT NULL,++ label text COLLATE "C" NOT NULL,++ url_template text COLLATE "C",++ enabled boolean NOT NULL DEFAULT true,++ position integer NOT NULL DEFAULT 0,++ limit_n integer NOT NULL DEFAULT 8,++ timeout_ms integer NOT NULL DEFAULT 1500,++ updated_at timestamptz NOT NULL DEFAULT now(),++ updated_by text COLLATE "C",++ CONSTRAINT repo_picker_sources_kind CHECK (kind IN ('recent', 'git', 'github', 'url')),++ CONSTRAINT repo_picker_sources_limit CHECK (limit_n BETWEEN 1 AND 20),++ CONSTRAINT repo_picker_sources_timeout CHECK (timeout_ms BETWEEN 100 AND 5000)++);++++-- The three sources Codes already reads, in the order the owner named them: what the person used here, their OpenVibe++-- Git repositories, then GitHub (their linked account, or an exact owner/name for a public repository).++INSERT INTO repo_picker_sources (id, kind, label, enabled, position, limit_n, timeout_ms)++VALUES++ ('recent', 'recent', 'Used here', true, 1, 8, 1500),++ ('git', 'git', 'OpenVibe Git', true, 2, 8, 1500),++ ('github', 'github', 'GitHub', true, 3, 8, 1500)++ON CONFLICT (id) DO NOTHING;@@@ -262,43 +262,6 @@});stream.addEventListener('end', () => { if (label) label.textContent = 'The turn ended.'; });});-- // The repository picker: the person's own repositories first, then GitHub's public answer for what they type.-- // Server-rendered options already hold the saved ones, so the field works with this script blocked.-- document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {-- const list = document.getElementById(input.getAttribute('list'));-- const status = input.closest('form')?.querySelector('[data-c4-repo-status]');-- const url = input.dataset.c4RepoUrl;-- if (!list || !url) return;-- let timer = 0;-- let seq = 0;-- const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };-- const search = async () => {-- const q = input.value.trim();-- if (q.length < 2 || /^https?:\/\//i.test(q)) { say(''); return; }-- const mine = ++seq;-- say('Searching your repositories…');-- try {-- const res = await fetch(`${url}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });-- if (!res.ok) throw new Error(`search answered ${res.status}`);-- const doc = await res.json();-- if (mine !== seq) return;-- list.textContent = '';-- for (const repo of doc.repos || []) {-- const option = document.createElement('option');-- option.value = repo.url;-- option.label = repo.full_name + (repo.recent ? ' · used here' : '');-- list.append(option);-- }-- const found = (doc.repos || []).length;-- if (doc.problem) say(doc.problem.detail || 'GitHub could not be searched', true);-- else say(found ? `${found} repositor${found === 1 ? 'y' : 'ies'}; pick one or paste a URL.` : 'No repository matched. Paste its URL.');-- } catch (e) {-- if (mine === seq) say('Search is not available; paste the repository URL.', true);-- }-- };-- input.addEventListener('input', () => {-- window.clearTimeout(timer);-- timer = window.setTimeout(search, 350);-- });-- });++ // The repository field is a plain input: a full Git address always works, and the shared dropdown++ // (data-ov-combo, openvibe-shared 4.202.0) enhances its search when it ships.})();@@@ -166,29 +166,10 @@const say = (msg, bad) => { if (status) { status.textContent = msg; status.classList.toggle('is-bad', !!bad); } };if (text) {startPage(app, form, text);-- // The repository: a recent one is a tap away, and typing searches every repository the person can reach.++ // The repository: a recent one is a tap away. A full Git address always works; the shared dropdown++ // (data-ov-combo, openvibe-shared 4.202.0) enhances the field's search when it ships.const repo = $('[data-ch-repo]', form);app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; if (!matchMedia('(pointer: coarse)').matches) text.focus(); } }));-- if (repo && repo.dataset.chRepoUrl) {-- const list = document.getElementById(repo.getAttribute('list'));-- let timer = 0, seq = 0;-- repo.addEventListener('input', () => {-- clearTimeout(timer);-- timer = setTimeout(async () => {-- const q = repo.value.trim();-- if (!list || q.length < 2 || /^https?:\/\//i.test(q)) return;-- const mine = ++seq;-- try {-- const res = await fetch(`${repo.dataset.chRepoUrl}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });-- if (!res.ok || mine !== seq) return;-- const doc = await res.json();-- if (mine !== seq) return;-- list.textContent = '';-- (doc.repos || []).forEach((r) => { const o = document.createElement('option'); o.value = r.url; o.label = r.full_name + (r.recent ? ' · used here' : ''); list.appendChild(o); });-- } catch { /* the typed URL still works */ }-- }, 250);-- });-- }// The keyboard hint only where Enter sends (a phone keyboard's return is a new line).if (status && !status.textContent && !matchMedia('(pointer: coarse)').matches) status.textContent = 'Enter sends · Shift+Enter for a new line';if ($('.ovt.is-start', app) && !matchMedia('(pointer: coarse)').matches) text.focus({ preventScroll: true });@@@ -144,9 +144,13 @@ async function createApp(opts = {}) {// The person's own memory store (OpenVibe Memory M2b): the notes live in OpenVibe.AI, sealed there, and only the// person's own token opens them. Codes forwards the token and renders the answer; it never holds a note.ctx.memory = createMemory({ config, fetchImpl: fetchImpl || globalThis.fetch, log });-- ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });++ ctx.github = opts.github || createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });// OpenVibe Git through Codes' service token, acting for the person it names (server/domain/forge.js).ctx.forge = opts.forge || require('./domain/forge').createForge({ config, fetchImpl: fetchImpl || globalThis.fetch, log });++ // The repository field's dropdown (ask 348): the owner's sources, merged in their order.++ ctx.repoPicker = opts.repoPicker || require('./domain/repo-picker').createRepoPicker({++ config, store, forge: ctx.forge, github: ctx.github, fetchImpl: fetchImpl || globalThis.fetch, fetchCustom: opts.fetchCustom || null, log, now: store.now,++ });tasks.setForge?.(ctx.forge);ctx.usage = createUsage({ store });// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through@@@ -312,7 +316,10 @@ async function createApp(opts = {}) {app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));-- app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, actorLimits: ctx.actorLimits, log }));++ app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, repoPicker: ctx.repoPicker, forge: ctx.forge, actorLimits: ctx.actorLimits, log }));++ // The owner's repository sources (ask 348): the dropdown's sources and their order.++ ctx.adminRepos = require('./http/admin-repos').createAdminRepos(ctx);++ app.use('/api/v1', ctx.adminRepos.api);// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).@@@ -354,6 +361,7 @@ async function createApp(opts = {}) {// The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.app.use(require('./http/chat').createChatRoutes(ctx));app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShell++ app.use(ctx.adminRepos.page);// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.c4Routes = require('./http/c4').createC4Routes(ctx);app.use(c4Routes);@@@ -101,6 +101,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datusername,displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || 'you'),role,++ githubLogin: typeof claims.github_login === 'string' && /^[A-Za-z0-9-]{1,39}$/.test(claims.github_login) ? claims.github_login : null,avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,token,@@@ -87,10 +87,10 @@ function createForge({ config, fetchImpl = globalThis.fetch, log = console }) {return { full_name: r.doc.full_name, html_url: r.doc.html_url, clone_url: r.doc.clone_url, private: r.doc.visibility === 'private' };}-- async function repos(actor) {++ async function repos(actor, { timeoutMs = 8000 } = {}) {if (!isEnabled() || !USER.test(String(actor && actor.subject || ''))) return [];if (!LOGIN.test(String(actor.username || ''))) return [];-- const r = await call('GET', `/api/v1/profile/${encodeURIComponent(actor.username.toLowerCase())}`, { subject: actor.subject }).catch(() => null);++ const r = await call('GET', `/api/v1/profile/${encodeURIComponent(actor.username.toLowerCase())}`, { subject: actor.subject, timeoutMs }).catch(() => null);if (!r || r.status !== 200 || !r.doc || !Array.isArray(r.doc.repositories)) return [];return r.doc.repositories.map((x) => ({full_name: x.full_name, html_url: x.html_url, private: x.visibility === 'private',@@@ -13,6 +13,8 @@* createGitHub({ config, store, fetchImpl, log })* search(subject, q) → { recent, found, problem }: repositories, the person's own first* pullRequest(url) → { url, state, ci, mergeable, checked_at } or { url, problem }++ * publicRepo(owner, name) → one public repository, or null++ * userRepos(login, q) → a linked account's public repositories, newest first** The optional token (CODES_GITHUB_TOKEN) raises GitHub's limits; it is never logged, never in an answer and never sent* anywhere but api.github.com.@@@ -74,12 +76,48 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}),});-- async function getJson(path) {-- const res = await fetchImpl(`https://api.github.com${path}`, { headers: headers(), signal: AbortSignal.timeout(6000) });++ async function getJson(path, timeoutMs = 6000) {++ const res = await fetchImpl(`https://api.github.com${path}`, { headers: headers(), signal: AbortSignal.timeout(timeoutMs) });const doc = await res.json().catch(() => null);return { status: res.status, ok: res.ok, doc };}++ /** One public repository, exactly as named (never a private one): null when GitHub does not have it or it is private. */++ async function publicRepo(owner, name, { timeoutMs = 6000 } = {}) {++ if (!OWNER.test(String(owner || '')) || !NAME.test(String(name || ''))) return null;++ const out = await getJson(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, timeoutMs);++ if (!out.ok || !out.doc || typeof out.doc !== 'object' || out.doc.private) return null;++ return {++ full_name: String(out.doc.full_name || `${owner}/${name}`).slice(0, 140),++ html_url: String(out.doc.html_url || `https://github.com/${owner}/${name}`).slice(0, 300),++ private: false,++ description: out.doc.description ? String(out.doc.description).slice(0, 160) : null,++ updated_at: out.doc.updated_at || null,++ };++ }++++ /**++ * A linked GitHub account's repositories, newest first, narrowed by the query. Codes only asks this when++ * OpenVibe.Network has told it the person's GitHub login; without a link there is no account to read.++ */++ async function userRepos(login, q, { limit = 8, timeoutMs = 6000 } = {}) {++ if (!/^[A-Za-z0-9-]{1,39}$/.test(String(login || ''))) return [];++ const out = await getJson(`/users/${encodeURIComponent(login)}/repos?per_page=100&sort=updated`, timeoutMs);++ if (!out.ok || !Array.isArray(out.doc)) return [];++ const asked = String(q || '').trim().toLowerCase();++ return out.doc++ .filter((x) => x && !x.private && x.full_name)++ .map((x) => ({++ full_name: String(x.full_name).slice(0, 140),++ html_url: String(x.html_url || `https://github.com/${x.full_name}`).slice(0, 300),++ private: false,++ description: x.description ? String(x.description).slice(0, 160) : null,++ updated_at: x.updated_at || null,++ }))++ .filter((x) => !asked || `${x.full_name} ${x.description || ''}`.toLowerCase().includes(asked))++ .slice(0, limit);++ }++/** The person's own repositories used here, newest first (a URL is enough: Codes stored them already). */async function recent(subject, limit = MAX_FOUND) {if (!store || !subject) return [];@@@ -196,7 +234,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,return value;}-- return { search, pullRequest, recent, githubRepo, pullRequestParts };++ return { search, pullRequest, recent, githubRepo, pullRequestParts, publicRepo, userRepos };}module.exports = { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord, MAX_FOUND };nnew file mode 100644@@@ -0,0 +1,335 @@++'use strict';++++/**++ * Where the repository field looks (ask 348, the owner 2026-11-10): Codes' answer for the dropdown in front of the++ * project field.++ *++ * GET /api/v1/me/repos?q=&limit= → { sections: [{ id, label, items }], create }++ * POST /api/v1/repos { name, private } → { url, label }++ * GET/PUT /api/v1/admin/repo-sources → the owner's sources (server/http/admin-repos.js)++ *++ * The sources are rows in repo_picker_sources (migration 0034), merged in their position order. Each one is asked++ * with its own time limit: a source that fails, times out or answers nonsense is left out of the sections and the++ * person sees no error. The three built-ins:++ *++ * recent the person's last tasks' repositories, from Codes' own run_tasks (any host)++ * git their repositories on OpenVibe Git, through Codes' service token (server/domain/forge.js)++ * github their linked GitHub account when OpenVibe.Network gives Codes one; otherwise only an exact owner/name,++ * public — no general GitHub search without a link++ *++ * A custom source (kind url) is the owner's https URL template with {q}. It is fetched through openvibe-shared/egress++ * (the estate's rule for a URL someone else chose): https only, public addresses only at every hop, no credentials in++ * the URL, a small cap on the answer. It answers { items: [...] } or { sections: [{ items }] }, with the same item++ * shape as ours: { label, url, host, private, updated_at, sub }.++ */++const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');++const { githubRepo } = require('./github');++++const BUILTIN = new Set(['recent', 'git', 'github']);++const KINDS = new Set(['recent', 'git', 'github', 'url']);++const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;++const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;++const MAX_SOURCES = 12;++const MAX_BODY = 256 * 1024;++const DEFAULT_LIMIT = 8;++const DEFAULT_TIMEOUT = 1500;++++const DEFAULT_SOURCES = [++ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1, limit_n: DEFAULT_LIMIT, timeout_ms: DEFAULT_TIMEOUT, url_template: null },++ { id: 'git', kind: 'git', label: 'OpenVibe Git', enabled: true, position: 2, limit_n: DEFAULT_LIMIT, timeout_ms: DEFAULT_TIMEOUT, url_template: null },++ { id: 'github', kind: 'github', label: 'GitHub', enabled: true, position: 3, limit_n: DEFAULT_LIMIT, timeout_ms: DEFAULT_TIMEOUT, url_template: null },++];++++/** owner/name, host and clone address for a repository URL (or null when it is not one). */++function partsOf(raw) {++ let s = String(raw || '').trim();++ if (!s) return null;++ if (/^git@[^:]+:/.test(s)) s = `https://${s.slice(4).replace(':', '/')}`;++ else if (!/^https?:\/\//i.test(s)) {++ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(s)) return null;++ s = `https://github.com/${s.replace(/\.git$/i, '')}`;++ }++ let u;++ try { u = new URL(s); } catch { return null; }++ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;++ if (u.username || u.password) return null;++ const bits = u.pathname.replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);++ if (bits.length !== 2) return null;++ const owner = bits[0];++ const name = bits[1];++ if (!NAME.test(owner) || !NAME.test(name)) return null;++ const host = u.hostname.toLowerCase();++ const clone = host === 'github.com' || host === 'git.openvibe.codes'++ ? `https://${host}/${owner}/${name}.git`++ : s;++ return { owner, name, label: `${owner}/${name}`, host, url: clone, web: `https://${host}/${owner}/${name}` };++}++++/** The `create` block: an OpenVibe Git repository can be made when Codes can reach Git and the person has a username. */++function createBlock(config, viewer, forge) {++ const host = (config.git && config.git.publicUrl) ? String(config.git.publicUrl).replace(/\/+$/, '') : 'https://git.openvibe.codes';++ const hostName = (() => { try { return new URL(host).host.toLowerCase(); } catch { return 'git.openvibe.codes'; } })();++ return {++ allowed: Boolean(forge && forge.enabled && viewer && NAME.test(String(viewer.username || '')) && /^usr_/.test(String(viewer.subject || ''))),++ host: hostName,++ owner: String((viewer && viewer.username) || '').slice(0, 64) || null,++ };++}++++function cleanLimit(value, fallback = DEFAULT_LIMIT) {++ const n = Number(value);++ return Number.isFinite(n) ? Math.min(20, Math.max(1, Math.trunc(n))) : fallback;++}++++function cleanTimeout(value, fallback = DEFAULT_TIMEOUT) {++ const n = Number(value);++ return Number.isFinite(n) ? Math.min(5000, Math.max(100, Math.trunc(n))) : fallback;++}++++/** The item shape the dropdown reads, with the source's own id prefix so two sources cannot collide. */++function item(source, x) {++ const p = partsOf(x && (x.url || x.full_name || x.html_url || x.clone_url));++ const url = String((x && (x.url || x.clone_url || x.html_url)) || (p && p.url) || '').trim();++ if (!url) return null;++ const parts = p || partsOf(url);++ if (!parts) return null;++ const label = String((x && (x.full_name || x.label)) || parts.label).trim().slice(0, 140);++ return {++ id: `${source.id}:${parts.label.toLowerCase()}`,++ label,++ url: parts.url,++ host: parts.host,++ private: x && x.private === true,++ updated_at: (x && (x.updated_at || x.pushed_at)) || null,++ sub: (x && x.sub) ? String(x.sub).slice(0, 120) : String((x && x.description) || '').slice(0, 120) || null,++ };++}++++/** Sort rows newest first, keeping only the requested query and count. */++function filterItems(list, q, limit) {++ const asked = String(q || '').trim().toLowerCase();++ const seen = new Set();++ const out = [];++ for (const it of list) {++ if (!it) continue;++ const hay = `${it.label} ${it.url}`.toLowerCase();++ if (asked && !hay.includes(asked)) continue;++ const key = String(it.url || it.label).toLowerCase();++ if (seen.has(key)) continue;++ seen.add(key);++ out.push(it);++ if (out.length >= limit) break;++ }++ return out;++}++++async function within(promise, ms) {++ let timer;++ const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('source timed out'), { code: 'source_timeout' })), ms); });++ if (timer.unref) timer.unref();++ try { return await Promise.race([promise, limit]); } finally { clearTimeout(timer); }++}++++function createRepoPicker({ config, store, forge = null, github = null, log = console, fetchImpl = globalThis.fetch, fetchCustom = null, now = () => Date.now() }) {++ const db = store.db;++ const publicUrl = String((config.git && config.git.publicUrl) || 'https://git.openvibe.codes').replace(/\/+$/, '');++++ // ── The owner's sources ─────────────────────────────────++ async function sources() {++ const rows = await db.many('SELECT * FROM repo_picker_sources ORDER BY position ASC, id ASC');++ return (rows.length ? rows : DEFAULT_SOURCES).map((r) => ({++ id: String(r.id), kind: String(r.kind), label: String(r.label || r.id), url_template: r.url_template || null,++ enabled: r.enabled !== false, position: Number(r.position) || 0, limit_n: cleanLimit(r.limit_n), timeout_ms: cleanTimeout(r.timeout_ms),++ }));++ }++++ /** The sources a person's dropdown should read: on, and of a kind this service understands. */++ async function activeSources() {++ return (await sources()).filter((s) => s.enabled && KINDS.has(s.kind));++ }++++ function validateSource(raw) {++ const id = String((raw && raw.id) || '').trim();++ const kind = String((raw && raw.kind) || '').trim();++ if (!KINDS.has(kind)) throw Object.assign(new Error('A source is recent, git, github or url.'), { status: 422 });++ if (kind !== 'url' && !BUILTIN.has(kind)) throw Object.assign(new Error(`Unknown source kind ${kind}.`), { status: 422 });++ if (id && !ID.test(id)) throw Object.assign(new Error('A source id uses letters, digits, dots, dashes or underscores.'), { status: 422 });++ const label = String((raw && raw.label) || '').trim().slice(0, 60);++ if (!label) throw Object.assign(new Error('Every source has a name.'), { status: 422 });++ const template = kind === 'url' ? String((raw && raw.url_template) || '').trim().slice(0, 500) : null;++ if (kind === 'url') {++ let u;++ try { u = new URL(template.replace(/\{q\}/g, 'x')); } catch { throw Object.assign(new Error('A custom source is an https URL with {q} where the search goes.'), { status: 422 }); }++ if (u.protocol !== 'https:') throw Object.assign(new Error('A custom source URL is https.'), { status: 422 });++ if (!template.includes('{q}')) throw Object.assign(new Error('A custom source URL needs {q} where the search goes.'), { status: 422 });++ }++ return {++ id: id || `src_${String(raw && raw.label || 'source').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 24)}`,++ kind, label, url_template: template,++ enabled: !(raw && raw.enabled === false),++ position: Number.isFinite(Number(raw && raw.position)) ? Math.trunc(Number(raw.position)) : 100,++ limit_n: cleanLimit(raw && raw.limit_n), timeout_ms: cleanTimeout(raw && raw.timeout_ms),++ };++ }++++ /** Replace the owner's list: built-ins upserted in place, custom sources added, removed custom sources deleted. */++ async function saveSources(wanted, { subject = null } = {}) {++ if (!Array.isArray(wanted)) throw Object.assign(new Error('sources is a list.'), { status: 422 });++ if (wanted.length > MAX_SOURCES) throw Object.assign(new Error(`At most ${MAX_SOURCES} sources.`), { status: 422 });++ const rows = wanted.map(validateSource);++ const seen = new Set();++ for (const row of rows) {++ if (seen.has(row.id)) throw Object.assign(new Error(`Two sources share the id ${row.id}.`), { status: 422 });++ seen.add(row.id);++ }++ for (const kind of BUILTIN) {++ if (rows.filter((x) => x.kind === kind).length > 1) throw Object.assign(new Error(`The ${kind} source is listed twice.`), { status: 422 });++ }++ if (!rows.some((x) => x.kind === 'recent')) throw Object.assign(new Error('The recent source stays: it is the person\'s own history.'), { status: 422 });++ await store.tx(async () => {++ const before = await db.many('SELECT id, kind FROM repo_picker_sources');++ const byId = new Map(before.map((r) => [String(r.id), r]));++ const keep = new Set(rows.map((r) => r.id));++ for (const row of rows) {++ const builtin = BUILTIN.has(row.kind) ? row.kind : null;++ if (builtin && byId.has(builtin)) row.id = builtin;++ if (byId.has(row.id)) {++ await db.exec(++ 'UPDATE repo_picker_sources SET kind = $2, label = $3, url_template = $4, enabled = $5, position = $6, limit_n = $7, timeout_ms = $8, updated_at = now(), updated_by = $9 WHERE id = $1',++ [row.id, row.kind, row.label, row.url_template, row.enabled, row.position, row.limit_n, row.timeout_ms, subject],++ );++ } else {++ await db.exec(++ 'INSERT INTO repo_picker_sources (id, kind, label, url_template, enabled, position, limit_n, timeout_ms, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',++ [row.id, row.kind, row.label, row.url_template, row.enabled, row.position, row.limit_n, row.timeout_ms, subject],++ );++ }++ }++ for (const row of before) {++ if (!keep.has(String(row.id)) && row.kind === 'url') await db.exec('DELETE FROM repo_picker_sources WHERE id = $1', [String(row.id)]);++ }++ });++ return sources();++ }++++ // ── The sources ─────────────────────────────────────────++ async function recentSource(source, { subject, q, limit }) {++ const asked = String(q || '').trim();++ const rows = await db.many(++ `SELECT repo, max(created_at) AS at FROM run_tasks++ WHERE subject = $1 AND repo LIKE 'https://%' AND ($2 = '' OR repo ILIKE '%' || $2 || '%')++ GROUP BY repo ORDER BY at DESC LIMIT 60`,++ [subject, asked],++ );++ return filterItems(rows.map((r) => {++ const it = item(source, { url: r.repo, updated_at: new Date(Number(r.at) || Date.now()).toISOString() });++ return it;++ }), q, limit);++ }++++ async function gitSource(source, { subject, username, q, limit }) {++ if (!forge || !forge.enabled || !username) return [];++ const out = await forge.repos({ subject, username }, { timeoutMs: source.timeout_ms });++ return filterItems((out || []).map((x) => item(source, {++ full_name: x.full_name, url: `${publicUrl}/${String(x.full_name).replace(/^\//, '')}.git`,++ private: x.private, updated_at: x.updated_at, description: x.description,++ })), q, limit);++ }++++ async function githubSource(source, { viewer, q, limit }) {++ if (!github) return [];++ const linked = (viewer && (viewer.githubLogin || viewer.github_login)) || null;++ if (linked && /^[A-Za-z0-9-]{1,39}$/.test(String(linked))) {++ const out = await github.userRepos(String(linked), q, { limit, timeoutMs: source.timeout_ms });++ return filterItems((out || []).map((x) => item(source, x)), q, limit);++ }++ // No linked account: only an exact owner/name, public, and only when the person names it.++ const exact = exactRepo(q);++ if (!exact) return [];++ const out = await github.publicRepo(exact.owner, exact.name, { timeoutMs: source.timeout_ms });++ return out ? [item(source, out)].filter(Boolean) : [];++ }++++ async function customSource(source, { q, limit }) {++ const template = String(source.url_template || '');++ if (!template.includes('{q}')) return [];++ const url = template.replace(/\{q\}/g, encodeURIComponent(String(q || '').trim().slice(0, 100)));++ const doc = fetchCustom++ ? await fetchCustom(url, source.timeout_ms)++ : await fetchThroughEgress(url, source.timeout_ms);++ const items = [];++ if (doc && Array.isArray(doc.items)) items.push(...doc.items);++ if (doc && Array.isArray(doc.sections)) for (const sec of doc.sections) if (sec && Array.isArray(sec.items)) items.push(...sec.items);++ return filterItems(items.map((x) => item(source, x)), q, limit).map((x) => ({ ...x, sub: x.sub || `${new URL(template.replace(/\{q\}/g, 'x')).host}` }));++ }++++ /** Ask every enabled source at once, each under its own time limit; a failure is that source's absence. */++ async function sections({ viewer, q, limit }) {++ const list = await activeSources();++ const jobs = list.map(async (source) => {++ const ctx = { subject: viewer.subject, username: viewer.username, viewer, q, limit: Math.min(limit, source.limit_n) };++ let items = [];++ try {++ if (source.kind === 'recent') items = await within(recentSource(source, ctx), source.timeout_ms);++ else if (source.kind === 'git') items = await within(gitSource(source, ctx), source.timeout_ms);++ else if (source.kind === 'github') items = await within(githubSource(source, ctx), source.timeout_ms);++ else if (source.kind === 'url') items = await within(customSource(source, ctx), source.timeout_ms);++ } catch (e) {++ log.warn(`[Codes] repository source ${source.id} left out: ${String((e && e.message) || e).slice(0, 160)}`);++ items = [];++ }++ return items.length ? { id: source.id, label: source.label, items } : null;++ });++ const out = await Promise.all(jobs);++ return out.filter(Boolean);++ }++++ return { sources, activeSources, saveSources, sections, validateSource, create: (viewer) => createBlock(config, viewer, forge) };++}++++/** `owner/name` (or a GitHub URL) when the query names a repository exactly, else null. */++function exactRepo(q) {++ const raw = String(q || '').trim();++ if (!raw) return null;++ const g = githubRepo(raw);++ if (g) return { owner: g.owner, name: g.name };++ const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,38})\/([A-Za-z0-9._-]{1,100})$/.exec(raw.replace(/\.git$/i, ''));++ return m ? { owner: m[1], name: m[2] } : null;++}++++/** A small GET through the estate's egress rule: https, public only at each hop, no credentials, a capped body. */++function fetchThroughEgress(url, timeoutMs) {++ const https = require('node:https');++ const MAX_HOPS = 2;++ const get = (target, hops) => new Promise((resolve, reject) => {++ assertPublicUrl(target, { protocols: ['https:'] }).then((u) => {++ const req = https.request(u, {++ method: 'GET', lookup: safeLookup, timeout: timeoutMs,++ headers: { Accept: 'application/json', 'User-Agent': 'openvibe-codes' },++ }, (res) => {++ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && hops < MAX_HOPS) {++ res.resume();++ const next = new URL(res.headers.location, u).href;++ return get(next, hops + 1).then(resolve, reject);++ }++ if (res.statusCode !== 200) { res.resume(); return reject(Object.assign(new Error(`source answered ${res.statusCode}`), { status: 502 })); }++ let size = 0;++ const chunks = [];++ res.on('data', (c) => {++ size += c.length;++ if (size > MAX_BODY) { req.destroy(Object.assign(new Error('source answer too large'), { code: 'source_too_large' })); return; }++ chunks.push(c);++ });++ res.on('end', () => {++ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(Object.assign(new Error('source answer is not JSON'), { status: 502 })); }++ });++ });++ req.on('timeout', () => req.destroy(Object.assign(new Error('source timed out'), { code: 'source_timeout' })));++ req.on('error', reject);++ req.end();++ }, reject);++ });++ return get(url, 0);++}++++module.exports = { createRepoPicker, partsOf, exactRepo, fetchThroughEgress, cleanLimit, cleanTimeout, DEFAULT_SOURCES, MAX_SOURCES };@@@ -37,9 +37,11 @@ const BUDGETS = {'codes.task.start': { minute: 6, hour: 60 },// A file attached to a message (10 at most per message): a handful a minute, not a flood of 20 MB bodies.'codes.attachment.upload': { minute: 60, hour: 1000 },-- // The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's-- // own repositories come first and cost nothing; this bounds what they may spend on the rest.-- 'codes.repo.search': { minute: 6, hour: 60 },++ // The repository dropdown (ask 348) types a query at a time: it asks the owner's sources, of which only GitHub++ // spends a shared limit. The person's own repositories and OpenVibe Git cost nothing; this bounds a burst.++ 'codes.repo.search': { minute: 60, hour: 1200 },++ // Making a repository on OpenVibe Git is a write on another service: a few a minute, not a script's burst.++ 'codes.repo.create': { minute: 3, hour: 20 },// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.'codes.knowledge.write': { minute: 30, hour: 600 },// The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++++/**++ * The owner's repository sources (ask 348): where the repository field looks, in what order, how many rows each source++ * may add and how long Codes waits for it. The owner alone sees and changes them.++ *++ * GET /admin/repo-sources the plain form (owner rank only; a non-owner gets 403)++ * POST /admin/repo-sources save it (same-origin, owner only), then back to the form++ * GET /api/v1/admin/repo-sources { sources: [...] }++ * PUT /api/v1/admin/repo-sources { sources: [...] } → { sources: [...] }++ *++ * The rows live in repo_picker_sources (migration 0034). The three built-ins may be switched off and moved, never++ * removed; custom sources (a name and an https URL with {q}) are added and removed here. A custom URL is fetched through++ * openvibe-shared/egress, never directly (server/domain/repo-picker.js).++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { esc } = require('../render/html');++const { asyncRouter } = require('./router');++++const OWNER_ONLY = 'Repository sources are the owner\'s. Your OpenVibe account does not have the owner rank.';++++function createAdminRepos(ctx) {++ const { config, repoPicker } = ctx;++ const page = asyncRouter();++ const api = asyncRouter();++ const json = express.json({ limit: '64kb' });++ const form = express.urlencoded({ extended: false, limit: '64kb' });++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const isOwner = (req) => signedIn(req) && req.viewer.role === 'owner';++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++ const say = (req) => {++ const saved = String(req.query.saved || '') === '1';++ const error = String(req.query.error || '').slice(0, 300);++ return `${saved ? '<p class="notice c4-notice" role="status">Saved. The next search uses the new order.</p>' : ''}${error ? `<p class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</p>` : ''}`;++ };++++ /** One row: on, its name, its place, the most rows it may add, and how long Codes may wait. */++ const rowOf = (s) => `<fieldset class="c4-card">++<input type="hidden" name="source.${esc(s.id)}.kind" value="${esc(s.kind)}">++<legend><b>${esc(s.label)}</b> <small class="c4-muted">${esc(s.kind === 'url' ? 'custom URL' : s.kind)}</small></legend>++<p><label class="c4-check"><input type="checkbox" name="source.${esc(s.id)}.enabled" value="1"${s.enabled ? ' checked' : ''}> On</label></p>++<p><label for="src-${esc(s.id)}-label">Name</label><br><input id="src-${esc(s.id)}-label" name="source.${esc(s.id)}.label" value="${esc(s.label)}" required maxlength="60"></p>++<p><label for="src-${esc(s.id)}-pos">Order <small class="c4-muted">(lowest first)</small></label><br><input id="src-${esc(s.id)}-pos" type="number" name="source.${esc(s.id)}.position" value="${esc(s.position)}" step="1"></p>++<p><label for="src-${esc(s.id)}-limit">Most rows</label><br><input id="src-${esc(s.id)}-limit" type="number" name="source.${esc(s.id)}.limit_n" value="${esc(s.limit_n)}" min="1" max="20"></p>++<p><label for="src-${esc(s.id)}-timeout">Wait, milliseconds</label><br><input id="src-${esc(s.id)}-timeout" type="number" name="source.${esc(s.id)}.timeout_ms" value="${esc(s.timeout_ms)}" min="100" max="5000" step="100"></p>++${s.kind === 'url' ? `<p><label for="src-${esc(s.id)}-url">URL with {q}</label><br><input id="src-${esc(s.id)}-url" type="url" name="source.${esc(s.id)}.url_template" value="${esc(s.url_template || '')}" required maxlength="500" placeholder="https://example.com/repos?q={q}"></p>` : ''}++</fieldset>`;++++ const body = (sources, notice = '') => `<div class="c4"><main class="c4-main">++<div class="c4-title"><div><h1>Repository sources</h1><p>Where the repository field looks, in this order. A source that fails or takes longer than its wait is left out quietly.</p></div><a class="c4-opill" href="/git">OpenVibe Git</a></div>++${notice || ''}++<form method="post" action="/admin/repo-sources" class="c4-form">++<p><button class="c4-button" type="submit">Save sources</button></p>++${sources.map(rowOf).join('')}++<fieldset class="c4-card"><legend><b>Add a custom source</b></legend>++<p><label for="new-label">Name</label><br><input id="new-label" name="new.label" maxlength="60" placeholder="My code host"></p>++<p><label for="new-url">URL with {q} <small class="c4-muted">(https; {q} is the search)</small></label><br><input id="new-url" type="url" name="new.url_template" maxlength="500" placeholder="https://git.example.com/api/repos?q={q}"></p>++<p><label class="c4-check"><input type="checkbox" name="new.enabled" value="1" checked> On</label></p>++<p><label for="new-limit">Most rows</label><br><input id="new-limit" type="number" name="new.limit_n" value="8" min="1" max="20"></p>++<p><label for="new-timeout">Wait, milliseconds</label><br><input id="new-timeout" type="number" name="new.timeout_ms" value="1500" min="100" max="5000" step="100"></p>++</fieldset>++<p><button class="c4-button" type="submit">Save sources</button></p>++</form>++</main></div>`;++++ function render(req, res, status, error) {++ if (status && status !== 200) res.status(status);++ const notice = error ? `<p class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</p>` : say(req);++ return ctx.chatShell(req, res, { title: 'Repository sources', body: body(req._sources || [], notice), css: ['css/c4.css'], here: '/admin/repo-sources' });++ }++++ page.get('/admin/repo-sources', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (!isOwner(req)) return res.status(403).type('text/plain').send(`${OWNER_ONLY}\n`);++ req._sources = await repoPicker.sources();++ return render(req, res, 200);++ });++++ page.post('/admin/repo-sources', form, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (!isOwner(req)) return res.status(403).type('text/plain').send(`${OWNER_ONLY}\n`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ const ids = [...new Set(Object.keys(b).map((k) => /^source\.([A-Za-z0-9_-]+)\.kind$/.exec(k)).filter(Boolean).map((m) => m[1]))];++ const sources = ids.map((id) => ({++ id,++ kind: b[`source.${id}.kind`],++ label: b[`source.${id}.label`],++ enabled: b[`source.${id}.enabled`] === '1',++ position: Number(b[`source.${id}.position`]),++ limit_n: Number(b[`source.${id}.limit_n`]),++ timeout_ms: Number(b[`source.${id}.timeout_ms`]),++ url_template: b[`source.${id}.url_template`] || null,++ }));++ if (String(b['new.label'] || '').trim() || String(b['new.url_template'] || '').trim()) {++ sources.push({++ kind: 'url', label: b['new.label'], url_template: b['new.url_template'],++ enabled: b['new.enabled'] === '1', position: 100,++ limit_n: Number(b['new.limit_n']) || 8, timeout_ms: Number(b['new.timeout_ms']) || 1500,++ });++ }++ try {++ await repoPicker.saveSources(sources, { subject: req.viewer.subject });++ return res.redirect(303, '/admin/repo-sources?saved=1');++ } catch (e) {++ req._sources = await repoPicker.sources();++ return render(req, res, e.status || 422, String(e.message || 'That could not be saved.'));++ }++ });++++ api.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++++ api.get('/admin/repo-sources', async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see where repositories are searched');++ if (!isOwner(req)) return problem(req, res, 403, 'repo_sources.owner_only', OWNER_ONLY);++ return res.json({ sources: await repoPicker.sources() });++ });++++ api.put('/admin/repo-sources', json, async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to choose where repositories are searched');++ if (!isOwner(req)) return problem(req, res, 403, 'repo_sources.owner_only', OWNER_ONLY);++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');++ try {++ const saved = await repoPicker.saveSources((req.body || {}).sources, { subject: req.viewer.subject });++ return res.json({ sources: saved });++ } catch (e) {++ return problem(req, res, Number(e && e.status) || 422, 'repo_sources.invalid', String((e && e.message) || 'that list cannot be saved'));++ }++ });++++ return { page, api };++}++++module.exports = { createAdminRepos, OWNER_ONLY };@@@ -442,7 +442,7 @@ ${showcase.faq({ items: [const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}--<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>++<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section><div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>@@@ -669,7 +669,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductorsreturn `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div><p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}--${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.link/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}++${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.my/#machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}</section>`;};@@@ -735,8 +735,8 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"}});-- // Machine management lives on Link. Existing Codes bookmarks lead to the owner’s Link hub.-- r.get('/machines', (_req, res) => res.redirect(301, 'https://openvibe.link/machines'));++ // Machine management lives on OpenVibe.my's machines section. Existing Codes bookmarks lead there.++ r.get('/machines', (_req, res) => res.redirect(301, 'https://openvibe.my/#machines'));// ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────r.get('/usage', async (req, res) => {@@@ -531,7 +531,7 @@ function createChatRoutes(ctx) {function whereAgents(list) {const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;-- if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="https://openvibe.link/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;++ if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="https://openvibe.my/#machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;const on = list.find((m) => m.state === 'online');const m = on || list[0];const name = esc(m.name || m.node_id || 'your computer');@@@ -581,7 +581,7 @@ function createChatRoutes(ctx) {const kindPick = `<div class="ch-kinds ch-for-new" role="radiogroup" aria-label="What it is" data-ovt-default="${esc(kind)}"><span class="ch-sr">What it is</span>${Object.entries(start.KINDS).map(([v, k]) => `<label class="ch-kind"><input type="radio" name="kind" value="${v}" form="ch-start"${kind === v ? ' checked' : ''} data-ch-kind><span class="ov-icon" data-icon="${esc(k.icon || 'ov')}" data-size="22" aria-hidden="true"></span><span>${esc(k.label)}</span></label>`).join('')}<span class="ch-dims" role="radiogroup" aria-label="2D or 3D" data-ovt-default="${esc(dim)}">${Object.keys(start.DIMS).map((d) => `<label class="ch-dim"><input type="radio" name="dim" value="${d}" form="ch-start"${dim === d ? ' checked' : ''}><span>${d.toUpperCase()}</span></label>`).join('')}</span></div>`;const groups = [...new Set(start.PROJECTS.map((p) => p.group))];const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project" data-ovt-default="${esc(chosenProject)}"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;-- const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;++ const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" autocomplete="off" data-ch-repo data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label>`;const ready = await readyFor(req);const catalog = await aiModels();const choices = (compute?.candidates || []).map((m) => `<option value="${esc(m.id)}"${m.status !== 'ready' ? ' disabled' : ''}${optionAttrs({ state: m.status === 'ready' ? 'ok' : 'off', badge: m.status === 'ready' ? 'Ready' : 'Unavailable', desc: m.reason || (m.price ? `$${m.price.amount_usd}/${m.price.unit}` : 'No rental fee') })}>${esc(m.name)} · ${m.status === 'ready' ? 'Ready' : esc(m.reason || 'Unavailable')}</option>`).join('');@@@ -128,7 +128,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };}-- if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.link/machines: add it there, so a conductor can run where its folder is' } };++ if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not paired at openvibe.my/#machines: add it there, so a conductor can run where its folder is' } };if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };return { device: hit.device };}@@@ -37,7 +37,8 @@ function createGitRoutes(ctx) {r.get('/git', async (req, res, next) => {if (!viewer(req)) return next();-- const head = header('OpenVibe Git', 'Your repositories on git.openvibe.codes, OpenVibe’s own code home. You sign in there with your OpenVibe account.');++ const head = header('OpenVibe Git', 'Your repositories on git.openvibe.codes, OpenVibe’s own code home. You sign in there with your OpenVibe account.')++ + (req.viewer.role === 'owner' ? '<p class="c4-sub"><a href="/admin/repo-sources">Where the repository field looks</a>, and the order it asks.</p>' : '');if (!forge || !forge.enabled) {return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>OpenVibe Git is not connected yet</h2></div><p class="c4-sub">You can already browse and clone every public repository at <a href="${esc(forge ? forge.publicUrl : 'https://git.openvibe.codes')}/">git.openvibe.codes</a>, and sign in there with OpenVibe. Making repositories from Codes opens when Codes can reach OpenVibe Git with its service token.</p></section>`);@@@ -9,7 +9,8 @@* POST /me/tasks → { task: codes.task@1 } (201)* GET /me/tasks/:id → codes.task@1* POST /me/tasks/:id/cancel → codes.task@1-- * GET /me/repos?q=… → { repos: [{ full_name, url, … }] }: the Home picker's search (Codes C4b)++ * GET /me/repos?q=&limit= → { sections: [{ id, label, items }], create }: the repository dropdown (ask 348)++ * POST /repos { name, private } → { url, label }: a repository made on OpenVibe Git in the person's name* GET /me/settings → { settings }: the person's choices, including who leads their tasks* PUT /me/settings → { settings }: save any of them (a field left out keeps what it had)*/@@@ -29,7 +30,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;const DELEGABLE = new Set(['owner', 'admin']);--function createMeApi({ config, store, sso, keys, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, approvals = null, log = console }) {++function createMeApi({ config, store, sso, keys, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, approvals = null, repoPicker = null, forge = null, log = console }) {const r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });@@@ -211,11 +212,40 @@ function createMeApi({ config, store, sso, keys, run, harnesses, userSettings =* public answer. Codes holds no GitHub account of a person, so the public search is what it can read; a GitHub that* does not answer is a `problem` beside the repositories Codes already knows, never an empty list pretending so.*/++ // The repository field's dropdown (ask 348): the owner's sources, merged in their order. A source that fails or++ // takes too long is simply not there: the person sees the repositories Codes could reach, never an error.r.get('/me/repos', actorLimits.budget('codes.repo.search'), async (req, res) => {if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to search your repositories');-- if (!github) return res.json({ q: String(req.query.q || ''), repos: [], problem: { status: 503, detail: 'the repository search is not available here' } });-- const out = await github.search(req.viewer.subject, req.query.q, { limit: 8 });-- return res.json({ q: out.q, repos: out.found, problem: out.problem });++ const q = String(req.query.q || '').trim().slice(0, 100);++ const limit = Math.min(20, Math.max(1, Number.parseInt(req.query.limit, 10) || 8));++ const sections = repoPicker ? await repoPicker.sections({ viewer: req.viewer, q, limit }) : [];++ const create = repoPicker ? repoPicker.create(req.viewer) : { allowed: false, host: 'git.openvibe.codes', owner: req.viewer.username || null };++ return res.json({ q, sections, create });++ });++++ // A new repository on OpenVibe Git, under the person signed in (their Network username is the Git namespace):++ // ask 348's "Create …" row. The name is validated, the call is same-origin, and it counts as one of their writes.++ r.post('/repos', actorLimits.budget('codes.repo.create'), json, async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to make a repository');++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');++ if (!forge || !forge.enabled) return problem(req, res, 503, 'repo.git_off', 'OpenVibe Git is not open from Codes right now. Try again in a minute.');++ const body = req.body || {};++ const name = String(body.name || '').trim().toLowerCase();++ try {++ const made = await forge.createRepo(++ { subject: req.viewer.subject, username: req.viewer.username || '' },++ { name, isPrivate: body.private === true },++ );++ return res.status(201).json({ url: made.clone_url, label: made.full_name });++ } catch (e) {++ const status = Number(e && e.status) || 502;++ if (status === 422) return problem(req, res, 422, 'repo.name_invalid', String(e.message || 'that repository name cannot be used'));++ if (status === 409 && e.code === 'git.unlinked') return problem(req, res, 409, 'repo.no_username', 'Your OpenVibe account has no username yet, so a repository has no address. Choose one on your account page, then try again.');++ if (status === 409) return problem(req, res, 409, 'repo.name_taken', String(e.message || 'you already have a repository with that name'));++ if (status === 503) return problem(req, res, 503, 'repo.git_off', 'OpenVibe Git did not answer. Try again in a minute.');++ log.warn(`[Codes] repository create refused: ${String((e && e.message) || e).slice(0, 160)}`);++ return problem(req, res, 502, 'repo.create_failed', 'OpenVibe Git did not make the repository. Try again in a minute.');++ }});r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {@@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge// their own agents read and add (Know K5).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|repos$|admin\/repo-sources$|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);@@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.const statusTable = table(['Piece', 'State'], [[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],-- [html`<a href="/run">Run locally</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],++ [html`<a href="/run">Run locally</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: pair your machine at openvibe.my/#machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],@@@ -11,7 +11,7 @@* POST /run/tasks/:id/cancel stop it** Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run-- * places it: on a machine the person added on openvibe.link/machines, never anyone else's (contracts 0.161.0).++ * places it: on a machine the person paired at openvibe.my (openvibe.my/#machines), never anyone else's (contracts 0.161.0).*/const express = require('express');const { serviceAuth, capabilities, http } = require('openvibe-contracts');@@@ -24,7 +24,7 @@ const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks')const { createSessionLines } = require('../domain/session-lines');const DELEGABLE = new Set(['owner', 'admin']);--const MACHINES_URL = 'https://openvibe.link/machines';++const MACHINES_URL = 'https://openvibe.my/#machines';const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;/** The task's outcome in the words a person reads. */@@@ -51,7 +51,7 @@ function guestRunPage() {title: 'Run locally', index: true, agent: true,body: html`<h1>Run coding agents locally, started from any browser</h1><p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>--<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>++<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.my</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol><p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,};}@@@ -162,7 +162,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`]);const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';const body = html`<h1>Run locally</h1>--<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>++<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.my</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>${defaults}${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}@@@ -246,7 +246,7 @@ ${tasksHtml(recent)}`;const files = Array.isArray(d.files) ? d.files : [];const push = d.push || null;const verdict = row.state !== 'succeeded'-- ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.link/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`++ ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online at openvibe.my/#machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;const ended = !(session && row.state === 'succeeded' && d.ok);@@@ -85,11 +85,11 @@ function createSetupRoutes(ctx, kit) {const sourceRows = sources.map((s) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${s.status === 'ready' ? ' c4-dot-ok' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(s.name)}</span><span class="c4-row-note">${s.status === 'ready' ? 'Ready' : s.status === 'coming_soon' ? 'Coming soon' : 'Unavailable'}${s.price && s.price.amount_usd != null ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}${s.reason ? ` · ${esc(s.reason)}` : ''}</span></li>`).join('');const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';const how = `<ol class="c4-setup-how">--<li><b>Get a one-time code</b> on <a href="https://openvibe.link/add-computer?return_to=https%3A%2F%2Fopenvibe.codes%2Fsetup" rel="noopener">openvibe.link</a>. It lasts ten minutes.</li>++<li><b>Get a one-time code</b> on <a href="https://openvibe.my/#machines" rel="noopener">openvibe.my</a>. It lasts ten minutes.</li><li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.link/install | sh -s -- --worker', 'On the machine')}</li><li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;return `${problem}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${how}--<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.link/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;++<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.my/#machines">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;}function agentsBody(d) {@@@ -351,7 +351,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task}const project = await taskStore.latestProject(p.subject);if (!project) {-- return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.link/machines)`);++ return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.my/#machines)`);}let row;try {@@@ -24,7 +24,7 @@ function structuredData(path, o, canonical) {{ '@type': 'ListItem', name: o.title, item: canonical },].map((item, i) => ({ ...item, position: i + 1 })) });const steps = path === '/run' ? [-- ['Add your computer', 'Add your computer on openvibe.link/machines: one command, which installs OpenVibe Link with its job worker.'],++ ['Add your computer', 'Pair your computer at openvibe.my/#machines: one command, which installs OpenVibe Link with its job worker.'],['Describe the task', 'Sign in here and describe the task.'],['Get the result', 'Your machine clones the repository, runs the agent and sends the result back.'],] : path === '/start' ? [@@@ -39,7 +39,8 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');assert.ok(shared > -1 && codes > -1, 'both repositories are offered');assert.ok(shared < codes, 'the last one used comes first');-- assert.ok(home.text.includes('data-ch-repo-url="/api/v1/me/repos"'), 'the repository field searches');++ assert.ok(home.text.includes('data-ov-combo="/api/v1/me/repos"'), 'the repository field asks the dropdown source');++ assert.ok(!home.text.includes('list="ch-repos"') && !home.text.includes('<datalist'), 'the datalist is gone');assert.ok(home.text.includes('data-ch-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the recent ones are a tap away');// A task cannot be pinned to a machine yet (Run's pin is a Bot device): the default machine is chosen in Settings.assert.ok(!home.text.includes('<option value="' + NODE + '">Desk'), 'the chat offers no machine it could not honour');@@@ -138,7 +139,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';await check('Machine management leads to Link', async () => {const page = await t.get('/machines', { as: ada });assert.strictEqual(page.status, 301);-- assert.strictEqual(page.headers.get('location'), 'https://openvibe.link/machines');++ assert.strictEqual(page.headers.get('location'), 'https://openvibe.my/#machines');});await check('Settings are remembered per person', async () => {@@@ -41,7 +41,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.const testdb = await require('./db').testDb();-- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });++ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.forge ? { forge: opts.forge } : {}), ...(opts.github ? { github: opts.github } : {}), ...(opts.fetchCustom ? { fetchCustom: opts.fetchCustom } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -64,14 +64,14 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mconst problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });-- function addUser(username, { role = 'user' } = {}) {-- const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };++ function addUser(username, { role = 'user', githubLogin = null } = {}) {++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role, github_login: githubLogin };st.users.set(u.id, u); st.byUsername.set(username, u);return u;}function userToken(u, { ttl = 3600, iat = null, audience = null } = {}) {const now = iat != null ? iat : Math.floor(Date.now() / 1000);-- return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, ...(u.github_login ? { github_login: u.github_login } : {}), aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);}/*** A Dock token exactly as Network mints one for the public client `companion`: the private `<network>/dock`nnew file mode 100644@@@ -0,0 +1,275 @@++'use strict';++/**++ * The repository field's dropdown (ask 348): the owner's sources in their order, each under its own time limit and a++ * failed one left out; the owner's settings (owner rank only); and making a repository on OpenVibe Git with a stub Git++ * API. The tests boot the real app, the real repo-picker and the real forge; only OpenVibe Git and GitHub are stubs.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++const { createForge } = require('../server/domain/forge');++++function listen(handler) {++ return new Promise((resolve) => {++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json)).catch((e) => json(500, { error: String(e && e.message) }));++ });++ });++ server.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${server.address().port}`,++ calls: [],++ close: () => new Promise((r) => server.close(r)),++ }));++ });++}++++(async () => {++ // ── A stub OpenVibe Git: its token endpoint and POST /api/v1/repos, recording what Codes asked for. ──++ const git = await listen((req, raw, json) => {++ git.calls.push([req.method, req.url, raw]);++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok', token_type: 'Bearer', expires_in: 3600 });++ if (req.method === 'GET' && req.url.startsWith('/api/v1/profile/')) {++ return json(200, { repositories: [{ full_name: 'ada/one', html_url: 'https://git.openvibe.codes/ada/one', visibility: 'public', pushed_at: '2026-11-09T10:00:00Z' }] });++ }++ if (req.method === 'POST' && req.url === '/api/v1/repos') {++ const body = JSON.parse(raw || '{}');++ if (body.name === 'taken') return json(409, { code: 'repo.exists', detail: 'That repository already exists.' });++ if (!/^[a-z0-9][a-z0-9._-]{0,99}$/.test(body.name || '')) return json(422, { code: 'repo.invalid_name', detail: 'That name cannot be used.' });++ return json(201, {++ full_name: `${body.owner_username}/${body.name}`, html_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}`,++ clone_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}.git`, visibility: body.visibility,++ });++ }++ return json(404, { code: 'repo.not_found' });++ });++ const forge = createForge({++ config: {++ git: { internalUrl: git.url, publicUrl: 'https://git.openvibe.codes' },++ networkInternalUrl: git.url,++ oauth: { clientId: 'codes', clientSecret: 'codes-secret' },++ },++ fetchImpl: globalThis.fetch,++ log: { warn() {}, info() {}, error() {} },++ });++++ const github = {++ async publicRepo(owner, name) {++ if (owner !== 'OpenVibers' || name !== 'OpenVibe.Codes') return null;++ return { full_name: 'OpenVibers/OpenVibe.Codes', html_url: 'https://github.com/OpenVibers/OpenVibe.Codes', private: false, description: 'Codes', updated_at: '2026-11-08T00:00:00Z' };++ },++ async userRepos(login) {++ return login === 'adalink' ? [{ full_name: 'adalink/linked-repo', html_url: 'https://github.com/adalink/linked-repo', private: false, updated_at: '2026-11-07T00:00:00Z' }] : [];++ },++ };++++ let custom = null;++ const fetchCustom = async (url, timeoutMs) => {++ custom = { url, timeoutMs };++ return { items: [{ label: 'pat/project', url: 'https://example.com/pat/project.git', private: false, sub: 'custom' }] };++ };++++ const t = await boot({ forge, github, fetchCustom });++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const ola = t.network.addUser('ola', { role: 'owner' });++ const insertTask = (repo, at) => t.ctx.store.db.exec(++ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, state, created_at, updated_at)++ VALUES ($1, $2, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', $3, 'a task', 'succeeded', $4, $4)`,++ [`tsk_${String(at).padStart(26, '0')}`, ada.subject, repo, at],++ );++ const call = (path, { as = null, method = 'GET', body, json, origin } = {}) => {++ const payload = json !== undefined ? json : body;++ return t.get(`/api/v1${path}`, { as, method, ...(payload !== undefined ? { json: payload } : {}), ...(origin ? { headers: { origin } } : {}) });++ };++++ try {++ await check('a guest is refused and the answer carries no CORS grant', async () => {++ const r = await call('/me/repos');++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null);++ });++++ await check('sections come from the person\'s tasks and OpenVibe Git, in the owner\'s order', async () => {++ await insertTask('https://git.openvibe.codes/ada/website', 1000);++ await insertTask('https://github.com/adalink/old-thing', 2000);++ const r = await call('/me/repos', { as: ada });++ assert.strictEqual(r.status, 200, r.text);++ const out = r.json();++ assert.deepStrictEqual(out.sections.map((s) => s.id), ['recent', 'git']);++ const recent = out.sections[0].items;++ assert.strictEqual(recent[0].label, 'adalink/old-thing', 'newest first');++ assert.strictEqual(recent[0].host, 'github.com');++ assert.strictEqual(recent[1].url, 'https://git.openvibe.codes/ada/website.git');++ assert.strictEqual(out.sections[1].items[0].label, 'ada/one');++ assert.strictEqual(out.sections[1].items[0].host, 'git.openvibe.codes');++ assert.deepStrictEqual(out.create, { allowed: true, host: 'git.openvibe.codes', owner: 'ada' });++ });++++ await check('a query filters each source; GitHub without a link answers only an exact owner/name', async () => {++ const one = await call('/me/repos?q=website', { as: ada });++ assert.deepStrictEqual(one.json().sections.map((s) => s.id), ['recent']);++ const exact = await call('/me/repos?q=OpenVibers%2FOpenVibe.Codes', { as: ada });++ assert.deepStrictEqual(exact.json().sections.map((s) => s.id), ['github']);++ assert.strictEqual(exact.json().sections[0].items[0].url, 'https://github.com/OpenVibers/OpenVibe.Codes.git');++ const partial = await call('/me/repos?q=OpenVibe', { as: ada });++ assert.ok(!partial.json().sections.some((s) => s.id === 'github'), 'no general GitHub search without a link');++ });++++ await check('a GitHub account linked through the Network is searched by login', async () => {++ const linked = t.network.addUser('ada2', { githubLogin: 'adalink' });++ const r = await call('/me/repos?q=linked', { as: linked });++ assert.strictEqual(r.status, 200, r.text);++ const githubSection = r.json().sections.find((s) => s.id === 'github');++ assert.ok(githubSection, JSON.stringify(r.json()));++ assert.strictEqual(githubSection.items[0].label, 'adalink/linked-repo');++ });++++ await check('a failed source is left out with no error to the person', async () => {++ const failing = { enabled: true, async repos() { throw new Error('Git did not answer'); }, async createRepo() { throw new Error('unused'); } };++ const t2 = await boot({ forge: failing, github });++ try {++ await t2.ctx.store.db.exec(++ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, state, created_at, updated_at)++ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2J2', $1, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', 'https://git.openvibe.codes/ada/one', 'a task', 'succeeded', 1, 1)`,++ [ada.subject],++ );++ const r = await t2.get('/api/v1/me/repos', { as: ada });++ assert.strictEqual(r.status, 200);++ const out = r.json();++ assert.ok(!out.problem && !out.error);++ assert.deepStrictEqual(out.sections.map((s) => s.id), ['recent']);++ } finally { await t2.close(); }++ });++++ await check('a source over its time limit is left out and the answer still comes', async () => {++ const slow = { enabled: true, async repos() { return await new Promise(() => {}); }, async createRepo() { return {}; } };++ const t3 = await boot({ forge: slow, github });++ try {++ const owner3 = t3.network.addUser('slo', { role: 'owner' });++ const put = await t3.get('/api/v1/admin/repo-sources', {++ as: owner3, method: 'PUT',++ json: { sources: [++ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1, limit_n: 8, timeout_ms: 1500 },++ { id: 'git', kind: 'git', label: 'OpenVibe Git', enabled: true, position: 2, limit_n: 8, timeout_ms: 100 },++ { id: 'github', kind: 'github', label: 'GitHub', enabled: false, position: 3, limit_n: 8, timeout_ms: 1500 },++ ] },++ });++ assert.strictEqual(put.status, 200, put.text);++ const at = Date.now();++ const r = await t3.get('/api/v1/me/repos', { as: owner3 });++ assert.strictEqual(r.status, 200);++ assert.ok(Date.now() - at < 1000, 'did not wait for the slow source');++ assert.ok(!r.json().sections.some((s) => s.id === 'git'));++ } finally { await t3.close(); }++ });++++ await check('the owner can turn, reorder and add sources; a custom one is fetched with {q}', async () => {++ const put = await call('/admin/repo-sources', {++ as: ola, method: 'PUT',++ json: { sources: [++ { id: 'git', kind: 'git', label: 'OpenVibe Git', enabled: true, position: 1, limit_n: 5, timeout_ms: 1500 },++ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 2, limit_n: 3, timeout_ms: 1500 },++ { id: 'github', kind: 'github', label: 'GitHub', enabled: false, position: 3, limit_n: 8, timeout_ms: 1500 },++ { id: 'src_mine', kind: 'url', label: 'My host', url_template: 'https://example.com/repos?q={q}', enabled: true, position: 4, limit_n: 4, timeout_ms: 1500 },++ ] },++ });++ assert.strictEqual(put.status, 200, put.text);++ assert.deepStrictEqual(put.json().sources.map((s) => s.id), ['git', 'recent', 'github', 'src_mine']);++ const got = await call('/admin/repo-sources', { as: ola });++ assert.strictEqual(got.status, 200);++ assert.deepStrictEqual(got.json().sources.map((s) => s.id), ['git', 'recent', 'github', 'src_mine']);++ const r = await call('/me/repos?q=a', { as: ada });++ assert.deepStrictEqual(r.json().sections.map((s) => s.id), ['git', 'recent', 'src_mine']);++ assert.strictEqual(custom.url, 'https://example.com/repos?q=a');++ assert.strictEqual(r.json().sections[2].items[0].label, 'pat/project');++ // The form the owner sees is the same rows.++ const page = await t.get('/admin/repo-sources', { as: ola });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Repository sources') && page.text.includes('https://example.com/repos?q={q}'));++ });++++ await check('the plain owner form saves the same list without JavaScript', async () => {++ const form = {++ 'source.recent.kind': 'recent', 'source.recent.label': 'Used here', 'source.recent.enabled': '1',++ 'source.recent.position': '2', 'source.recent.limit_n': '8', 'source.recent.timeout_ms': '1500',++ 'source.git.kind': 'git', 'source.git.label': 'OpenVibe Git', 'source.git.enabled': '1',++ 'source.git.position': '1', 'source.git.limit_n': '8', 'source.git.timeout_ms': '1500',++ 'source.github.kind': 'github', 'source.github.label': 'GitHub',++ 'source.github.position': '3', 'source.github.limit_n': '8', 'source.github.timeout_ms': '1500',++ 'new.label': 'Form host', 'new.url_template': 'https://example.com/list?q={q}', 'new.enabled': '1', 'new.limit_n': '4', 'new.timeout_ms': '1200',++ };++ const saved = await t.get('/admin/repo-sources', { as: ola, method: 'POST', form });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.strictEqual(saved.headers.get('location'), '/admin/repo-sources?saved=1');++ const page = await t.get('/admin/repo-sources', { as: ola });++ assert.ok(page.text.includes('Form host') && page.text.includes('https://example.com/list?q={q}'));++ });++++ await check('a non-owner gets 403 and the guest 401 on the owner settings', async () => {++ assert.strictEqual((await call('/admin/repo-sources', { as: ada })).status, 403);++ assert.strictEqual((await call('/admin/repo-sources', { as: ada, method: 'PUT', json: { sources: [] } })).status, 403);++ assert.strictEqual((await call('/admin/repo-sources')).status, 401);++ assert.strictEqual((await t.get('/admin/repo-sources', { as: ada })).status, 403);++ });++++ await check('a custom source must be https and carry {q}', async () => {++ const bad = await call('/admin/repo-sources', {++ as: ola, method: 'PUT',++ json: { sources: [++ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1, limit_n: 8, timeout_ms: 1500 },++ { id: 'src_x', kind: 'url', label: 'X', url_template: 'http://example.com/?q={q}', enabled: true, position: 2 },++ ] },++ });++ assert.strictEqual(bad.status, 422);++ const noQ = await call('/admin/repo-sources', {++ as: ola, method: 'PUT',++ json: { sources: [{ id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1 }, { id: 'src_y', kind: 'url', label: 'Y', url_template: 'https://example.com/?q=', enabled: true, position: 2 }] },++ });++ assert.strictEqual(noQ.status, 422);++ });++++ await check('creating a repository against the stub Git API: a bad name, a good one, and a taken one', async () => {++ const bad = await call('/repos', { as: ada, method: 'POST', body: { name: 'not a name!' } });++ assert.strictEqual(bad.status, 422, bad.text);++ assert.strictEqual(bad.json().code, 'repo.name_invalid');++ const good = await call('/repos', { as: ada, method: 'POST', body: { name: 'new-site', private: true } });++ assert.strictEqual(good.status, 201, good.text);++ assert.deepStrictEqual(good.json(), { url: 'https://git.openvibe.codes/ada/new-site.git', label: 'ada/new-site' });++ const sent = git.calls.filter((c) => c[0] === 'POST' && c[1] === '/api/v1/repos').pop();++ assert.strictEqual(JSON.parse(sent[2]).visibility, 'private');++ assert.strictEqual(JSON.parse(sent[2]).owner_username, 'ada');++ const taken = await call('/repos', { as: ada, method: 'POST', body: { name: 'taken' } });++ assert.strictEqual(taken.status, 409);++ assert.strictEqual(taken.json().code, 'repo.name_taken');++ });++++ await check('creating is rate limited per person', async () => {++ const t4 = await boot({ forge, github, actorLimits: true });++ try {++ const u = t4.network.addUser('rita');++ const statuses = [];++ for (let i = 0; i < 4; i++) {++ const r = await t4.get('/api/v1/repos', { as: u, method: 'POST', json: { name: `rate-${i}` } });++ statuses.push(r.status);++ }++ assert.deepStrictEqual(statuses.slice(0, 3), [201, 201, 201]);++ assert.strictEqual(statuses[3], 429, statuses.join(','));++ } finally { await t4.close(); }++ });++++ await check('a guest cannot create and a cross-site post is refused', async () => {++ assert.strictEqual((await call('/repos', { method: 'POST', body: { name: 'x' } })).status, 401);++ const cross = await call('/repos', { as: ada, method: 'POST', body: { name: 'x' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ });++ } finally {++ await t.close();++ await git.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');await check('a guest is told what it is and asked to sign in', async () => {const r = await t.get('/run');assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('Run coding agents locally, started from any browser') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));++ assert.ok(r.text.includes('Run coding agents locally, started from any browser') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.my'));});let project;@@@ -70,6 +70,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex','server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person','server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',++ 'server/domain/repo-picker.js': 'the owner\'s custom source URL (https, with {q}) is the only URL a person influenced: it is fetched with openvibe-shared/egress assertPublicUrl and safeLookup at every hop, https only, no credentials, a capped body; the built-in sources go to the configured OpenVibe Git (forge.js) and api.github.com at fixed paths from a validated owner/name, never a URL from a person','server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) ; one read-only call each, never a URL from a person','server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person','server/data/public-updates.js': 'the configured Network base (config.networkUrl) public changelog feed at the fixed path /api/v1/changelog?service=codes&limit=50 for the /updates page; no URL from a person',
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+-- phase: expand
+-- Where the repository field looks (ask 348, the owner 2026-11-10): Codes' own dropdown asks several sources and merges
+-- their answers in this table's order. The owner sets which are on, their order, each one's limit and time, and any
+-- custom source of their own. A custom source is an https URL template with {q}; Codes fetches it through the egress
+-- rule for user-chosen URLs, never directly.
+--
+-- id 'recent', 'git', 'github' for the three built-ins, or 'src_<ULID>' for a custom source
+-- kind recent (the person's tasks), git (OpenVibe Git), github, or url (the custom template)
+-- url_template the https URL with {q}, for kind = url only
+-- position the order the sections appear in (lowest first)
+-- limit_n the most rows this source may add to one answer (1–20)
+-- timeout_ms how long Codes waits for it (100–5000; a source over it is left out, not reported)
+CREATE TABLE IF NOT EXISTS repo_picker_sources (
+ id text COLLATE "C" PRIMARY KEY,
+ kind text COLLATE "C" NOT NULL,
+ label text COLLATE "C" NOT NULL,
+ url_template text COLLATE "C",
+ enabled boolean NOT NULL DEFAULT true,
+ position integer NOT NULL DEFAULT 0,
+ limit_n integer NOT NULL DEFAULT 8,
+ timeout_ms integer NOT NULL DEFAULT 1500,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ updated_by text COLLATE "C",
+ CONSTRAINT repo_picker_sources_kind CHECK (kind IN ('recent', 'git', 'github', 'url')),
+ CONSTRAINT repo_picker_sources_limit CHECK (limit_n BETWEEN 1 AND 20),
+ CONSTRAINT repo_picker_sources_timeout CHECK (timeout_ms BETWEEN 100 AND 5000)
+);
+
+-- The three sources Codes already reads, in the order the owner named them: what the person used here, their OpenVibe
+-- Git repositories, then GitHub (their linked account, or an exact owner/name for a public repository).
+INSERT INTO repo_picker_sources (id, kind, label, enabled, position, limit_n, timeout_ms)
+VALUES
+ ('recent', 'recent', 'Used here', true, 1, 8, 1500),
+ ('git', 'git', 'OpenVibe Git', true, 2, 8, 1500),
+ ('github', 'github', 'GitHub', true, 3, 8, 1500)
+ON CONFLICT (id) DO NOTHING;
@@ -262,43 +262,6 @@
@@ -262,43 +262,6 @@
});
});
stream.addEventListener('end', () => { if (label) label.textContent = 'The turn ended.'; });
stream.addEventListener('end', () => { if (label) label.textContent = 'The turn ended.'; });
});
});
- // The repository picker: the person's own repositories first, then GitHub's public answer for what they type.
- // Server-rendered options already hold the saved ones, so the field works with this script blocked.
- document.querySelectorAll('[data-c4-repo-search]').forEach((input) => {
- const list = document.getElementById(input.getAttribute('list'));
- const status = input.closest('form')?.querySelector('[data-c4-repo-status]');
- const url = input.dataset.c4RepoUrl;
- if (!list || !url) return;
- let timer = 0;
- let seq = 0;
- const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };
- const search = async () => {
- const q = input.value.trim();
- if (q.length < 2 || /^https?:\/\//i.test(q)) { say(''); return; }
- const mine = ++seq;
- say('Searching your repositories…');
- try {
- const res = await fetch(`${url}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
- if (!res.ok) throw new Error(`search answered ${res.status}`);
- const doc = await res.json();
- if (mine !== seq) return;
- list.textContent = '';
- for (const repo of doc.repos || []) {
- const option = document.createElement('option');
- option.value = repo.url;
- option.label = repo.full_name + (repo.recent ? ' · used here' : '');
- list.append(option);
- }
- const found = (doc.repos || []).length;
- if (doc.problem) say(doc.problem.detail || 'GitHub could not be searched', true);
- else say(found ? `${found} repositor${found === 1 ? 'y' : 'ies'}; pick one or paste a URL.` : 'No repository matched. Paste its URL.');
- } catch (e) {
- if (mine === seq) say('Search is not available; paste the repository URL.', true);
- }
- };
- input.addEventListener('input', () => {
- window.clearTimeout(timer);
- timer = window.setTimeout(search, 350);
- });
- });
+ // The repository field is a plain input: a full Git address always works, and the shared dropdown
+ // (data-ov-combo, openvibe-shared 4.202.0) enhances its search when it ships.
})();
})();
@@ -166,29 +166,10 @@
@@ -166,29 +166,10 @@
const say = (msg, bad) => { if (status) { status.textContent = msg; status.classList.toggle('is-bad', !!bad); } };
const say = (msg, bad) => { if (status) { status.textContent = msg; status.classList.toggle('is-bad', !!bad); } };
if (text) {
if (text) {
startPage(app, form, text);
startPage(app, form, text);
- // The repository: a recent one is a tap away, and typing searches every repository the person can reach.
+ // The repository: a recent one is a tap away. A full Git address always works; the shared dropdown
+ // (data-ov-combo, openvibe-shared 4.202.0) enhances the field's search when it ships.
const repo = $('[data-ch-repo]', form);
const repo = $('[data-ch-repo]', form);
app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; if (!matchMedia('(pointer: coarse)').matches) text.focus(); } }));
app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; if (!matchMedia('(pointer: coarse)').matches) text.focus(); } }));
- if (repo && repo.dataset.chRepoUrl) {
- const list = document.getElementById(repo.getAttribute('list'));
- let timer = 0, seq = 0;
- repo.addEventListener('input', () => {
- clearTimeout(timer);
- timer = setTimeout(async () => {
- const q = repo.value.trim();
- if (!list || q.length < 2 || /^https?:\/\//i.test(q)) return;
- const mine = ++seq;
- try {
- const res = await fetch(`${repo.dataset.chRepoUrl}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
- if (!res.ok || mine !== seq) return;
- const doc = await res.json();
- if (mine !== seq) return;
- list.textContent = '';
- (doc.repos || []).forEach((r) => { const o = document.createElement('option'); o.value = r.url; o.label = r.full_name + (r.recent ? ' · used here' : ''); list.appendChild(o); });
- } catch { /* the typed URL still works */ }
- }, 250);
- });
- }
// The keyboard hint only where Enter sends (a phone keyboard's return is a new line).
// The keyboard hint only where Enter sends (a phone keyboard's return is a new line).
if (status && !status.textContent && !matchMedia('(pointer: coarse)').matches) status.textContent = 'Enter sends · Shift+Enter for a new line';
if (status && !status.textContent && !matchMedia('(pointer: coarse)').matches) status.textContent = 'Enter sends · Shift+Enter for a new line';
if ($('.ovt.is-start', app) && !matchMedia('(pointer: coarse)').matches) text.focus({ preventScroll: true });
if ($('.ovt.is-start', app) && !matchMedia('(pointer: coarse)').matches) text.focus({ preventScroll: true });
@@ -144,9 +144,13 @@ async function createApp(opts = {}) {
@@ -144,9 +144,13 @@ async function createApp(opts = {}) {
// The person's own memory store (OpenVibe Memory M2b): the notes live in OpenVibe.AI, sealed there, and only the
// The person's own memory store (OpenVibe Memory M2b): the notes live in OpenVibe.AI, sealed there, and only the
// person's own token opens them. Codes forwards the token and renders the answer; it never holds a note.
// person's own token opens them. Codes forwards the token and renders the answer; it never holds a note.
ctx.memory = createMemory({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.memory = createMemory({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
- ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
+ ctx.github = opts.github || createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
// OpenVibe Git through Codes' service token, acting for the person it names (server/domain/forge.js).
// OpenVibe Git through Codes' service token, acting for the person it names (server/domain/forge.js).
ctx.forge = opts.forge || require('./domain/forge').createForge({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.forge = opts.forge || require('./domain/forge').createForge({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
+ // The repository field's dropdown (ask 348): the owner's sources, merged in their order.
+ ctx.repoPicker = opts.repoPicker || require('./domain/repo-picker').createRepoPicker({
+ config, store, forge: ctx.forge, github: ctx.github, fetchImpl: fetchImpl || globalThis.fetch, fetchCustom: opts.fetchCustom || null, log, now: store.now,
+ });
tasks.setForge?.(ctx.forge);
tasks.setForge?.(ctx.forge);
ctx.usage = createUsage({ store });
ctx.usage = createUsage({ store });
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
@@ -312,7 +316,10 @@ async function createApp(opts = {}) {
@@ -312,7 +316,10 @@ async function createApp(opts = {}) {
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));
// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
- app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, actorLimits: ctx.actorLimits, log }));
+ app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, repoPicker: ctx.repoPicker, forge: ctx.forge, actorLimits: ctx.actorLimits, log }));
+ // The owner's repository sources (ask 348): the dropdown's sources and their order.
+ ctx.adminRepos = require('./http/admin-repos').createAdminRepos(ctx);
+ app.use('/api/v1', ctx.adminRepos.api);
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
@@ -354,6 +361,7 @@ async function createApp(opts = {}) {
@@ -354,6 +361,7 @@ async function createApp(opts = {}) {
// The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.
// The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.
app.use(require('./http/chat').createChatRoutes(ctx));
app.use(require('./http/chat').createChatRoutes(ctx));
app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShell
app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShell
+ app.use(ctx.adminRepos.page);
// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
c4Routes = require('./http/c4').createC4Routes(ctx);
c4Routes = require('./http/c4').createC4Routes(ctx);
app.use(c4Routes);
app.use(c4Routes);
@@ -101,6 +101,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -101,6 +101,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
username,
username,
displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || 'you'),
displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || 'you'),
role,
role,
+ githubLogin: typeof claims.github_login === 'string' && /^[A-Za-z0-9-]{1,39}$/.test(claims.github_login) ? claims.github_login : null,
avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
token,
token,
@@ -87,10 +87,10 @@ function createForge({ config, fetchImpl = globalThis.fetch, log = console }) {
@@ -87,10 +87,10 @@ function createForge({ config, fetchImpl = globalThis.fetch, log = console }) {
return { full_name: r.doc.full_name, html_url: r.doc.html_url, clone_url: r.doc.clone_url, private: r.doc.visibility === 'private' };
return { full_name: r.doc.full_name, html_url: r.doc.html_url, clone_url: r.doc.clone_url, private: r.doc.visibility === 'private' };
}
}
- async function repos(actor) {
+ async function repos(actor, { timeoutMs = 8000 } = {}) {
if (!isEnabled() || !USER.test(String(actor && actor.subject || ''))) return [];
if (!isEnabled() || !USER.test(String(actor && actor.subject || ''))) return [];
if (!LOGIN.test(String(actor.username || ''))) return [];
if (!LOGIN.test(String(actor.username || ''))) return [];
- const r = await call('GET', `/api/v1/profile/${encodeURIComponent(actor.username.toLowerCase())}`, { subject: actor.subject }).catch(() => null);
+ const r = await call('GET', `/api/v1/profile/${encodeURIComponent(actor.username.toLowerCase())}`, { subject: actor.subject, timeoutMs }).catch(() => null);
if (!r || r.status !== 200 || !r.doc || !Array.isArray(r.doc.repositories)) return [];
if (!r || r.status !== 200 || !r.doc || !Array.isArray(r.doc.repositories)) return [];
return r.doc.repositories.map((x) => ({
return r.doc.repositories.map((x) => ({
full_name: x.full_name, html_url: x.html_url, private: x.visibility === 'private',
full_name: x.full_name, html_url: x.html_url, private: x.visibility === 'private',
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* createGitHub({ config, store, fetchImpl, log })
* createGitHub({ config, store, fetchImpl, log })
* search(subject, q) → { recent, found, problem }: repositories, the person's own first
* search(subject, q) → { recent, found, problem }: repositories, the person's own first
* pullRequest(url) → { url, state, ci, mergeable, checked_at } or { url, problem }
* pullRequest(url) → { url, state, ci, mergeable, checked_at } or { url, problem }
+ * publicRepo(owner, name) → one public repository, or null
+ * userRepos(login, q) → a linked account's public repositories, newest first
*
*
* The optional token (CODES_GITHUB_TOKEN) raises GitHub's limits; it is never logged, never in an answer and never sent
* The optional token (CODES_GITHUB_TOKEN) raises GitHub's limits; it is never logged, never in an answer and never sent
* anywhere but api.github.com.
* anywhere but api.github.com.
@@ -74,12 +76,48 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
@@ -74,12 +76,48 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}),
...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}),
});
});
- async function getJson(path) {
- const res = await fetchImpl(`https://api.github.com${path}`, { headers: headers(), signal: AbortSignal.timeout(6000) });
+ async function getJson(path, timeoutMs = 6000) {
+ const res = await fetchImpl(`https://api.github.com${path}`, { headers: headers(), signal: AbortSignal.timeout(timeoutMs) });
const doc = await res.json().catch(() => null);
const doc = await res.json().catch(() => null);
return { status: res.status, ok: res.ok, doc };
return { status: res.status, ok: res.ok, doc };
}
}
+ /** One public repository, exactly as named (never a private one): null when GitHub does not have it or it is private. */
+ async function publicRepo(owner, name, { timeoutMs = 6000 } = {}) {
+ if (!OWNER.test(String(owner || '')) || !NAME.test(String(name || ''))) return null;
+ const out = await getJson(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, timeoutMs);
+ if (!out.ok || !out.doc || typeof out.doc !== 'object' || out.doc.private) return null;
+ return {
+ full_name: String(out.doc.full_name || `${owner}/${name}`).slice(0, 140),
+ html_url: String(out.doc.html_url || `https://github.com/${owner}/${name}`).slice(0, 300),
+ private: false,
+ description: out.doc.description ? String(out.doc.description).slice(0, 160) : null,
+ updated_at: out.doc.updated_at || null,
+ };
+ }
+
+ /**
+ * A linked GitHub account's repositories, newest first, narrowed by the query. Codes only asks this when
+ * OpenVibe.Network has told it the person's GitHub login; without a link there is no account to read.
+ */
+ async function userRepos(login, q, { limit = 8, timeoutMs = 6000 } = {}) {
+ if (!/^[A-Za-z0-9-]{1,39}$/.test(String(login || ''))) return [];
+ const out = await getJson(`/users/${encodeURIComponent(login)}/repos?per_page=100&sort=updated`, timeoutMs);
+ if (!out.ok || !Array.isArray(out.doc)) return [];
+ const asked = String(q || '').trim().toLowerCase();
+ return out.doc
+ .filter((x) => x && !x.private && x.full_name)
+ .map((x) => ({
+ full_name: String(x.full_name).slice(0, 140),
+ html_url: String(x.html_url || `https://github.com/${x.full_name}`).slice(0, 300),
+ private: false,
+ description: x.description ? String(x.description).slice(0, 160) : null,
+ updated_at: x.updated_at || null,
+ }))
+ .filter((x) => !asked || `${x.full_name} ${x.description || ''}`.toLowerCase().includes(asked))
+ .slice(0, limit);
+ }
+
/** The person's own repositories used here, newest first (a URL is enough: Codes stored them already). */
/** The person's own repositories used here, newest first (a URL is enough: Codes stored them already). */
async function recent(subject, limit = MAX_FOUND) {
async function recent(subject, limit = MAX_FOUND) {
if (!store || !subject) return [];
if (!store || !subject) return [];
@@ -196,7 +234,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
@@ -196,7 +234,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
return value;
return value;
}
}
- return { search, pullRequest, recent, githubRepo, pullRequestParts };
+ return { search, pullRequest, recent, githubRepo, pullRequestParts, publicRepo, userRepos };
}
}
module.exports = { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord, MAX_FOUND };
module.exports = { createGitHub, githubRepo, pullRequestParts, mergeWord, ciWord, MAX_FOUND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,335 @@
@@ -0,0 +1,335 @@
+'use strict';
+
+/**
+ * Where the repository field looks (ask 348, the owner 2026-11-10): Codes' answer for the dropdown in front of the
+ * project field.
+ *
+ * GET /api/v1/me/repos?q=&limit= → { sections: [{ id, label, items }], create }
+ * POST /api/v1/repos { name, private } → { url, label }
+ * GET/PUT /api/v1/admin/repo-sources → the owner's sources (server/http/admin-repos.js)
+ *
+ * The sources are rows in repo_picker_sources (migration 0034), merged in their position order. Each one is asked
+ * with its own time limit: a source that fails, times out or answers nonsense is left out of the sections and the
+ * person sees no error. The three built-ins:
+ *
+ * recent the person's last tasks' repositories, from Codes' own run_tasks (any host)
+ * git their repositories on OpenVibe Git, through Codes' service token (server/domain/forge.js)
+ * github their linked GitHub account when OpenVibe.Network gives Codes one; otherwise only an exact owner/name,
+ * public — no general GitHub search without a link
+ *
+ * A custom source (kind url) is the owner's https URL template with {q}. It is fetched through openvibe-shared/egress
+ * (the estate's rule for a URL someone else chose): https only, public addresses only at every hop, no credentials in
+ * the URL, a small cap on the answer. It answers { items: [...] } or { sections: [{ items }] }, with the same item
+ * shape as ours: { label, url, host, private, updated_at, sub }.
+ */
+const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');
+const { githubRepo } = require('./github');
+
+const BUILTIN = new Set(['recent', 'git', 'github']);
+const KINDS = new Set(['recent', 'git', 'github', 'url']);
+const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
+const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
+const MAX_SOURCES = 12;
+const MAX_BODY = 256 * 1024;
+const DEFAULT_LIMIT = 8;
+const DEFAULT_TIMEOUT = 1500;
+
+const DEFAULT_SOURCES = [
+ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1, limit_n: DEFAULT_LIMIT, timeout_ms: DEFAULT_TIMEOUT, url_template: null },
+ { id: 'git', kind: 'git', label: 'OpenVibe Git', enabled: true, position: 2, limit_n: DEFAULT_LIMIT, timeout_ms: DEFAULT_TIMEOUT, url_template: null },
+ { id: 'github', kind: 'github', label: 'GitHub', enabled: true, position: 3, limit_n: DEFAULT_LIMIT, timeout_ms: DEFAULT_TIMEOUT, url_template: null },
+];
+
+/** owner/name, host and clone address for a repository URL (or null when it is not one). */
+function partsOf(raw) {
+ let s = String(raw || '').trim();
+ if (!s) return null;
+ if (/^git@[^:]+:/.test(s)) s = `https://${s.slice(4).replace(':', '/')}`;
+ else if (!/^https?:\/\//i.test(s)) {
+ if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(s)) return null;
+ s = `https://github.com/${s.replace(/\.git$/i, '')}`;
+ }
+ let u;
+ try { u = new URL(s); } catch { return null; }
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
+ if (u.username || u.password) return null;
+ const bits = u.pathname.replace(/\/+$/, '').replace(/\.git$/i, '').split('/').filter(Boolean);
+ if (bits.length !== 2) return null;
+ const owner = bits[0];
+ const name = bits[1];
+ if (!NAME.test(owner) || !NAME.test(name)) return null;
+ const host = u.hostname.toLowerCase();
+ const clone = host === 'github.com' || host === 'git.openvibe.codes'
+ ? `https://${host}/${owner}/${name}.git`
+ : s;
+ return { owner, name, label: `${owner}/${name}`, host, url: clone, web: `https://${host}/${owner}/${name}` };
+}
+
+/** The `create` block: an OpenVibe Git repository can be made when Codes can reach Git and the person has a username. */
+function createBlock(config, viewer, forge) {
+ const host = (config.git && config.git.publicUrl) ? String(config.git.publicUrl).replace(/\/+$/, '') : 'https://git.openvibe.codes';
+ const hostName = (() => { try { return new URL(host).host.toLowerCase(); } catch { return 'git.openvibe.codes'; } })();
+ return {
+ allowed: Boolean(forge && forge.enabled && viewer && NAME.test(String(viewer.username || '')) && /^usr_/.test(String(viewer.subject || ''))),
+ host: hostName,
+ owner: String((viewer && viewer.username) || '').slice(0, 64) || null,
+ };
+}
+
+function cleanLimit(value, fallback = DEFAULT_LIMIT) {
+ const n = Number(value);
+ return Number.isFinite(n) ? Math.min(20, Math.max(1, Math.trunc(n))) : fallback;
+}
+
+function cleanTimeout(value, fallback = DEFAULT_TIMEOUT) {
+ const n = Number(value);
+ return Number.isFinite(n) ? Math.min(5000, Math.max(100, Math.trunc(n))) : fallback;
+}
+
+/** The item shape the dropdown reads, with the source's own id prefix so two sources cannot collide. */
+function item(source, x) {
+ const p = partsOf(x && (x.url || x.full_name || x.html_url || x.clone_url));
+ const url = String((x && (x.url || x.clone_url || x.html_url)) || (p && p.url) || '').trim();
+ if (!url) return null;
+ const parts = p || partsOf(url);
+ if (!parts) return null;
+ const label = String((x && (x.full_name || x.label)) || parts.label).trim().slice(0, 140);
+ return {
+ id: `${source.id}:${parts.label.toLowerCase()}`,
+ label,
+ url: parts.url,
+ host: parts.host,
+ private: x && x.private === true,
+ updated_at: (x && (x.updated_at || x.pushed_at)) || null,
+ sub: (x && x.sub) ? String(x.sub).slice(0, 120) : String((x && x.description) || '').slice(0, 120) || null,
+ };
+}
+
+/** Sort rows newest first, keeping only the requested query and count. */
+function filterItems(list, q, limit) {
+ const asked = String(q || '').trim().toLowerCase();
+ const seen = new Set();
+ const out = [];
+ for (const it of list) {
+ if (!it) continue;
+ const hay = `${it.label} ${it.url}`.toLowerCase();
+ if (asked && !hay.includes(asked)) continue;
+ const key = String(it.url || it.label).toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(it);
+ if (out.length >= limit) break;
+ }
+ return out;
+}
+
+async function within(promise, ms) {
+ let timer;
+ const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('source timed out'), { code: 'source_timeout' })), ms); });
+ if (timer.unref) timer.unref();
+ try { return await Promise.race([promise, limit]); } finally { clearTimeout(timer); }
+}
+
+function createRepoPicker({ config, store, forge = null, github = null, log = console, fetchImpl = globalThis.fetch, fetchCustom = null, now = () => Date.now() }) {
+ const db = store.db;
+ const publicUrl = String((config.git && config.git.publicUrl) || 'https://git.openvibe.codes').replace(/\/+$/, '');
+
+ // ── The owner's sources ─────────────────────────────────
+ async function sources() {
+ const rows = await db.many('SELECT * FROM repo_picker_sources ORDER BY position ASC, id ASC');
+ return (rows.length ? rows : DEFAULT_SOURCES).map((r) => ({
+ id: String(r.id), kind: String(r.kind), label: String(r.label || r.id), url_template: r.url_template || null,
+ enabled: r.enabled !== false, position: Number(r.position) || 0, limit_n: cleanLimit(r.limit_n), timeout_ms: cleanTimeout(r.timeout_ms),
+ }));
+ }
+
+ /** The sources a person's dropdown should read: on, and of a kind this service understands. */
+ async function activeSources() {
+ return (await sources()).filter((s) => s.enabled && KINDS.has(s.kind));
+ }
+
+ function validateSource(raw) {
+ const id = String((raw && raw.id) || '').trim();
+ const kind = String((raw && raw.kind) || '').trim();
+ if (!KINDS.has(kind)) throw Object.assign(new Error('A source is recent, git, github or url.'), { status: 422 });
+ if (kind !== 'url' && !BUILTIN.has(kind)) throw Object.assign(new Error(`Unknown source kind ${kind}.`), { status: 422 });
+ if (id && !ID.test(id)) throw Object.assign(new Error('A source id uses letters, digits, dots, dashes or underscores.'), { status: 422 });
+ const label = String((raw && raw.label) || '').trim().slice(0, 60);
+ if (!label) throw Object.assign(new Error('Every source has a name.'), { status: 422 });
+ const template = kind === 'url' ? String((raw && raw.url_template) || '').trim().slice(0, 500) : null;
+ if (kind === 'url') {
+ let u;
+ try { u = new URL(template.replace(/\{q\}/g, 'x')); } catch { throw Object.assign(new Error('A custom source is an https URL with {q} where the search goes.'), { status: 422 }); }
+ if (u.protocol !== 'https:') throw Object.assign(new Error('A custom source URL is https.'), { status: 422 });
+ if (!template.includes('{q}')) throw Object.assign(new Error('A custom source URL needs {q} where the search goes.'), { status: 422 });
+ }
+ return {
+ id: id || `src_${String(raw && raw.label || 'source').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 24)}`,
+ kind, label, url_template: template,
+ enabled: !(raw && raw.enabled === false),
+ position: Number.isFinite(Number(raw && raw.position)) ? Math.trunc(Number(raw.position)) : 100,
+ limit_n: cleanLimit(raw && raw.limit_n), timeout_ms: cleanTimeout(raw && raw.timeout_ms),
+ };
+ }
+
+ /** Replace the owner's list: built-ins upserted in place, custom sources added, removed custom sources deleted. */
+ async function saveSources(wanted, { subject = null } = {}) {
+ if (!Array.isArray(wanted)) throw Object.assign(new Error('sources is a list.'), { status: 422 });
+ if (wanted.length > MAX_SOURCES) throw Object.assign(new Error(`At most ${MAX_SOURCES} sources.`), { status: 422 });
+ const rows = wanted.map(validateSource);
+ const seen = new Set();
+ for (const row of rows) {
+ if (seen.has(row.id)) throw Object.assign(new Error(`Two sources share the id ${row.id}.`), { status: 422 });
+ seen.add(row.id);
+ }
+ for (const kind of BUILTIN) {
+ if (rows.filter((x) => x.kind === kind).length > 1) throw Object.assign(new Error(`The ${kind} source is listed twice.`), { status: 422 });
+ }
+ if (!rows.some((x) => x.kind === 'recent')) throw Object.assign(new Error('The recent source stays: it is the person\'s own history.'), { status: 422 });
+ await store.tx(async () => {
+ const before = await db.many('SELECT id, kind FROM repo_picker_sources');
+ const byId = new Map(before.map((r) => [String(r.id), r]));
+ const keep = new Set(rows.map((r) => r.id));
+ for (const row of rows) {
+ const builtin = BUILTIN.has(row.kind) ? row.kind : null;
+ if (builtin && byId.has(builtin)) row.id = builtin;
+ if (byId.has(row.id)) {
+ await db.exec(
+ 'UPDATE repo_picker_sources SET kind = $2, label = $3, url_template = $4, enabled = $5, position = $6, limit_n = $7, timeout_ms = $8, updated_at = now(), updated_by = $9 WHERE id = $1',
+ [row.id, row.kind, row.label, row.url_template, row.enabled, row.position, row.limit_n, row.timeout_ms, subject],
+ );
+ } else {
+ await db.exec(
+ 'INSERT INTO repo_picker_sources (id, kind, label, url_template, enabled, position, limit_n, timeout_ms, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
+ [row.id, row.kind, row.label, row.url_template, row.enabled, row.position, row.limit_n, row.timeout_ms, subject],
+ );
+ }
+ }
+ for (const row of before) {
+ if (!keep.has(String(row.id)) && row.kind === 'url') await db.exec('DELETE FROM repo_picker_sources WHERE id = $1', [String(row.id)]);
+ }
+ });
+ return sources();
+ }
+
+ // ── The sources ─────────────────────────────────────────
+ async function recentSource(source, { subject, q, limit }) {
+ const asked = String(q || '').trim();
+ const rows = await db.many(
+ `SELECT repo, max(created_at) AS at FROM run_tasks
+ WHERE subject = $1 AND repo LIKE 'https://%' AND ($2 = '' OR repo ILIKE '%' || $2 || '%')
+ GROUP BY repo ORDER BY at DESC LIMIT 60`,
+ [subject, asked],
+ );
+ return filterItems(rows.map((r) => {
+ const it = item(source, { url: r.repo, updated_at: new Date(Number(r.at) || Date.now()).toISOString() });
+ return it;
+ }), q, limit);
+ }
+
+ async function gitSource(source, { subject, username, q, limit }) {
+ if (!forge || !forge.enabled || !username) return [];
+ const out = await forge.repos({ subject, username }, { timeoutMs: source.timeout_ms });
+ return filterItems((out || []).map((x) => item(source, {
+ full_name: x.full_name, url: `${publicUrl}/${String(x.full_name).replace(/^\//, '')}.git`,
+ private: x.private, updated_at: x.updated_at, description: x.description,
+ })), q, limit);
+ }
+
+ async function githubSource(source, { viewer, q, limit }) {
+ if (!github) return [];
+ const linked = (viewer && (viewer.githubLogin || viewer.github_login)) || null;
+ if (linked && /^[A-Za-z0-9-]{1,39}$/.test(String(linked))) {
+ const out = await github.userRepos(String(linked), q, { limit, timeoutMs: source.timeout_ms });
+ return filterItems((out || []).map((x) => item(source, x)), q, limit);
+ }
+ // No linked account: only an exact owner/name, public, and only when the person names it.
+ const exact = exactRepo(q);
+ if (!exact) return [];
+ const out = await github.publicRepo(exact.owner, exact.name, { timeoutMs: source.timeout_ms });
+ return out ? [item(source, out)].filter(Boolean) : [];
+ }
+
+ async function customSource(source, { q, limit }) {
+ const template = String(source.url_template || '');
+ if (!template.includes('{q}')) return [];
+ const url = template.replace(/\{q\}/g, encodeURIComponent(String(q || '').trim().slice(0, 100)));
+ const doc = fetchCustom
+ ? await fetchCustom(url, source.timeout_ms)
+ : await fetchThroughEgress(url, source.timeout_ms);
+ const items = [];
+ if (doc && Array.isArray(doc.items)) items.push(...doc.items);
+ if (doc && Array.isArray(doc.sections)) for (const sec of doc.sections) if (sec && Array.isArray(sec.items)) items.push(...sec.items);
+ return filterItems(items.map((x) => item(source, x)), q, limit).map((x) => ({ ...x, sub: x.sub || `${new URL(template.replace(/\{q\}/g, 'x')).host}` }));
+ }
+
+ /** Ask every enabled source at once, each under its own time limit; a failure is that source's absence. */
+ async function sections({ viewer, q, limit }) {
+ const list = await activeSources();
+ const jobs = list.map(async (source) => {
+ const ctx = { subject: viewer.subject, username: viewer.username, viewer, q, limit: Math.min(limit, source.limit_n) };
+ let items = [];
+ try {
+ if (source.kind === 'recent') items = await within(recentSource(source, ctx), source.timeout_ms);
+ else if (source.kind === 'git') items = await within(gitSource(source, ctx), source.timeout_ms);
+ else if (source.kind === 'github') items = await within(githubSource(source, ctx), source.timeout_ms);
+ else if (source.kind === 'url') items = await within(customSource(source, ctx), source.timeout_ms);
+ } catch (e) {
+ log.warn(`[Codes] repository source ${source.id} left out: ${String((e && e.message) || e).slice(0, 160)}`);
+ items = [];
+ }
+ return items.length ? { id: source.id, label: source.label, items } : null;
+ });
+ const out = await Promise.all(jobs);
+ return out.filter(Boolean);
+ }
+
+ return { sources, activeSources, saveSources, sections, validateSource, create: (viewer) => createBlock(config, viewer, forge) };
+}
+
+/** `owner/name` (or a GitHub URL) when the query names a repository exactly, else null. */
+function exactRepo(q) {
+ const raw = String(q || '').trim();
+ if (!raw) return null;
+ const g = githubRepo(raw);
+ if (g) return { owner: g.owner, name: g.name };
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,38})\/([A-Za-z0-9._-]{1,100})$/.exec(raw.replace(/\.git$/i, ''));
+ return m ? { owner: m[1], name: m[2] } : null;
+}
+
+/** A small GET through the estate's egress rule: https, public only at each hop, no credentials, a capped body. */
+function fetchThroughEgress(url, timeoutMs) {
+ const https = require('node:https');
+ const MAX_HOPS = 2;
+ const get = (target, hops) => new Promise((resolve, reject) => {
+ assertPublicUrl(target, { protocols: ['https:'] }).then((u) => {
+ const req = https.request(u, {
+ method: 'GET', lookup: safeLookup, timeout: timeoutMs,
+ headers: { Accept: 'application/json', 'User-Agent': 'openvibe-codes' },
+ }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && hops < MAX_HOPS) {
+ res.resume();
+ const next = new URL(res.headers.location, u).href;
+ return get(next, hops + 1).then(resolve, reject);
+ }
+ if (res.statusCode !== 200) { res.resume(); return reject(Object.assign(new Error(`source answered ${res.statusCode}`), { status: 502 })); }
+ let size = 0;
+ const chunks = [];
+ res.on('data', (c) => {
+ size += c.length;
+ if (size > MAX_BODY) { req.destroy(Object.assign(new Error('source answer too large'), { code: 'source_too_large' })); return; }
+ chunks.push(c);
+ });
+ res.on('end', () => {
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(Object.assign(new Error('source answer is not JSON'), { status: 502 })); }
+ });
+ });
+ req.on('timeout', () => req.destroy(Object.assign(new Error('source timed out'), { code: 'source_timeout' })));
+ req.on('error', reject);
+ req.end();
+ }, reject);
+ });
+ return get(url, 0);
+}
+
+module.exports = { createRepoPicker, partsOf, exactRepo, fetchThroughEgress, cleanLimit, cleanTimeout, DEFAULT_SOURCES, MAX_SOURCES };
@@ -37,9 +37,11 @@ const BUDGETS = {
@@ -37,9 +37,11 @@ const BUDGETS = {
'codes.task.start': { minute: 6, hour: 60 },
'codes.task.start': { minute: 6, hour: 60 },
// A file attached to a message (10 at most per message): a handful a minute, not a flood of 20 MB bodies.
// A file attached to a message (10 at most per message): a handful a minute, not a flood of 20 MB bodies.
'codes.attachment.upload': { minute: 60, hour: 1000 },
'codes.attachment.upload': { minute: 60, hour: 1000 },
- // The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's
- // own repositories come first and cost nothing; this bounds what they may spend on the rest.
- 'codes.repo.search': { minute: 6, hour: 60 },
+ // The repository dropdown (ask 348) types a query at a time: it asks the owner's sources, of which only GitHub
+ // spends a shared limit. The person's own repositories and OpenVibe Git cost nothing; this bounds a burst.
+ 'codes.repo.search': { minute: 60, hour: 1200 },
+ // Making a repository on OpenVibe Git is a write on another service: a few a minute, not a script's burst.
+ 'codes.repo.create': { minute: 3, hour: 20 },
// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.
// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.
'codes.knowledge.write': { minute: 30, hour: 600 },
'codes.knowledge.write': { minute: 30, hour: 600 },
// The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.
// The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+
+/**
+ * The owner's repository sources (ask 348): where the repository field looks, in what order, how many rows each source
+ * may add and how long Codes waits for it. The owner alone sees and changes them.
+ *
+ * GET /admin/repo-sources the plain form (owner rank only; a non-owner gets 403)
+ * POST /admin/repo-sources save it (same-origin, owner only), then back to the form
+ * GET /api/v1/admin/repo-sources { sources: [...] }
+ * PUT /api/v1/admin/repo-sources { sources: [...] } → { sources: [...] }
+ *
+ * The rows live in repo_picker_sources (migration 0034). The three built-ins may be switched off and moved, never
+ * removed; custom sources (a name and an https URL with {q}) are added and removed here. A custom URL is fetched through
+ * openvibe-shared/egress, never directly (server/domain/repo-picker.js).
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { esc } = require('../render/html');
+const { asyncRouter } = require('./router');
+
+const OWNER_ONLY = 'Repository sources are the owner\'s. Your OpenVibe account does not have the owner rank.';
+
+function createAdminRepos(ctx) {
+ const { config, repoPicker } = ctx;
+ const page = asyncRouter();
+ const api = asyncRouter();
+ const json = express.json({ limit: '64kb' });
+ const form = express.urlencoded({ extended: false, limit: '64kb' });
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const isOwner = (req) => signedIn(req) && req.viewer.role === 'owner';
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+ const say = (req) => {
+ const saved = String(req.query.saved || '') === '1';
+ const error = String(req.query.error || '').slice(0, 300);
+ return `${saved ? '<p class="notice c4-notice" role="status">Saved. The next search uses the new order.</p>' : ''}${error ? `<p class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</p>` : ''}`;
+ };
+
+ /** One row: on, its name, its place, the most rows it may add, and how long Codes may wait. */
+ const rowOf = (s) => `<fieldset class="c4-card">
+<input type="hidden" name="source.${esc(s.id)}.kind" value="${esc(s.kind)}">
+<legend><b>${esc(s.label)}</b> <small class="c4-muted">${esc(s.kind === 'url' ? 'custom URL' : s.kind)}</small></legend>
+<p><label class="c4-check"><input type="checkbox" name="source.${esc(s.id)}.enabled" value="1"${s.enabled ? ' checked' : ''}> On</label></p>
+<p><label for="src-${esc(s.id)}-label">Name</label><br><input id="src-${esc(s.id)}-label" name="source.${esc(s.id)}.label" value="${esc(s.label)}" required maxlength="60"></p>
+<p><label for="src-${esc(s.id)}-pos">Order <small class="c4-muted">(lowest first)</small></label><br><input id="src-${esc(s.id)}-pos" type="number" name="source.${esc(s.id)}.position" value="${esc(s.position)}" step="1"></p>
+<p><label for="src-${esc(s.id)}-limit">Most rows</label><br><input id="src-${esc(s.id)}-limit" type="number" name="source.${esc(s.id)}.limit_n" value="${esc(s.limit_n)}" min="1" max="20"></p>
+<p><label for="src-${esc(s.id)}-timeout">Wait, milliseconds</label><br><input id="src-${esc(s.id)}-timeout" type="number" name="source.${esc(s.id)}.timeout_ms" value="${esc(s.timeout_ms)}" min="100" max="5000" step="100"></p>
+${s.kind === 'url' ? `<p><label for="src-${esc(s.id)}-url">URL with {q}</label><br><input id="src-${esc(s.id)}-url" type="url" name="source.${esc(s.id)}.url_template" value="${esc(s.url_template || '')}" required maxlength="500" placeholder="https://example.com/repos?q={q}"></p>` : ''}
+</fieldset>`;
+
+ const body = (sources, notice = '') => `<div class="c4"><main class="c4-main">
+<div class="c4-title"><div><h1>Repository sources</h1><p>Where the repository field looks, in this order. A source that fails or takes longer than its wait is left out quietly.</p></div><a class="c4-opill" href="/git">OpenVibe Git</a></div>
+${notice || ''}
+<form method="post" action="/admin/repo-sources" class="c4-form">
+<p><button class="c4-button" type="submit">Save sources</button></p>
+${sources.map(rowOf).join('')}
+<fieldset class="c4-card"><legend><b>Add a custom source</b></legend>
+<p><label for="new-label">Name</label><br><input id="new-label" name="new.label" maxlength="60" placeholder="My code host"></p>
+<p><label for="new-url">URL with {q} <small class="c4-muted">(https; {q} is the search)</small></label><br><input id="new-url" type="url" name="new.url_template" maxlength="500" placeholder="https://git.example.com/api/repos?q={q}"></p>
+<p><label class="c4-check"><input type="checkbox" name="new.enabled" value="1" checked> On</label></p>
+<p><label for="new-limit">Most rows</label><br><input id="new-limit" type="number" name="new.limit_n" value="8" min="1" max="20"></p>
+<p><label for="new-timeout">Wait, milliseconds</label><br><input id="new-timeout" type="number" name="new.timeout_ms" value="1500" min="100" max="5000" step="100"></p>
+</fieldset>
+<p><button class="c4-button" type="submit">Save sources</button></p>
+</form>
+</main></div>`;
+
+ function render(req, res, status, error) {
+ if (status && status !== 200) res.status(status);
+ const notice = error ? `<p class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</p>` : say(req);
+ return ctx.chatShell(req, res, { title: 'Repository sources', body: body(req._sources || [], notice), css: ['css/c4.css'], here: '/admin/repo-sources' });
+ }
+
+ page.get('/admin/repo-sources', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (!isOwner(req)) return res.status(403).type('text/plain').send(`${OWNER_ONLY}\n`);
+ req._sources = await repoPicker.sources();
+ return render(req, res, 200);
+ });
+
+ page.post('/admin/repo-sources', form, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (!isOwner(req)) return res.status(403).type('text/plain').send(`${OWNER_ONLY}\n`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ const ids = [...new Set(Object.keys(b).map((k) => /^source\.([A-Za-z0-9_-]+)\.kind$/.exec(k)).filter(Boolean).map((m) => m[1]))];
+ const sources = ids.map((id) => ({
+ id,
+ kind: b[`source.${id}.kind`],
+ label: b[`source.${id}.label`],
+ enabled: b[`source.${id}.enabled`] === '1',
+ position: Number(b[`source.${id}.position`]),
+ limit_n: Number(b[`source.${id}.limit_n`]),
+ timeout_ms: Number(b[`source.${id}.timeout_ms`]),
+ url_template: b[`source.${id}.url_template`] || null,
+ }));
+ if (String(b['new.label'] || '').trim() || String(b['new.url_template'] || '').trim()) {
+ sources.push({
+ kind: 'url', label: b['new.label'], url_template: b['new.url_template'],
+ enabled: b['new.enabled'] === '1', position: 100,
+ limit_n: Number(b['new.limit_n']) || 8, timeout_ms: Number(b['new.timeout_ms']) || 1500,
+ });
+ }
+ try {
+ await repoPicker.saveSources(sources, { subject: req.viewer.subject });
+ return res.redirect(303, '/admin/repo-sources?saved=1');
+ } catch (e) {
+ req._sources = await repoPicker.sources();
+ return render(req, res, e.status || 422, String(e.message || 'That could not be saved.'));
+ }
+ });
+
+ api.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+
+ api.get('/admin/repo-sources', async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see where repositories are searched');
+ if (!isOwner(req)) return problem(req, res, 403, 'repo_sources.owner_only', OWNER_ONLY);
+ return res.json({ sources: await repoPicker.sources() });
+ });
+
+ api.put('/admin/repo-sources', json, async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to choose where repositories are searched');
+ if (!isOwner(req)) return problem(req, res, 403, 'repo_sources.owner_only', OWNER_ONLY);
+ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');
+ try {
+ const saved = await repoPicker.saveSources((req.body || {}).sources, { subject: req.viewer.subject });
+ return res.json({ sources: saved });
+ } catch (e) {
+ return problem(req, res, Number(e && e.status) || 422, 'repo_sources.invalid', String((e && e.message) || 'that list cannot be saved'));
+ }
+ });
+
+ return { page, api };
+}
+
+module.exports = { createAdminRepos, OWNER_ONLY };
@@ -442,7 +442,7 @@ ${showcase.faq({ items: [
@@ -442,7 +442,7 @@ ${showcase.faq({ items: [
const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
const agentOptions = (harnesses?.list?.() || []).map((a) => `<option value="${esc(a.id)}"${settings && settings.default_agent === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('');
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
-<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" list="c4-repo-options" data-c4-repo-search data-c4-repo-url="/api/v1/me/repos"><datalist id="c4-repo-options">${repos.map((repo) => `<option value="${esc(repo.url)}">${esc(repo.full_name)}</option>`).join('')}</datalist></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
+<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>
<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>
${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
@@ -669,7 +669,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -669,7 +669,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
-${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.link/machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}
+${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.my/#machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}
</section>`;
</section>`;
};
};
@@ -735,8 +735,8 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"
@@ -735,8 +735,8 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"
}
}
});
});
- // Machine management lives on Link. Existing Codes bookmarks lead to the owner’s Link hub.
- r.get('/machines', (_req, res) => res.redirect(301, 'https://openvibe.link/machines'));
+ // Machine management lives on OpenVibe.my's machines section. Existing Codes bookmarks lead there.
+ r.get('/machines', (_req, res) => res.redirect(301, 'https://openvibe.my/#machines'));
// ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────
// ── Usage: what Codes knows, and no prices it does not know ────────────────────────────────────────────────
r.get('/usage', async (req, res) => {
r.get('/usage', async (req, res) => {
@@ -531,7 +531,7 @@ function createChatRoutes(ctx) {
@@ -531,7 +531,7 @@ function createChatRoutes(ctx) {
function whereAgents(list) {
function whereAgents(list) {
const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';
const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';
if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;
if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;
- if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="https://openvibe.link/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;
+ if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="https://openvibe.my/#machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;
const on = list.find((m) => m.state === 'online');
const on = list.find((m) => m.state === 'online');
const m = on || list[0];
const m = on || list[0];
const name = esc(m.name || m.node_id || 'your computer');
const name = esc(m.name || m.node_id || 'your computer');
@@ -581,7 +581,7 @@ function createChatRoutes(ctx) {
@@ -581,7 +581,7 @@ function createChatRoutes(ctx) {
const kindPick = `<div class="ch-kinds ch-for-new" role="radiogroup" aria-label="What it is" data-ovt-default="${esc(kind)}"><span class="ch-sr">What it is</span>${Object.entries(start.KINDS).map(([v, k]) => `<label class="ch-kind"><input type="radio" name="kind" value="${v}" form="ch-start"${kind === v ? ' checked' : ''} data-ch-kind><span class="ov-icon" data-icon="${esc(k.icon || 'ov')}" data-size="22" aria-hidden="true"></span><span>${esc(k.label)}</span></label>`).join('')}<span class="ch-dims" role="radiogroup" aria-label="2D or 3D" data-ovt-default="${esc(dim)}">${Object.keys(start.DIMS).map((d) => `<label class="ch-dim"><input type="radio" name="dim" value="${d}" form="ch-start"${dim === d ? ' checked' : ''}><span>${d.toUpperCase()}</span></label>`).join('')}</span></div>`;
const kindPick = `<div class="ch-kinds ch-for-new" role="radiogroup" aria-label="What it is" data-ovt-default="${esc(kind)}"><span class="ch-sr">What it is</span>${Object.entries(start.KINDS).map(([v, k]) => `<label class="ch-kind"><input type="radio" name="kind" value="${v}" form="ch-start"${kind === v ? ' checked' : ''} data-ch-kind><span class="ov-icon" data-icon="${esc(k.icon || 'ov')}" data-size="22" aria-hidden="true"></span><span>${esc(k.label)}</span></label>`).join('')}<span class="ch-dims" role="radiogroup" aria-label="2D or 3D" data-ovt-default="${esc(dim)}">${Object.keys(start.DIMS).map((d) => `<label class="ch-dim"><input type="radio" name="dim" value="${d}" form="ch-start"${dim === d ? ' checked' : ''}><span>${d.toUpperCase()}</span></label>`).join('')}</span></div>`;
const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project" data-ovt-default="${esc(chosenProject)}"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project data-ov-select="bare" data-ov-title="Which OpenVibe project" data-ovt-default="${esc(chosenProject)}"><option value="auto"${chosenProject === 'auto' ? ' selected' : ''}>Codes picks for you</option>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
- const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
+ const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" autocomplete="off" data-ch-repo data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label>`;
const ready = await readyFor(req);
const ready = await readyFor(req);
const catalog = await aiModels();
const catalog = await aiModels();
const choices = (compute?.candidates || []).map((m) => `<option value="${esc(m.id)}"${m.status !== 'ready' ? ' disabled' : ''}${optionAttrs({ state: m.status === 'ready' ? 'ok' : 'off', badge: m.status === 'ready' ? 'Ready' : 'Unavailable', desc: m.reason || (m.price ? `$${m.price.amount_usd}/${m.price.unit}` : 'No rental fee') })}>${esc(m.name)} · ${m.status === 'ready' ? 'Ready' : esc(m.reason || 'Unavailable')}</option>`).join('');
const choices = (compute?.candidates || []).map((m) => `<option value="${esc(m.id)}"${m.status !== 'ready' ? ' disabled' : ''}${optionAttrs({ state: m.status === 'ready' ? 'ok' : 'off', badge: m.status === 'ready' ? 'Ready' : 'Unavailable', desc: m.reason || (m.price ? `$${m.price.amount_usd}/${m.price.unit}` : 'No rental fee') })}>${esc(m.name)} · ${m.status === 'ready' ? 'Ready' : esc(m.reason || 'Unavailable')}</option>`).join('');
@@ -128,7 +128,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
@@ -128,7 +128,7 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);
log.warn(`[Codes] conductor: ${machine} could not be pinned: ${String(e && e.message).slice(0, 200)}`);
return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };
return { problem: { status: 503, code: 'conductor.machine_unpinned', detail: 'OpenVibe.Bot did not answer, so Codes cannot pin the conductor to that machine; a conductor runs where its folder is, never wherever' } };
}
}
- if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not on openvibe.link/machines: add it there, so a conductor can run where its folder is' } };
+ if (!hit) return { problem: { status: 422, code: 'conductor.machine_invalid', detail: 'that machine is not paired at openvibe.my/#machines: add it there, so a conductor can run where its folder is' } };
if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };
if (!hit.online) return { problem: { status: 409, code: 'conductor.machine_offline', detail: `${hit.name || 'that machine'} is offline: a conductor starts on the machine its folder is on` } };
return { device: hit.device };
return { device: hit.device };
}
}
@@ -37,7 +37,8 @@ function createGitRoutes(ctx) {
@@ -37,7 +37,8 @@ function createGitRoutes(ctx) {
r.get('/git', async (req, res, next) => {
r.get('/git', async (req, res, next) => {
if (!viewer(req)) return next();
if (!viewer(req)) return next();
- const head = header('OpenVibe Git', 'Your repositories on git.openvibe.codes, OpenVibe’s own code home. You sign in there with your OpenVibe account.');
+ const head = header('OpenVibe Git', 'Your repositories on git.openvibe.codes, OpenVibe’s own code home. You sign in there with your OpenVibe account.')
+ + (req.viewer.role === 'owner' ? '<p class="c4-sub"><a href="/admin/repo-sources">Where the repository field looks</a>, and the order it asks.</p>' : '');
if (!forge || !forge.enabled) {
if (!forge || !forge.enabled) {
return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>OpenVibe Git is not connected yet</h2></div>
return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>OpenVibe Git is not connected yet</h2></div>
<p class="c4-sub">You can already browse and clone every public repository at <a href="${esc(forge ? forge.publicUrl : 'https://git.openvibe.codes')}/">git.openvibe.codes</a>, and sign in there with OpenVibe. Making repositories from Codes opens when Codes can reach OpenVibe Git with its service token.</p></section>`);
<p class="c4-sub">You can already browse and clone every public repository at <a href="${esc(forge ? forge.publicUrl : 'https://git.openvibe.codes')}/">git.openvibe.codes</a>, and sign in there with OpenVibe. Making repositories from Codes opens when Codes can reach OpenVibe Git with its service token.</p></section>`);
@@ -9,7 +9,8 @@
@@ -9,7 +9,8 @@
* POST /me/tasks → { task: codes.task@1 } (201)
* POST /me/tasks → { task: codes.task@1 } (201)
* GET /me/tasks/:id → codes.task@1
* GET /me/tasks/:id → codes.task@1
* POST /me/tasks/:id/cancel → codes.task@1
* POST /me/tasks/:id/cancel → codes.task@1
- * GET /me/repos?q=… → { repos: [{ full_name, url, … }] }: the Home picker's search (Codes C4b)
+ * GET /me/repos?q=&limit= → { sections: [{ id, label, items }], create }: the repository dropdown (ask 348)
+ * POST /repos { name, private } → { url, label }: a repository made on OpenVibe Git in the person's name
* GET /me/settings → { settings }: the person's choices, including who leads their tasks
* GET /me/settings → { settings }: the person's choices, including who leads their tasks
* PUT /me/settings → { settings }: save any of them (a field left out keeps what it had)
* PUT /me/settings → { settings }: save any of them (a field left out keeps what it had)
*/
*/
@@ -29,7 +30,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -29,7 +30,7 @@ const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const DELEGABLE = new Set(['owner', 'admin']);
const DELEGABLE = new Set(['owner', 'admin']);
-function createMeApi({ config, store, sso, keys, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, approvals = null, log = console }) {
+function createMeApi({ config, store, sso, keys, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, approvals = null, repoPicker = null, forge = null, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@ -211,11 +212,40 @@ function createMeApi({ config, store, sso, keys, run, harnesses, userSettings =
@@ -211,11 +212,40 @@ function createMeApi({ config, store, sso, keys, run, harnesses, userSettings =
* public answer. Codes holds no GitHub account of a person, so the public search is what it can read; a GitHub that
* public answer. Codes holds no GitHub account of a person, so the public search is what it can read; a GitHub that
* does not answer is a `problem` beside the repositories Codes already knows, never an empty list pretending so.
* does not answer is a `problem` beside the repositories Codes already knows, never an empty list pretending so.
*/
*/
+ // The repository field's dropdown (ask 348): the owner's sources, merged in their order. A source that fails or
+ // takes too long is simply not there: the person sees the repositories Codes could reach, never an error.
r.get('/me/repos', actorLimits.budget('codes.repo.search'), async (req, res) => {
r.get('/me/repos', actorLimits.budget('codes.repo.search'), async (req, res) => {
if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to search your repositories');
if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to search your repositories');
- if (!github) return res.json({ q: String(req.query.q || ''), repos: [], problem: { status: 503, detail: 'the repository search is not available here' } });
- const out = await github.search(req.viewer.subject, req.query.q, { limit: 8 });
- return res.json({ q: out.q, repos: out.found, problem: out.problem });
+ const q = String(req.query.q || '').trim().slice(0, 100);
+ const limit = Math.min(20, Math.max(1, Number.parseInt(req.query.limit, 10) || 8));
+ const sections = repoPicker ? await repoPicker.sections({ viewer: req.viewer, q, limit }) : [];
+ const create = repoPicker ? repoPicker.create(req.viewer) : { allowed: false, host: 'git.openvibe.codes', owner: req.viewer.username || null };
+ return res.json({ q, sections, create });
+ });
+
+ // A new repository on OpenVibe Git, under the person signed in (their Network username is the Git namespace):
+ // ask 348's "Create …" row. The name is validated, the call is same-origin, and it counts as one of their writes.
+ r.post('/repos', actorLimits.budget('codes.repo.create'), json, async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to make a repository');
+ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');
+ if (!forge || !forge.enabled) return problem(req, res, 503, 'repo.git_off', 'OpenVibe Git is not open from Codes right now. Try again in a minute.');
+ const body = req.body || {};
+ const name = String(body.name || '').trim().toLowerCase();
+ try {
+ const made = await forge.createRepo(
+ { subject: req.viewer.subject, username: req.viewer.username || '' },
+ { name, isPrivate: body.private === true },
+ );
+ return res.status(201).json({ url: made.clone_url, label: made.full_name });
+ } catch (e) {
+ const status = Number(e && e.status) || 502;
+ if (status === 422) return problem(req, res, 422, 'repo.name_invalid', String(e.message || 'that repository name cannot be used'));
+ if (status === 409 && e.code === 'git.unlinked') return problem(req, res, 409, 'repo.no_username', 'Your OpenVibe account has no username yet, so a repository has no address. Choose one on your account page, then try again.');
+ if (status === 409) return problem(req, res, 409, 'repo.name_taken', String(e.message || 'you already have a repository with that name'));
+ if (status === 503) return problem(req, res, 503, 'repo.git_off', 'OpenVibe Git did not answer. Try again in a minute.');
+ log.warn(`[Codes] repository create refused: ${String((e && e.message) || e).slice(0, 160)}`);
+ return problem(req, res, 502, 'repo.create_failed', 'OpenVibe Git did not make the repository. Try again in a minute.');
+ }
});
});
r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {
r.get('/me/tasks/:id', actorLimits.reads('codes.read'), async (req, res) => {
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// their own agents read and add (Know K5).
// their own agents read and add (Know K5).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|repos$|admin\/repo-sources$|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.
@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.
const statusTable = table(['Piece', 'State'], [
const statusTable = table(['Piece', 'State'], [
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
- [html`<a href="/run">Run locally</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],
+ [html`<a href="/run">Run locally</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: pair your machine at openvibe.my/#machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* POST /run/tasks/:id/cancel stop it
* POST /run/tasks/:id/cancel stop it
*
*
* Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run
* Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run
- * places it: on a machine the person added on openvibe.link/machines, never anyone else's (contracts 0.161.0).
+ * places it: on a machine the person paired at openvibe.my (openvibe.my/#machines), never anyone else's (contracts 0.161.0).
*/
*/
const express = require('express');
const express = require('express');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
@@ -24,7 +24,7 @@ const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks')
@@ -24,7 +24,7 @@ const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks')
const { createSessionLines } = require('../domain/session-lines');
const { createSessionLines } = require('../domain/session-lines');
const DELEGABLE = new Set(['owner', 'admin']);
const DELEGABLE = new Set(['owner', 'admin']);
-const MACHINES_URL = 'https://openvibe.link/machines';
+const MACHINES_URL = 'https://openvibe.my/#machines';
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
/** The task's outcome in the words a person reads. */
/** The task's outcome in the words a person reads. */
@@ -51,7 +51,7 @@ function guestRunPage() {
@@ -51,7 +51,7 @@ function guestRunPage() {
title: 'Run locally', index: true, agent: true,
title: 'Run locally', index: true, agent: true,
body: html`<h1>Run coding agents locally, started from any browser</h1>
body: html`<h1>Run coding agents locally, started from any browser</h1>
<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
-<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
+<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.my</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
};
};
}
}
@@ -162,7 +162,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -162,7 +162,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
]);
]);
const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
const body = html`<h1>Run locally</h1>
const body = html`<h1>Run locally</h1>
-<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
+<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.my</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
${defaults}
${defaults}
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
@@ -246,7 +246,7 @@ ${tasksHtml(recent)}`;
@@ -246,7 +246,7 @@ ${tasksHtml(recent)}`;
const files = Array.isArray(d.files) ? d.files : [];
const files = Array.isArray(d.files) ? d.files : [];
const push = d.push || null;
const push = d.push || null;
const verdict = row.state !== 'succeeded'
const verdict = row.state !== 'succeeded'
- ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.link/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
+ ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online at openvibe.my/#machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
const ended = !(session && row.state === 'succeeded' && d.ok);
const ended = !(session && row.state === 'succeeded' && d.ok);
@@ -85,11 +85,11 @@ function createSetupRoutes(ctx, kit) {
@@ -85,11 +85,11 @@ function createSetupRoutes(ctx, kit) {
const sourceRows = sources.map((s) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${s.status === 'ready' ? ' c4-dot-ok' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(s.name)}</span><span class="c4-row-note">${s.status === 'ready' ? 'Ready' : s.status === 'coming_soon' ? 'Coming soon' : 'Unavailable'}${s.price && s.price.amount_usd != null ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}${s.reason ? ` · ${esc(s.reason)}` : ''}</span></li>`).join('');
const sourceRows = sources.map((s) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${s.status === 'ready' ? ' c4-dot-ok' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(s.name)}</span><span class="c4-row-note">${s.status === 'ready' ? 'Ready' : s.status === 'coming_soon' ? 'Coming soon' : 'Unavailable'}${s.price && s.price.amount_usd != null ? ` · $${esc(s.price.amount_usd)}/${esc(s.price.unit)}` : ''}${s.reason ? ` · ${esc(s.reason)}` : ''}</span></li>`).join('');
const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';
const problem = d.machines.ok === false && d.machines.problem ? note(esc(d.machines.problem.detail || 'Your machines could not be read.'), 'bad') : '';
const how = `<ol class="c4-setup-how">
const how = `<ol class="c4-setup-how">
-<li><b>Get a one-time code</b> on <a href="https://openvibe.link/add-computer?return_to=https%3A%2F%2Fopenvibe.codes%2Fsetup" rel="noopener">openvibe.link</a>. It lasts ten minutes.</li>
+<li><b>Get a one-time code</b> on <a href="https://openvibe.my/#machines" rel="noopener">openvibe.my</a>. It lasts ten minutes.</li>
<li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.link/install | sh -s -- --worker', 'On the machine')}</li>
<li><b>Run the installer on that computer</b> and give it the code:${command('curl -fsSL https://openvibe.link/install | sh -s -- --worker', 'On the machine')}</li>
<li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;
<li><b>It pairs itself</b> and shows up here as Online. It runs your work and nobody else's.</li></ol>`;
return `${problem}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${how}
return `${problem}${d.compute.ok === false && d.compute.problem ? note(esc(d.compute.problem.detail), 'bad') : ''}<p class="c4-hint">Choose a place for your work. Link checks readiness before a task can start. Rentals need a live price and a spending limit.</p>${sourceRows ? `<ul class="c4-rows">${sourceRows}</ul>` : ''}${how}
-<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.link/machines#pair">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;
+<div class="c4-setup-actions"><a class="c4-button${list.length ? ' c4-button-light' : ''}" href="https://openvibe.my/#machines">${list.length ? 'Manage machines' : 'I have a code'}</a><a class="c4-opill" href="/setup#step-machine">Check again</a></div>`;
}
}
function agentsBody(d) {
function agentsBody(d) {
@@ -351,7 +351,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -351,7 +351,7 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
}
}
const project = await taskStore.latestProject(p.subject);
const project = await taskStore.latestProject(p.subject);
if (!project) {
if (!project) {
- return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.link/machines)`);
+ return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.my/#machines)`);
}
}
let row;
let row;
try {
try {
@@ -24,7 +24,7 @@ function structuredData(path, o, canonical) {
@@ -24,7 +24,7 @@ function structuredData(path, o, canonical) {
{ '@type': 'ListItem', name: o.title, item: canonical },
{ '@type': 'ListItem', name: o.title, item: canonical },
].map((item, i) => ({ ...item, position: i + 1 })) });
].map((item, i) => ({ ...item, position: i + 1 })) });
const steps = path === '/run' ? [
const steps = path === '/run' ? [
- ['Add your computer', 'Add your computer on openvibe.link/machines: one command, which installs OpenVibe Link with its job worker.'],
+ ['Add your computer', 'Pair your computer at openvibe.my/#machines: one command, which installs OpenVibe Link with its job worker.'],
['Describe the task', 'Sign in here and describe the task.'],
['Describe the task', 'Sign in here and describe the task.'],
['Get the result', 'Your machine clones the repository, runs the agent and sends the result back.'],
['Get the result', 'Your machine clones the repository, runs the agent and sends the result back.'],
] : path === '/start' ? [
] : path === '/start' ? [
@@ -39,7 +39,8 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -39,7 +39,8 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');
const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');
assert.ok(shared > -1 && codes > -1, 'both repositories are offered');
assert.ok(shared > -1 && codes > -1, 'both repositories are offered');
assert.ok(shared < codes, 'the last one used comes first');
assert.ok(shared < codes, 'the last one used comes first');
- assert.ok(home.text.includes('data-ch-repo-url="/api/v1/me/repos"'), 'the repository field searches');
+ assert.ok(home.text.includes('data-ov-combo="/api/v1/me/repos"'), 'the repository field asks the dropdown source');
+ assert.ok(!home.text.includes('list="ch-repos"') && !home.text.includes('<datalist'), 'the datalist is gone');
assert.ok(home.text.includes('data-ch-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the recent ones are a tap away');
assert.ok(home.text.includes('data-ch-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the recent ones are a tap away');
// A task cannot be pinned to a machine yet (Run's pin is a Bot device): the default machine is chosen in Settings.
// A task cannot be pinned to a machine yet (Run's pin is a Bot device): the default machine is chosen in Settings.
assert.ok(!home.text.includes('<option value="' + NODE + '">Desk'), 'the chat offers no machine it could not honour');
assert.ok(!home.text.includes('<option value="' + NODE + '">Desk'), 'the chat offers no machine it could not honour');
@@ -138,7 +139,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -138,7 +139,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
await check('Machine management leads to Link', async () => {
await check('Machine management leads to Link', async () => {
const page = await t.get('/machines', { as: ada });
const page = await t.get('/machines', { as: ada });
assert.strictEqual(page.status, 301);
assert.strictEqual(page.status, 301);
- assert.strictEqual(page.headers.get('location'), 'https://openvibe.link/machines');
+ assert.strictEqual(page.headers.get('location'), 'https://openvibe.my/#machines');
});
});
await check('Settings are remembered per person', async () => {
await check('Settings are remembered per person', async () => {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
+ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.goalVerifier ? { goalVerifier: opts.goalVerifier } : {}), ...(opts.forge ? { forge: opts.forge } : {}), ...(opts.github ? { github: opts.github } : {}), ...(opts.fetchCustom ? { fetchCustom: opts.fetchCustom } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -64,14 +64,14 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -64,14 +64,14 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
- function addUser(username, { role = 'user' } = {}) {
- const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
+ function addUser(username, { role = 'user', githubLogin = null } = {}) {
+ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role, github_login: githubLogin };
st.users.set(u.id, u); st.byUsername.set(username, u);
st.users.set(u.id, u); st.byUsername.set(username, u);
return u;
return u;
}
}
function userToken(u, { ttl = 3600, iat = null, audience = null } = {}) {
function userToken(u, { ttl = 3600, iat = null, audience = null } = {}) {
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
const now = iat != null ? iat : Math.floor(Date.now() / 1000);
- return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
+ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, ...(u.github_login ? { github_login: u.github_login } : {}), aud: audience || ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
}
}
/**
/**
* A Dock token exactly as Network mints one for the public client `companion`: the private `<network>/dock`
* A Dock token exactly as Network mints one for the public client `companion`: the private `<network>/dock`
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
+'use strict';
+/**
+ * The repository field's dropdown (ask 348): the owner's sources in their order, each under its own time limit and a
+ * failed one left out; the owner's settings (owner rank only); and making a repository on OpenVibe Git with a stub Git
+ * API. The tests boot the real app, the real repo-picker and the real forge; only OpenVibe Git and GitHub are stubs.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+const { createForge } = require('../server/domain/forge');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json)).catch((e) => json(500, { error: String(e && e.message) }));
+ });
+ });
+ server.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${server.address().port}`,
+ calls: [],
+ close: () => new Promise((r) => server.close(r)),
+ }));
+ });
+}
+
+(async () => {
+ // ── A stub OpenVibe Git: its token endpoint and POST /api/v1/repos, recording what Codes asked for. ──
+ const git = await listen((req, raw, json) => {
+ git.calls.push([req.method, req.url, raw]);
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok', token_type: 'Bearer', expires_in: 3600 });
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/profile/')) {
+ return json(200, { repositories: [{ full_name: 'ada/one', html_url: 'https://git.openvibe.codes/ada/one', visibility: 'public', pushed_at: '2026-11-09T10:00:00Z' }] });
+ }
+ if (req.method === 'POST' && req.url === '/api/v1/repos') {
+ const body = JSON.parse(raw || '{}');
+ if (body.name === 'taken') return json(409, { code: 'repo.exists', detail: 'That repository already exists.' });
+ if (!/^[a-z0-9][a-z0-9._-]{0,99}$/.test(body.name || '')) return json(422, { code: 'repo.invalid_name', detail: 'That name cannot be used.' });
+ return json(201, {
+ full_name: `${body.owner_username}/${body.name}`, html_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}`,
+ clone_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}.git`, visibility: body.visibility,
+ });
+ }
+ return json(404, { code: 'repo.not_found' });
+ });
+ const forge = createForge({
+ config: {
+ git: { internalUrl: git.url, publicUrl: 'https://git.openvibe.codes' },
+ networkInternalUrl: git.url,
+ oauth: { clientId: 'codes', clientSecret: 'codes-secret' },
+ },
+ fetchImpl: globalThis.fetch,
+ log: { warn() {}, info() {}, error() {} },
+ });
+
+ const github = {
+ async publicRepo(owner, name) {
+ if (owner !== 'OpenVibers' || name !== 'OpenVibe.Codes') return null;
+ return { full_name: 'OpenVibers/OpenVibe.Codes', html_url: 'https://github.com/OpenVibers/OpenVibe.Codes', private: false, description: 'Codes', updated_at: '2026-11-08T00:00:00Z' };
+ },
+ async userRepos(login) {
+ return login === 'adalink' ? [{ full_name: 'adalink/linked-repo', html_url: 'https://github.com/adalink/linked-repo', private: false, updated_at: '2026-11-07T00:00:00Z' }] : [];
+ },
+ };
+
+ let custom = null;
+ const fetchCustom = async (url, timeoutMs) => {
+ custom = { url, timeoutMs };
+ return { items: [{ label: 'pat/project', url: 'https://example.com/pat/project.git', private: false, sub: 'custom' }] };
+ };
+
+ const t = await boot({ forge, github, fetchCustom });
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const ola = t.network.addUser('ola', { role: 'owner' });
+ const insertTask = (repo, at) => t.ctx.store.db.exec(
+ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, state, created_at, updated_at)
+ VALUES ($1, $2, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', $3, 'a task', 'succeeded', $4, $4)`,
+ [`tsk_${String(at).padStart(26, '0')}`, ada.subject, repo, at],
+ );
+ const call = (path, { as = null, method = 'GET', body, json, origin } = {}) => {
+ const payload = json !== undefined ? json : body;
+ return t.get(`/api/v1${path}`, { as, method, ...(payload !== undefined ? { json: payload } : {}), ...(origin ? { headers: { origin } } : {}) });
+ };
+
+ try {
+ await check('a guest is refused and the answer carries no CORS grant', async () => {
+ const r = await call('/me/repos');
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), null);
+ });
+
+ await check('sections come from the person\'s tasks and OpenVibe Git, in the owner\'s order', async () => {
+ await insertTask('https://git.openvibe.codes/ada/website', 1000);
+ await insertTask('https://github.com/adalink/old-thing', 2000);
+ const r = await call('/me/repos', { as: ada });
+ assert.strictEqual(r.status, 200, r.text);
+ const out = r.json();
+ assert.deepStrictEqual(out.sections.map((s) => s.id), ['recent', 'git']);
+ const recent = out.sections[0].items;
+ assert.strictEqual(recent[0].label, 'adalink/old-thing', 'newest first');
+ assert.strictEqual(recent[0].host, 'github.com');
+ assert.strictEqual(recent[1].url, 'https://git.openvibe.codes/ada/website.git');
+ assert.strictEqual(out.sections[1].items[0].label, 'ada/one');
+ assert.strictEqual(out.sections[1].items[0].host, 'git.openvibe.codes');
+ assert.deepStrictEqual(out.create, { allowed: true, host: 'git.openvibe.codes', owner: 'ada' });
+ });
+
+ await check('a query filters each source; GitHub without a link answers only an exact owner/name', async () => {
+ const one = await call('/me/repos?q=website', { as: ada });
+ assert.deepStrictEqual(one.json().sections.map((s) => s.id), ['recent']);
+ const exact = await call('/me/repos?q=OpenVibers%2FOpenVibe.Codes', { as: ada });
+ assert.deepStrictEqual(exact.json().sections.map((s) => s.id), ['github']);
+ assert.strictEqual(exact.json().sections[0].items[0].url, 'https://github.com/OpenVibers/OpenVibe.Codes.git');
+ const partial = await call('/me/repos?q=OpenVibe', { as: ada });
+ assert.ok(!partial.json().sections.some((s) => s.id === 'github'), 'no general GitHub search without a link');
+ });
+
+ await check('a GitHub account linked through the Network is searched by login', async () => {
+ const linked = t.network.addUser('ada2', { githubLogin: 'adalink' });
+ const r = await call('/me/repos?q=linked', { as: linked });
+ assert.strictEqual(r.status, 200, r.text);
+ const githubSection = r.json().sections.find((s) => s.id === 'github');
+ assert.ok(githubSection, JSON.stringify(r.json()));
+ assert.strictEqual(githubSection.items[0].label, 'adalink/linked-repo');
+ });
+
+ await check('a failed source is left out with no error to the person', async () => {
+ const failing = { enabled: true, async repos() { throw new Error('Git did not answer'); }, async createRepo() { throw new Error('unused'); } };
+ const t2 = await boot({ forge: failing, github });
+ try {
+ await t2.ctx.store.db.exec(
+ `INSERT INTO run_tasks (id, subject, project_id, repo, prompt, state, created_at, updated_at)
+ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2J2', $1, 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', 'https://git.openvibe.codes/ada/one', 'a task', 'succeeded', 1, 1)`,
+ [ada.subject],
+ );
+ const r = await t2.get('/api/v1/me/repos', { as: ada });
+ assert.strictEqual(r.status, 200);
+ const out = r.json();
+ assert.ok(!out.problem && !out.error);
+ assert.deepStrictEqual(out.sections.map((s) => s.id), ['recent']);
+ } finally { await t2.close(); }
+ });
+
+ await check('a source over its time limit is left out and the answer still comes', async () => {
+ const slow = { enabled: true, async repos() { return await new Promise(() => {}); }, async createRepo() { return {}; } };
+ const t3 = await boot({ forge: slow, github });
+ try {
+ const owner3 = t3.network.addUser('slo', { role: 'owner' });
+ const put = await t3.get('/api/v1/admin/repo-sources', {
+ as: owner3, method: 'PUT',
+ json: { sources: [
+ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1, limit_n: 8, timeout_ms: 1500 },
+ { id: 'git', kind: 'git', label: 'OpenVibe Git', enabled: true, position: 2, limit_n: 8, timeout_ms: 100 },
+ { id: 'github', kind: 'github', label: 'GitHub', enabled: false, position: 3, limit_n: 8, timeout_ms: 1500 },
+ ] },
+ });
+ assert.strictEqual(put.status, 200, put.text);
+ const at = Date.now();
+ const r = await t3.get('/api/v1/me/repos', { as: owner3 });
+ assert.strictEqual(r.status, 200);
+ assert.ok(Date.now() - at < 1000, 'did not wait for the slow source');
+ assert.ok(!r.json().sections.some((s) => s.id === 'git'));
+ } finally { await t3.close(); }
+ });
+
+ await check('the owner can turn, reorder and add sources; a custom one is fetched with {q}', async () => {
+ const put = await call('/admin/repo-sources', {
+ as: ola, method: 'PUT',
+ json: { sources: [
+ { id: 'git', kind: 'git', label: 'OpenVibe Git', enabled: true, position: 1, limit_n: 5, timeout_ms: 1500 },
+ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 2, limit_n: 3, timeout_ms: 1500 },
+ { id: 'github', kind: 'github', label: 'GitHub', enabled: false, position: 3, limit_n: 8, timeout_ms: 1500 },
+ { id: 'src_mine', kind: 'url', label: 'My host', url_template: 'https://example.com/repos?q={q}', enabled: true, position: 4, limit_n: 4, timeout_ms: 1500 },
+ ] },
+ });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.deepStrictEqual(put.json().sources.map((s) => s.id), ['git', 'recent', 'github', 'src_mine']);
+ const got = await call('/admin/repo-sources', { as: ola });
+ assert.strictEqual(got.status, 200);
+ assert.deepStrictEqual(got.json().sources.map((s) => s.id), ['git', 'recent', 'github', 'src_mine']);
+ const r = await call('/me/repos?q=a', { as: ada });
+ assert.deepStrictEqual(r.json().sections.map((s) => s.id), ['git', 'recent', 'src_mine']);
+ assert.strictEqual(custom.url, 'https://example.com/repos?q=a');
+ assert.strictEqual(r.json().sections[2].items[0].label, 'pat/project');
+ // The form the owner sees is the same rows.
+ const page = await t.get('/admin/repo-sources', { as: ola });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Repository sources') && page.text.includes('https://example.com/repos?q={q}'));
+ });
+
+ await check('the plain owner form saves the same list without JavaScript', async () => {
+ const form = {
+ 'source.recent.kind': 'recent', 'source.recent.label': 'Used here', 'source.recent.enabled': '1',
+ 'source.recent.position': '2', 'source.recent.limit_n': '8', 'source.recent.timeout_ms': '1500',
+ 'source.git.kind': 'git', 'source.git.label': 'OpenVibe Git', 'source.git.enabled': '1',
+ 'source.git.position': '1', 'source.git.limit_n': '8', 'source.git.timeout_ms': '1500',
+ 'source.github.kind': 'github', 'source.github.label': 'GitHub',
+ 'source.github.position': '3', 'source.github.limit_n': '8', 'source.github.timeout_ms': '1500',
+ 'new.label': 'Form host', 'new.url_template': 'https://example.com/list?q={q}', 'new.enabled': '1', 'new.limit_n': '4', 'new.timeout_ms': '1200',
+ };
+ const saved = await t.get('/admin/repo-sources', { as: ola, method: 'POST', form });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.strictEqual(saved.headers.get('location'), '/admin/repo-sources?saved=1');
+ const page = await t.get('/admin/repo-sources', { as: ola });
+ assert.ok(page.text.includes('Form host') && page.text.includes('https://example.com/list?q={q}'));
+ });
+
+ await check('a non-owner gets 403 and the guest 401 on the owner settings', async () => {
+ assert.strictEqual((await call('/admin/repo-sources', { as: ada })).status, 403);
+ assert.strictEqual((await call('/admin/repo-sources', { as: ada, method: 'PUT', json: { sources: [] } })).status, 403);
+ assert.strictEqual((await call('/admin/repo-sources')).status, 401);
+ assert.strictEqual((await t.get('/admin/repo-sources', { as: ada })).status, 403);
+ });
+
+ await check('a custom source must be https and carry {q}', async () => {
+ const bad = await call('/admin/repo-sources', {
+ as: ola, method: 'PUT',
+ json: { sources: [
+ { id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1, limit_n: 8, timeout_ms: 1500 },
+ { id: 'src_x', kind: 'url', label: 'X', url_template: 'http://example.com/?q={q}', enabled: true, position: 2 },
+ ] },
+ });
+ assert.strictEqual(bad.status, 422);
+ const noQ = await call('/admin/repo-sources', {
+ as: ola, method: 'PUT',
+ json: { sources: [{ id: 'recent', kind: 'recent', label: 'Used here', enabled: true, position: 1 }, { id: 'src_y', kind: 'url', label: 'Y', url_template: 'https://example.com/?q=', enabled: true, position: 2 }] },
+ });
+ assert.strictEqual(noQ.status, 422);
+ });
+
+ await check('creating a repository against the stub Git API: a bad name, a good one, and a taken one', async () => {
+ const bad = await call('/repos', { as: ada, method: 'POST', body: { name: 'not a name!' } });
+ assert.strictEqual(bad.status, 422, bad.text);
+ assert.strictEqual(bad.json().code, 'repo.name_invalid');
+ const good = await call('/repos', { as: ada, method: 'POST', body: { name: 'new-site', private: true } });
+ assert.strictEqual(good.status, 201, good.text);
+ assert.deepStrictEqual(good.json(), { url: 'https://git.openvibe.codes/ada/new-site.git', label: 'ada/new-site' });
+ const sent = git.calls.filter((c) => c[0] === 'POST' && c[1] === '/api/v1/repos').pop();
+ assert.strictEqual(JSON.parse(sent[2]).visibility, 'private');
+ assert.strictEqual(JSON.parse(sent[2]).owner_username, 'ada');
+ const taken = await call('/repos', { as: ada, method: 'POST', body: { name: 'taken' } });
+ assert.strictEqual(taken.status, 409);
+ assert.strictEqual(taken.json().code, 'repo.name_taken');
+ });
+
+ await check('creating is rate limited per person', async () => {
+ const t4 = await boot({ forge, github, actorLimits: true });
+ try {
+ const u = t4.network.addUser('rita');
+ const statuses = [];
+ for (let i = 0; i < 4; i++) {
+ const r = await t4.get('/api/v1/repos', { as: u, method: 'POST', json: { name: `rate-${i}` } });
+ statuses.push(r.status);
+ }
+ assert.deepStrictEqual(statuses.slice(0, 3), [201, 201, 201]);
+ assert.strictEqual(statuses[3], 429, statuses.join(','));
+ } finally { await t4.close(); }
+ });
+
+ await check('a guest cannot create and a cross-site post is refused', async () => {
+ assert.strictEqual((await call('/repos', { method: 'POST', body: { name: 'x' } })).status, 401);
+ const cross = await call('/repos', { as: ada, method: 'POST', body: { name: 'x' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ });
+ } finally {
+ await t.close();
+ await git.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('a guest is told what it is and asked to sign in', async () => {
await check('a guest is told what it is and asked to sign in', async () => {
const r = await t.get('/run');
const r = await t.get('/run');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('Run coding agents locally, started from any browser') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));
+ assert.ok(r.text.includes('Run coding agents locally, started from any browser') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.my'));
});
});
let project;
let project;
@@ -70,6 +70,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -70,6 +70,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
+ 'server/domain/repo-picker.js': 'the owner\'s custom source URL (https, with {q}) is the only URL a person influenced: it is fetched with openvibe-shared/egress assertPublicUrl and safeLookup at every hop, https only, no credentials, a capped body; the built-in sources go to the configured OpenVibe Git (forge.js) and api.github.com at fixed paths from a validated owner/name, never a URL from a person',
'server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) ; one read-only call each, never a URL from a person',
'server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) ; one read-only call each, never a URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
'server/data/public-updates.js': 'the configured Network base (config.networkUrl) public changelog feed at the fixed path /api/v1/changelog?service=codes&limit=50 for the /updates page; no URL from a person',
'server/data/public-updates.js': 'the configured Network base (config.networkUrl) public changelog feed at the fixed path /api/v1/changelog?service=codes&limit=50 for the /updates page; no URL from a person',