openvibers/openvibe.codes Commits

OpenVibe Git through your own account: link once, make a repository, work on it from your machine (CLI 0.6.2) (#221)

OP OpenVibers committed 12 days ago · fe684c70f1 · 16 files, +608 −17

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex edd9fff..8fdb18b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -59,7 +59,8 @@
"proof of a finished task: its tests, screenshots, diff and a second-model review, and the outcome in plain words",
"the conductor (C9b): one Claude Code session on the person's own machine, driven from the browser or the phone, with its approvals",
"the job runner (CLI 0.6.0): each turn at the effort a person picks, in each agent's own words, and a conversation's next turn starting from the work before it (the last diff applied with git apply --3way, and the conversation so far), on any agent",
-- "the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config"
++ "the job runner (CLI 0.6.1): \"edit\" means edits on the person's own computer (OpenCode and Command Code run unattended only in a Node sandbox or for full; Aider suggests no shell commands there), and a private repository clones with a token the machine holds (GITHUB_TOKEN, GITLAB_TOKEN, OPENVIBE_GIT_CREDENTIALS) sent as a header, never in the URL or .git/config",
++ "OpenVibe Git through the person's own account (/git, CLI 0.6.2): link once (the forge's OAuth2 consent with PKCE, tokens sealed with CODES_SEAL_KEY), make a repository in your name and start a chat on it; a task on a forge repository takes the person's credential line to their machine for git only (never the agent), and the branch and its pull request open on OpenVibe Git"
],
"updated": "2026-10-31"
}
ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex 9b949bd..927682c 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -170,17 +170,19 @@ async function main() {
// harness is built with it, so the agent's process gets the token and never the job's other input.
let wanted = {};
try { wanted = jobEnv(JSON.parse(input)); } catch { wanted = {}; }
-- const jobHarness = createHarness({ env: { ...process.env, ...wanted } });
++ // A forge credential line is for git alone (the clone and the push): the agent's environment never has it.
++ const { OPENVIBE_GIT_CREDENTIALS: gitCredentials, ...forAgents } = { ...process.env, ...wanted };
++ const jobHarness = createHarness({ env: forAgents });
// The proof adapters: the repo's own tests, its pages in a browser, its diff, and the second model. The review
// spends the same run token the attempt already carries, so it names the same person.
const proof = createProofAdapters({
cwd: process.cwd(),
-- env: { ...process.env, ...wanted },
-- jobEnv: wanted,
++ env: forAgents,
++ jobEnv: (({ OPENVIBE_GIT_CREDENTIALS: _drop, ...rest }) => rest)(wanted),
// The models this machine can offer a reviewer: the catalog's own.
models: (jobHarness.agents() || []).flatMap((a) => (a.agents || []).map((x) => x.model)).filter(Boolean),
});
-- await runJob({ input, harness: jobHarness, emit: out, writeResult: resultWriter(out), proof });
++ await runJob({ input, harness: jobHarness, emit: out, writeResult: resultWriter(out), proof, env: { ...forAgents, ...(gitCredentials ? { OPENVIBE_GIT_CREDENTIALS: gitCredentials } : {}) } });
return 0;
}
if (cmd === 'runs') {
ddiff --git a/harness/job.js b/harness/job.js
iindex 2bd64ad..ec1621f 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -78,10 +78,10 @@ function pushTarget(requested, base, { exists = false, now = Date.now() } = {})
* command line, never in the clone's remote URL, never in .git/config the agent can read.
* → { env } to add to git's environment, or {} when no token is for this host.
*/
--function gitAuthFor(repo, env = process.env) {
++function credentialFor(repo, env = process.env) {
let u;
-- try { u = new URL(String(repo || '')); } catch { return {}; }
-- if (u.protocol !== 'https:') return {};
++ try { u = new URL(String(repo || '')); } catch { return null; }
++ if (u.protocol !== 'https:') return null;
const host = u.host.toLowerCase();
const creds = new Map();
if (env.GITHUB_TOKEN) creds.set('github.com', { user: 'x-access-token', token: String(env.GITHUB_TOKEN) });
@@@ -93,7 +93,13 @@ function gitAuthFor(repo, env = process.env) {
} catch { /* a line that is not https://user:token@host is skipped */ }
});
const c = creds.get(host);
-- if (!c || !c.token || /[\r\n]/.test(c.token + c.user)) return {};
++ if (!c || !c.token || /[\r\n]/.test(c.token + c.user)) return null;
++ return { ...c, host };
++}
++function gitAuthFor(repo, env = process.env) {
++ const c = credentialFor(repo, env);
++ if (!c) return {};
++ const host = c.host;
const basic = Buffer.from(`${c.user}:${c.token}`).toString('base64');
return { env: { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.https://${host}/.extraheader`, GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}` } };
}
@@@ -122,6 +128,9 @@ const ENV_NAMES = new Set([
'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY',
'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY',
'OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS',
++ // A repository on OpenVibe Git: the person's credential line for the forge (Codes' forge link). For git only:
++ // bin/openvibe-codes.js keeps it out of the agent's environment.
++ 'OPENVIBE_GIT_CREDENTIALS',
]);
/** The environment a job's input asks for: the allowlisted names only, each a short string. */
@@@ -446,9 +455,58 @@ async function writeAttachments(job, { dir, env, fetchImpl, say }) {
* exist yet: never the base, never a protected name, never someone's existing branch (GitHub is asked first, and a push
* is never forced, so a branch made meanwhile is not overwritten either).
*/
++// The forges that answer Gitea's API (Forgejo, Gitea): OpenVibe Git, and any the machine names in OPENVIBE_GITEA_HOSTS.
++function giteaHosts(env = process.env) {
++ return new Set(['git.openvibe.codes', ...String(env.OPENVIBE_GITEA_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
++}
++
++/** Push the branch to a Gitea-compatible forge with the machine's credential for it, and open the pull request there. */
++async function pushForge(dir, job, { env, fetchImpl, base }) {
++ const cred = credentialFor(job.repo, env);
++ let u;
++ try { u = new URL(job.repo); } catch { return { pushed: false, reason: 'the repository address is not a URL' }; }
++ const host = u.host.toLowerCase();
++ if (!cred) return { pushed: false, reason: `this machine has no token for ${host}` };
++ const [owner, name, ...rest] = u.pathname.replace(/\.git$/i, '').split('/').filter(Boolean);
++ if (!owner || !name || rest.length) return { pushed: false, reason: 'push is for owner/name repositories' };
++ const auth = gitAuthFor(job.repo, env).env;
++ const first = pushTarget(job.push.branch, base);
++ if (first.refused) return { pushed: false, reason: first.refused };
++ const branch = first.branch;
++ let exists = null;
++ try { exists = (await git(['ls-remote', '--heads', job.repo, `refs/heads/${branch}`], { cwd: dir, timeout: 60_000, env: auth })).trim() !== ''; } catch { exists = null; }
++ const checked = pushTarget(branch, base, { exists });
++ if (checked.refused) return { pushed: false, reason: checked.refused.replace('GitHub could not say', `${host} could not say`) };
++ const who = {
++ GIT_AUTHOR_NAME: env.GIT_AUTHOR_NAME || 'OpenVibe.Codes', GIT_AUTHOR_EMAIL: env.GIT_AUTHOR_EMAIL || '[email protected]',
++ GIT_COMMITTER_NAME: env.GIT_AUTHOR_NAME || 'OpenVibe.Codes', GIT_COMMITTER_EMAIL: env.GIT_AUTHOR_EMAIL || '[email protected]',
++ };
++ await git(['checkout', '-b', branch], { cwd: dir });
++ await git(['commit', '-m', job.prompt.split('\n')[0].slice(0, 72) || 'OpenVibe.Codes task'], { cwd: dir, env: who });
++ await git(['push', job.repo, `HEAD:refs/heads/${branch}`], { cwd: dir, timeout: 180_000, env: auth });
++ const out = { pushed: true, branch, branch_url: `https://${host}/${owner}/${name}/src/branch/${encodeURIComponent(branch)}` };
++ if (job.push.pull_request) {
++ const r = await fetchImpl(`https://${host}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`, {
++ method: 'POST',
++ headers: { Authorization: `token ${cred.token}`, Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'openvibe-codes' },
++ body: JSON.stringify({ title: job.prompt.split('\n')[0].slice(0, 120), head: branch, base, body: `Made with OpenVibe.Codes on the requester's own machine.\n\n${job.prompt.slice(0, 4000)}` }),
++ signal: AbortSignal.timeout(30_000),
++ }).catch(() => null);
++ const doc = r ? await r.json().catch(() => null) : null;
++ if (r && r.ok && doc && doc.html_url) out.pull_request = doc.html_url;
++ else out.pull_request_error = r ? `${host} answered ${r.status}` : `${host} did not answer`;
++ }
++ return out;
++}
++
async function pushBranch(dir, job, { env, fetchImpl, base }) {
const token = env.GITHUB_TOKEN;
const gh = githubRepo(job.repo);
++ if (!gh) {
++ let host = '';
++ try { host = new URL(job.repo).host.toLowerCase(); } catch { host = ''; }
++ if (host && giteaHosts(env).has(host)) return pushForge(dir, job, { env, fetchImpl, base });
++ }
if (!token || !gh) return { pushed: false, reason: !gh ? 'push is for github.com repositories' : 'this machine has no GITHUB_TOKEN' };
const first = pushTarget(job.push.branch, base);
if (first.refused) return { pushed: false, reason: first.refused };
@@@ -640,4 +698,4 @@ function resultWriter(emit) {
};
}
--module.exports = { gitAuthFor, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };
++module.exports = { gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };
ddiff --git a/migrations/0022_forge_links.sql b/migrations/0022_forge_links.sql
nnew file mode 100644
iindex 0000000..c2b7037
---- /dev/null
++++ b/migrations/0022_forge_links.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- A person's link to their OpenVibe Git account (git.openvibe.codes, Forgejo): the forge's OAuth2 tokens Codes uses to
++-- create repositories in their name and to let their own machine clone and push them. The tokens are sealed
++-- (AES-256-GCM, CODES_SEAL_KEY) and never exported, shown or logged; the person unlinks here or revokes on the forge.
++CREATE TABLE IF NOT EXISTS codes_forge_links (
++ subject text COLLATE "C" PRIMARY KEY, -- usr_… the person
++ forge_user text COLLATE "C" NOT NULL, -- their login on the forge
++ token_sealed text NOT NULL, -- the access token, sealed
++ refresh_sealed text, -- the refresh token, sealed
++ expires_at timestamptz, -- when the access token ends
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex dc8b03d..73f122d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-codes",
-- "version": "0.6.1",
++ "version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-codes",
-- "version": "0.6.1",
++ "version": "0.6.2",
"license": "AGPL-3.0-only",
"dependencies": {
"cookie-parser": "^1.4.7",
ddiff --git a/package.json b/package.json
iindex 669bf4b..bd08b0a 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-codes",
-- "version": "0.6.1",
++ "version": "0.6.2",
"private": true,
"description": "OpenVibe.Codes: the open coding-agent harness. Run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with routing by capability and cost and hand-offs between agents; plus the site with the agent catalog, a public routing API and the way to improve OpenVibe itself.",
"main": "harness/index.js",
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex a8136f2..007a91e 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -186,6 +186,7 @@ details[open]>summary .ch-caret{rotate:180deg}
/* The repositories used last, one tap each: a sideways row that never wraps the composer taller. */
.ch-recent{display:flex;gap:6px;margin:8px 0 0;overflow-x:auto;scrollbar-width:none}
.ch-recent .ch-chip{flex:none;font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
++.ch-recent .ch-chip-new{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 40%,var(--ch-line))}
/* Where the work runs, under the bar. */
.ch-where{margin:6px 2px 0;font-size:12.5px;line-height:1.45;color:var(--ch-soft)}
.ch-where>span{display:flex;align-items:flex-start;gap:7px}
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 93ff9f7..7dfd62e 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -43,6 +43,8 @@ const TABLES = [
// The token itself is never stored; its jti is an internal handle and stays out of the export.
{ table: 'codes_run_tokens', subject: 'subject', file: 'run-tokens.json', order: 'created_at', columns: ['id', 'task_id', 'attempt_id', 'provider', 'expires_at', 'created_at'] },
{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },
++ // The OpenVibe Git link: which forge account, and since when. The sealed tokens never leave; erased with the rest.
++ { table: 'codes_forge_links', subject: 'subject', file: 'openvibe-git-link.json', order: 'created_at', columns: ['forge_user', 'expires_at', 'created_at', 'updated_at'] },
];
// The rows that hang under a person's tasks: exported under the task, erased before it.
ddiff --git a/server/app.js b/server/app.js
iindex 9b29064..0062c6b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -111,6 +111,9 @@ async function createApp(opts = {}) {
// 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 });
++ // OpenVibe Git through the person's own account (server/domain/forge.js): off until the forge trusts Codes as an app.
++ ctx.forge = opts.forge || require('./domain/forge').createForge({ config, store, log });
++ 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
// tasks, so the two are wired to each other here, not in a constructor (which would be a cycle).
@@@ -249,6 +252,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
// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
app.use(require('./http/c4').createC4Routes(ctx));
app.use(pageRoutes);
ddiff --git a/server/config.js b/server/config.js
iindex 9587ac7..b527b66 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -50,6 +50,12 @@ function load(env = process.env) {
git: {
internalUrl: trim(env.CODES_GIT_INTERNAL_URL || 'http://127.0.0.1:5060'),
publicUrl: trim(env.CODES_GIT_URL || 'https://git.openvibe.codes'),
++ // Linking a person's OpenVibe Git account (the owner, 11-01: "make a repository through your OpenVibe
++ // account on our actual Git"): an OAuth2 application on the forge, and the key that seals the tokens it
++ // hands Codes (32 bytes, base64). Without all three, linking is off and the page says so.
++ clientId: env.CODES_FORGE_CLIENT_ID || '',
++ clientSecret: env.CODES_FORGE_CLIENT_SECRET || '',
++ sealKey: env.CODES_SEAL_KEY || '',
},
// The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).
serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',
ddiff --git a/server/domain/forge.js b/server/domain/forge.js
nnew file mode 100644
iindex 0000000..0cc9ef7
---- /dev/null
++++ b/server/domain/forge.js
@@@ -0,0 +1,167 @@
++'use strict';
++/**
++ * A person's OpenVibe Git (git.openvibe.codes, Forgejo), through their own account. The owner, 2026-11-01: "you should
++ * be able to make a repository… link the repositories to OpenVibe OAuth… through your OpenVibe account on our actual
++ * Git." The forge's sign-in is OpenVibe's, so linking is one consent: Codes is an OAuth2 application on the forge, the
++ * person allows it once, and Codes keeps the tokens sealed (seal.js). Codes holds no admin key: everything it does on
++ * the forge, it does as that person, and the person can unlink here or revoke it in the forge's settings.
++ *
++ * createForge({ config, store, log })
++ * enabled linking is configured (the application and the seal key)
++ * publicUrl, host where the forge is
++ * authorizeUrl({ state, challenge }) the forge's consent page (PKCE S256)
++ * connect(subject, { code, verifier }) the code exchanged, the forge login read, the tokens sealed → { forge_user }
++ * link(subject) → { forge_user, created_at } or null
++ * unlink(subject)
++ * createRepo(subject, { name, description, isPrivate }) → { full_name, html_url, clone_url, private }
++ * repos(subject) → their repositories, last changed first (up to 50)
++ * jobEnv(subject, repoUrl) → { OPENVIBE_GIT_CREDENTIALS } for a repository on the forge, or null
++ *
++ * The forge listens on 127.0.0.1:5060, a port fetch refuses (SIP), so the calls go through node:http (as home.js does).
++ * No token is ever logged, returned by a route or written anywhere but codes_forge_links, sealed.
++ */
++const { createSeal } = require('./seal');
++
++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
++const LOGIN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
++
++function call(base, method, pathname, { headers = {}, json = null, form = null, timeoutMs = 8000 } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(pathname, `${String(base).replace(/\/+$/, '')}/`);
++ const lib = u.protocol === 'https:' ? require('https') : require('http');
++ const body = json != null ? JSON.stringify(json) : form != null ? new URLSearchParams(form).toString() : null;
++ const req = lib.request(u, {
++ method,
++ timeout: timeoutMs,
++ headers: {
++ Accept: 'application/json',
++ ...(json != null ? { 'Content-Type': 'application/json' } : form != null ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
++ ...(body != null ? { 'Content-Length': Buffer.byteLength(body) } : {}),
++ ...headers,
++ },
++ }, (res) => {
++ let text = '';
++ res.setEncoding('utf8');
++ res.on('data', (c) => { text += c; if (text.length > 2_000_000) req.destroy(new Error('the forge answered too much')); });
++ res.on('end', () => { let doc = null; try { doc = JSON.parse(text); } catch { doc = null; } resolve({ status: res.statusCode, doc }); });
++ });
++ req.on('timeout', () => req.destroy(new Error('the forge did not answer in time')));
++ req.on('error', reject);
++ if (body != null) req.write(body);
++ req.end();
++ });
++}
++
++function createForge({ config, store, log = console }) {
++ const git = config.git || {};
++ const publicUrl = String(git.publicUrl || 'https://git.openvibe.codes').replace(/\/+$/, '');
++ const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');
++ const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();
++ const seal = createSeal(git.sealKey);
++ const enabled = !!(git.clientId && git.clientSecret && seal);
++ const redirectUri = `${String(config.baseUrl || '').replace(/\/+$/, '')}/git/callback`;
++ const db = store.db;
++ const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
++
++ function authorizeUrl({ state, challenge }) {
++ const q = new URLSearchParams({
++ client_id: git.clientId, redirect_uri: redirectUri, response_type: 'code', state,
++ code_challenge: challenge, code_challenge_method: 'S256', scope: 'write:repository read:user',
++ });
++ return `${publicUrl}/login/oauth/authorize?${q}`;
++ }
++
++ async function token(form) {
++ const r = await call(internalUrl, 'POST', '/login/oauth/access_token', { form: { client_id: git.clientId, client_secret: git.clientSecret, ...form } });
++ if (r.status !== 200 || !r.doc || !r.doc.access_token) throw fail(502, 'forge.token_refused', `OpenVibe Git did not hand over a token (${r.status})`);
++ return r.doc;
++ }
++ async function save(subject, login, t) {
++ const expires = Number(t.expires_in) > 0 ? new Date(Date.now() + Number(t.expires_in) * 1000) : null;
++ await db.exec(
++ `INSERT INTO codes_forge_links (subject, forge_user, token_sealed, refresh_sealed, expires_at)
++ VALUES ($1, $2, $3, $4, $5)
++ ON CONFLICT (subject) DO UPDATE SET forge_user = EXCLUDED.forge_user, token_sealed = EXCLUDED.token_sealed,
++ refresh_sealed = EXCLUDED.refresh_sealed, expires_at = EXCLUDED.expires_at, updated_at = now()`,
++ [subject, login, seal.close(t.access_token, subject), t.refresh_token ? seal.close(t.refresh_token, subject) : null, expires],
++ );
++ }
++
++ async function connect(subject, { code, verifier }) {
++ if (!enabled) throw fail(503, 'forge.off', 'Linking OpenVibe Git is not open yet');
++ if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');
++ const t = await token({ grant_type: 'authorization_code', code: String(code || ''), code_verifier: String(verifier || ''), redirect_uri: redirectUri });
++ const me = await call(internalUrl, 'GET', '/api/v1/user', { headers: { Authorization: `Bearer ${t.access_token}` } });
++ const login = me.doc && me.doc.login;
++ if (me.status !== 200 || !LOGIN.test(String(login || ''))) throw fail(502, 'forge.user_unknown', 'OpenVibe Git did not say who you are there');
++ await save(subject, login, t);
++ return { forge_user: login };
++ }
++
++ async function link(subject) {
++ const row = await db.maybe('SELECT forge_user, created_at FROM codes_forge_links WHERE subject = $1', [subject]);
++ return row ? { forge_user: row.forge_user, created_at: row.created_at } : null;
++ }
++ async function unlink(subject) { await db.exec('DELETE FROM codes_forge_links WHERE subject = $1', [subject]); }
++
++ /** A live access token for the person: refreshed when it ends within `margin` ms. null when unlinked or refused. */
++ async function tokenFor(subject, margin = 60_000) {
++ if (!enabled) return null;
++ const row = await db.maybe('SELECT forge_user, token_sealed, refresh_sealed, expires_at FROM codes_forge_links WHERE subject = $1', [subject]);
++ if (!row) return null;
++ const ends = row.expires_at ? new Date(row.expires_at).getTime() : Infinity;
++ if (ends - Date.now() > margin) { const t = seal.open(row.token_sealed, subject); return t ? { token: t, login: row.forge_user } : null; }
++ const refresh = row.refresh_sealed ? seal.open(row.refresh_sealed, subject) : null;
++ if (!refresh) return null;
++ try {
++ const t = await token({ grant_type: 'refresh_token', refresh_token: refresh });
++ await save(subject, row.forge_user, t);
++ return { token: t.access_token, login: row.forge_user };
++ } catch (e) {
++ log.warn(`[Codes] OpenVibe Git refused a refresh for a linked account: ${String(e && e.message).slice(0, 120)}`);
++ return null;
++ }
++ }
++
++ async function createRepo(subject, { name, description = '', isPrivate = false }) {
++ const n = String(name || '').trim();
++ if (!REPO_NAME.test(n) || /\.git$/i.test(n)) throw fail(422, 'forge.name_invalid', 'A name is letters, digits, dots, dashes or underscores (up to 100), and not ending in .git');
++ const t = await tokenFor(subject);
++ if (!t) throw fail(409, 'forge.unlinked', 'Link your OpenVibe Git account first');
++ const r = await call(internalUrl, 'POST', '/api/v1/user/repos', {
++ headers: { Authorization: `Bearer ${t.token}` },
++ json: { name: n, description: String(description || '').slice(0, 255), private: !!isPrivate, auto_init: true, default_branch: 'main', readme: 'Default' },
++ });
++ if (r.status === 409) throw fail(409, 'forge.name_taken', `You already have a repository called ${n}`);
++ if (r.status !== 201 || !r.doc) throw fail(502, 'forge.create_failed', `OpenVibe Git did not make the repository (${r.status})`);
++ return { full_name: r.doc.full_name, html_url: r.doc.html_url, clone_url: r.doc.clone_url, private: !!r.doc.private };
++ }
++
++ async function repos(subject) {
++ const t = await tokenFor(subject);
++ if (!t) return [];
++ const r = await call(internalUrl, 'GET', '/api/v1/user/repos?limit=50', { headers: { Authorization: `Bearer ${t.token}` } }).catch(() => null);
++ if (!r || r.status !== 200 || !Array.isArray(r.doc)) return [];
++ return r.doc.map((x) => ({ full_name: x.full_name, html_url: x.html_url, private: !!x.private, updated_at: x.updated_at || null, description: x.description || '' }))
++ .sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
++ }
++
++ /**
++ * What a job on the person's machine needs for a repository on the forge: one credential line for the forge's host
++ * (harness/job.js gitAuthFor), so a private repository clones and the branch pushes. The token lives at least an
++ * hour from now (a job's cap), and it goes to git only: the job keeps it out of the agent's environment.
++ */
++ async function jobEnv(subject, repoUrl) {
++ let u;
++ try { u = new URL(String(repoUrl || '')); } catch { return null; }
++ if (u.protocol !== 'https:' || u.host.toLowerCase() !== host) return null;
++ const t = await tokenFor(subject, 65 * 60_000).catch(() => null);
++ if (!t) return null;
++ return { OPENVIBE_GIT_CREDENTIALS: `https://${encodeURIComponent(t.login)}:${encodeURIComponent(t.token)}@${host}` };
++ }
++
++ return { enabled, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };
++}
++
++module.exports = { createForge, REPO_NAME };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 1d12d68..57b3583 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -251,6 +251,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
*
* → { env, mode } with mode 'ai' (through OpenVibe.AI), 'plan' (the person's own subscription) or 'none'
*/
++ /**
++ * A job's input with its environment: the run token's (attemptEnv), and for a repository on OpenVibe Git the
++ * person's credential line for the forge (server/domain/forge.js jobEnv), so their machine clones a private
++ * repository and pushes the branch. The job keeps the line out of the agent's environment (harness/job.js).
++ */
++ let forge = null;
++ async function withEnv(subject, args, env) {
++ const git = forge && args && args.repo ? await forge.jobEnv(subject, args.repo).catch(() => null) : null;
++ const all = { ...(env || {}), ...(git || {}) };
++ return Object.keys(all).length ? { ...args, env: all } : args;
++ }
++
async function attemptEnv({ subject, taskId, attemptId = null, agent = null, budget = null }) {
if (!runTokens) return { env: null, mode: 'none' };
try {
@@@ -568,7 +580,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// travels with the job's input and the machine's worker hands it to the harness.
const funding = await attemptEnv({ subject, taskId: id, attemptId: first.attempt.id, agent: agent || null, budget });
await attemptStore.bind(first.attempt.id, { run_mode: funding.mode });
-- job = await run.submit({ subject, project, args: funding.env ? { ...runArgs, env: funding.env } : runArgs, key: id });
++ job = await run.submit({ subject, project, args: await withEnv(subject, runArgs, funding.env), key: id });
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
await attemptStore.end(first.attempt.id, { state: 'failed', reason: refusal ? 'refused' : 'error', finished_at: store.iso() });
@@@ -769,7 +781,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
// through AI is capped like any other attempt.
const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });
await attemptStore.bind(attempt.id, { run_mode: funding.mode });
-- job = await run.submit({ subject: row.subject, project: row.project_id, args: funding.env ? { ...args, env: funding.env } : args, key: attempt.id });
++ job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id });
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
@@@ -851,7 +863,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const funding = await attemptEnv({ subject: row.subject, taskId: row.id, attemptId: attempt.id, agent: plan.agent || null, budget: left.left });
await attemptStore.bind(attempt.id, { run_mode: funding.mode });
const args = followUpArgs(row, plan);
-- job = await run.submit({ subject: row.subject, project: row.project_id, args: funding.env ? { ...args, env: funding.env } : args, key: attempt.id });
++ job = await run.submit({ subject: row.subject, project: row.project_id, args: await withEnv(row.subject, args, funding.env), key: attempt.id });
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
await attemptStore.end(attempt.id, { state: 'failed', reason: 'error', finished_at: store.iso() });
@@@ -953,6 +965,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return {
start, refresh, applyJob, applyEvent, recordState, afterEnd, continueTask, stopTask, proof: taskProof,
latestProject, present, who, attempts: attemptStore, setConductor,
++ setForge(f) { forge = f || null; },
};
}
ddiff --git a/server/domain/seal.js b/server/domain/seal.js
nnew file mode 100644
iindex 0000000..e339a10
---- /dev/null
++++ b/server/domain/seal.js
@@@ -0,0 +1,40 @@
++'use strict';
++/**
++ * Sealing a secret Codes must keep (a person's OpenVibe Git tokens): AES-256-GCM under CODES_SEAL_KEY, a fresh 12-byte
++ * nonce each time, and the subject as associated data, so a sealed value moved to another person's row does not open.
++ *
++ * const seal = createSeal(base64Key) // null when the key is missing or not 32 bytes: the feature stays off
++ * seal.close(plain, subject) → 'v1.<nonce>.<tag>.<cipher>' (base64url)
++ * seal.open(sealed, subject) → plain, or null when it was not sealed with this key for this subject
++ *
++ * Nothing here logs, and a value that does not open is null, never an exception with the value in it.
++ */
++const crypto = require('crypto');
++
++function createSeal(base64Key) {
++ let key;
++ try { key = Buffer.from(String(base64Key || ''), 'base64'); } catch { return null; }
++ if (key.length !== 32) return null;
++ const b64 = (b) => b.toString('base64url');
++ return {
++ close(plain, subject) {
++ const nonce = crypto.randomBytes(12);
++ const c = crypto.createCipheriv('aes-256-gcm', key, nonce);
++ c.setAAD(Buffer.from(String(subject || ''), 'utf8'));
++ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
++ return `v1.${b64(nonce)}.${b64(c.getAuthTag())}.${b64(body)}`;
++ },
++ open(sealed, subject) {
++ const parts = String(sealed || '').split('.');
++ if (parts.length !== 4 || parts[0] !== 'v1') return null;
++ try {
++ const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1], 'base64url'));
++ d.setAAD(Buffer.from(String(subject || ''), 'utf8'));
++ d.setAuthTag(Buffer.from(parts[2], 'base64url'));
++ return Buffer.concat([d.update(Buffer.from(parts[3], 'base64url')), d.final()]).toString('utf8');
++ } catch { return null; }
++ },
++ };
++}
++
++module.exports = { createSeal };
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex a9a8a9d..bb90056 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -177,6 +177,7 @@ function createChatRoutes(ctx) {
place('Conductor', '/conductor', 'conductor'),
place('Agents', '/agents', 'agents'),
place('Machines', '/machines', 'machines'),
++ place('OpenVibe Git', '/git', 'repo'),
place('Memory', '/memory', 'memory'),
place('Usage', '/usage', 'usage'),
...(waitingIds.size ? [{ href: '/approvals', label: 'Approvals', icon: 'hand', here: here === '/approvals', count: waitingIds.size }] : []),
@@@ -428,7 +429,8 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
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 pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents' }) + effortPicker('', 'ch-for-agents');
// The repositories a person used last are a tap away (the box also searches every one they can reach).
-- const recent = repos.length > 1 ? `<div class="ch-recent ch-for-code" role="group" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : '';
++ const newRepo = `<a class="ch-chip ch-chip-new" href="/git">${ico('new')}New on OpenVibe Git</a>`;
++ const recent = `<div class="ch-recent ch-for-code" role="group" aria-label="Your repositories">${newRepo}${repos.length > 1 ? repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('') : ''}</div>`;
const where = `${recent}<p class="ch-where"><span class="ch-for-new">${ico('web')}<span>Built on OpenVibe’s servers at <b>openvibe.space</b> <span class="ch-where-tail">and you get a live link; you check it before anything is built</span></span></span><span class="ch-for-agents">${whereAgents(mine)}</span></p>`;
// The ideas rail: it drifts sideways, stops under a pointer or the keyboard, and has its own pause. A press fills
ddiff --git a/server/http/git.js b/server/http/git.js
nnew file mode 100644
iindex 0000000..a7f5175
---- /dev/null
++++ b/server/http/git.js
@@@ -0,0 +1,125 @@
++'use strict';
++/**
++ * OpenVibe Git in Codes (/git): link your account on git.openvibe.codes, make a repository there in your own name, and
++ * start working on it in one press. The owner, 2026-11-01: "make a repository… through your OpenVibe account on our
++ * actual Git… ours is the official." The forge's sign-in is OpenVibe's, so linking is one consent (server/domain/forge.js).
++ *
++ * GET /git what is linked, a new-repository form and your repositories (each one: "Work on it")
++ * GET /git/connect to the forge's consent page (state and the PKCE verifier in a short cookie on /git)
++ * GET /git/callback back from the forge: the code exchanged, the account linked
++ * POST /git/repos a new repository in your name, then a new chat on it
++ * POST /git/unlink forget the link (the forge's own settings revoke the app's access too)
++ *
++ * Every page is the person's own (signed in), every form a same-origin post; no token ever reaches a page.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { esc } = require('../render/html');
++const { asyncRouter } = require('./router');
++
++const STATE_COOKIE = 'codes_forge_oauth';
++
++function createGitRoutes(ctx) {
++ const r = asyncRouter();
++ const { config, forge } = ctx;
++ const form = express.urlencoded({ extended: false, limit: '8kb' });
++ const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ 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 signIn = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ const back = (res, key, msg) => res.redirect(303, `/git?${key}=${encodeURIComponent(msg)}`);
++ const cookieOpts = () => ({ httpOnly: true, sameSite: 'lax', secure: config.cookies && config.cookies.secure !== false, path: '/git', maxAge: 10 * 60 * 1000 });
++
++ const wrap = (content) => `<div class="c4"><main class="c4-main">${content}</main></div>`;
++ const header = (title, desc) => `<div class="c4-title"><div><h1>${esc(title)}</h1><p>${esc(desc)}</p></div></div>`;
++ const flash = (req) => {
++ const ok = String(req.query.ok || '').slice(0, 160);
++ const error = String(req.query.error || '').slice(0, 240);
++ return `${ok ? `<div class="notice c4-notice" role="status">${esc(ok)}</div>` : ''}${error ? `<div class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</div>` : ''}`;
++ };
++ const page = (req, res, body) => ctx.chatShell(req, res, { title: 'OpenVibe Git', body: wrap(body), css: ['css/c4.css'], here: '/git' });
++
++ 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.');
++ if (!forge || !forge.enabled) {
++ return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Linking opens soon</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 OpenVibe Git trusts Codes as an app.</p></section>`);
++ }
++ const link = await forge.link(req.viewer.subject);
++ if (!link) {
++ return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Link your OpenVibe Git account</h2></div>
++<p class="c4-sub">One press: OpenVibe Git asks you to allow Codes, and you come straight back. Codes can then make repositories in your name, and your own computer can clone and push them, private ones too. You can unlink here, or revoke Codes in OpenVibe Git’s settings, at any time.</p>
++<p><a class="c4-button" href="/git/connect">Link OpenVibe Git</a></p></section>`);
++ }
++ const mine = await forge.repos(req.viewer.subject);
++ const list = mine.length
++ ? `<ul class="c4-rows">${mine.map((x) => `<li class="c4-row c4-row-flat"><span class="c4-row-label"><a href="${esc(x.html_url)}" rel="noopener">${esc(x.full_name)}</a></span><span class="c4-row-note">${x.private ? 'Private' : 'Public'}${x.description ? ` · ${esc(x.description.slice(0, 120))}` : ''}</span><a class="c4-opill" href="/?mode=code&amp;repo=${encodeURIComponent(x.html_url)}">Work on it</a></li>`).join('')}</ul>`
++ : '<p class="c4-emptyline">No repositories yet. Make your first one above.</p>';
++ return page(req, res, `${head}${flash(req)}
++<section class="c4-card"><div class="c4-card-head"><h2>A new repository</h2></div>
++<p class="c4-sub">It starts with a README on <code class="c4-code-inline">main</code>, in your name (${esc(link.forge_user)}), and opens straight in a new chat.</p>
++<form class="c4-form" method="post" action="/git/repos">
++<p><label for="git-name">Name</label><br><input id="git-name" name="name" required maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._\\-]{0,99}" autocomplete="off" spellcheck="false" placeholder="my-first-site"></p>
++<p><label for="git-desc">What it is <small class="c4-muted">(optional)</small></label><br><input id="git-desc" name="description" maxlength="255" placeholder="A site for my bakery"></p>
++<p><label class="c4-check"><input type="checkbox" name="private" value="1"> Private: only you can see it</label></p>
++<p><button class="c4-button" type="submit">Make it and start</button></p>
++</form></section>
++<section class="c4-card"><div class="c4-card-head"><h2>Your repositories</h2><a href="${esc(forge.publicUrl)}/${encodeURIComponent(link.forge_user)}" rel="noopener">On OpenVibe Git</a></div>${list}</section>
++<section class="c4-card"><div class="c4-card-head"><h2>The link</h2></div><p class="c4-sub">Linked as <b>${esc(link.forge_user)}</b>.</p>
++<form method="post" action="/git/unlink"><button class="c4-button c4-button-light" type="submit">Unlink</button></form></section>`);
++ });
++
++ r.get('/git/connect', (req, res, next) => {
++ if (!viewer(req)) return signIn(req, res);
++ if (!forge || !forge.enabled) return next();
++ const state = crypto.randomBytes(18).toString('base64url');
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ res.cookie(STATE_COOKIE, `${state}.${verifier}`, cookieOpts());
++ return res.redirect(303, forge.authorizeUrl({ state, challenge }));
++ });
++
++ r.get('/git/callback', async (req, res, next) => {
++ if (!viewer(req)) return signIn(req, res);
++ if (!forge || !forge.enabled) return next();
++ const kept = String((req.cookies && req.cookies[STATE_COOKIE]) || '');
++ res.clearCookie(STATE_COOKIE, { path: '/git' });
++ const [state, verifier] = kept.split('.');
++ if (req.query.error) return back(res, 'error', 'OpenVibe Git was not linked: you said no there, or it refused.');
++ if (!state || !verifier || String(req.query.state || '') !== state) return back(res, 'error', 'That link attempt expired or did not start here. Press Link again.');
++ try {
++ const out = await forge.connect(req.viewer.subject, { code: String(req.query.code || ''), verifier });
++ return back(res, 'ok', `Linked as ${out.forge_user}.`);
++ } catch (e) {
++ return back(res, 'error', e && e.status && e.status < 500 ? e.message : 'OpenVibe Git did not finish linking. Try again in a minute.');
++ }
++ });
++
++ r.post('/git/repos', form, async (req, res) => {
++ if (!viewer(req)) return signIn(req, res);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ if (!forge || !forge.enabled) return back(res, 'error', 'Linking OpenVibe Git is not open yet.');
++ const b = req.body || {};
++ try {
++ const made = await forge.createRepo(req.viewer.subject, { name: b.name, description: b.description, isPrivate: b.private === '1' });
++ return res.redirect(303, `/?mode=code&repo=${encodeURIComponent(made.html_url)}`);
++ } catch (e) {
++ return back(res, 'error', e && e.status && e.status < 500 ? e.message : 'OpenVibe Git did not make the repository. Try again in a minute.');
++ }
++ });
++
++ r.post('/git/unlink', form, async (req, res) => {
++ if (!viewer(req)) return signIn(req, res);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ if (forge) await forge.unlink(req.viewer.subject);
++ return back(res, 'ok', 'Unlinked. To take back Codes’ access on OpenVibe Git too, remove it under Settings → Applications there.');
++ });
++
++ return r;
++}
++
++module.exports = { createGitRoutes, STATE_COOKIE };
ddiff --git a/test/forge.test.js b/test/forge.test.js
nnew file mode 100644
iindex 0000000..f50b119
---- /dev/null
++++ b/test/forge.test.js
@@@ -0,0 +1,157 @@
++'use strict';
++/**
++ * OpenVibe Git through the person's own account (server/domain/forge.js, server/http/git.js; the owner, 2026-11-01:
++ * "make a repository through your OpenVibe account on our actual Git"). A stand-in forge answers the OAuth2 token
++ * exchange (checking the PKCE verifier against the challenge the page sent), who the person is, their repositories and
++ * a new one. Linking is off without the app and the seal key; the tokens are sealed; a repository on the forge takes
++ * the person's credential line to their machine, and the job keeps it from the agent.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { check, done, boot } = require('./helpers/boot');
++const { createSeal } = require('../server/domain/seal');
++const job = require('../harness/job');
++
++const SEAL_KEY = crypto.randomBytes(32).toString('base64');
++const TOKEN = 'forge-access-0123456789abcdef';
++
++function fakeForge() {
++ const st = { challenges: new Map(), repos: [], codes: new Map(), refreshes: 0 };
++ const srv = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ const u = new URL(req.url, 'http://x');
++ const send = (status, doc) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(doc)); };
++ const authed = req.headers.authorization === `Bearer ${TOKEN}`;
++ if (req.method === 'POST' && u.pathname === '/login/oauth/access_token') {
++ const f = new URLSearchParams(body);
++ if (f.get('client_id') !== 'codes-app' || f.get('client_secret') !== 'codes-app-secret') return send(401, { error: 'invalid_client' });
++ if (f.get('grant_type') === 'refresh_token') { st.refreshes++; return send(200, { access_token: TOKEN, refresh_token: 'r2', expires_in: 3600 }); }
++ const challenge = st.codes.get(f.get('code'));
++ const ok = challenge && crypto.createHash('sha256').update(String(f.get('code_verifier'))).digest('base64url') === challenge;
++ return ok ? send(200, { access_token: TOKEN, refresh_token: 'r1', expires_in: 3600 }) : send(400, { error: 'invalid_grant' });
++ }
++ if (!authed) return send(401, { message: 'token is required' });
++ if (req.method === 'GET' && u.pathname === '/api/v1/user') return send(200, { login: 'ada', id: 7 });
++ if (req.method === 'GET' && u.pathname === '/api/v1/user/repos') return send(200, st.repos);
++ if (req.method === 'POST' && u.pathname === '/api/v1/user/repos') {
++ const b = JSON.parse(body || '{}');
++ if (st.repos.some((r) => r.name === b.name)) return send(409, { message: 'exists' });
++ const r = { name: b.name, full_name: `ada/${b.name}`, html_url: `https://git.openvibe.codes/ada/${b.name}`, clone_url: `https://git.openvibe.codes/ada/${b.name}.git`, private: !!b.private, description: b.description, updated_at: new Date().toISOString(), auto_init: b.auto_init };
++ st.repos.push(r);
++ return send(201, r);
++ }
++ return send(404, { message: 'not found' });
++ });
++ });
++ return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve({ st, url: `http://127.0.0.1:${srv.address().port}`, close: () => new Promise((r) => srv.close(r)) })));
++}
++
++(async () => {
++ await check('a sealed token opens only with the same key for the same person', () => {
++ const seal = createSeal(SEAL_KEY);
++ const sealed = seal.close('secret-token', 'usr_A');
++ assert.ok(!sealed.includes('secret-token'));
++ assert.strictEqual(seal.open(sealed, 'usr_A'), 'secret-token');
++ assert.strictEqual(seal.open(sealed, 'usr_B'), null, 'moved to another person, it does not open');
++ assert.strictEqual(createSeal(crypto.randomBytes(32).toString('base64')).open(sealed, 'usr_A'), null, 'another key cannot open it');
++ assert.strictEqual(createSeal('short'), null, 'no key, no sealing');
++ });
++
++ await check('without the forge app and a seal key, linking says it opens soon and nothing else answers', async () => {
++ const t = await boot();
++ try {
++ const ada = t.network.addUser('ada-off');
++ const page = await t.get('/git', { as: ada });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /Linking opens soon/);
++ assert.strictEqual((await t.get('/git/connect', { as: ada })).status, 404);
++ } finally { await t.close(); }
++ });
++
++ const forge = await fakeForge();
++ const submits = [];
++ const run = {
++ enabled: true,
++ async submit(o) { submits.push(o); return { id: `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${String(submits.length).padStart(2, '0')}`, state: 'queued' }; },
++ async read(id) { return { id, state: 'queued' }; },
++ async cancel(id) { return { id, state: 'cancelled' }; },
++ async streamUrl() { throw new Error('not used'); },
++ };
++ const t = await boot({ run, env: { CODES_GIT_INTERNAL_URL: forge.url, CODES_FORGE_CLIENT_ID: 'codes-app', CODES_FORGE_CLIENT_SECRET: 'codes-app-secret', CODES_SEAL_KEY: SEAL_KEY } });
++ const db = t.ctx.store.db;
++ const ada = t.network.addUser('ada');
++ const cookieOf = (r) => String(r.headers.get('set-cookie') || '').split(';')[0];
++ try {
++ await check('linking: the forge’s consent with PKCE, back with a code, the tokens sealed', async () => {
++ const first = await t.get('/git', { as: ada });
++ assert.match(first.text, /<a class="c4-button" href="\/git\/connect">Link OpenVibe Git<\/a>/);
++ const go = await t.get('/git/connect', { as: ada });
++ assert.strictEqual(go.status, 303);
++ const to = new URL(go.headers.get('location'));
++ assert.strictEqual(to.origin + to.pathname, 'https://git.openvibe.codes/login/oauth/authorize');
++ assert.strictEqual(to.searchParams.get('code_challenge_method'), 'S256');
++ assert.strictEqual(to.searchParams.get('redirect_uri'), 'https://openvibe.codes/git/callback');
++ const cookie = cookieOf(go);
++ assert.match(cookie, /^codes_forge_oauth=/);
++ assert.match(String(go.headers.get('set-cookie')), /HttpOnly/i);
++ forge.st.codes.set('code-1', to.searchParams.get('code_challenge'));
++ const wrong = await t.get(`/git/callback?code=code-1&state=nope`, { as: ada, headers: { cookie: `${t.signIn(ada)}; ${cookie}` } });
++ assert.match(decodeURIComponent(wrong.headers.get('location')), /did not start here/, 'a state that is not ours is refused');
++ const back = await t.get(`/git/callback?code=code-1&state=${to.searchParams.get('state')}`, { as: ada, headers: { cookie: `${t.signIn(ada)}; ${cookie}` } });
++ assert.strictEqual(back.status, 303);
++ assert.match(decodeURIComponent(back.headers.get('location')), /ok=Linked as ada/);
++ const row = await db.maybe('SELECT forge_user, token_sealed, refresh_sealed FROM codes_forge_links WHERE subject = $1', [ada.subject]);
++ assert.strictEqual(row.forge_user, 'ada');
++ assert.ok(!row.token_sealed.includes(TOKEN) && row.token_sealed.startsWith('v1.'), 'sealed, never plain');
++ assert.ok(!(await t.dbDump()).includes(TOKEN), 'the token is nowhere in the database in the clear');
++ });
++
++ await check('a new repository in the person’s name opens a new chat on it; a taken or bad name is said in words', async () => {
++ const made = await t.get('/git/repos', { as: ada, form: { name: 'my-site', description: 'A site for my bakery', private: '1' } });
++ assert.strictEqual(made.status, 303);
++ assert.strictEqual(made.headers.get('location'), `/?mode=code&repo=${encodeURIComponent('https://git.openvibe.codes/ada/my-site')}`);
++ assert.ok(forge.st.repos[0].private && forge.st.repos[0].auto_init, 'private, with a README on main');
++ const again = await t.get('/git/repos', { as: ada, form: { name: 'my-site' } });
++ assert.match(decodeURIComponent(again.headers.get('location')), /already have a repository called my-site/);
++ const bad = await t.get('/git/repos', { as: ada, form: { name: '../etc' } });
++ assert.match(decodeURIComponent(bad.headers.get('location')), /A name is letters/);
++ const page = await t.get('/git', { as: ada });
++ assert.match(page.text, /ada\/my-site<\/a>[\s\S]*?Private[\s\S]*?href="\/\?mode=code&amp;repo=https%3A%2F%2Fgit\.openvibe\.codes%2Fada%2Fmy-site">Work on it/);
++ assert.ok(!page.text.includes(TOKEN), 'no token on a page');
++ });
++
++ await check('a task on a forge repository takes the credential line to the machine; any other repository does not', async () => {
++ const start = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://git.openvibe.codes/ada/my-site', text: 'Add a menu page' } });
++ assert.match(start.headers.get('location'), /^\/tasks\/tsk_/, start.headers.get('location'));
++ const sent = submits[submits.length - 1];
++ assert.strictEqual(sent.args.env.OPENVIBE_GIT_CREDENTIALS, `https://ada:${TOKEN}@git.openvibe.codes`);
++ await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://github.com/ada/elsewhere', text: 'Fix it' } });
++ const other = submits[submits.length - 1];
++ assert.ok(!(other.args.env && other.args.env.OPENVIBE_GIT_CREDENTIALS), 'no forge line for a GitHub repository');
++ assert.ok(job.ENV_NAMES.has('OPENVIBE_GIT_CREDENTIALS'), 'the job accepts it');
++ const header = job.gitAuthFor('https://git.openvibe.codes/ada/my-site', { OPENVIBE_GIT_CREDENTIALS: sent.args.env.OPENVIBE_GIT_CREDENTIALS });
++ assert.strictEqual(header.env.GIT_CONFIG_KEY_0, 'http.https://git.openvibe.codes/.extraheader', 'the machine clones and pushes with it');
++ assert.ok(job.giteaHosts({}).has('git.openvibe.codes'), 'a push to OpenVibe Git opens its pull request there');
++ });
++
++ await check('the composer offers a new repository on OpenVibe Git, and the rail has its place', async () => {
++ const home = await t.get('/', { as: ada });
++ assert.match(home.text, /<a class="ch-chip ch-chip-new" href="\/git">[\s\S]*?New on OpenVibe Git<\/a>/);
++ assert.match(home.text, /href="\/git"[^>]*>[\s\S]{0,400}?OpenVibe Git/);
++ });
++
++ await check('unlinking forgets the tokens', async () => {
++ const r = await t.get('/git/unlink', { as: ada, form: { confirm: '1' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(await db.maybe('SELECT 1 FROM codes_forge_links WHERE subject = $1', [ada.subject]), null);
++ assert.match((await t.get('/git', { as: ada })).text, /Link OpenVibe Git/);
++ });
++ } finally {
++ await t.close();
++ await forge.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });