openvibers/openvibe.codes Commits
OpenVibe Git through a service token, not Forgejo (#343)
Changes
@@@ -8,7 +8,7 @@**License:** AGPL-3.0 (same as every OpenVibe service).--**OpenVibe Git** (since 2026-10-15): Forgejo at [git.openvibe.codes](https://git.openvibe.codes/) (its first address, openvibe.codes/git, redirects there), signed in with your OpenVibe account, with read mirrors of the OpenVibers GitHub repositories. Install and operations: [deploy/forgejo](deploy/forgejo/README.md).++**OpenVibe Git** (since 2026-10-15): [git.openvibe.codes](https://git.openvibe.codes/), OpenVibe's own code home (OpenVibers/OpenVibe-Git), signed in with your OpenVibe account, with read mirrors of the OpenVibers GitHub repositories. Codes makes repositories there and pushes a task's branch with its own service token, acting for the person signed in.## PurposeOne place to send a coding task to the agent that fits it — by what the task needs first, then by cost — hand it over when one agent gets stuck, and keep the run in one place; and the front door for anyone who wants to improve OpenVibe itself. The agents run on people's own machines or OpenVibe's; Codes routes, records and hands off, and never holds a person's provider keys in the clear.ddeleted file mode 100644@@@ -1,41 +0,0 @@--# OpenVibe Git (Forgejo)----The OpenVibe projects' own Git host, at **https://git.openvibe.codes/** (since 2026-10-21; it began at--`openvibe.codes/git/` on 2026-10-15, and every old address redirects to the same path on the new host). It runs Forgejo,--which is lighter than GitLab on the 15 GB host.----- **Sign-in:** people sign in with their OpenVibe account through OpenVibe.Network's OpenID Connect (the OAuth client-- `forgejo`, which Network seeds). There is no local sign-up; the first sign-in creates the account with the OpenVibe-- username.--- **Mirrors:** the `OpenVibers` organisation holds read mirrors of the public GitHub repositories, pulled hourly.-- GitHub stays canonical until a repository is moved here, one at a time. Then the direction flips to a push mirror-- to GitHub.--- **Cloning:** HTTPS only. `git.openvibe.codes` is behind Cloudflare's proxy, which carries no SSH.--- **Off:** mail, Actions (CI) and federation are off for now; turn them on in `app.ini` when they have a job.----## Files----| File | What it does |--|---|---|--| `install.sh` | Binary (sha256-checked), the `forgejo` user, the PostgreSQL role and the `ov_forgejo` database, `/etc/forgejo/app.ini` with secrets generated on the host, and the systemd unit. Idempotent; `FORGEJO_VERSION=x.y.z` upgrades the binary. |--| `app.ini.template` | The config; `@…@` values are filled in by `install.sh`. |--| `custom/` | The OpenVibe look: `theme-openvibe.css`, Forgejo's dark theme in the network's colours, which is the default theme. |--| `openvibe-forgejo.service` | The unit: `127.0.0.1:5060`, hardened, 1 GB of memory at most. |--| `setup-sso.sh` | The site admin (a random password in `/root/forgejo-admin.txt`) and the OpenVibe sign-in. The client secret is read from Network's database. |--| `mirror-github.sh [file]` | The `OpenVibers` organisation and one pull mirror per public repository: names from `gh`, or one per line in `file`. |----nginx: `deploy/nginx/openvibe.codes.conf` serves `git.openvibe.codes` from `127.0.0.1:5060`, streamed, with a 512 MB--limit, and answers `openvibe.codes/git/…` with a permanent redirect to the same path there.----## Install (on the host, from the Codes checkout)----```bash--sudo bash deploy/forgejo/install.sh--sudo ov-vhost-install deploy/nginx/openvibe.codes.conf openvibe.codes.conf--sudo bash deploy/forgejo/setup-sso.sh # after Network seeds the forgejo client--gh repo list OpenVibers --visibility public --limit 200 --json name -q '.[].name' > /tmp/repos.txt # where gh is signed in--sudo bash deploy/forgejo/mirror-github.sh /tmp/repos.txt--```----Backups: `ov_forgejo` is a PostgreSQL database like the others (add it to the host inventory's databases), and the--repositories live in `/var/lib/forgejo/repositories`.ddeleted file mode 100644@@@ -1,112 +0,0 @@--; OpenVibe Git: Forgejo at https://git.openvibe.codes/ (deploy/forgejo/install.sh writes /etc/forgejo/app.ini from this).--; @VALUES@ are filled in on the host; the secrets are generated there and never leave it.--APP_NAME = OpenVibe Git--RUN_USER = forgejo--RUN_MODE = prod--WORK_PATH = /var/lib/forgejo----[server]--PROTOCOL = http--HTTP_ADDR = 127.0.0.1--HTTP_PORT = 5060--DOMAIN = git.openvibe.codes--ROOT_URL = https://git.openvibe.codes/--; Cloning and pushing are over HTTPS only: git.openvibe.codes is behind Cloudflare's proxy, which carries no SSH.--DISABLE_SSH = true--START_SSH_SERVER = false--LANDING_PAGE = explore--OFFLINE_MODE = true--LFS_START_SERVER = false--APP_DATA_PATH = /var/lib/forgejo/data----[database]--DB_TYPE = postgres--HOST = 127.0.0.1:5432--NAME = ov_forgejo--USER = forgejo--PASSWD = @DB_PASSWORD@--SSL_MODE = disable--MAX_OPEN_CONNS = 10--MAX_IDLE_CONNS = 4----[repository]--ROOT = /var/lib/forgejo/repositories--DEFAULT_BRANCH = main--DEFAULT_PRIVATE = public----[service]--; People join by signing in with their OpenVibe account (the OpenVibe authentication source); there is no local sign-up.--DISABLE_REGISTRATION = false--ALLOW_ONLY_EXTERNAL_REGISTRATION = true--SHOW_REGISTRATION_BUTTON = false--REQUIRE_SIGNIN_VIEW = false--DEFAULT_KEEP_EMAIL_PRIVATE = true--DEFAULT_ALLOW_CREATE_ORGANIZATION = false--ENABLE_NOTIFY_MAIL = false--NO_REPLY_ADDRESS = noreply.openvibe.codes----[oauth2_client]--ENABLE_AUTO_REGISTRATION = true--; goth's OpenID Connect provider puts the preferred_username claim (the OpenVibe username) in the nickname.--USERNAME = nickname--UPDATE_AVATAR = true--ACCOUNT_LINKING = login--OPENID_CONNECT_SCOPES = openid profile email----[openid]--ENABLE_OPENID_SIGNIN = false--ENABLE_OPENID_SIGNUP = false----[oauth2]--; Forgejo as an OAuth provider is not needed: OpenVibe.Network is the identity provider.--ENABLED = false----[security]--INSTALL_LOCK = true--SECRET_KEY = @SECRET_KEY@--INTERNAL_TOKEN = @INTERNAL_TOKEN@--PASSWORD_HASH_ALGO = argon2--MIN_PASSWORD_LENGTH = 16----[session]--PROVIDER = db--COOKIE_SECURE = true--COOKIE_NAME = ov_git_session----[mailer]--ENABLED = false----[picture]--DISABLE_GRAVATAR = true--ENABLE_FEDERATED_AVATAR = false----[ui]--; openvibe: Forgejo's dark theme in OpenVibe's colours (custom/public/assets/css/theme-openvibe.css).--DEFAULT_THEME = openvibe--THEMES = openvibe,forgejo-auto,forgejo-light,forgejo-dark----[ui.meta]--AUTHOR = OpenVibers--DESCRIPTION = OpenVibe Git: the OpenVibe projects' own source, with your OpenVibe account.----[mirror]--ENABLED = true--DEFAULT_INTERVAL = 8h--MIN_INTERVAL = 10m----[migrations]--ALLOWED_DOMAINS = github.com,*.github.com,codeberg.org--ALLOW_LOCALNETWORKS = false----[actions]--ENABLED = false----[federation]--ENABLED = false----[log]--MODE = console--LEVEL = Info----[cron.update_mirrors]--SCHEDULE = @every 1hddeleted file mode 100644@@@ -1,44 +0,0 @@--/* OpenVibe Git: Forgejo's dark theme in OpenVibe's colours (the network's default "vibe" theme: deep navy, blue accent).-- Installed by deploy/forgejo/install.sh into /var/lib/forgejo/custom/public/assets/css/; app.ini makes it the default. */--@import url("./theme-forgejo-dark.css");----:root {-- --is-dark-theme: true;-- --color-body: #0b1220;-- --color-box-body: #131c2e;-- --color-box-body-highlight: #182338;-- --color-box-header: #111a2b;-- --color-nav-bg: #0f1729;-- --color-nav-hover-bg: #182338;-- --color-header-wrapper: #0f1729;-- --color-footer: #0f1729;-- --color-menu: #131c2e;-- --color-card: #131c2e;-- --color-input-background: #0f1729;-- --color-input-border: #2c3d5c;-- --color-input-border-hover: #3b82f6;-- --color-secondary: #1f2d47;-- --color-secondary-dark-1: #2c3d5c;-- --color-secondary-dark-2: #3a4e72;-- --color-light-border: #1f2d47;-- --color-text: #e6edf7;-- --color-text-light: #b7c4d9;-- --color-text-light-1: #96a7c2;-- --color-text-light-2: #7d8ea9;-- --color-text-light-3: #66768f;-- --color-primary: #3b82f6;-- --color-primary-contrast: #ffffff;-- --color-primary-dark-1: #2f74e8;-- --color-primary-dark-2: #2563eb;-- --color-primary-dark-3: #1d4ed8;-- --color-primary-light-1: #5b97f7;-- --color-primary-light-2: #7aabf8;-- --color-primary-light-3: #9abffa;-- --color-primary-alpha-10: #3b82f61a;-- --color-primary-alpha-20: #3b82f633;-- --color-primary-alpha-30: #3b82f64d;-- --color-primary-alpha-40: #3b82f666;-- --color-primary-alpha-50: #3b82f680;-- --color-accent: #3b82f6;-- --color-active-line: #3b82f622;--}ddeleted file mode 100644@@@ -1,67 +0,0 @@--#!/bin/bash--# OpenVibe Git (Forgejo) on the host: the binary (checked against the release's sha256), its user and directories, a--# PostgreSQL role and database, /etc/forgejo/app.ini from app.ini.template with secrets generated here, and the--# systemd unit. Idempotent: a second run upgrades the binary to FORGEJO_VERSION and leaves the config and data alone.--#--# sudo bash deploy/forgejo/install.sh (from the Codes checkout on the host)--#--# Prints no secret. The database password and Forgejo's own keys exist only in /etc/forgejo/app.ini (root:forgejo 0660).--set -euo pipefail--VERSION="${FORGEJO_VERSION:-16.0.6}"--KIT="$(cd "$(dirname "$0")" && pwd)"--LIB=/usr/local/lib/forgejo--URL="https://codeberg.org/forgejo/forgejo/releases/download/v${VERSION}/forgejo-${VERSION}-linux-amd64"--[ "$(id -u)" = 0 ] || { echo "install.sh: run as root" >&2; exit 1; }----# 1. The binary, verified.--mkdir -p "$LIB"--if [ ! -x "$LIB/forgejo-$VERSION" ]; then-- tmp=$(mktemp -d)-- curl -fsSL -o "$tmp/forgejo" "$URL"-- curl -fsSL -o "$tmp/forgejo.sha256" "$URL.sha256"-- want=$(cut -d' ' -f1 "$tmp/forgejo.sha256")-- have=$(sha256sum "$tmp/forgejo" | cut -d' ' -f1)-- [ -n "$want" ] && [ "$want" = "$have" ] || { echo "install.sh: sha256 mismatch for forgejo $VERSION" >&2; rm -rf "$tmp"; exit 1; }-- install -m 0755 "$tmp/forgejo" "$LIB/forgejo-$VERSION"-- rm -rf "$tmp"-- echo "forgejo $VERSION installed (sha256 verified)"--fi--ln -sfn "$LIB/forgejo-$VERSION" /usr/local/bin/forgejo----# 2. Its user and directories.--id forgejo >/dev/null 2>&1 || adduser --system --group --home /var/lib/forgejo --shell /bin/bash --disabled-password --gecos 'OpenVibe Git' forgejo--install -d -o forgejo -g forgejo -m 0750 /var/lib/forgejo /var/lib/forgejo/custom /var/lib/forgejo/data /var/lib/forgejo/log /var/lib/forgejo/repositories--install -d -o root -g forgejo -m 0770 /etc/forgejo----# 3 and 4. The database and the config, once.--if [ ! -f /etc/forgejo/app.ini ]; then-- pw=$(openssl rand -hex 24)-- printf "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'forgejo') THEN CREATE ROLE forgejo LOGIN PASSWORD '%s'; ELSE ALTER ROLE forgejo LOGIN PASSWORD '%s'; END IF; END \$\$;\n" "$pw" "$pw" \-- | sudo -u postgres psql -q -v ON_ERROR_STOP=1 >/dev/null-- if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'ov_forgejo'" | grep -q 1; then-- sudo -u postgres createdb -O forgejo -E UTF8 -T template0 ov_forgejo-- fi-- key=$(/usr/local/bin/forgejo generate secret SECRET_KEY)-- tok=$(/usr/local/bin/forgejo generate secret INTERNAL_TOKEN)-- umask 0007-- sed -e "s|@DB_PASSWORD@|$pw|" -e "s|@SECRET_KEY@|$key|" -e "s|@INTERNAL_TOKEN@|$tok|" "$KIT/app.ini.template" > /etc/forgejo/app.ini-- chown root:forgejo /etc/forgejo/app.ini-- chmod 0660 /etc/forgejo/app.ini-- echo "config written to /etc/forgejo/app.ini (database ov_forgejo, role forgejo)"--fi----# The OpenVibe look (custom/: the openvibe theme), refreshed on every run. app.ini names it; an install from before the--# theme gets the two [ui] lines updated in place.--cp -r "$KIT/custom/." /var/lib/forgejo/custom/--chown -R forgejo:forgejo /var/lib/forgejo/custom--if ! grep -q '^DEFAULT_THEME = openvibe' /etc/forgejo/app.ini; then-- sed -i -e 's/^DEFAULT_THEME = .*/DEFAULT_THEME = openvibe/' -e 's/^THEMES = .*/THEMES = openvibe,forgejo-auto,forgejo-light,forgejo-dark/' /etc/forgejo/app.ini--fi----# 5. The service.--install -m 0644 "$KIT/openvibe-forgejo.service" /etc/systemd/system/openvibe-forgejo.service--systemctl daemon-reload--systemctl enable --now openvibe-forgejo.service >/dev/null--systemctl restart openvibe-forgejo.service--for _ in $(seq 1 30); do curl -fsS -o /dev/null http://127.0.0.1:5060/api/healthz && break; read -rt 1 < <(tail -f /dev/null) || true; done--curl -fsS http://127.0.0.1:5060/api/healthz >/dev/null && echo "openvibe-forgejo is up on 127.0.0.1:5060" || { echo "install.sh: forgejo did not come up; journalctl -u openvibe-forgejo" >&2; exit 1; }ddeleted file mode 100644@@@ -1,28 +0,0 @@--#!/bin/bash--# Read mirrors of OpenVibers' public GitHub repositories on OpenVibe Git, in the OpenVibers organisation: Forgejo pulls--# each one every MIN_INTERVAL/DEFAULT_INTERVAL (app.ini [mirror]). GitHub stays canonical until a repository is moved--# here (plan: one repository at a time); then the direction flips to a push mirror. Uses a short-lived site-admin API--# token it creates and deletes. Idempotent: a repository already mirrored is skipped.--#--# sudo bash deploy/forgejo/mirror-github.sh (needs `gh` signed in as someone who can list OpenVibers)--set -euo pipefail--[ "$(id -u)" = 0 ] || { echo "mirror-github.sh: run as root" >&2; exit 1; }--FJ=(sudo -u forgejo /usr/local/bin/forgejo --config /etc/forgejo/app.ini --work-path /var/lib/forgejo)--API=http://127.0.0.1:5060/api/v1--REPOS_FILE="${1:-}"--name="mirror-$(date +%s)"--token=$("${FJ[@]}" admin user generate-access-token --username openvibe-admin --token-name "$name" --scopes write:organization,write:repository --raw)--# A token cannot delete itself through the API (403): remove it from Forgejo's database when the script ends.--trap 'sudo -u postgres psql -d ov_forgejo -qtAc "DELETE FROM access_token WHERE name = '"'"'$name'"'"'" >/dev/null || true' EXIT--call() { curl -fsS -H "Authorization: token $token" -H 'Content-Type: application/json' "$@"; }--call -o /dev/null "$API/orgs/OpenVibers" 2>/dev/null || call -o /dev/null -X POST "$API/orgs" -d '{"username":"OpenVibers","full_name":"OpenVibers","website":"https://openvibe.network","visibility":"public","description":"The OpenVibe projects."}'--if [ -n "$REPOS_FILE" ]; then list=$(cat "$REPOS_FILE"); else list=$(gh repo list OpenVibers --visibility public --limit 200 --json name -q '.[].name'); fi--made=0; kept=0--while read -r repo; do-- [ -n "$repo" ] || continue-- # Forgejo reserves names ending in .wiki (a repository's wiki): OpenVibe.Wiki is mirrored as OpenVibe-Wiki.-- local_name="$repo"; case "$repo" in *.wiki|*.Wiki) local_name="${repo%.*}-${repo##*.}" ;; esac-- if call -o /dev/null "$API/repos/OpenVibers/$local_name" 2>/dev/null; then kept=$((kept + 1)); continue; fi-- call -o /dev/null -X POST "$API/repos/migrate" -d "{\"clone_addr\":\"https://github.com/OpenVibers/$repo.git\",\"repo_owner\":\"OpenVibers\",\"repo_name\":\"$local_name\",\"mirror\":true,\"mirror_interval\":\"1h\",\"service\":\"git\",\"description\":\"Mirror of github.com/OpenVibers/$repo\"}" && made=$((made + 1)) || echo "mirror of $repo failed" >&2--done <<< "$list"--echo "mirrors: $made created, $kept already there"ddeleted file mode 100644@@@ -1,23 +0,0 @@--[Unit]--Description=OpenVibe Git (Forgejo) at https://git.openvibe.codes/--After=network.target postgresql.service--Wants=postgresql.service----[Service]--Type=simple--User=forgejo--Group=forgejo--WorkingDirectory=/var/lib/forgejo--ExecStart=/usr/local/bin/forgejo web --config /etc/forgejo/app.ini --work-path /var/lib/forgejo--Restart=always--RestartSec=3--Environment=USER=forgejo HOME=/var/lib/forgejo GITEA_WORK_DIR=/var/lib/forgejo--NoNewPrivileges=true--PrivateTmp=true--ProtectSystem=full--ProtectHome=true--ReadWritePaths=/var/lib/forgejo /etc/forgejo--MemoryMax=1G----[Install]--WantedBy=multi-user.targetddeleted file mode 100644@@@ -1,32 +0,0 @@--#!/bin/bash--# After install.sh: the site admin (a random password, written to /root/forgejo-admin.txt, never printed) and the--# "OpenVibe" sign-in: an OpenID Connect source on OpenVibe.Network's discovery document, with the OAuth client--# `forgejo` that Network seeds (OpenVibe.Network server/db/database.js). Its secret is read from Network's database--# here and handed to Forgejo directly. Idempotent: an existing admin or source is updated, not duplicated.--#--# sudo bash deploy/forgejo/setup-sso.sh--set -euo pipefail--[ "$(id -u)" = 0 ] || { echo "setup-sso.sh: run as root" >&2; exit 1; }--FJ=(sudo -u forgejo /usr/local/bin/forgejo --config /etc/forgejo/app.ini --work-path /var/lib/forgejo)--NETWORK_DB="${NETWORK_DB:-ov_network}"----if ! "${FJ[@]}" admin user list --admin 2>/dev/null | awk 'NR > 1 { print $2 }' | grep -qx openvibe-admin; then-- umask 0077-- "${FJ[@]}" admin user create --admin --username openvibe-admin --email [email protected] --random-password --must-change-password=false > /root/forgejo-admin.txt-- echo "site admin openvibe-admin created (password in /root/forgejo-admin.txt)"--fi----secret=$(sudo -u postgres psql -d "$NETWORK_DB" -tAc "SELECT client_secret FROM oauth_clients WHERE client_id = 'forgejo'")--[ -n "$secret" ] || { echo "setup-sso.sh: Network has no OAuth client 'forgejo' yet (deploy Network first)" >&2; exit 1; }--args=(--name OpenVibe --provider openidConnect --key forgejo --secret "$secret"-- --auto-discover-url https://openvibe.network/.well-known/openid-configuration-- --scopes openid --scopes profile --scopes email --skip-local-2fa-- --icon-url https://openvibe.network/favicon.ico)--id=$("${FJ[@]}" admin auth list 2>/dev/null | awk '$2 == "OpenVibe" { print $1 }')--if [ -n "$id" ]; then-- "${FJ[@]}" admin auth update-oauth --id "$id" "${args[@]}" >/dev/null-- echo "OpenVibe sign-in updated (source $id)"--else-- "${FJ[@]}" admin auth add-oauth "${args[@]}" >/dev/null-- echo "OpenVibe sign-in added"--fi@@@ -23,15 +23,6 @@ server {location / { return 301 https://openvibe.codes$request_uri; }}--server {-- listen 80;-- listen [::]:80;-- server_name git.openvibe.codes;---- location /.well-known/acme-challenge/ { root /var/www/certbot; }-- location / { return 301 https://git.openvibe.codes$request_uri; }--}--server {listen 443 ssl;listen [::]:443 ssl;@@@ -138,47 +129,5 @@ server {}}--# ── OpenVibe Git: Forgejo (deploy/forgejo) at https://git.openvibe.codes/ (its ROOT_URL) ──────────────--# The openvibe.codes wildcard certificate covers the name; Cloudflare proxies it (HTTPS only, no SSH).--# Pushes and clones are streamed (no buffering), up to 512 MB a request (Cloudflare's own limit is lower).--server {-- listen 443 ssl;-- listen [::]:443 ssl;-- http2 on;-- server_name git.openvibe.codes;-- include snippets/security-txt.conf; # /.well-known/security.txt (the host's file)---- ssl_certificate /etc/letsencrypt/live/openvibe.codes/fullchain.pem;-- ssl_certificate_key /etc/letsencrypt/live/openvibe.codes/privkey.pem;-- ssl_protocols TLSv1.2 TLSv1.3;---- access_log /var/log/nginx/git.openvibe.codes.access.log;-- error_log /var/log/nginx/git.openvibe.codes.error.log;---- add_header X-Content-Type-Options nosniff always;-- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;---- # Forgejo's own Prometheus endpoint stays off the internet (app.ini leaves [metrics] at its default, off).-- location ~* ^/metrics(/|$) { return 404; }---- # Meta's AI crawler (meta-externalagent) walked every commit of every repository past robots.txt's wildcard rules:-- # 97% of this site's requests, about two a second, and 8% of a core in Forgejo (2026-11-02). robots.txt now names-- # it (Disallow: /, in Forgejo's custom/public/robots.txt); until it reads that, it gets robots.txt and nothing else.-- set $meta_crawler 0;-- if ($http_user_agent ~* "meta-externalagent") { set $meta_crawler 1; }-- if ($uri = /robots.txt) { set $meta_crawler 0; }-- if ($meta_crawler) { return 403; }---- location / {-- client_max_body_size 512m;-- proxy_request_buffering off;-- proxy_buffering off;-- proxy_read_timeout 600s;-- proxy_send_timeout 600s;-- proxy_set_header Host $host;-- proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $remote_addr;-- proxy_set_header X-Forwarded-Proto https;-- proxy_pass http://127.0.0.1:5060;-- }--}++# ── OpenVibe Git (git.openvibe.codes) is its own service since 2026-11-09: OpenVibers/OpenVibe-Git ships its vhost++# (deploy/nginx/git.openvibe.codes.conf, 127.0.0.1:5270). Forgejo is retired; /git here still redirects there.@@@ -133,9 +133,10 @@ 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',++ // A repository on OpenVibe Git: the person's credential line for its host. The local root lets a session on the++ // same host clone from the disk (G3) instead of the network. For git only: bin/openvibe-codes.js keeps them out++ // of the agent's environment.++ 'OPENVIBE_GIT_CREDENTIALS', 'OPENVIBE_GIT_LOCAL_ROOT',]);/** The environment a job's input asks for: the allowlisted names only, each a short string. */@@@ -538,7 +539,9 @@ function attachmentPrompt(files) {* 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.++// The forges that answer Gitea's API (Gitea and its descendants): any the machine names in OPENVIBE_GITEA_HOSTS.++// OpenVibe Git itself is ours now (git.openvibe.codes): its push works with the person's Git token, and opening a++// change request there arrives with G2, so the request below answers until then.function giteaHosts(env = process.env) {return new Set(['git.openvibe.codes', ...String(env.OPENVIBE_GITEA_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);}@@@ -567,7 +570,10 @@ async function pushForge(dir, job, { env, fetchImpl, base }) {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)}` };++ const own = host === 'git.openvibe.codes';++ const out = { pushed: true, branch, branch_url: own++ ? `https://${host}/${owner}/${name}/tree?ref=${encodeURIComponent(branch)}`++ : `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',@@@ -637,7 +643,30 @@ function agentPrompt(job) {}/**-- * Run one job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.++ * The bare repository on this machine, when the job is for OpenVibe Git and its disk is here (G3: a Codes session++ * on the same host as git.openvibe.codes clones from the disk, no network). The HTTPS address stays the origin, so++ * the branch is pushed back over HTTPS with the person's token.++ */++const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;++const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;++function localGitSource(repoUrl, env = process.env) {++ const root = String(env.OPENVIBE_GIT_LOCAL_ROOT || '').trim();++ if (!root) return null;++ let u;++ try { u = new URL(String(repoUrl)); } catch { return null; }++ if (u.protocol !== 'https:') return null;++ const line = String(env.OPENVIBE_GIT_CREDENTIALS || '').split(/[\n,]+/).map((l) => l.trim()).filter(Boolean)[0];++ let host = null;++ try { host = line ? new URL(line).host.toLowerCase() : null; } catch { host = null; }++ if (!host || u.host.toLowerCase() !== host) return null;++ const parts = u.pathname.replace(/\.git$/i, '').split('/').filter(Boolean);++ if (parts.length !== 2 || !OWNER_RE.test(parts[0]) || !NAME_RE.test(parts[1])) return null;++ const bare = path.join(root, 'repos', parts[0], `${parts[1]}.git`);++ try { return fs.statSync(bare).isDirectory() ? bare : null; } catch { return null; }++}++++/**++ * One job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.* → the result document.*/async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch, proof = null }) {@@@ -662,8 +691,13 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmif (job.kind === 'code') {say({ t: 'step', text: `Cloning ${scrub(job.repo)}${job.ref ? ` (${job.ref})` : ''}` });const auth = gitAuthFor(job.repo, env);++ const local = localGitSource(job.repo, env);try {-- await git(['clone', '--depth', '50', ...(job.ref ? ['--branch', job.ref] : []), '--', job.repo, work], { timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });++ await git(['clone', '--depth', '50', ...(job.ref ? ['--branch', job.ref] : []), '--', local || job.repo, work], { timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });++ if (local) {++ await git(['remote', 'set-url', 'origin', job.repo], { cwd: work });++ say({ t: 'step', text: 'Cloned from the disk on this host; the branch is pushed over HTTPS' });++ }} catch (e) { throw new Error(cloneProblem(e.message, job.repo)); }if (auth.env) say({ t: 'step', text: 'Cloned with this machine\'s token for a private repository' });base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();@@@ -819,4 +853,4 @@ function resultWriter(emit) {};}--module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, attachmentPrompt, agentPrompt, jobAttachments };++module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, localGitSource, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, attachmentPrompt, agentPrompt, jobAttachments };@@@ -57,7 +57,6 @@ const { createKnowledgeApi } = require('./http/knowledge-api');const { createMovedRoutes } = require('./http/moved');const { createCodesReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');--const { createForgejoClient } = require('./domain/forgejo');const createAiCatalogConfig = require('./ai-catalog-config').create;const createOwnerInputs = require('./owner-inputs-config').create;const { createOwnerInputTests } = require('./owner-inputs-tests');@@@ -93,17 +92,14 @@ async function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });// D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.// The owner's outside accounts (codes.accounts, codes.publishing; the owner-setup design): sealed in Codes' store,-- // read at use time with the environment as the fallback. config.git's app and config.github's token become getters,-- // so a value entered in the admin panel takes effect without a restart.++ // read at use time with the environment as the fallback. config.github's token becomes a getter, so a value++ // entered in the admin panel takes effect without a restart.const ownerInputs = await createOwnerInputs({ db: store.db, env: opts.env || process.env, log });-- const envGit = { clientId: config.git.clientId, clientSecret: config.git.clientSecret };const envGithubToken = config.github ? config.github.token : '';const fromPanel = (key, fallback) => { const v = ownerInputs.accounts.get(key); return typeof v === 'string' && v ? v : (fallback || ''); };-- Object.defineProperty(config.git, 'clientId', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_id', envGit.clientId) });-- Object.defineProperty(config.git, 'clientSecret', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_secret', envGit.clientSecret) });config.github = config.github || {};Object.defineProperty(config.github, 'token', { enumerable: true, configurable: true, get: () => fromPanel('github.token', envGithubToken) });-- const findTest = createOwnerInputTests({ config, ownerInputs, fetchImpl: opts.fetchImpl || globalThis.fetch });++ const findTest = createOwnerInputTests({ ownerInputs, fetchImpl: opts.fetchImpl || globalThis.fetch });const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log, also: ownerInputs.stores, test: findTest });Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });@@@ -143,15 +139,14 @@ async function createApp(opts = {}) {const skills = opts.skills || createSkills({ store, sharing, log });const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };-- ctx.forgejo = createForgejoClient({ config, log });// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });// 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 });-- // 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 });++ // 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 });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@@@ -48,17 +48,15 @@ function load(env = process.env) {// OpenVibe.Report (the session report of a finished task, Agent Streams S3).reportInternalUrl: trim(env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080'),networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),-- // OpenVibe Git (Forgejo, deploy/forgejo): its loopback address for the home page's repository list (public-- // repositories only, no token), and the public address people use.++ // OpenVibe Git (git.openvibe.codes): its loopback address for the home page's repository list and the API++ // calls, and the public address people use. Codes acts for a person with its own Network service token++ // (audience openvibe.git), so there is no app secret of its own here.git: {-- internalUrl: trim(env.CODES_GIT_INTERNAL_URL || 'http://127.0.0.1:5060'),++ internalUrl: trim(env.CODES_GIT_INTERNAL_URL || 'http://127.0.0.1:5270'),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 || '',++ // G3: the directory OpenVibe Git keeps its bare repositories in, when Codes runs on the same host. A++ // session then clones from the disk instead of the network; empty anywhere else.++ dataDir: trim(env.CODES_GIT_DATA_DIR || ''),},// OpenVibe.Live (Agent Streams S1, Dock K-D3): the person's own agent streams, read and steered with their own// token for the Dock's agent view (server/http/agent-view.js). Codes holds no token of its own here.@@@ -1,168 +1,146 @@'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.++ * A person's OpenVibe Git (git.openvibe.codes) through Codes' service token. Codes holds a typed Network token++ * for audience openvibe.git (client credentials, git.repo.read and git.repo.write) and names the person it works for in++ * X-OV-Subject; OpenVibe.Git makes the repository in their name and mints their clone token. Codes never holds a++ * person's password and never asks them to link an account: the OpenVibe sign-in in Codes is the only one.*-- * 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++ * createForge({ config, fetchImpl, log })++ * enabled Codes has its Network client (a service token can be had)++ * publicUrl, host where OpenVibe Git is++ * repo(owner, name) → one repository (default_branch, …) or null++ * createRepo({ subject, username }, { name, description, isPrivate }) → { full_name, html_url, clone_url, private }++ * repos({ subject, username }) → their repositories, last changed first (up to 50)++ * jobEnv(subject, repoUrl) → { OPENVIBE_GIT_CREDENTIALS, OPENVIBE_GIT_LOCAL_ROOT? } 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.++ * The loopback address is config.git.internalUrl (the default port is OpenVibe.Git's 5270), so no call leaves the++ * host. The service token is cached by the SDK's client until a minute before it ends; no token is ever logged or++ * returned by a route.*/--const { createSeal } = require('./seal');++const fs = require('node:fs');++const path = require('node:path');++const { createServiceTokenClient } = require('openvibe-sdk/auth');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 }) {++function createForge({ config, fetchImpl = globalThis.fetch, log = console }) {const git = config.git || {};const publicUrl = String(git.publicUrl || 'https://git.openvibe.codes').replace(/\/+$/, '');const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');++ const dataDir = String(git.dataDir || '').replace(/\/+$/, '');const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();-- const seal = createSeal(git.sealKey);-- // At use time: the app's ID and secret may be entered in the admin panel after boot (codes.accounts forge.*).-- const isEnabled = () => !!(git.clientId && git.clientSecret && seal);-- const redirectUri = `${String(config.baseUrl || '').replace(/\/+$/, '')}/git/callback`;-- const db = store.db;++ // At use time: Codes' Network client may be set in the admin panel after boot (codes.accounts forge.* is gone;++ // the service's own OAuth client, codes, is what a service token is minted with).++ const tokens = (config.oauth && config.oauth.clientId && config.oauth.clientSecret)++ ? createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.git', scope: 'git.repo.read git.repo.write', fetch: fetchImpl,++ })++ : null;++ const isEnabled = () => !!tokens;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],-- );++ /** One call to OpenVibe.Git's API, as Codes; `subject` is the person it acts for. */++ async function call(method, pathname, { subject = null, json = null, auth = true, timeoutMs = 8000 } = {}) {++ const headers = { Accept: 'application/json' };++ if (auth) headers.Authorization = `Bearer ${await tokens.getToken()}`;++ if (subject) headers['X-OV-Subject'] = subject;++ if (json != null) headers['Content-Type'] = 'application/json';++ let res;++ try {++ res = await fetchImpl(`${internalUrl}${pathname}`, {++ method, headers, ...(json != null ? { body: JSON.stringify(json) } : {}),++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (e) {++ throw fail(503, 'git.unreachable', `OpenVibe Git did not answer: ${e.message}`);++ }++ const text = await res.text().catch(() => '');++ let doc = null;++ try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }++ return { status: res.status, doc };}-- async function connect(subject, { code, verifier }) {-- if (!isEnabled()) 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 repo(owner, name) {++ if (!isEnabled()) return null;++ const r = await call('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, { auth: false }).catch(() => null);++ return r && r.status === 200 ? r.doc : null;}-- 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 createRepo(actor, { name, description = '', isPrivate = false } = {}) {++ const n = String(name || '').trim().toLowerCase();++ if (!REPO_NAME.test(n) || /\.git$/i.test(n)) throw fail(422, 'git.name_invalid', 'A name is letters, digits, dots, dashes or underscores (up to 100), and not ending in .git');++ if (!isEnabled()) throw fail(503, 'git.off', 'OpenVibe Git is not open from Codes yet');++ if (!USER.test(String(actor && actor.subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');++ if (!LOGIN.test(String(actor.username || ''))) throw fail(409, 'git.unlinked', 'Your OpenVibe account has no username Codes can make a repository under yet');++ const r = await call('POST', '/api/v1/repos', {++ subject: actor.subject,++ json: {++ name: n, owner_username: actor.username, description: String(description || '').slice(0, 500),++ visibility: isPrivate ? 'private' : 'public', starter: 'empty',++ },++ });++ if (r.status === 409) throw fail(409, 'git.name_taken', r.doc && r.doc.detail && /username/.test(r.doc.detail) ? r.doc.detail : `You already have a repository called ${n}`);++ if (r.status !== 201 || !r.doc) throw fail(502, 'git.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.visibility === 'private' };}-- 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 (!isEnabled()) 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 repos(actor) {++ 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);++ 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',++ updated_at: x.pushed_at || x.updated_at || null, description: x.description || '',++ })).sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));}-- 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 };++ /** A Git token for the person's session, minted by the service and good for two hours. */++ async function mintToken(subject) {++ if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');++ const r = await call('POST', '/api/v1/tokens', { subject, json: { scopes: ['git.repo.read', 'git.repo.write'], hours: 2, label: 'Codes session' } });++ if (r.status !== 201 || !r.doc || !r.doc.token) throw fail(502, 'git.token_refused', `OpenVibe Git did not hand over a token (${r.status})`);++ return r.doc.token;}-- 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 || '')));++ /** The bare repository on this host, when Codes and OpenVibe Git share the disk (G3: no network clone). */++ function localRoot(repoUrl) {++ if (!dataDir) return null;++ let u;++ try { u = new URL(String(repoUrl || '')); } catch { return null; }++ if (u.protocol !== 'https:' || u.host.toLowerCase() !== host) return null;++ const parts = u.pathname.replace(/\.git$/i, '').split('/').filter(Boolean);++ if (parts.length !== 2 || !LOGIN.test(parts[0]) || !REPO_NAME.test(parts[1])) return null;++ const bare = path.join(dataDir, 'repos', parts[0], `${parts[1]}.git`);++ try { return fs.statSync(bare).isDirectory() ? dataDir : null; } catch { return null; }}/**-- * 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.++ * What a job on the person's machine needs for a repository on OpenVibe Git: one credential line for its host++ * (harness/job.js gitAuthFor), so a private repository clones and the branch pushes. The token lives two hours.++ * When Codes and OpenVibe Git share their disk, the job also learns where the bare repositories are: it clones++ * from the local disk and pushes over HTTPS as usual.*/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}` };++ let token;++ try { token = await mintToken(subject); } catch (e) {++ log.warn(`[Codes] no OpenVibe Git token for a session: ${String(e && e.message).slice(0, 120)}`);++ return null;++ }++ const root = localRoot(repoUrl);++ return {++ OPENVIBE_GIT_CREDENTIALS: `https://x-access-token:${encodeURIComponent(token)}@${host}`,++ ...(root ? { OPENVIBE_GIT_LOCAL_ROOT: root } : {}),++ };}-- return { get enabled() { return isEnabled(); }, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };++ return { get enabled() { return isEnabled(); }, publicUrl, host, repo, createRepo, repos, mintToken, jobEnv };}module.exports = { createForge, REPO_NAME };ddeleted file mode 100644@@@ -1,133 +0,0 @@--'use strict';----/**-- * OpenVibe Git (Forgejo) client for the server side (plan 2026-10-28 §6, decision 7):-- * branches, pull requests, reviews and webhooks on the OpenVibe forge. The internal address is read from-- * config.git.internalUrl; Forgejo listens on 127.0.0.1:5060, a port the Fetch standard blocks, so this client uses-- * node:http / node:https. A caller without a token gets `forgejo.unconfigured`; a 4xx/5xx becomes a plain Error with-- * the Forgejo message and a `status` property.-- *-- * createForgejoClient({ config, fetchImpl?, log? })-- * getRepo(owner, name)-- * listBranches(owner, name)-- * createBranch(owner, name, { base_branch, new_branch_name })-- * listPullRequests(owner, name, { state })-- * createPullRequest(owner, name, { base, head, title, body })-- * getPullRequest(owner, name, index)-- * listReviews(owner, name, index)-- * createWebhook(owner, name, { type, config, events })-- * deleteWebhook(owner, name, id)-- */----const http = require('http');--const https = require('https');----function slug(s) {-- return String(s || '').replace(/^\/+|\/+$/g, '');--}----function requestJson(base, token, method, path, body, { timeoutMs = 15000 } = {}) {-- return new Promise((resolve, reject) => {-- const u = new URL(`${base.replace(/\/+$/, '')}/${slug(path)}`);-- const lib = u.protocol === 'https:' ? https : http;-- const payload = body == null ? null : JSON.stringify(body);-- const req = lib.request(u, {-- method,-- headers: {-- Accept: 'application/json',-- ...(token ? { Authorization: `token ${token}` } : {}),-- ...(payload ? { 'Content-Type': 'application/json' } : {}),-- },-- timeout: timeoutMs,-- }, (res) => {-- let raw = '';-- res.setEncoding('utf8');-- res.on('data', (c) => { raw += c; if (raw.length > 4_000_000) req.destroy(new Error('response too large')); });-- res.on('end', () => {-- let parsed = null;-- if (raw.trim()) {-- try { parsed = JSON.parse(raw); } catch (e) { parsed = { message: raw.slice(0, 500) }; }-- }-- if (res.statusCode >= 400) {-- const err = new Error(parsed && parsed.message ? String(parsed.message) : `Forgejo ${res.statusCode}`);-- err.status = res.statusCode;-- err.body = parsed;-- return reject(err);-- }-- resolve({ status: res.statusCode, body: parsed });-- });-- });-- req.on('timeout', () => req.destroy(new Error('timeout')));-- req.on('error', reject);-- if (payload) req.write(payload);-- req.end();-- });--}----function createForgejoClient({ config, log = console }) {-- const base = String((config && config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');-- const publicUrl = String((config && config.git && config.git.publicUrl) || 'https://git.openvibe.codes').replace(/\/+$/, '');-- const token = (process.env.CODES_GIT_TOKEN || process.env.FORGEJO_TOKEN || '').trim();---- function configured() {-- if (!token) return { ok: false, code: 'forgejo.unconfigured', detail: 'CODES_GIT_TOKEN is not set' };-- return { ok: true };-- }---- function call(method, path, body) {-- const check = configured();-- if (!check.ok) {-- const err = new Error(check.detail);-- err.code = check.code;-- err.status = 503;-- return Promise.reject(err);-- }-- return requestJson(base, token, method, `api/v1/${path}`, body);-- }---- const repoPath = (owner, name) => `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;---- return {-- baseUrl: base,-- publicUrl,-- configured,-- async getRepo(owner, name) {-- const { body } = await call('GET', `${repoPath(owner, name)}`);-- return body;-- },-- async listBranches(owner, name) {-- const { body } = await call('GET', `${repoPath(owner, name)}/branches`);-- return Array.isArray(body) ? body : [];-- },-- async createBranch(owner, name, { base_branch, new_branch_name }) {-- const { body } = await call('POST', `${repoPath(owner, name)}/branches`, { base_branch, new_branch_name });-- return body;-- },-- async listPullRequests(owner, name, { state = 'open' } = {}) {-- const { body } = await call('GET', `${repoPath(owner, name)}/pulls?state=${encodeURIComponent(state)}`);-- return Array.isArray(body) ? body : [];-- },-- async createPullRequest(owner, name, { base, head, title, body: prBody }) {-- const { body } = await call('POST', `${repoPath(owner, name)}/pulls`, { base, head, title, body: prBody });-- return body;-- },-- async getPullRequest(owner, name, index) {-- const { body } = await call('GET', `${repoPath(owner, name)}/pulls/${encodeURIComponent(index)}`);-- return body;-- },-- async listReviews(owner, name, index) {-- const { body } = await call('GET', `${repoPath(owner, name)}/pulls/${encodeURIComponent(index)}/reviews`);-- return Array.isArray(body) ? body : [];-- },-- async createWebhook(owner, name, { type = 'forgejo', config, events = ['push', 'pull_request'] }) {-- const { body } = await call('POST', `${repoPath(owner, name)}/hooks`, { type, config, events });-- return body;-- },-- async deleteWebhook(owner, name, id) {-- const { body } = await call('DELETE', `${repoPath(owner, name)}/hooks/${encodeURIComponent(id)}`);-- return body;-- },-- };--}----module.exports = { createForgejoClient, requestJson };@@@ -257,8 +257,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou*//*** 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).++ * person's Git token line (server/domain/forge.js jobEnv), so their machine clones a private repository and++ * pushes the branch; when Codes shares the host's disk it also names the bare repositories for a local clone.++ * The job keeps both out of the agent's environment (harness/job.js).*/let forge = null;async function withEnv(subject, args, env) {ddeleted file mode 100644@@@ -1,40 +0,0 @@--'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 };@@@ -1,24 +1,18 @@'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).++ * OpenVibe Git in Codes (/git): your repositories on git.openvibe.codes, and a repository made there in your name++ * and opened in a new chat. There is nothing to link: Codes signs in with OpenVibe, holds a service token for++ * OpenVibe Git, and acts for the person already signed in (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)++ * GET /git your repositories and a new-repository form++ * POST /git/repos a new repository in your name, then a new chat on it** 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;@@@ -31,7 +25,6 @@ function createGitRoutes(ctx) {};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>`;@@@ -46,80 +39,44 @@ function createGitRoutes(ctx) {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>`);++ 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>`);}-- 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 who = { subject: req.viewer.subject, username: req.viewer.username || '' };++ if (!who.username) {++ return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>One more step on your account</h2></div>++<p class="c4-sub">Your OpenVibe account has no username yet, so a repository has no address to live at. Choose one at <a href="https://openvibe.network/my" rel="noopener">openvibe.network/my</a>, then come back.</p></section>`);}-- const mine = await forge.repos(req.viewer.subject);++ const mine = await forge.repos(who);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&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>++<p class="c4-sub">It starts with a README on <code class="c4-code-inline">main</code>, in your name (${esc(who.username)}), 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 for="git-desc">What it is <small class="c4-muted">(optional)</small></label><br><input id="git-desc" name="description" maxlength="500" 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.');-- }++<section class="c4-card"><div class="c4-card-head"><h2>Your repositories</h2><a href="${esc(forge.publicUrl)}/${encodeURIComponent(who.username)}" rel="noopener">On OpenVibe Git</a></div>${list}</section>`);});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.');++ if (!forge || !forge.enabled) return back(res, 'error', 'OpenVibe Git is not connected from Codes yet.');const b = req.body || {};try {-- const made = await forge.createRepo(req.viewer.subject, { name: b.name, description: b.description, isPrivate: b.private === '1' });++ const made = await forge.createRepo({ subject: req.viewer.subject, username: req.viewer.username || '' }, { 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 };++module.exports = { createGitRoutes };@@@ -3,15 +3,15 @@* The home page's sections beyond the kit's generic ones: OpenVibe Git (the forge at git.openvibe.codes, read from its* public API: how many repositories, which changed last), quick-start commands for the harness and the public router,* and the questions people ask, which the FAQPage JSON-LD repeats. The repository list is read in the background at most-- * every ten minutes and never awaited by a page; a forge that does not answer leaves the list out.++ * every ten minutes and never awaited by a page; a Git that does not answer leaves the list out.*/const contracts = require('openvibe-contracts');const showcase = require('openvibe-shared/showcase');const esc = showcase.esc;--// Forgejo listens on 127.0.0.1:5060, a port the Fetch standard blocks (SIP), so Node's fetch refuses it ("bad port"):--// the read goes through node:http instead, which has no such list.++// OpenVibe Git's API is read over node:http on its loopback address; its default port (5270) is not a blocked one,++// but the reader stays on node:http so a configured port can never be refused by the Fetch standard.function getJson(url, timeoutMs) {return new Promise((resolve, reject) => {const u = new URL(url);@@@ -36,13 +36,12 @@ function createGitReader({ internalUrl, publicUrl, enabled = true, ttlMs = 10 *if (inflight) return inflight;inflight = (async () => {try {-- const res = await getJson(`${String(internalUrl).replace(/\/+$/, '')}/api/v1/repos/search?limit=${limit}&sort=updated&order=desc`, timeoutMs);++ const res = await getJson(`${String(internalUrl).replace(/\/+$/, '')}/api/v1/repos?limit=${limit}&sort=pushed`, timeoutMs);if (res.status === 200) {-- const { body, total } = res;-- const repos = (Array.isArray(body.data) ? body.data : [])-- .filter((r) => r && !r.private && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(r.full_name || '')))-- .map((r) => ({ name: r.full_name, url: `${pub}/${r.full_name}`, updated: r.updated_at || null, language: r.language || '' }));-- if (repos.length) value = { total: total && total >= repos.length ? total : null, repos, publicUrl: pub };++ const repos = (Array.isArray(res.body && res.body.repositories) ? res.body.repositories : [])++ .filter((r) => r && r.visibility !== 'private' && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(r.full_name || '')))++ .map((r) => ({ name: r.full_name, url: `${pub}/${r.full_name}`, updated: r.pushed_at || r.updated_at || null, language: r.language || '' }));++ if (repos.length) value = { total: null, repos, publicUrl: pub };}} catch { /* keep the last answer */ }// A failed read is tried again after a minute, not after the full ten.@@@ -317,7 +317,7 @@ ${offers.map((o) => html`<h3>${o.name} <code>${o.id}</code></h3>${o.agents.lengt});return created.id;};-- const repoForgejoUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}`;++ const repoGitUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}`;const repoCloneUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}.git`;// The four steps of every change; the index is step 1, a repository's page is step 2.@@@ -359,18 +359,18 @@ ${raw(lists)}async function improveRepoPage(owner, name, { signed = false } = {}) {const meta = repoMeta().find((m) => m.repository === `${owner}/${name}`);if (!meta) return null;-- let forgejoInfo = null;-- let forgejoError = null;-- if (signed) {-- try { forgejoInfo = await ctx.forgejo.getRepo(owner, name); } catch (e) { forgejoError = e.code === 'forgejo.unconfigured' ? null : e.message; }++ let gitInfo = null;++ let gitError = null;++ if (signed && ctx.forge && ctx.forge.enabled) {++ try { gitInfo = await ctx.forge.repo(owner, name); } catch (e) { gitError = e.message; }}const agents = harnesses.list();const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;const form = signed ? html`<form class="run-card run-form" method="post" action="/improve/start">--<input type="hidden" name="repo" value="${repoForgejoUrl(owner, name)}">--<input type="hidden" name="ref" value="${(forgejoInfo && forgejoInfo.default_branch) || 'main'}">++<input type="hidden" name="repo" value="${repoGitUrl(owner, name)}">++<input type="hidden" name="ref" value="${(gitInfo && gitInfo.default_branch) || 'main'}"><h2>Start a guided run</h2>--${forgejoError ? html`<p class="run-notice run-notice-error" role="alert">OpenVibe Git is not reachable right now (${forgejoError}); you can still start a run on your machine.</p>` : ''}++${gitError ? html`<p class="run-notice run-notice-error" role="alert">OpenVibe Git is not reachable right now (${gitError}); you can still start a run on your machine.</p>` : ''}<label class="run-field"><span>What should the agent change?</span><textarea name="prompt" rows="5" required maxlength="16000" placeholder="Add a test for the date parser and fix what it finds"></textarea></label><div class="run-field-row"><label class="run-field"><span>Agent</span><select name="agent">${opt('', 'Any: the best fit, with hand-offs', true)}${agents.map((a) => opt(a.id, a.name))}</select></label>@@@ -391,7 +391,7 @@ ${forgejoError ? html`<p class="run-notice run-notice-error" role="alert">OpenVi${raw(improveSteps(1))}${form}${raw(kit.fold({ title: 'How the run works', body: '<ol class="steps"><li>The harness clones the repository onto <strong>your machine</strong> and checks out the default branch.</li><li>The agent makes the change you described and runs the tests.</li><li>If you asked it to push, it commits to a new branch and opens a pull request on OpenVibe Git.</li><li>You review the result, then the maintainers review the pull request.</li></ol>' }))}--${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.snippet({ code: `git clone ${repoCloneUrl(owner, name)}`, id: 'clone-cmd' })}<p class="ovk-muted">Browse it at <a href="${kit.esc(repoForgejoUrl(owner, name))}">${kit.esc(repoForgejoUrl(owner, name))}</a> or on <a href="${kit.esc(`${GITHUB}/${name}`)}">GitHub</a>.</p>` }))}`,++${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.snippet({ code: `git clone ${repoCloneUrl(owner, name)}`, id: 'clone-cmd' })}<p class="ovk-muted">Browse it at <a href="${kit.esc(repoGitUrl(owner, name))}">${kit.esc(repoGitUrl(owner, name))}</a> or on <a href="${kit.esc(`${GITHUB}/${name}`)}">GitHub</a>.</p>` }))}`,};}r.get('/improve/:owner/:name', async (req, res) => {@@@ -52,7 +52,7 @@ function createSetupRoutes(ctx, kit) {let keys = null;try { keys = connections ? await sso.asViewer(req, res, (token) => connections.mine(token)) : null; } catch { keys = null; }let git = null;-- try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }++ try { git = forge && forge.enabled ? await forge.repos({ subject, username: req.viewer.username || '' }) : null; } catch { git = null; }const settings = await userSettings.get(subject);const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));return { machines: machineList, compute, rows, fleetProblem, keys, git, settings, saved };@@@ -67,7 +67,7 @@ function createSetupRoutes(ctx, kit) {{ id: 'machine', title: 'Pick where it runs', short: 'Runs on', done: online.length > 0, warn: !online.length && live.length > 0, required: true },{ id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },{ id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },-- { id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },++ { id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!(d.git && d.git.length), optional: true },{ id: 'choices', title: 'Your choices', short: 'Choices', done: d.saved, required: true },];}@@@ -125,10 +125,10 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline}function accountsBody(d) {-- if (!forge || !forge.enabled) return '<p class="c4-hint">OpenVibe Git opens here once it trusts Codes. Your tasks can already work on any GitHub or OpenVibe Git repository you can reach.</p>';-- return d.git-- ? `<ul class="c4-rows"><li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">OpenVibe Git</span><span class="c4-row-note">Linked as ${esc(d.git.forge_user || 'you')}</span>${pill('Linked', 'ok')}</li></ul><div class="c4-setup-actions"><a class="c4-opill" href="/git">Your repositories</a></div>`-- : `<p class="c4-hint">Link OpenVibe Git once, and Codes can make repositories in your name and push your tasks' branches there. It is one consent with your OpenVibe account.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git/connect">Link OpenVibe Git</a></div>`;++ if (!forge || !forge.enabled) return '<p class="c4-hint">OpenVibe Git opens here once Codes can reach it. Your tasks can already work on any GitHub or OpenVibe Git repository you can reach.</p>';++ return d.git && d.git.length++ ? `<ul class="c4-rows"><li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">OpenVibe Git</span><span class="c4-row-note">${d.git.length} ${d.git.length === 1 ? 'repository' : 'repositories'} in your name</span>${pill('Ready', 'ok')}</li></ul><div class="c4-setup-actions"><a class="c4-opill" href="/git">Your repositories</a></div>`++ : '<p class="c4-hint">Codes makes repositories on OpenVibe Git in your name with your OpenVibe account, and your tasks push their branches there. Nothing to link.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git">Make a repository</a></div>';}function choicesBody(d, machines) {@@@ -4,9 +4,6 @@* as two namespaces on openvibe-shared/config that the admin panel's Setup tab lists.** codes.accounts the sign-in apps and the GitHub reader:-- * forge.client_id / forge.client_secret the OpenVibe Git (Forgejo) OAuth app that lets a-- * person link their OpenVibe Git account (read by-- * server/domain/forge.js; CODES_FORGE_CLIENT_* fallback)* github.client_id / github.client_secret the GitHub OAuth app for a GitHub sign-in in Codes.* Nothing reads it yet: Optional, waiting for that sign-in* github.token a read-only GitHub token that raises GitHub's limits@@@ -33,30 +30,6 @@ const ACCOUNTS_SCHEMA = {type: 'object',additionalProperties: false,properties: {-- 'forge.client_id': {-- type: 'string', minLength: 8, maxLength: 200, title: 'OpenVibe Git app: client ID', 'x-group': 'OpenVibe Git', 'x-example': '3f2a9c1e-…',-- description: 'The client ID of the OAuth2 application registered on OpenVibe Git for Codes. With its secret and the seal key it lets people link their OpenVibe Git account; without it linking stays closed.',-- 'x-owner-input': {-- group: 'sign-in-apps',-- unlocks: 'Lets people link their OpenVibe Git account in Codes, so tasks can make repositories and push as them. Without it the Link button says linking is not open yet.',-- get_it_at: 'https://git.openvibe.codes/user/settings/applications',-- get_it_how: 'Signed in as the owner, create an OAuth2 application named Codes with the redirect URI https://openvibe.codes/git/callback, and copy its Client ID.',-- test: false,-- },-- 'x-required-for': 'Linking an OpenVibe Git account',-- },-- 'forge.client_secret': {-- type: 'string', minLength: 8, maxLength: 512, title: 'OpenVibe Git app: client secret', 'x-group': 'OpenVibe Git',-- description: 'The secret of the same OpenVibe Git application, shown once when it is created. Codes sends it only to OpenVibe Git, when a person links their account; it is never shown again here.',-- 'x-owner-input': {-- group: 'sign-in-apps',-- unlocks: 'Completes the OpenVibe Git app: with the client ID it lets people link their OpenVibe Git account in Codes.',-- get_it_at: 'https://git.openvibe.codes/user/settings/applications',-- get_it_how: 'Copy the Client Secret shown when the application is created (or regenerate it there).',-- test: false,-- },-- 'x-required-for': 'Linking an OpenVibe Git account',-- },'github.client_id': {type: 'string', minLength: 8, maxLength: 200, title: 'GitHub app: client ID', 'x-group': 'GitHub', 'x-example': 'Ov23li…',description: 'The client ID of a GitHub OAuth app for signing in to Codes with GitHub. Codes does not offer that sign-in yet; the app waits here until it does, and nothing uses it before then.',@@@ -159,14 +132,12 @@ const PUBLISHING_SCHEMA = {},};--const ACCOUNTS_SECRETS = new Set(['forge.client_secret', 'github.client_secret', 'github.token']);++const ACCOUNTS_SECRETS = new Set(['github.client_secret', 'github.token']);const classifyAccounts = (key) => (ACCOUNTS_SECRETS.has(key) ? 'secret' : 'internal');const classifyPublishing = (key) => (key === 'chrome.client_id' || key === 'firefox.jwt_issuer' ? 'internal' : 'secret');/** The environment variable each key falls back to (only the keys Codes reads today have one). */const ENV_FALLBACK = Object.freeze({-- 'forge.client_id': 'CODES_FORGE_CLIENT_ID',-- 'forge.client_secret': 'CODES_FORGE_CLIENT_SECRET','github.token': 'CODES_GITHUB_TOKEN',});@@@ -33,7 +33,7 @@ async function oauthApp(name, url, clientId, clientSecret, fetchImpl) {return { ok: false, message: `${name} answered ${r.status}${said(r.json) ? `: ${said(r.json)}` : ''}; the app could not be checked.` };}--function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fetch }) {++function createOwnerInputTests({ ownerInputs, fetchImpl = globalThis.fetch }) {const github = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');const tests = {'codes.accounts': {@@@ -47,10 +47,6 @@ function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fetreturn { ok: false, message: `GitHub ${r.status === 401 ? 'refused the token (401)' : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };},'github.client_secret': async (secret) => oauthApp('GitHub', 'https://github.com/login/oauth/access_token', ownerInputs.value('codes.accounts', 'github.client_id').value, secret, fetchImpl),-- 'forge.client_secret': async (secret) => {-- const base = String((config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');-- return oauthApp('OpenVibe Git', `${base}/login/oauth/access_token`, ownerInputs.value('codes.accounts', 'forge.client_id').value, secret, fetchImpl);-- },},'codes.publishing': {'npm.token': async (token) => {@@@ -1,77 +1,70 @@'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.++ * OpenVibe Git through Codes' service token (server/domain/forge.js, server/http/git.js): Codes names the person++ * signed in in X-OV-Subject, OpenVibe.Git makes a repository in their name and mints their clone token. Nothing is++ * linked and no person's password is involved. A stand-in Git answers the three calls Codes makes.*/const assert = require('assert');--const crypto = require('crypto');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');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';++const TOKEN = 'git_test_0123456789abcdef';--function fakeForge() {-- const st = { challenges: new Map(), repos: [], codes: new Map(), refreshes: 0 };++function claimsOf(header) {++ const raw = String(header || '').replace(/^Bearer\s+/i, '');++ try { return JSON.parse(Buffer.from(raw.split('.')[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function fakeGit() {++ const st = { repos: [], tokens: [], calls: [] };const srv = http.createServer((req, res) => {-- let body = '';-- req.on('data', (c) => { body += c; });++ let raw = '';++ req.on('data', (c) => { raw += c; });req.on('end', () => {const u = new URL(req.url, 'http://x');++ const body = raw ? JSON.parse(raw) : null;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' });++ const subject = String(req.headers['x-ov-subject'] || '');++ st.calls.push({ method: req.method, path: u.pathname, subject, body, claims: claimsOf(req.headers.authorization) });++ if (req.method === 'POST' && u.pathname === '/api/v1/repos') {++ if (!req.headers.authorization || !subject) return send(401, { code: 'auth.subject_required', detail: 'name the person' });++ if (st.repos.some((r) => r.full_name === `${body.owner_username}/${body.name}`)) return send(409, { code: 'repo.exists', detail: 'That repository already exists.' });++ const row = {++ name: body.name, full_name: `${body.owner_username}/${body.name}`, visibility: body.visibility,++ html_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}`,++ clone_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}.git`,++ default_branch: 'main', owner: { username: body.owner_username, subject },++ };++ st.repos.push(row);++ return send(201, row);++ }++ const repoMatch = u.pathname.match(/^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/);++ if (req.method === 'GET' && repoMatch) {++ const row = st.repos.find((r) => r.full_name === `${repoMatch[1]}/${repoMatch[2]}`);++ return row ? send(200, row) : send(404, { code: 'repo.not_found' });}-- 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);++ const who = u.pathname.match(/^\/api\/v1\/profile\/([^/]+)$/);++ if (req.method === 'GET' && who) {++ if (!req.headers.authorization || !subject) return send(401, { code: 'auth.required' });++ return send(200, { username: who[1], repositories: st.repos.filter((r) => r.full_name.startsWith(`${who[1]}/`)) });}-- return send(404, { message: 'not found' });++ if (req.method === 'POST' && u.pathname === '/api/v1/tokens') {++ if (!req.headers.authorization || !subject) return send(401, { code: 'auth.subject_required' });++ st.tokens.push({ subject, body });++ return send(201, { token: TOKEN, token_type: 'GitToken', scopes: body.scopes, expires_at: new Date(Date.now() + 7200_000).toISOString() });++ }++ return send(404, { code: 'route.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 git = await fakeGit();const submits = [];const run = {enabled: true,@@@ -80,78 +73,74 @@ function fakeForge() {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 dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-git-data-'));++ const t = await boot({ run, env: { CODES_GIT_INTERNAL_URL: git.url, CODES_GIT_DATA_DIR: dataDir } });const ada = t.network.addUser('ada');-- const cookieOf = (r) => String(r.headers.get('set-cookie') || '').split(';')[0];++ const nameless = t.network.addUser('');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('the page asks for no link: Codes acts with its service token for the person signed in', async () => {++ const page = await t.get('/git', { as: ada });++ assert.strictEqual(page.status, 200);++ assert.doesNotMatch(page.text, /Link OpenVibe Git/);++ assert.match(page.text, /A new repository/);++ assert.match(page.text, /No repositories yet/);++ const call = git.st.calls.find((c) => c.path === '/api/v1/profile/ada');++ assert.ok(call, 'the page read ada\'s repositories');++ assert.strictEqual(call.subject, ada.subject, 'named in X-OV-Subject');++ assert.strictEqual(call.claims.sub, 'svc:codes', 'the service token, not a person\'s');++ assert.ok(call.claims.cap.includes('git.repo.write'), 'with git.repo.write');++ assert.ok(call.claims.aud.includes('openvibe.git'));});-- 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 () => {++ await check('a person with no username is told where to choose one', async () => {++ const page = await t.get('/git', { as: nameless });++ assert.match(page.text, /One more step on your account/);++ const refused = await t.get('/git/repos', { as: nameless, form: { name: 'x' } });++ assert.strictEqual(refused.status, 303);++ assert.match(decodeURIComponent(refused.headers.get('location')), /no username/);++ });++++ await check('a new repository is made in their name, private, and opens a new chat', 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 call = git.st.calls.filter((c) => c.method === 'POST' && c.path === '/api/v1/repos').pop();++ assert.strictEqual(call.subject, ada.subject);++ assert.deepStrictEqual([call.body.name, call.body.owner_username, call.body.visibility, call.body.starter], ['my-site', 'ada', 'private', 'empty']);++ });++++ await check('their repositories are listed, private ones too, with a way to work on each', async () => {const page = await t.get('/git', { as: ada });assert.match(page.text, /ada\/my-site<\/a>[\s\S]*?Private[\s\S]*?href="\/\?mode=code&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 () => {++ await check('a task on the repository gets a Git token for the person, and the local disk when Codes shares it', async () => {++ fs.mkdirSync(path.join(dataDir, 'repos', 'ada', 'my-site.git'), { recursive: true });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');++ assert.strictEqual(sent.args.env.OPENVIBE_GIT_CREDENTIALS, `https://x-access-token:${TOKEN}@git.openvibe.codes`);++ assert.strictEqual(sent.args.env.OPENVIBE_GIT_LOCAL_ROOT, dataDir, 'clones from the disk, not the network');++ const minted = git.st.tokens.pop();++ assert.strictEqual(minted.subject, ada.subject, 'the token is the person\'s');++ assert.deepStrictEqual(minted.body.scopes, ['git.repo.read', 'git.repo.write']);++ assert.ok(job.ENV_NAMES.has('OPENVIBE_GIT_LOCAL_ROOT'), 'the job accepts the local root');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/);++ assert.strictEqual(Buffer.from(header.env.GIT_CONFIG_VALUE_0.replace(/^Authorization: Basic /, ''), 'base64').toString(), `x-access-token:${TOKEN}`);++ await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://github.com/ada/elsewhere', text: 'Fix it' } });++ assert.ok(!(submits[submits.length - 1].args.env && submits[submits.length - 1].args.env.OPENVIBE_GIT_LOCAL_ROOT), 'no local root for another host');});-- 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/);++ await check('a repository on another host takes no OpenVibe Git token', async () => {++ assert.strictEqual(await t.ctx.forge.jobEnv(ada.subject, 'https://code.example.org/ada/site'), null);++ assert.strictEqual(await t.ctx.forge.jobEnv(ada.subject, 'not a url'), null);});} finally {await t.close();-- await forge.close();++ await git.close();++ fs.rmSync(dataDir, { recursive: true, force: true });}done();})().catch((e) => { console.error(e); process.exit(1); });ddeleted file mode 100644@@@ -1,110 +0,0 @@--'use strict';--/**-- * OpenVibe Git / Forgejo client (server/domain/forgejo.js): branches, pull requests, reviews and webhooks.-- * The client uses node:http / node:https (not fetch) so it can reach Forgejo on 127.0.0.1:5060, and it refuses to do-- * anything without a token. A stand-in Forgejo answers the same paths so no real forge is contacted.-- */--const assert = require('assert');--const http = require('http');--const { createForgejoClient } = require('../server/domain/forgejo');--const { check, done } = require('./helpers/boot');----function mockForgejo() {-- const calls = [];-- const repoBase = '/api/v1/repos/OpenVibers/OpenVibe.Codes';-- const server = http.createServer((req, res) => {-- let raw = '';-- req.on('data', (c) => { raw += c; });-- req.on('end', () => {-- const auth = req.headers.authorization || '';-- const body = raw ? JSON.parse(raw) : null;-- const pathname = new URL(req.url, 'http://x').pathname;-- calls.push({ method: req.method, path: req.url, auth, body });-- res.writeHead(200, { 'Content-Type': 'application/json' });-- if (pathname === repoBase) {-- return res.end(JSON.stringify({ full_name: 'OpenVibers/OpenVibe.Codes', default_branch: 'main' }));-- }-- if (pathname === `${repoBase}/branches` && req.method === 'POST') {-- return res.end(JSON.stringify({ name: body.new_branch_name }));-- }-- if (pathname === `${repoBase}/branches`) {-- return res.end(JSON.stringify([{ name: 'main' }, { name: 'feature' }]));-- }-- if (pathname === `${repoBase}/pulls` && req.method === 'POST') {-- return res.end(JSON.stringify({ number: 5, title: body.title }));-- }-- if (pathname === `${repoBase}/pulls`) {-- return res.end(JSON.stringify([{ number: 5, title: 'A PR' }]));-- }-- if (pathname === `${repoBase}/pulls/5`) {-- return res.end(JSON.stringify({ number: 5, title: 'PR' }));-- }-- if (pathname === `${repoBase}/pulls/5/reviews`) {-- return res.end(JSON.stringify([{ id: 1, state: 'APPROVED' }]));-- }-- if (pathname === `${repoBase}/hooks` && req.method === 'POST') {-- return res.end(JSON.stringify({ id: 9 }));-- }-- if (pathname === `${repoBase}/hooks/9` && req.method === 'DELETE') {-- return res.end('{}');-- }-- res.end('{}');-- });-- });-- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, calls, url: `http://127.0.0.1:${server.address().port}` })));--}----(async () => {-- const forgejo = await mockForgejo();-- const old = process.env.FORGEJO_TOKEN;-- process.env.FORGEJO_TOKEN = 'f-token';-- const client = createForgejoClient({ config: { git: { internalUrl: forgejo.url, publicUrl: 'https://git.openvibe.codes' } }, log: { warn() {} } });---- try {-- await check('without a token the client is unconfigured', async () => {-- delete process.env.FORGEJO_TOKEN;-- delete process.env.CODES_GIT_TOKEN;-- const noToken = createForgejoClient({ config: { git: { internalUrl: forgejo.url } }, log: { warn() {} } });-- const c = noToken.configured();-- assert.strictEqual(c.ok, false);-- assert.strictEqual(c.code, 'forgejo.unconfigured');-- await assert.rejects(noToken.getRepo('OpenVibers', 'OpenVibe.Codes'), (e) => e.code === 'forgejo.unconfigured');-- process.env.FORGEJO_TOKEN = 'f-token';-- });---- await check('getRepo, listBranches and createBranch call the right Forgejo paths', async () => {-- const repo = await client.getRepo('OpenVibers', 'OpenVibe.Codes');-- assert.strictEqual(repo.default_branch, 'main');-- const branches = await client.listBranches('OpenVibers', 'OpenVibe.Codes');-- assert.strictEqual(branches.length, 2);-- const created = await client.createBranch('OpenVibers', 'OpenVibe.Codes', { base_branch: 'main', new_branch_name: 'feature' });-- assert.strictEqual(created.name, 'feature');-- assert.ok(forgejo.calls.some((c) => c.method === 'GET' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/branches'));-- assert.ok(forgejo.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/branches'));-- assert.ok(forgejo.calls.some((c) => c.auth === 'token f-token'));-- });---- await check('pull requests and reviews are read and created', async () => {-- const prs = await client.listPullRequests('OpenVibers', 'OpenVibe.Codes');-- assert.strictEqual(prs[0].number, 5);-- const pr = await client.getPullRequest('OpenVibers', 'OpenVibe.Codes', 5);-- assert.strictEqual(pr.number, 5);-- const created = await client.createPullRequest('OpenVibers', 'OpenVibe.Codes', { base: 'main', head: 'feature', title: 'Add test', body: 'Desc' });-- assert.strictEqual(created.number, 5);-- const reviews = await client.listReviews('OpenVibers', 'OpenVibe.Codes', 5);-- assert.strictEqual(reviews[0].state, 'APPROVED');-- });---- await check('webhooks are created and deleted', async () => {-- const hook = await client.createWebhook('OpenVibers', 'OpenVibe.Codes', { type: 'forgejo', config: { url: 'https://openvibe.codes/hooks' }, events: ['push'] });-- assert.strictEqual(hook.id, 9);-- await client.deleteWebhook('OpenVibers', 'OpenVibe.Codes', 9);-- assert.ok(forgejo.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/hooks'));-- assert.ok(forgejo.calls.some((c) => c.method === 'DELETE' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/hooks/9'));-- });-- } finally {-- process.env.FORGEJO_TOKEN = old;-- await new Promise((r) => forgejo.server.close(r));-- }-- done();--})().catch((e) => { console.error(e); process.exit(1); });@@@ -1,6 +1,6 @@'use strict';/**-- * The home page's own sections (server/http/home.js): OpenVibe Git from the forge's public API (left out when it never++ * The home page's own sections (server/http/home.js): OpenVibe Git from its public API (left out when it never* answered), the quick start with the release-tag install line and the public router, and a FAQ that its FAQPage JSON-LD* repeats word for word. The git reader keeps only public repositories with a plain owner/name.*/@@@ -10,26 +10,25 @@ const home = require('../server/http/home');const { boot, check, done } = require('./helpers/boot');(async () => {-- await check('the git reader: public repositories, newest first as the forge answers, with the total it reports', async () => {++ await check('the git reader: public repositories, newest first as OpenVibe Git answers', async () => {const srv = http.createServer((req, res) => {-- assert.match(req.url, /^\/api\/v1\/repos\/search\?limit=8&sort=updated&order=desc$/);++ assert.match(req.url, /^\/api\/v1\/repos\?limit=8&sort=pushed$/);res.setHeader('Content-Type', 'application/json');-- res.setHeader('X-Total-Count', '52');-- res.end(JSON.stringify({ ok: true, data: [-- { full_name: 'OpenVibers/OpenVibe.Codes', private: false, updated_at: '2026-10-22T10:00:00Z', language: 'JavaScript' },-- { full_name: 'OpenVibers/secret', private: true },-- { full_name: 'bad name/<x>', private: false },++ res.end(JSON.stringify({ repositories: [++ { full_name: 'OpenVibers/OpenVibe.Codes', visibility: 'public', pushed_at: '2026-10-22T10:00:00Z', language: 'JavaScript' },++ { full_name: 'OpenVibers/secret', visibility: 'private' },++ { full_name: 'bad name/<x>', visibility: 'public' },] }));});await new Promise((r) => srv.listen(0, '127.0.0.1', r));try {const reader = home.createGitReader({ internalUrl: `http://127.0.0.1:${srv.address().port}`, publicUrl: 'https://git.openvibe.codes' });const v = await reader.refresh();-- assert.strictEqual(v.total, 52);++ assert.strictEqual(v.total, null);assert.deepStrictEqual(v.repos.map((r) => r.url), ['https://git.openvibe.codes/OpenVibers/OpenVibe.Codes']);} finally { srv.close(); }const down = home.createGitReader({ internalUrl: 'http://127.0.0.1:9', timeoutMs: 500 });-- assert.strictEqual(await down.refresh(), null, 'a forge that does not answer gives no list');++ assert.strictEqual(await down.refresh(), null, 'a Git that does not answer gives no list');assert.strictEqual(home.ago('2026-10-22T10:00:00Z', Date.parse('2026-10-22T12:00:00Z')), '2 h ago');});@@@ -21,13 +21,12 @@ const ENV_GH = 'github_pat_madeUpENVtokenFROMSETTINGSFILE0001';const PANEL_GH = 'github_pat_madeUpPANELtokenNEVERSHOWN000002';const APP_SECRET = 'madeUpGitHubAppSecretNEVERSHOWN0000000003';const BAD_NPM = 'npm_madeUpBadTokenTHATNPMECHOESBACK0000004';--const FORGE_SECRET = 'madeUpForgeSecretNEVERSHOWN00000000000005';--const SECRETS = [ENV_GH, PANEL_GH, APP_SECRET, BAD_NPM, FORGE_SECRET];++const SECRETS = [ENV_GH, PANEL_GH, APP_SECRET, BAD_NPM];const calls = [];async function fakeFetch(url, init = {}) {const u = String(url);-- const outside = /^https:\/\/(api\.github\.com|github\.com|registry\.npmjs\.org)\//.test(u) || /\/login\/oauth\/access_token$/.test(u);++ const outside = /^https:\/\/(api\.github\.com|github\.com|registry\.npmjs\.org)\//.test(u);if (!outside) return globalThis.fetch(url, init);const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');calls.push({ url: u, auth, body: init.body ? String(init.body) : '' });@@@ -43,10 +42,6 @@ async function fakeFetch(url, init = {}) {? json(200, { error: 'bad_verification_code', error_description: 'The code passed is incorrect or expired.' }): json(200, { error: 'incorrect_client_credentials', error_description: 'The client_id and/or client_secret passed are incorrect.' });}-- if (u.endsWith('/login/oauth/access_token')) {-- const form = new URLSearchParams(String(init.body || ''));-- return form.get('client_secret') === FORGE_SECRET ? json(400, { error: 'invalid_grant', error_description: 'client is not authorized' }) : json(400, { error: 'unauthorized_client', error_description: 'invalid client secret' });-- }if (u === 'https://registry.npmjs.org/-/whoami') return json(401, { error: `token ${auth.replace(/^Bearer /, '')} is not valid` });return json(404, { message: 'not here' });}@@@ -55,7 +50,7 @@ async function fakeFetch(url, init = {}) {const consoleLines = [];const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };-- const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', CODES_GITHUB_TOKEN: ENV_GH, CODES_SEAL_KEY: crypto.randomBytes(32).toString('base64') }, fetchImpl: fakeFetch });++ const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', CODES_GITHUB_TOKEN: ENV_GH }, fetchImpl: fakeFetch });const admin = () => t.network.mintService('svc:network', 'openvibe.codes', CAP);const cfg = (method, p, json) => t.get(p, { method, bearer: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });const test = (ns, key) => cfg('POST', `/internal/config/${ns}/test/${key}`);@@@ -69,8 +64,7 @@ async function fakeFetch(url, init = {}) {await check('the sign-in apps, the GitHub token and the publishing tokens are owner inputs in their groups', async () => {const a = (await cfg('GET', '/internal/config/codes.accounts')).json().schema.properties;-- assert.deepStrictEqual(['forge.client_id', 'forge.client_secret', 'github.client_id', 'github.client_secret'].map((k) => a[k]['x-owner-input'].group), ['sign-in-apps', 'sign-in-apps', 'sign-in-apps', 'sign-in-apps']);-- assert.strictEqual(a['forge.client_secret']['x-required-for'], 'Linking an OpenVibe Git account', 'Codes reads it: linking is off without it');++ assert.deepStrictEqual(['github.client_id', 'github.client_secret'].map((k) => a[k]['x-owner-input'].group), ['sign-in-apps', 'sign-in-apps']);assert.strictEqual(a['github.client_secret']['x-required-for'], undefined, 'nothing reads the GitHub app yet: Optional');assert.match(a['github.client_id']['x-owner-input'].unlocks, /Optional/);assert.deepStrictEqual([a['github.token']['x-owner-input'].group, a['github.token']['x-owner-input'].test], ['data-sources', true]);@@@ -106,18 +100,6 @@ async function fakeFetch(url, init = {}) {assert.ok(!row.values_json.includes(PANEL_GH), 'sealed at rest');});-- await check('the OpenVibe Git app from the panel opens linking at use time; its test checks the app with the forge', async () => {-- assert.strictEqual(t.ctx.forge.enabled, false, 'no app yet: linking is closed');-- const id = await cfg('POST', '/internal/config/codes.accounts', { values: { 'forge.client_id': 'forge-client-1234' }, merge: true, reason: 'setup' });-- assert.strictEqual(id.status, 200, id.text.slice(0, 300));-- const put = await cfg('PUT', '/internal/config/codes.accounts/secrets/forge.client_secret', { value: FORGE_SECRET, reason: 'setup' });-- assert.strictEqual(put.status, 200, put.text.slice(0, 300));-- assert.strictEqual(t.ctx.forge.enabled, true, 'the panel values opened linking without a restart');-- assert.strictEqual(t.config.git.clientId, 'forge-client-1234');-- const r = await test('codes.accounts', 'forge.client_secret');-- assert.deepStrictEqual([r.json().ok, r.json().source, r.json().message], [true, 'vault', 'OpenVibe Git accepted the app\'s client ID and secret.']);-- });--await check('the GitHub app is tested without signing anyone in; a wrong secret is refused in one line', async () => {const none = await test('codes.accounts', 'github.client_secret');assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);@@@ -42,19 +42,18 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS','server/auth/sso.js': 'Network OAuth token and revoke (configured)','server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',-- 'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',++ 'server/domain/forge.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback) with Codes\' own Network service token (audience openvibe.git); paths built from validated owner/name, the person named in X-OV-Subject','server/domain/agent-copilot.js': 'OpenVibe.Chat (the configured Chat base) with Codes\' own Network service token, for the owner\'s Co-pilot room: the digest, the poll and the answer line; the room id is the stream\'s own id, never a URL from a person',-- 'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',-- 'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',++ 'server/http/home.js': 'OpenVibe Git\'s public repository list (CODES_GIT_INTERNAL_URL, configured loopback) for the home page; no token','server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person','server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin','server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, pause, curtain, mute and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person','server/domain/agent-copilot.js': 'OpenVibe.Chat (OV_CHAT_URL/config, configured) with Codes\' own service token for the room digest, answer and poll, and OpenVibe.Live for the state, copilot-ready and step calls; the stream id must be ags_<ULID> and is URL-encoded; no URL from a person or a chat line','server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',-- 'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',++ 'server/http/home.js': 'OpenVibe Git\'s public repository list (CODES_GIT_INTERNAL_URL, configured loopback) for the home page; no token','server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person','server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',-- 'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',++ 'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; OpenVibe Git repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person','server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person','server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer','server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',@@@ -70,7 +69,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/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) and OpenVibe Git\'s token endpoint on the configured loopback (CODES_GIT_INTERNAL_URL); 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',};const root = path.join(__dirname, '..');
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
-**OpenVibe Git** (since 2026-10-15): Forgejo at [git.openvibe.codes](https://git.openvibe.codes/) (its first address, openvibe.codes/git, redirects there), signed in with your OpenVibe account, with read mirrors of the OpenVibers GitHub repositories. Install and operations: [deploy/forgejo](deploy/forgejo/README.md).
+**OpenVibe Git** (since 2026-10-15): [git.openvibe.codes](https://git.openvibe.codes/), OpenVibe's own code home (OpenVibers/OpenVibe-Git), signed in with your OpenVibe account, with read mirrors of the OpenVibers GitHub repositories. Codes makes repositories there and pushes a task's branch with its own service token, acting for the person signed in.
## Purpose
## Purpose
One place to send a coding task to the agent that fits it — by what the task needs first, then by cost — hand it over when one agent gets stuck, and keep the run in one place; and the front door for anyone who wants to improve OpenVibe itself. The agents run on people's own machines or OpenVibe's; Codes routes, records and hands off, and never holds a person's provider keys in the clear.
One place to send a coding task to the agent that fits it — by what the task needs first, then by cost — hand it over when one agent gets stuck, and keep the run in one place; and the front door for anyone who wants to improve OpenVibe itself. The agents run on people's own machines or OpenVibe's; Codes routes, records and hands off, and never holds a person's provider keys in the clear.
deleted file mode 100644
deleted file mode 100644
@@ -1,41 +0,0 @@
@@ -1,41 +0,0 @@
-# OpenVibe Git (Forgejo)
-
-The OpenVibe projects' own Git host, at **https://git.openvibe.codes/** (since 2026-10-21; it began at
-`openvibe.codes/git/` on 2026-10-15, and every old address redirects to the same path on the new host). It runs Forgejo,
-which is lighter than GitLab on the 15 GB host.
-
-- **Sign-in:** people sign in with their OpenVibe account through OpenVibe.Network's OpenID Connect (the OAuth client
- `forgejo`, which Network seeds). There is no local sign-up; the first sign-in creates the account with the OpenVibe
- username.
-- **Mirrors:** the `OpenVibers` organisation holds read mirrors of the public GitHub repositories, pulled hourly.
- GitHub stays canonical until a repository is moved here, one at a time. Then the direction flips to a push mirror
- to GitHub.
-- **Cloning:** HTTPS only. `git.openvibe.codes` is behind Cloudflare's proxy, which carries no SSH.
-- **Off:** mail, Actions (CI) and federation are off for now; turn them on in `app.ini` when they have a job.
-
-## Files
-
-| File | What it does |
-|---|---|
-| `install.sh` | Binary (sha256-checked), the `forgejo` user, the PostgreSQL role and the `ov_forgejo` database, `/etc/forgejo/app.ini` with secrets generated on the host, and the systemd unit. Idempotent; `FORGEJO_VERSION=x.y.z` upgrades the binary. |
-| `app.ini.template` | The config; `@…@` values are filled in by `install.sh`. |
-| `custom/` | The OpenVibe look: `theme-openvibe.css`, Forgejo's dark theme in the network's colours, which is the default theme. |
-| `openvibe-forgejo.service` | The unit: `127.0.0.1:5060`, hardened, 1 GB of memory at most. |
-| `setup-sso.sh` | The site admin (a random password in `/root/forgejo-admin.txt`) and the OpenVibe sign-in. The client secret is read from Network's database. |
-| `mirror-github.sh [file]` | The `OpenVibers` organisation and one pull mirror per public repository: names from `gh`, or one per line in `file`. |
-
-nginx: `deploy/nginx/openvibe.codes.conf` serves `git.openvibe.codes` from `127.0.0.1:5060`, streamed, with a 512 MB
-limit, and answers `openvibe.codes/git/…` with a permanent redirect to the same path there.
-
-## Install (on the host, from the Codes checkout)
-
-```bash
-sudo bash deploy/forgejo/install.sh
-sudo ov-vhost-install deploy/nginx/openvibe.codes.conf openvibe.codes.conf
-sudo bash deploy/forgejo/setup-sso.sh # after Network seeds the forgejo client
-gh repo list OpenVibers --visibility public --limit 200 --json name -q '.[].name' > /tmp/repos.txt # where gh is signed in
-sudo bash deploy/forgejo/mirror-github.sh /tmp/repos.txt
-```
-
-Backups: `ov_forgejo` is a PostgreSQL database like the others (add it to the host inventory's databases), and the
-repositories live in `/var/lib/forgejo/repositories`.
deleted file mode 100644
deleted file mode 100644
@@ -1,112 +0,0 @@
@@ -1,112 +0,0 @@
-; OpenVibe Git: Forgejo at https://git.openvibe.codes/ (deploy/forgejo/install.sh writes /etc/forgejo/app.ini from this).
-; @VALUES@ are filled in on the host; the secrets are generated there and never leave it.
-APP_NAME = OpenVibe Git
-RUN_USER = forgejo
-RUN_MODE = prod
-WORK_PATH = /var/lib/forgejo
-
-[server]
-PROTOCOL = http
-HTTP_ADDR = 127.0.0.1
-HTTP_PORT = 5060
-DOMAIN = git.openvibe.codes
-ROOT_URL = https://git.openvibe.codes/
-; Cloning and pushing are over HTTPS only: git.openvibe.codes is behind Cloudflare's proxy, which carries no SSH.
-DISABLE_SSH = true
-START_SSH_SERVER = false
-LANDING_PAGE = explore
-OFFLINE_MODE = true
-LFS_START_SERVER = false
-APP_DATA_PATH = /var/lib/forgejo/data
-
-[database]
-DB_TYPE = postgres
-HOST = 127.0.0.1:5432
-NAME = ov_forgejo
-USER = forgejo
-PASSWD = @DB_PASSWORD@
-SSL_MODE = disable
-MAX_OPEN_CONNS = 10
-MAX_IDLE_CONNS = 4
-
-[repository]
-ROOT = /var/lib/forgejo/repositories
-DEFAULT_BRANCH = main
-DEFAULT_PRIVATE = public
-
-[service]
-; People join by signing in with their OpenVibe account (the OpenVibe authentication source); there is no local sign-up.
-DISABLE_REGISTRATION = false
-ALLOW_ONLY_EXTERNAL_REGISTRATION = true
-SHOW_REGISTRATION_BUTTON = false
-REQUIRE_SIGNIN_VIEW = false
-DEFAULT_KEEP_EMAIL_PRIVATE = true
-DEFAULT_ALLOW_CREATE_ORGANIZATION = false
-ENABLE_NOTIFY_MAIL = false
-NO_REPLY_ADDRESS = noreply.openvibe.codes
-
-[oauth2_client]
-ENABLE_AUTO_REGISTRATION = true
-; goth's OpenID Connect provider puts the preferred_username claim (the OpenVibe username) in the nickname.
-USERNAME = nickname
-UPDATE_AVATAR = true
-ACCOUNT_LINKING = login
-OPENID_CONNECT_SCOPES = openid profile email
-
-[openid]
-ENABLE_OPENID_SIGNIN = false
-ENABLE_OPENID_SIGNUP = false
-
-[oauth2]
-; Forgejo as an OAuth provider is not needed: OpenVibe.Network is the identity provider.
-ENABLED = false
-
-[security]
-INSTALL_LOCK = true
-SECRET_KEY = @SECRET_KEY@
-INTERNAL_TOKEN = @INTERNAL_TOKEN@
-PASSWORD_HASH_ALGO = argon2
-MIN_PASSWORD_LENGTH = 16
-
-[session]
-PROVIDER = db
-COOKIE_SECURE = true
-COOKIE_NAME = ov_git_session
-
-[mailer]
-ENABLED = false
-
-[picture]
-DISABLE_GRAVATAR = true
-ENABLE_FEDERATED_AVATAR = false
-
-[ui]
-; openvibe: Forgejo's dark theme in OpenVibe's colours (custom/public/assets/css/theme-openvibe.css).
-DEFAULT_THEME = openvibe
-THEMES = openvibe,forgejo-auto,forgejo-light,forgejo-dark
-
-[ui.meta]
-AUTHOR = OpenVibers
-DESCRIPTION = OpenVibe Git: the OpenVibe projects' own source, with your OpenVibe account.
-
-[mirror]
-ENABLED = true
-DEFAULT_INTERVAL = 8h
-MIN_INTERVAL = 10m
-
-[migrations]
-ALLOWED_DOMAINS = github.com,*.github.com,codeberg.org
-ALLOW_LOCALNETWORKS = false
-
-[actions]
-ENABLED = false
-
-[federation]
-ENABLED = false
-
-[log]
-MODE = console
-LEVEL = Info
-
-[cron.update_mirrors]
-SCHEDULE = @every 1h
deleted file mode 100644
deleted file mode 100644
@@ -1,44 +0,0 @@
@@ -1,44 +0,0 @@
-/* OpenVibe Git: Forgejo's dark theme in OpenVibe's colours (the network's default "vibe" theme: deep navy, blue accent).
- Installed by deploy/forgejo/install.sh into /var/lib/forgejo/custom/public/assets/css/; app.ini makes it the default. */
-@import url("./theme-forgejo-dark.css");
-
-:root {
- --is-dark-theme: true;
- --color-body: #0b1220;
- --color-box-body: #131c2e;
- --color-box-body-highlight: #182338;
- --color-box-header: #111a2b;
- --color-nav-bg: #0f1729;
- --color-nav-hover-bg: #182338;
- --color-header-wrapper: #0f1729;
- --color-footer: #0f1729;
- --color-menu: #131c2e;
- --color-card: #131c2e;
- --color-input-background: #0f1729;
- --color-input-border: #2c3d5c;
- --color-input-border-hover: #3b82f6;
- --color-secondary: #1f2d47;
- --color-secondary-dark-1: #2c3d5c;
- --color-secondary-dark-2: #3a4e72;
- --color-light-border: #1f2d47;
- --color-text: #e6edf7;
- --color-text-light: #b7c4d9;
- --color-text-light-1: #96a7c2;
- --color-text-light-2: #7d8ea9;
- --color-text-light-3: #66768f;
- --color-primary: #3b82f6;
- --color-primary-contrast: #ffffff;
- --color-primary-dark-1: #2f74e8;
- --color-primary-dark-2: #2563eb;
- --color-primary-dark-3: #1d4ed8;
- --color-primary-light-1: #5b97f7;
- --color-primary-light-2: #7aabf8;
- --color-primary-light-3: #9abffa;
- --color-primary-alpha-10: #3b82f61a;
- --color-primary-alpha-20: #3b82f633;
- --color-primary-alpha-30: #3b82f64d;
- --color-primary-alpha-40: #3b82f666;
- --color-primary-alpha-50: #3b82f680;
- --color-accent: #3b82f6;
- --color-active-line: #3b82f622;
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,67 +0,0 @@
@@ -1,67 +0,0 @@
-#!/bin/bash
-# OpenVibe Git (Forgejo) on the host: the binary (checked against the release's sha256), its user and directories, a
-# PostgreSQL role and database, /etc/forgejo/app.ini from app.ini.template with secrets generated here, and the
-# systemd unit. Idempotent: a second run upgrades the binary to FORGEJO_VERSION and leaves the config and data alone.
-#
-# sudo bash deploy/forgejo/install.sh (from the Codes checkout on the host)
-#
-# Prints no secret. The database password and Forgejo's own keys exist only in /etc/forgejo/app.ini (root:forgejo 0660).
-set -euo pipefail
-VERSION="${FORGEJO_VERSION:-16.0.6}"
-KIT="$(cd "$(dirname "$0")" && pwd)"
-LIB=/usr/local/lib/forgejo
-URL="https://codeberg.org/forgejo/forgejo/releases/download/v${VERSION}/forgejo-${VERSION}-linux-amd64"
-[ "$(id -u)" = 0 ] || { echo "install.sh: run as root" >&2; exit 1; }
-
-# 1. The binary, verified.
-mkdir -p "$LIB"
-if [ ! -x "$LIB/forgejo-$VERSION" ]; then
- tmp=$(mktemp -d)
- curl -fsSL -o "$tmp/forgejo" "$URL"
- curl -fsSL -o "$tmp/forgejo.sha256" "$URL.sha256"
- want=$(cut -d' ' -f1 "$tmp/forgejo.sha256")
- have=$(sha256sum "$tmp/forgejo" | cut -d' ' -f1)
- [ -n "$want" ] && [ "$want" = "$have" ] || { echo "install.sh: sha256 mismatch for forgejo $VERSION" >&2; rm -rf "$tmp"; exit 1; }
- install -m 0755 "$tmp/forgejo" "$LIB/forgejo-$VERSION"
- rm -rf "$tmp"
- echo "forgejo $VERSION installed (sha256 verified)"
-fi
-ln -sfn "$LIB/forgejo-$VERSION" /usr/local/bin/forgejo
-
-# 2. Its user and directories.
-id forgejo >/dev/null 2>&1 || adduser --system --group --home /var/lib/forgejo --shell /bin/bash --disabled-password --gecos 'OpenVibe Git' forgejo
-install -d -o forgejo -g forgejo -m 0750 /var/lib/forgejo /var/lib/forgejo/custom /var/lib/forgejo/data /var/lib/forgejo/log /var/lib/forgejo/repositories
-install -d -o root -g forgejo -m 0770 /etc/forgejo
-
-# 3 and 4. The database and the config, once.
-if [ ! -f /etc/forgejo/app.ini ]; then
- pw=$(openssl rand -hex 24)
- printf "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'forgejo') THEN CREATE ROLE forgejo LOGIN PASSWORD '%s'; ELSE ALTER ROLE forgejo LOGIN PASSWORD '%s'; END IF; END \$\$;\n" "$pw" "$pw" \
- | sudo -u postgres psql -q -v ON_ERROR_STOP=1 >/dev/null
- if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'ov_forgejo'" | grep -q 1; then
- sudo -u postgres createdb -O forgejo -E UTF8 -T template0 ov_forgejo
- fi
- key=$(/usr/local/bin/forgejo generate secret SECRET_KEY)
- tok=$(/usr/local/bin/forgejo generate secret INTERNAL_TOKEN)
- umask 0007
- sed -e "s|@DB_PASSWORD@|$pw|" -e "s|@SECRET_KEY@|$key|" -e "s|@INTERNAL_TOKEN@|$tok|" "$KIT/app.ini.template" > /etc/forgejo/app.ini
- chown root:forgejo /etc/forgejo/app.ini
- chmod 0660 /etc/forgejo/app.ini
- echo "config written to /etc/forgejo/app.ini (database ov_forgejo, role forgejo)"
-fi
-
-# The OpenVibe look (custom/: the openvibe theme), refreshed on every run. app.ini names it; an install from before the
-# theme gets the two [ui] lines updated in place.
-cp -r "$KIT/custom/." /var/lib/forgejo/custom/
-chown -R forgejo:forgejo /var/lib/forgejo/custom
-if ! grep -q '^DEFAULT_THEME = openvibe' /etc/forgejo/app.ini; then
- sed -i -e 's/^DEFAULT_THEME = .*/DEFAULT_THEME = openvibe/' -e 's/^THEMES = .*/THEMES = openvibe,forgejo-auto,forgejo-light,forgejo-dark/' /etc/forgejo/app.ini
-fi
-
-# 5. The service.
-install -m 0644 "$KIT/openvibe-forgejo.service" /etc/systemd/system/openvibe-forgejo.service
-systemctl daemon-reload
-systemctl enable --now openvibe-forgejo.service >/dev/null
-systemctl restart openvibe-forgejo.service
-for _ in $(seq 1 30); do curl -fsS -o /dev/null http://127.0.0.1:5060/api/healthz && break; read -rt 1 < <(tail -f /dev/null) || true; done
-curl -fsS http://127.0.0.1:5060/api/healthz >/dev/null && echo "openvibe-forgejo is up on 127.0.0.1:5060" || { echo "install.sh: forgejo did not come up; journalctl -u openvibe-forgejo" >&2; exit 1; }
deleted file mode 100644
deleted file mode 100644
@@ -1,28 +0,0 @@
@@ -1,28 +0,0 @@
-#!/bin/bash
-# Read mirrors of OpenVibers' public GitHub repositories on OpenVibe Git, in the OpenVibers organisation: Forgejo pulls
-# each one every MIN_INTERVAL/DEFAULT_INTERVAL (app.ini [mirror]). GitHub stays canonical until a repository is moved
-# here (plan: one repository at a time); then the direction flips to a push mirror. Uses a short-lived site-admin API
-# token it creates and deletes. Idempotent: a repository already mirrored is skipped.
-#
-# sudo bash deploy/forgejo/mirror-github.sh (needs `gh` signed in as someone who can list OpenVibers)
-set -euo pipefail
-[ "$(id -u)" = 0 ] || { echo "mirror-github.sh: run as root" >&2; exit 1; }
-FJ=(sudo -u forgejo /usr/local/bin/forgejo --config /etc/forgejo/app.ini --work-path /var/lib/forgejo)
-API=http://127.0.0.1:5060/api/v1
-REPOS_FILE="${1:-}"
-name="mirror-$(date +%s)"
-token=$("${FJ[@]}" admin user generate-access-token --username openvibe-admin --token-name "$name" --scopes write:organization,write:repository --raw)
-# A token cannot delete itself through the API (403): remove it from Forgejo's database when the script ends.
-trap 'sudo -u postgres psql -d ov_forgejo -qtAc "DELETE FROM access_token WHERE name = '"'"'$name'"'"'" >/dev/null || true' EXIT
-call() { curl -fsS -H "Authorization: token $token" -H 'Content-Type: application/json' "$@"; }
-call -o /dev/null "$API/orgs/OpenVibers" 2>/dev/null || call -o /dev/null -X POST "$API/orgs" -d '{"username":"OpenVibers","full_name":"OpenVibers","website":"https://openvibe.network","visibility":"public","description":"The OpenVibe projects."}'
-if [ -n "$REPOS_FILE" ]; then list=$(cat "$REPOS_FILE"); else list=$(gh repo list OpenVibers --visibility public --limit 200 --json name -q '.[].name'); fi
-made=0; kept=0
-while read -r repo; do
- [ -n "$repo" ] || continue
- # Forgejo reserves names ending in .wiki (a repository's wiki): OpenVibe.Wiki is mirrored as OpenVibe-Wiki.
- local_name="$repo"; case "$repo" in *.wiki|*.Wiki) local_name="${repo%.*}-${repo##*.}" ;; esac
- if call -o /dev/null "$API/repos/OpenVibers/$local_name" 2>/dev/null; then kept=$((kept + 1)); continue; fi
- call -o /dev/null -X POST "$API/repos/migrate" -d "{\"clone_addr\":\"https://github.com/OpenVibers/$repo.git\",\"repo_owner\":\"OpenVibers\",\"repo_name\":\"$local_name\",\"mirror\":true,\"mirror_interval\":\"1h\",\"service\":\"git\",\"description\":\"Mirror of github.com/OpenVibers/$repo\"}" && made=$((made + 1)) || echo "mirror of $repo failed" >&2
-done <<< "$list"
-echo "mirrors: $made created, $kept already there"
deleted file mode 100644
deleted file mode 100644
@@ -1,23 +0,0 @@
@@ -1,23 +0,0 @@
-[Unit]
-Description=OpenVibe Git (Forgejo) at https://git.openvibe.codes/
-After=network.target postgresql.service
-Wants=postgresql.service
-
-[Service]
-Type=simple
-User=forgejo
-Group=forgejo
-WorkingDirectory=/var/lib/forgejo
-ExecStart=/usr/local/bin/forgejo web --config /etc/forgejo/app.ini --work-path /var/lib/forgejo
-Restart=always
-RestartSec=3
-Environment=USER=forgejo HOME=/var/lib/forgejo GITEA_WORK_DIR=/var/lib/forgejo
-NoNewPrivileges=true
-PrivateTmp=true
-ProtectSystem=full
-ProtectHome=true
-ReadWritePaths=/var/lib/forgejo /etc/forgejo
-MemoryMax=1G
-
-[Install]
-WantedBy=multi-user.target
deleted file mode 100644
deleted file mode 100644
@@ -1,32 +0,0 @@
@@ -1,32 +0,0 @@
-#!/bin/bash
-# After install.sh: the site admin (a random password, written to /root/forgejo-admin.txt, never printed) and the
-# "OpenVibe" sign-in: an OpenID Connect source on OpenVibe.Network's discovery document, with the OAuth client
-# `forgejo` that Network seeds (OpenVibe.Network server/db/database.js). Its secret is read from Network's database
-# here and handed to Forgejo directly. Idempotent: an existing admin or source is updated, not duplicated.
-#
-# sudo bash deploy/forgejo/setup-sso.sh
-set -euo pipefail
-[ "$(id -u)" = 0 ] || { echo "setup-sso.sh: run as root" >&2; exit 1; }
-FJ=(sudo -u forgejo /usr/local/bin/forgejo --config /etc/forgejo/app.ini --work-path /var/lib/forgejo)
-NETWORK_DB="${NETWORK_DB:-ov_network}"
-
-if ! "${FJ[@]}" admin user list --admin 2>/dev/null | awk 'NR > 1 { print $2 }' | grep -qx openvibe-admin; then
- umask 0077
- "${FJ[@]}" admin user create --admin --username openvibe-admin --email [email protected] --random-password --must-change-password=false > /root/forgejo-admin.txt
- echo "site admin openvibe-admin created (password in /root/forgejo-admin.txt)"
-fi
-
-secret=$(sudo -u postgres psql -d "$NETWORK_DB" -tAc "SELECT client_secret FROM oauth_clients WHERE client_id = 'forgejo'")
-[ -n "$secret" ] || { echo "setup-sso.sh: Network has no OAuth client 'forgejo' yet (deploy Network first)" >&2; exit 1; }
-args=(--name OpenVibe --provider openidConnect --key forgejo --secret "$secret"
- --auto-discover-url https://openvibe.network/.well-known/openid-configuration
- --scopes openid --scopes profile --scopes email --skip-local-2fa
- --icon-url https://openvibe.network/favicon.ico)
-id=$("${FJ[@]}" admin auth list 2>/dev/null | awk '$2 == "OpenVibe" { print $1 }')
-if [ -n "$id" ]; then
- "${FJ[@]}" admin auth update-oauth --id "$id" "${args[@]}" >/dev/null
- echo "OpenVibe sign-in updated (source $id)"
-else
- "${FJ[@]}" admin auth add-oauth "${args[@]}" >/dev/null
- echo "OpenVibe sign-in added"
-fi
@@ -23,15 +23,6 @@ server {
@@ -23,15 +23,6 @@ server {
location / { return 301 https://openvibe.codes$request_uri; }
location / { return 301 https://openvibe.codes$request_uri; }
}
}
-server {
- listen 80;
- listen [::]:80;
- server_name git.openvibe.codes;
-
- location /.well-known/acme-challenge/ { root /var/www/certbot; }
- location / { return 301 https://git.openvibe.codes$request_uri; }
-}
-
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
@@ -138,47 +129,5 @@ server {
@@ -138,47 +129,5 @@ server {
}
}
}
}
-# ── OpenVibe Git: Forgejo (deploy/forgejo) at https://git.openvibe.codes/ (its ROOT_URL) ──────────────
-# The openvibe.codes wildcard certificate covers the name; Cloudflare proxies it (HTTPS only, no SSH).
-# Pushes and clones are streamed (no buffering), up to 512 MB a request (Cloudflare's own limit is lower).
-server {
- listen 443 ssl;
- listen [::]:443 ssl;
- http2 on;
- server_name git.openvibe.codes;
- include snippets/security-txt.conf; # /.well-known/security.txt (the host's file)
-
- ssl_certificate /etc/letsencrypt/live/openvibe.codes/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/openvibe.codes/privkey.pem;
- ssl_protocols TLSv1.2 TLSv1.3;
-
- access_log /var/log/nginx/git.openvibe.codes.access.log;
- error_log /var/log/nginx/git.openvibe.codes.error.log;
-
- add_header X-Content-Type-Options nosniff always;
- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
-
- # Forgejo's own Prometheus endpoint stays off the internet (app.ini leaves [metrics] at its default, off).
- location ~* ^/metrics(/|$) { return 404; }
-
- # Meta's AI crawler (meta-externalagent) walked every commit of every repository past robots.txt's wildcard rules:
- # 97% of this site's requests, about two a second, and 8% of a core in Forgejo (2026-11-02). robots.txt now names
- # it (Disallow: /, in Forgejo's custom/public/robots.txt); until it reads that, it gets robots.txt and nothing else.
- set $meta_crawler 0;
- if ($http_user_agent ~* "meta-externalagent") { set $meta_crawler 1; }
- if ($uri = /robots.txt) { set $meta_crawler 0; }
- if ($meta_crawler) { return 403; }
-
- location / {
- client_max_body_size 512m;
- proxy_request_buffering off;
- proxy_buffering off;
- proxy_read_timeout 600s;
- proxy_send_timeout 600s;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $remote_addr;
- proxy_set_header X-Forwarded-Proto https;
- proxy_pass http://127.0.0.1:5060;
- }
-}
+# ── OpenVibe Git (git.openvibe.codes) is its own service since 2026-11-09: OpenVibers/OpenVibe-Git ships its vhost
+# (deploy/nginx/git.openvibe.codes.conf, 127.0.0.1:5270). Forgejo is retired; /git here still redirects there.
@@ -133,9 +133,10 @@ const ENV_NAMES = new Set([
@@ -133,9 +133,10 @@ const ENV_NAMES = new Set([
'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY',
'DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY',
'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY',
'OPENVIBE_AI_URL', 'OPENVIBE_AI_TOKEN', 'OPENVIBE_CODES_BASE_URL', 'OPENVIBE_CODES_API_KEY',
'OPENVIBE_CODES_MODEL', 'OPENVIBE_CODES_HEADERS',
'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',
+ // A repository on OpenVibe Git: the person's credential line for its host. The local root lets a session on the
+ // same host clone from the disk (G3) instead of the network. For git only: bin/openvibe-codes.js keeps them out
+ // of the agent's environment.
+ 'OPENVIBE_GIT_CREDENTIALS', 'OPENVIBE_GIT_LOCAL_ROOT',
]);
]);
/** The environment a job's input asks for: the allowlisted names only, each a short string. */
/** The environment a job's input asks for: the allowlisted names only, each a short string. */
@@ -538,7 +539,9 @@ function attachmentPrompt(files) {
@@ -538,7 +539,9 @@ function attachmentPrompt(files) {
* exist yet: never the base, never a protected name, never someone's existing branch (GitHub is asked first, and a push
* 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).
* 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.
+// The forges that answer Gitea's API (Gitea and its descendants): any the machine names in OPENVIBE_GITEA_HOSTS.
+// OpenVibe Git itself is ours now (git.openvibe.codes): its push works with the person's Git token, and opening a
+// change request there arrives with G2, so the request below answers until then.
function giteaHosts(env = process.env) {
function giteaHosts(env = process.env) {
return new Set(['git.openvibe.codes', ...String(env.OPENVIBE_GITEA_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
return new Set(['git.openvibe.codes', ...String(env.OPENVIBE_GITEA_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
}
}
@@ -567,7 +570,10 @@ async function pushForge(dir, job, { env, fetchImpl, base }) {
@@ -567,7 +570,10 @@ async function pushForge(dir, job, { env, fetchImpl, base }) {
await git(['checkout', '-b', branch], { cwd: dir });
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(['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 });
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)}` };
+ const own = host === 'git.openvibe.codes';
+ const out = { pushed: true, branch, branch_url: own
+ ? `https://${host}/${owner}/${name}/tree?ref=${encodeURIComponent(branch)}`
+ : `https://${host}/${owner}/${name}/src/branch/${encodeURIComponent(branch)}` };
if (job.push.pull_request) {
if (job.push.pull_request) {
const r = await fetchImpl(`https://${host}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`, {
const r = await fetchImpl(`https://${host}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`, {
method: 'POST',
method: 'POST',
@@ -637,7 +643,30 @@ function agentPrompt(job) {
@@ -637,7 +643,30 @@ function agentPrompt(job) {
}
}
/**
/**
- * Run one job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.
+ * The bare repository on this machine, when the job is for OpenVibe Git and its disk is here (G3: a Codes session
+ * on the same host as git.openvibe.codes clones from the disk, no network). The HTTPS address stays the origin, so
+ * the branch is pushed back over HTTPS with the person's token.
+ */
+const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
+const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
+function localGitSource(repoUrl, env = process.env) {
+ const root = String(env.OPENVIBE_GIT_LOCAL_ROOT || '').trim();
+ if (!root) return null;
+ let u;
+ try { u = new URL(String(repoUrl)); } catch { return null; }
+ if (u.protocol !== 'https:') return null;
+ const line = String(env.OPENVIBE_GIT_CREDENTIALS || '').split(/[\n,]+/).map((l) => l.trim()).filter(Boolean)[0];
+ let host = null;
+ try { host = line ? new URL(line).host.toLowerCase() : null; } catch { host = null; }
+ if (!host || u.host.toLowerCase() !== host) return null;
+ const parts = u.pathname.replace(/\.git$/i, '').split('/').filter(Boolean);
+ if (parts.length !== 2 || !OWNER_RE.test(parts[0]) || !NAME_RE.test(parts[1])) return null;
+ const bare = path.join(root, 'repos', parts[0], `${parts[1]}.git`);
+ try { return fs.statSync(bare).isDirectory() ? bare : null; } catch { return null; }
+}
+
+/**
+ * One job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.
* → the result document.
* → the result document.
*/
*/
async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch, proof = null }) {
async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch, proof = null }) {
@@ -662,8 +691,13 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -662,8 +691,13 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
if (job.kind === 'code') {
if (job.kind === 'code') {
say({ t: 'step', text: `Cloning ${scrub(job.repo)}${job.ref ? ` (${job.ref})` : ''}` });
say({ t: 'step', text: `Cloning ${scrub(job.repo)}${job.ref ? ` (${job.ref})` : ''}` });
const auth = gitAuthFor(job.repo, env);
const auth = gitAuthFor(job.repo, env);
+ const local = localGitSource(job.repo, env);
try {
try {
- await git(['clone', '--depth', '50', ...(job.ref ? ['--branch', job.ref] : []), '--', job.repo, work], { timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });
+ await git(['clone', '--depth', '50', ...(job.ref ? ['--branch', job.ref] : []), '--', local || job.repo, work], { timeout: 300_000, ...(auth.env ? { env: auth.env } : {}) });
+ if (local) {
+ await git(['remote', 'set-url', 'origin', job.repo], { cwd: work });
+ say({ t: 'step', text: 'Cloned from the disk on this host; the branch is pushed over HTTPS' });
+ }
} catch (e) { throw new Error(cloneProblem(e.message, job.repo)); }
} catch (e) { throw new Error(cloneProblem(e.message, job.repo)); }
if (auth.env) say({ t: 'step', text: 'Cloned with this machine\'s token for a private repository' });
if (auth.env) say({ t: 'step', text: 'Cloned with this machine\'s token for a private repository' });
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
@@ -819,4 +853,4 @@ function resultWriter(emit) {
@@ -819,4 +853,4 @@ function resultWriter(emit) {
};
};
}
}
-module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, attachmentPrompt, agentPrompt, jobAttachments };
+module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, localGitSource, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, attachmentPrompt, agentPrompt, jobAttachments };
@@ -57,7 +57,6 @@ const { createKnowledgeApi } = require('./http/knowledge-api');
@@ -57,7 +57,6 @@ const { createKnowledgeApi } = require('./http/knowledge-api');
const { createMovedRoutes } = require('./http/moved');
const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
-const { createForgejoClient } = require('./domain/forgejo');
const createAiCatalogConfig = require('./ai-catalog-config').create;
const createAiCatalogConfig = require('./ai-catalog-config').create;
const createOwnerInputs = require('./owner-inputs-config').create;
const createOwnerInputs = require('./owner-inputs-config').create;
const { createOwnerInputTests } = require('./owner-inputs-tests');
const { createOwnerInputTests } = require('./owner-inputs-tests');
@@ -93,17 +92,14 @@ async function createApp(opts = {}) {
@@ -93,17 +92,14 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
// D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.
// D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.
// The owner's outside accounts (codes.accounts, codes.publishing; the owner-setup design): sealed in Codes' store,
// The owner's outside accounts (codes.accounts, codes.publishing; the owner-setup design): sealed in Codes' store,
- // read at use time with the environment as the fallback. config.git's app and config.github's token become getters,
- // so a value entered in the admin panel takes effect without a restart.
+ // read at use time with the environment as the fallback. config.github's token becomes a getter, so a value
+ // entered in the admin panel takes effect without a restart.
const ownerInputs = await createOwnerInputs({ db: store.db, env: opts.env || process.env, log });
const ownerInputs = await createOwnerInputs({ db: store.db, env: opts.env || process.env, log });
- const envGit = { clientId: config.git.clientId, clientSecret: config.git.clientSecret };
const envGithubToken = config.github ? config.github.token : '';
const envGithubToken = config.github ? config.github.token : '';
const fromPanel = (key, fallback) => { const v = ownerInputs.accounts.get(key); return typeof v === 'string' && v ? v : (fallback || ''); };
const fromPanel = (key, fallback) => { const v = ownerInputs.accounts.get(key); return typeof v === 'string' && v ? v : (fallback || ''); };
- Object.defineProperty(config.git, 'clientId', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_id', envGit.clientId) });
- Object.defineProperty(config.git, 'clientSecret', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_secret', envGit.clientSecret) });
config.github = config.github || {};
config.github = config.github || {};
Object.defineProperty(config.github, 'token', { enumerable: true, configurable: true, get: () => fromPanel('github.token', envGithubToken) });
Object.defineProperty(config.github, 'token', { enumerable: true, configurable: true, get: () => fromPanel('github.token', envGithubToken) });
- const findTest = createOwnerInputTests({ config, ownerInputs, fetchImpl: opts.fetchImpl || globalThis.fetch });
+ const findTest = createOwnerInputTests({ ownerInputs, fetchImpl: opts.fetchImpl || globalThis.fetch });
const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log, also: ownerInputs.stores, test: findTest });
const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log, also: ownerInputs.stores, test: findTest });
Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
@@ -143,15 +139,14 @@ async function createApp(opts = {}) {
@@ -143,15 +139,14 @@ async function createApp(opts = {}) {
const skills = opts.skills || createSkills({ store, sharing, log });
const skills = opts.skills || createSkills({ store, sharing, log });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
- ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
// 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 = 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 });
+ // 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 });
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
@@ -48,17 +48,15 @@ function load(env = process.env) {
@@ -48,17 +48,15 @@ function load(env = process.env) {
// OpenVibe.Report (the session report of a finished task, Agent Streams S3).
// OpenVibe.Report (the session report of a finished task, Agent Streams S3).
reportInternalUrl: trim(env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080'),
reportInternalUrl: trim(env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
- // OpenVibe Git (Forgejo, deploy/forgejo): its loopback address for the home page's repository list (public
- // repositories only, no token), and the public address people use.
+ // OpenVibe Git (git.openvibe.codes): its loopback address for the home page's repository list and the API
+ // calls, and the public address people use. Codes acts for a person with its own Network service token
+ // (audience openvibe.git), so there is no app secret of its own here.
git: {
git: {
- internalUrl: trim(env.CODES_GIT_INTERNAL_URL || 'http://127.0.0.1:5060'),
+ internalUrl: trim(env.CODES_GIT_INTERNAL_URL || 'http://127.0.0.1:5270'),
publicUrl: trim(env.CODES_GIT_URL || 'https://git.openvibe.codes'),
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 || '',
+ // G3: the directory OpenVibe Git keeps its bare repositories in, when Codes runs on the same host. A
+ // session then clones from the disk instead of the network; empty anywhere else.
+ dataDir: trim(env.CODES_GIT_DATA_DIR || ''),
},
},
// OpenVibe.Live (Agent Streams S1, Dock K-D3): the person's own agent streams, read and steered with their own
// OpenVibe.Live (Agent Streams S1, Dock K-D3): the person's own agent streams, read and steered with their own
// token for the Dock's agent view (server/http/agent-view.js). Codes holds no token of its own here.
// token for the Dock's agent view (server/http/agent-view.js). Codes holds no token of its own here.
@@ -1,168 +1,146 @@
@@ -1,168 +1,146 @@
'use strict';
'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.
+ * A person's OpenVibe Git (git.openvibe.codes) through Codes' service token. Codes holds a typed Network token
+ * for audience openvibe.git (client credentials, git.repo.read and git.repo.write) and names the person it works for in
+ * X-OV-Subject; OpenVibe.Git makes the repository in their name and mints their clone token. Codes never holds a
+ * person's password and never asks them to link an account: the OpenVibe sign-in in Codes is the only one.
*
*
- * 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
+ * createForge({ config, fetchImpl, log })
+ * enabled Codes has its Network client (a service token can be had)
+ * publicUrl, host where OpenVibe Git is
+ * repo(owner, name) → one repository (default_branch, …) or null
+ * createRepo({ subject, username }, { name, description, isPrivate }) → { full_name, html_url, clone_url, private }
+ * repos({ subject, username }) → their repositories, last changed first (up to 50)
+ * jobEnv(subject, repoUrl) → { OPENVIBE_GIT_CREDENTIALS, OPENVIBE_GIT_LOCAL_ROOT? } 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.
+ * The loopback address is config.git.internalUrl (the default port is OpenVibe.Git's 5270), so no call leaves the
+ * host. The service token is cached by the SDK's client until a minute before it ends; no token is ever logged or
+ * returned by a route.
*/
*/
-const { createSeal } = require('./seal');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
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}$/;
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 }) {
+function createForge({ config, fetchImpl = globalThis.fetch, log = console }) {
const git = config.git || {};
const git = config.git || {};
const publicUrl = String(git.publicUrl || 'https://git.openvibe.codes').replace(/\/+$/, '');
const publicUrl = String(git.publicUrl || 'https://git.openvibe.codes').replace(/\/+$/, '');
const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');
const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');
+ const dataDir = String(git.dataDir || '').replace(/\/+$/, '');
const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();
const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();
- const seal = createSeal(git.sealKey);
- // At use time: the app's ID and secret may be entered in the admin panel after boot (codes.accounts forge.*).
- const isEnabled = () => !!(git.clientId && git.clientSecret && seal);
- const redirectUri = `${String(config.baseUrl || '').replace(/\/+$/, '')}/git/callback`;
- const db = store.db;
+ // At use time: Codes' Network client may be set in the admin panel after boot (codes.accounts forge.* is gone;
+ // the service's own OAuth client, codes, is what a service token is minted with).
+ const tokens = (config.oauth && config.oauth.clientId && config.oauth.clientSecret)
+ ? createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: 'openvibe.git', scope: 'git.repo.read git.repo.write', fetch: fetchImpl,
+ })
+ : null;
+ const isEnabled = () => !!tokens;
const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
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],
- );
+ /** One call to OpenVibe.Git's API, as Codes; `subject` is the person it acts for. */
+ async function call(method, pathname, { subject = null, json = null, auth = true, timeoutMs = 8000 } = {}) {
+ const headers = { Accept: 'application/json' };
+ if (auth) headers.Authorization = `Bearer ${await tokens.getToken()}`;
+ if (subject) headers['X-OV-Subject'] = subject;
+ if (json != null) headers['Content-Type'] = 'application/json';
+ let res;
+ try {
+ res = await fetchImpl(`${internalUrl}${pathname}`, {
+ method, headers, ...(json != null ? { body: JSON.stringify(json) } : {}),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (e) {
+ throw fail(503, 'git.unreachable', `OpenVibe Git did not answer: ${e.message}`);
+ }
+ const text = await res.text().catch(() => '');
+ let doc = null;
+ try { doc = text ? JSON.parse(text) : null; } catch { doc = null; }
+ return { status: res.status, doc };
}
}
- async function connect(subject, { code, verifier }) {
- if (!isEnabled()) 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 repo(owner, name) {
+ if (!isEnabled()) return null;
+ const r = await call('GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, { auth: false }).catch(() => null);
+ return r && r.status === 200 ? r.doc : null;
}
}
- 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 createRepo(actor, { name, description = '', isPrivate = false } = {}) {
+ const n = String(name || '').trim().toLowerCase();
+ if (!REPO_NAME.test(n) || /\.git$/i.test(n)) throw fail(422, 'git.name_invalid', 'A name is letters, digits, dots, dashes or underscores (up to 100), and not ending in .git');
+ if (!isEnabled()) throw fail(503, 'git.off', 'OpenVibe Git is not open from Codes yet');
+ if (!USER.test(String(actor && actor.subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');
+ if (!LOGIN.test(String(actor.username || ''))) throw fail(409, 'git.unlinked', 'Your OpenVibe account has no username Codes can make a repository under yet');
+ const r = await call('POST', '/api/v1/repos', {
+ subject: actor.subject,
+ json: {
+ name: n, owner_username: actor.username, description: String(description || '').slice(0, 500),
+ visibility: isPrivate ? 'private' : 'public', starter: 'empty',
+ },
+ });
+ if (r.status === 409) throw fail(409, 'git.name_taken', r.doc && r.doc.detail && /username/.test(r.doc.detail) ? r.doc.detail : `You already have a repository called ${n}`);
+ if (r.status !== 201 || !r.doc) throw fail(502, 'git.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.visibility === 'private' };
}
}
- 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 (!isEnabled()) 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 repos(actor) {
+ 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);
+ 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',
+ updated_at: x.pushed_at || x.updated_at || null, description: x.description || '',
+ })).sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
}
}
- 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 };
+ /** A Git token for the person's session, minted by the service and good for two hours. */
+ async function mintToken(subject) {
+ if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');
+ const r = await call('POST', '/api/v1/tokens', { subject, json: { scopes: ['git.repo.read', 'git.repo.write'], hours: 2, label: 'Codes session' } });
+ if (r.status !== 201 || !r.doc || !r.doc.token) throw fail(502, 'git.token_refused', `OpenVibe Git did not hand over a token (${r.status})`);
+ return r.doc.token;
}
}
- 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 || '')));
+ /** The bare repository on this host, when Codes and OpenVibe Git share the disk (G3: no network clone). */
+ function localRoot(repoUrl) {
+ if (!dataDir) return null;
+ let u;
+ try { u = new URL(String(repoUrl || '')); } catch { return null; }
+ if (u.protocol !== 'https:' || u.host.toLowerCase() !== host) return null;
+ const parts = u.pathname.replace(/\.git$/i, '').split('/').filter(Boolean);
+ if (parts.length !== 2 || !LOGIN.test(parts[0]) || !REPO_NAME.test(parts[1])) return null;
+ const bare = path.join(dataDir, 'repos', parts[0], `${parts[1]}.git`);
+ try { return fs.statSync(bare).isDirectory() ? dataDir : null; } catch { return null; }
}
}
/**
/**
- * 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.
+ * What a job on the person's machine needs for a repository on OpenVibe Git: one credential line for its host
+ * (harness/job.js gitAuthFor), so a private repository clones and the branch pushes. The token lives two hours.
+ * When Codes and OpenVibe Git share their disk, the job also learns where the bare repositories are: it clones
+ * from the local disk and pushes over HTTPS as usual.
*/
*/
async function jobEnv(subject, repoUrl) {
async function jobEnv(subject, repoUrl) {
let u;
let u;
try { u = new URL(String(repoUrl || '')); } catch { return null; }
try { u = new URL(String(repoUrl || '')); } catch { return null; }
if (u.protocol !== 'https:' || u.host.toLowerCase() !== host) 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}` };
+ let token;
+ try { token = await mintToken(subject); } catch (e) {
+ log.warn(`[Codes] no OpenVibe Git token for a session: ${String(e && e.message).slice(0, 120)}`);
+ return null;
+ }
+ const root = localRoot(repoUrl);
+ return {
+ OPENVIBE_GIT_CREDENTIALS: `https://x-access-token:${encodeURIComponent(token)}@${host}`,
+ ...(root ? { OPENVIBE_GIT_LOCAL_ROOT: root } : {}),
+ };
}
}
- return { get enabled() { return isEnabled(); }, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };
+ return { get enabled() { return isEnabled(); }, publicUrl, host, repo, createRepo, repos, mintToken, jobEnv };
}
}
module.exports = { createForge, REPO_NAME };
module.exports = { createForge, REPO_NAME };
deleted file mode 100644
deleted file mode 100644
@@ -1,133 +0,0 @@
@@ -1,133 +0,0 @@
-'use strict';
-
-/**
- * OpenVibe Git (Forgejo) client for the server side (plan 2026-10-28 §6, decision 7):
- * branches, pull requests, reviews and webhooks on the OpenVibe forge. The internal address is read from
- * config.git.internalUrl; Forgejo listens on 127.0.0.1:5060, a port the Fetch standard blocks, so this client uses
- * node:http / node:https. A caller without a token gets `forgejo.unconfigured`; a 4xx/5xx becomes a plain Error with
- * the Forgejo message and a `status` property.
- *
- * createForgejoClient({ config, fetchImpl?, log? })
- * getRepo(owner, name)
- * listBranches(owner, name)
- * createBranch(owner, name, { base_branch, new_branch_name })
- * listPullRequests(owner, name, { state })
- * createPullRequest(owner, name, { base, head, title, body })
- * getPullRequest(owner, name, index)
- * listReviews(owner, name, index)
- * createWebhook(owner, name, { type, config, events })
- * deleteWebhook(owner, name, id)
- */
-
-const http = require('http');
-const https = require('https');
-
-function slug(s) {
- return String(s || '').replace(/^\/+|\/+$/g, '');
-}
-
-function requestJson(base, token, method, path, body, { timeoutMs = 15000 } = {}) {
- return new Promise((resolve, reject) => {
- const u = new URL(`${base.replace(/\/+$/, '')}/${slug(path)}`);
- const lib = u.protocol === 'https:' ? https : http;
- const payload = body == null ? null : JSON.stringify(body);
- const req = lib.request(u, {
- method,
- headers: {
- Accept: 'application/json',
- ...(token ? { Authorization: `token ${token}` } : {}),
- ...(payload ? { 'Content-Type': 'application/json' } : {}),
- },
- timeout: timeoutMs,
- }, (res) => {
- let raw = '';
- res.setEncoding('utf8');
- res.on('data', (c) => { raw += c; if (raw.length > 4_000_000) req.destroy(new Error('response too large')); });
- res.on('end', () => {
- let parsed = null;
- if (raw.trim()) {
- try { parsed = JSON.parse(raw); } catch (e) { parsed = { message: raw.slice(0, 500) }; }
- }
- if (res.statusCode >= 400) {
- const err = new Error(parsed && parsed.message ? String(parsed.message) : `Forgejo ${res.statusCode}`);
- err.status = res.statusCode;
- err.body = parsed;
- return reject(err);
- }
- resolve({ status: res.statusCode, body: parsed });
- });
- });
- req.on('timeout', () => req.destroy(new Error('timeout')));
- req.on('error', reject);
- if (payload) req.write(payload);
- req.end();
- });
-}
-
-function createForgejoClient({ config, log = console }) {
- const base = String((config && config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');
- const publicUrl = String((config && config.git && config.git.publicUrl) || 'https://git.openvibe.codes').replace(/\/+$/, '');
- const token = (process.env.CODES_GIT_TOKEN || process.env.FORGEJO_TOKEN || '').trim();
-
- function configured() {
- if (!token) return { ok: false, code: 'forgejo.unconfigured', detail: 'CODES_GIT_TOKEN is not set' };
- return { ok: true };
- }
-
- function call(method, path, body) {
- const check = configured();
- if (!check.ok) {
- const err = new Error(check.detail);
- err.code = check.code;
- err.status = 503;
- return Promise.reject(err);
- }
- return requestJson(base, token, method, `api/v1/${path}`, body);
- }
-
- const repoPath = (owner, name) => `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
-
- return {
- baseUrl: base,
- publicUrl,
- configured,
- async getRepo(owner, name) {
- const { body } = await call('GET', `${repoPath(owner, name)}`);
- return body;
- },
- async listBranches(owner, name) {
- const { body } = await call('GET', `${repoPath(owner, name)}/branches`);
- return Array.isArray(body) ? body : [];
- },
- async createBranch(owner, name, { base_branch, new_branch_name }) {
- const { body } = await call('POST', `${repoPath(owner, name)}/branches`, { base_branch, new_branch_name });
- return body;
- },
- async listPullRequests(owner, name, { state = 'open' } = {}) {
- const { body } = await call('GET', `${repoPath(owner, name)}/pulls?state=${encodeURIComponent(state)}`);
- return Array.isArray(body) ? body : [];
- },
- async createPullRequest(owner, name, { base, head, title, body: prBody }) {
- const { body } = await call('POST', `${repoPath(owner, name)}/pulls`, { base, head, title, body: prBody });
- return body;
- },
- async getPullRequest(owner, name, index) {
- const { body } = await call('GET', `${repoPath(owner, name)}/pulls/${encodeURIComponent(index)}`);
- return body;
- },
- async listReviews(owner, name, index) {
- const { body } = await call('GET', `${repoPath(owner, name)}/pulls/${encodeURIComponent(index)}/reviews`);
- return Array.isArray(body) ? body : [];
- },
- async createWebhook(owner, name, { type = 'forgejo', config, events = ['push', 'pull_request'] }) {
- const { body } = await call('POST', `${repoPath(owner, name)}/hooks`, { type, config, events });
- return body;
- },
- async deleteWebhook(owner, name, id) {
- const { body } = await call('DELETE', `${repoPath(owner, name)}/hooks/${encodeURIComponent(id)}`);
- return body;
- },
- };
-}
-
-module.exports = { createForgejoClient, requestJson };
@@ -257,8 +257,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -257,8 +257,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
*/
*/
/**
/**
* A job's input with its environment: the run token's (attemptEnv), and for a repository on OpenVibe Git the
* 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).
+ * person's Git token line (server/domain/forge.js jobEnv), so their machine clones a private repository and
+ * pushes the branch; when Codes shares the host's disk it also names the bare repositories for a local clone.
+ * The job keeps both out of the agent's environment (harness/job.js).
*/
*/
let forge = null;
let forge = null;
async function withEnv(subject, args, env) {
async function withEnv(subject, args, env) {
deleted file mode 100644
deleted file mode 100644
@@ -1,40 +0,0 @@
@@ -1,40 +0,0 @@
-'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 };
@@ -1,24 +1,18 @@
@@ -1,24 +1,18 @@
'use strict';
'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).
+ * OpenVibe Git in Codes (/git): your repositories on git.openvibe.codes, and a repository made there in your name
+ * and opened in a new chat. There is nothing to link: Codes signs in with OpenVibe, holds a service token for
+ * OpenVibe Git, and acts for the person already signed in (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)
+ * GET /git your repositories and a new-repository form
+ * POST /git/repos a new repository in your name, then a new chat on it
*
*
* Every page is the person's own (signed in), every form a same-origin post; no token ever reaches a page.
* 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 express = require('express');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
-const STATE_COOKIE = 'codes_forge_oauth';
-
function createGitRoutes(ctx) {
function createGitRoutes(ctx) {
const r = asyncRouter();
const r = asyncRouter();
const { config, forge } = ctx;
const { config, forge } = ctx;
@@ -31,7 +25,6 @@ function createGitRoutes(ctx) {
@@ -31,7 +25,6 @@ function createGitRoutes(ctx) {
};
};
const signIn = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
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 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 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 header = (title, desc) => `<div class="c4-title"><div><h1>${esc(title)}</h1><p>${esc(desc)}</p></div></div>`;
@@ -46,80 +39,44 @@ function createGitRoutes(ctx) {
@@ -46,80 +39,44 @@ function createGitRoutes(ctx) {
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.');
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>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>`);
+ 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>`);
}
}
- 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 who = { subject: req.viewer.subject, username: req.viewer.username || '' };
+ if (!who.username) {
+ return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>One more step on your account</h2></div>
+<p class="c4-sub">Your OpenVibe account has no username yet, so a repository has no address to live at. Choose one at <a href="https://openvibe.network/my" rel="noopener">openvibe.network/my</a>, then come back.</p></section>`);
}
}
- const mine = await forge.repos(req.viewer.subject);
+ const mine = await forge.repos(who);
const list = mine.length
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&repo=${encodeURIComponent(x.html_url)}">Work on it</a></li>`).join('')}</ul>`
? `<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&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>';
: '<p class="c4-emptyline">No repositories yet. Make your first one above.</p>';
return page(req, res, `${head}${flash(req)}
return page(req, res, `${head}${flash(req)}
<section class="c4-card"><div class="c4-card-head"><h2>A new repository</h2></div>
<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>
+<p class="c4-sub">It starts with a README on <code class="c4-code-inline">main</code>, in your name (${esc(who.username)}), and opens straight in a new chat.</p>
<form class="c4-form" method="post" action="/git/repos">
<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-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 for="git-desc">What it is <small class="c4-muted">(optional)</small></label><br><input id="git-desc" name="description" maxlength="500" 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><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>
<p><button class="c4-button" type="submit">Make it and start</button></p>
</form></section>
</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.');
- }
+<section class="c4-card"><div class="c4-card-head"><h2>Your repositories</h2><a href="${esc(forge.publicUrl)}/${encodeURIComponent(who.username)}" rel="noopener">On OpenVibe Git</a></div>${list}</section>`);
});
});
r.post('/git/repos', form, async (req, res) => {
r.post('/git/repos', form, async (req, res) => {
if (!viewer(req)) return signIn(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 (!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.');
+ if (!forge || !forge.enabled) return back(res, 'error', 'OpenVibe Git is not connected from Codes yet.');
const b = req.body || {};
const b = req.body || {};
try {
try {
- const made = await forge.createRepo(req.viewer.subject, { name: b.name, description: b.description, isPrivate: b.private === '1' });
+ const made = await forge.createRepo({ subject: req.viewer.subject, username: req.viewer.username || '' }, { name: b.name, description: b.description, isPrivate: b.private === '1' });
return res.redirect(303, `/?mode=code&repo=${encodeURIComponent(made.html_url)}`);
return res.redirect(303, `/?mode=code&repo=${encodeURIComponent(made.html_url)}`);
} catch (e) {
} 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.');
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;
return r;
}
}
-module.exports = { createGitRoutes, STATE_COOKIE };
+module.exports = { createGitRoutes };
@@ -3,15 +3,15 @@
@@ -3,15 +3,15 @@
* The home page's sections beyond the kit's generic ones: OpenVibe Git (the forge at git.openvibe.codes, read from its
* The home page's sections beyond the kit's generic ones: OpenVibe Git (the forge at git.openvibe.codes, read from its
* public API: how many repositories, which changed last), quick-start commands for the harness and the public router,
* public API: how many repositories, which changed last), quick-start commands for the harness and the public router,
* and the questions people ask, which the FAQPage JSON-LD repeats. The repository list is read in the background at most
* and the questions people ask, which the FAQPage JSON-LD repeats. The repository list is read in the background at most
- * every ten minutes and never awaited by a page; a forge that does not answer leaves the list out.
+ * every ten minutes and never awaited by a page; a Git that does not answer leaves the list out.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const esc = showcase.esc;
const esc = showcase.esc;
-// Forgejo listens on 127.0.0.1:5060, a port the Fetch standard blocks (SIP), so Node's fetch refuses it ("bad port"):
-// the read goes through node:http instead, which has no such list.
+// OpenVibe Git's API is read over node:http on its loopback address; its default port (5270) is not a blocked one,
+// but the reader stays on node:http so a configured port can never be refused by the Fetch standard.
function getJson(url, timeoutMs) {
function getJson(url, timeoutMs) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const u = new URL(url);
const u = new URL(url);
@@ -36,13 +36,12 @@ function createGitReader({ internalUrl, publicUrl, enabled = true, ttlMs = 10 *
@@ -36,13 +36,12 @@ function createGitReader({ internalUrl, publicUrl, enabled = true, ttlMs = 10 *
if (inflight) return inflight;
if (inflight) return inflight;
inflight = (async () => {
inflight = (async () => {
try {
try {
- const res = await getJson(`${String(internalUrl).replace(/\/+$/, '')}/api/v1/repos/search?limit=${limit}&sort=updated&order=desc`, timeoutMs);
+ const res = await getJson(`${String(internalUrl).replace(/\/+$/, '')}/api/v1/repos?limit=${limit}&sort=pushed`, timeoutMs);
if (res.status === 200) {
if (res.status === 200) {
- const { body, total } = res;
- const repos = (Array.isArray(body.data) ? body.data : [])
- .filter((r) => r && !r.private && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(r.full_name || '')))
- .map((r) => ({ name: r.full_name, url: `${pub}/${r.full_name}`, updated: r.updated_at || null, language: r.language || '' }));
- if (repos.length) value = { total: total && total >= repos.length ? total : null, repos, publicUrl: pub };
+ const repos = (Array.isArray(res.body && res.body.repositories) ? res.body.repositories : [])
+ .filter((r) => r && r.visibility !== 'private' && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(r.full_name || '')))
+ .map((r) => ({ name: r.full_name, url: `${pub}/${r.full_name}`, updated: r.pushed_at || r.updated_at || null, language: r.language || '' }));
+ if (repos.length) value = { total: null, repos, publicUrl: pub };
}
}
} catch { /* keep the last answer */ }
} catch { /* keep the last answer */ }
// A failed read is tried again after a minute, not after the full ten.
// A failed read is tried again after a minute, not after the full ten.
@@ -317,7 +317,7 @@ ${offers.map((o) => html`<h3>${o.name} <code>${o.id}</code></h3>${o.agents.lengt
@@ -317,7 +317,7 @@ ${offers.map((o) => html`<h3>${o.name} <code>${o.id}</code></h3>${o.agents.lengt
});
});
return created.id;
return created.id;
};
};
- const repoForgejoUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}`;
+ const repoGitUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}`;
const repoCloneUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}.git`;
const repoCloneUrl = (owner, name) => `${config.git.publicUrl}/${owner}/${name}.git`;
// The four steps of every change; the index is step 1, a repository's page is step 2.
// The four steps of every change; the index is step 1, a repository's page is step 2.
@@ -359,18 +359,18 @@ ${raw(lists)}
@@ -359,18 +359,18 @@ ${raw(lists)}
async function improveRepoPage(owner, name, { signed = false } = {}) {
async function improveRepoPage(owner, name, { signed = false } = {}) {
const meta = repoMeta().find((m) => m.repository === `${owner}/${name}`);
const meta = repoMeta().find((m) => m.repository === `${owner}/${name}`);
if (!meta) return null;
if (!meta) return null;
- let forgejoInfo = null;
- let forgejoError = null;
- if (signed) {
- try { forgejoInfo = await ctx.forgejo.getRepo(owner, name); } catch (e) { forgejoError = e.code === 'forgejo.unconfigured' ? null : e.message; }
+ let gitInfo = null;
+ let gitError = null;
+ if (signed && ctx.forge && ctx.forge.enabled) {
+ try { gitInfo = await ctx.forge.repo(owner, name); } catch (e) { gitError = e.message; }
}
}
const agents = harnesses.list();
const agents = harnesses.list();
const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
const form = signed ? html`<form class="run-card run-form" method="post" action="/improve/start">
const form = signed ? html`<form class="run-card run-form" method="post" action="/improve/start">
-<input type="hidden" name="repo" value="${repoForgejoUrl(owner, name)}">
-<input type="hidden" name="ref" value="${(forgejoInfo && forgejoInfo.default_branch) || 'main'}">
+<input type="hidden" name="repo" value="${repoGitUrl(owner, name)}">
+<input type="hidden" name="ref" value="${(gitInfo && gitInfo.default_branch) || 'main'}">
<h2>Start a guided run</h2>
<h2>Start a guided run</h2>
-${forgejoError ? html`<p class="run-notice run-notice-error" role="alert">OpenVibe Git is not reachable right now (${forgejoError}); you can still start a run on your machine.</p>` : ''}
+${gitError ? html`<p class="run-notice run-notice-error" role="alert">OpenVibe Git is not reachable right now (${gitError}); you can still start a run on your machine.</p>` : ''}
<label class="run-field"><span>What should the agent change?</span><textarea name="prompt" rows="5" required maxlength="16000" placeholder="Add a test for the date parser and fix what it finds"></textarea></label>
<label class="run-field"><span>What should the agent change?</span><textarea name="prompt" rows="5" required maxlength="16000" placeholder="Add a test for the date parser and fix what it finds"></textarea></label>
<div class="run-field-row">
<div class="run-field-row">
<label class="run-field"><span>Agent</span><select name="agent">${opt('', 'Any: the best fit, with hand-offs', true)}${agents.map((a) => opt(a.id, a.name))}</select></label>
<label class="run-field"><span>Agent</span><select name="agent">${opt('', 'Any: the best fit, with hand-offs', true)}${agents.map((a) => opt(a.id, a.name))}</select></label>
@@ -391,7 +391,7 @@ ${forgejoError ? html`<p class="run-notice run-notice-error" role="alert">OpenVi
@@ -391,7 +391,7 @@ ${forgejoError ? html`<p class="run-notice run-notice-error" role="alert">OpenVi
${raw(improveSteps(1))}
${raw(improveSteps(1))}
${form}
${form}
${raw(kit.fold({ title: 'How the run works', body: '<ol class="steps"><li>The harness clones the repository onto <strong>your machine</strong> and checks out the default branch.</li><li>The agent makes the change you described and runs the tests.</li><li>If you asked it to push, it commits to a new branch and opens a pull request on OpenVibe Git.</li><li>You review the result, then the maintainers review the pull request.</li></ol>' }))}
${raw(kit.fold({ title: 'How the run works', body: '<ol class="steps"><li>The harness clones the repository onto <strong>your machine</strong> and checks out the default branch.</li><li>The agent makes the change you described and runs the tests.</li><li>If you asked it to push, it commits to a new branch and opens a pull request on OpenVibe Git.</li><li>You review the result, then the maintainers review the pull request.</li></ol>' }))}
-${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.snippet({ code: `git clone ${repoCloneUrl(owner, name)}`, id: 'clone-cmd' })}<p class="ovk-muted">Browse it at <a href="${kit.esc(repoForgejoUrl(owner, name))}">${kit.esc(repoForgejoUrl(owner, name))}</a> or on <a href="${kit.esc(`${GITHUB}/${name}`)}">GitHub</a>.</p>` }))}`,
+${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.snippet({ code: `git clone ${repoCloneUrl(owner, name)}`, id: 'clone-cmd' })}<p class="ovk-muted">Browse it at <a href="${kit.esc(repoGitUrl(owner, name))}">${kit.esc(repoGitUrl(owner, name))}</a> or on <a href="${kit.esc(`${GITHUB}/${name}`)}">GitHub</a>.</p>` }))}`,
};
};
}
}
r.get('/improve/:owner/:name', async (req, res) => {
r.get('/improve/:owner/:name', async (req, res) => {
@@ -52,7 +52,7 @@ function createSetupRoutes(ctx, kit) {
@@ -52,7 +52,7 @@ function createSetupRoutes(ctx, kit) {
let keys = null;
let keys = null;
try { keys = connections ? await sso.asViewer(req, res, (token) => connections.mine(token)) : null; } catch { keys = null; }
try { keys = connections ? await sso.asViewer(req, res, (token) => connections.mine(token)) : null; } catch { keys = null; }
let git = null;
let git = null;
- try { git = forge && forge.enabled ? await forge.link(subject) : null; } catch { git = null; }
+ try { git = forge && forge.enabled ? await forge.repos({ subject, username: req.viewer.username || '' }) : null; } catch { git = null; }
const settings = await userSettings.get(subject);
const settings = await userSettings.get(subject);
const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));
const saved = !!(await db.maybe('SELECT 1 AS one FROM codes_user_settings WHERE subject = $1', [subject]));
return { machines: machineList, compute, rows, fleetProblem, keys, git, settings, saved };
return { machines: machineList, compute, rows, fleetProblem, keys, git, settings, saved };
@@ -67,7 +67,7 @@ function createSetupRoutes(ctx, kit) {
@@ -67,7 +67,7 @@ function createSetupRoutes(ctx, kit) {
{ id: 'machine', title: 'Pick where it runs', short: 'Runs on', done: online.length > 0, warn: !online.length && live.length > 0, required: true },
{ id: 'machine', title: 'Pick where it runs', short: 'Runs on', done: online.length > 0, warn: !online.length && live.length > 0, required: true },
{ id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },
{ id: 'agents', title: 'Your agents', short: 'Agents', done: ready.length > 0, required: true },
{ id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },
{ id: 'keys', title: 'Your AI keys', short: 'AI keys', done: credentials.some((k) => k.status !== 'failing'), optional: true },
- { id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!d.git, optional: true },
+ { id: 'accounts', title: 'Your accounts', short: 'Accounts', done: !!(d.git && d.git.length), optional: true },
{ id: 'choices', title: 'Your choices', short: 'Choices', done: d.saved, required: true },
{ id: 'choices', title: 'Your choices', short: 'Choices', done: d.saved, required: true },
];
];
}
}
@@ -125,10 +125,10 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
@@ -125,10 +125,10 @@ ${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline
}
}
function accountsBody(d) {
function accountsBody(d) {
- if (!forge || !forge.enabled) return '<p class="c4-hint">OpenVibe Git opens here once it trusts Codes. Your tasks can already work on any GitHub or OpenVibe Git repository you can reach.</p>';
- return d.git
- ? `<ul class="c4-rows"><li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">OpenVibe Git</span><span class="c4-row-note">Linked as ${esc(d.git.forge_user || 'you')}</span>${pill('Linked', 'ok')}</li></ul><div class="c4-setup-actions"><a class="c4-opill" href="/git">Your repositories</a></div>`
- : `<p class="c4-hint">Link OpenVibe Git once, and Codes can make repositories in your name and push your tasks' branches there. It is one consent with your OpenVibe account.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git/connect">Link OpenVibe Git</a></div>`;
+ if (!forge || !forge.enabled) return '<p class="c4-hint">OpenVibe Git opens here once Codes can reach it. Your tasks can already work on any GitHub or OpenVibe Git repository you can reach.</p>';
+ return d.git && d.git.length
+ ? `<ul class="c4-rows"><li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span><span class="c4-row-label">OpenVibe Git</span><span class="c4-row-note">${d.git.length} ${d.git.length === 1 ? 'repository' : 'repositories'} in your name</span>${pill('Ready', 'ok')}</li></ul><div class="c4-setup-actions"><a class="c4-opill" href="/git">Your repositories</a></div>`
+ : '<p class="c4-hint">Codes makes repositories on OpenVibe Git in your name with your OpenVibe account, and your tasks push their branches there. Nothing to link.</p><div class="c4-setup-actions"><a class="c4-button c4-button-light" href="/git">Make a repository</a></div>';
}
}
function choicesBody(d, machines) {
function choicesBody(d, machines) {
@@ -4,9 +4,6 @@
@@ -4,9 +4,6 @@
* as two namespaces on openvibe-shared/config that the admin panel's Setup tab lists.
* as two namespaces on openvibe-shared/config that the admin panel's Setup tab lists.
*
*
* codes.accounts the sign-in apps and the GitHub reader:
* codes.accounts the sign-in apps and the GitHub reader:
- * forge.client_id / forge.client_secret the OpenVibe Git (Forgejo) OAuth app that lets a
- * person link their OpenVibe Git account (read by
- * server/domain/forge.js; CODES_FORGE_CLIENT_* fallback)
* github.client_id / github.client_secret the GitHub OAuth app for a GitHub sign-in in Codes.
* github.client_id / github.client_secret the GitHub OAuth app for a GitHub sign-in in Codes.
* Nothing reads it yet: Optional, waiting for that sign-in
* Nothing reads it yet: Optional, waiting for that sign-in
* github.token a read-only GitHub token that raises GitHub's limits
* github.token a read-only GitHub token that raises GitHub's limits
@@ -33,30 +30,6 @@ const ACCOUNTS_SCHEMA = {
@@ -33,30 +30,6 @@ const ACCOUNTS_SCHEMA = {
type: 'object',
type: 'object',
additionalProperties: false,
additionalProperties: false,
properties: {
properties: {
- 'forge.client_id': {
- type: 'string', minLength: 8, maxLength: 200, title: 'OpenVibe Git app: client ID', 'x-group': 'OpenVibe Git', 'x-example': '3f2a9c1e-…',
- description: 'The client ID of the OAuth2 application registered on OpenVibe Git for Codes. With its secret and the seal key it lets people link their OpenVibe Git account; without it linking stays closed.',
- 'x-owner-input': {
- group: 'sign-in-apps',
- unlocks: 'Lets people link their OpenVibe Git account in Codes, so tasks can make repositories and push as them. Without it the Link button says linking is not open yet.',
- get_it_at: 'https://git.openvibe.codes/user/settings/applications',
- get_it_how: 'Signed in as the owner, create an OAuth2 application named Codes with the redirect URI https://openvibe.codes/git/callback, and copy its Client ID.',
- test: false,
- },
- 'x-required-for': 'Linking an OpenVibe Git account',
- },
- 'forge.client_secret': {
- type: 'string', minLength: 8, maxLength: 512, title: 'OpenVibe Git app: client secret', 'x-group': 'OpenVibe Git',
- description: 'The secret of the same OpenVibe Git application, shown once when it is created. Codes sends it only to OpenVibe Git, when a person links their account; it is never shown again here.',
- 'x-owner-input': {
- group: 'sign-in-apps',
- unlocks: 'Completes the OpenVibe Git app: with the client ID it lets people link their OpenVibe Git account in Codes.',
- get_it_at: 'https://git.openvibe.codes/user/settings/applications',
- get_it_how: 'Copy the Client Secret shown when the application is created (or regenerate it there).',
- test: false,
- },
- 'x-required-for': 'Linking an OpenVibe Git account',
- },
'github.client_id': {
'github.client_id': {
type: 'string', minLength: 8, maxLength: 200, title: 'GitHub app: client ID', 'x-group': 'GitHub', 'x-example': 'Ov23li…',
type: 'string', minLength: 8, maxLength: 200, title: 'GitHub app: client ID', 'x-group': 'GitHub', 'x-example': 'Ov23li…',
description: 'The client ID of a GitHub OAuth app for signing in to Codes with GitHub. Codes does not offer that sign-in yet; the app waits here until it does, and nothing uses it before then.',
description: 'The client ID of a GitHub OAuth app for signing in to Codes with GitHub. Codes does not offer that sign-in yet; the app waits here until it does, and nothing uses it before then.',
@@ -159,14 +132,12 @@ const PUBLISHING_SCHEMA = {
@@ -159,14 +132,12 @@ const PUBLISHING_SCHEMA = {
},
},
};
};
-const ACCOUNTS_SECRETS = new Set(['forge.client_secret', 'github.client_secret', 'github.token']);
+const ACCOUNTS_SECRETS = new Set(['github.client_secret', 'github.token']);
const classifyAccounts = (key) => (ACCOUNTS_SECRETS.has(key) ? 'secret' : 'internal');
const classifyAccounts = (key) => (ACCOUNTS_SECRETS.has(key) ? 'secret' : 'internal');
const classifyPublishing = (key) => (key === 'chrome.client_id' || key === 'firefox.jwt_issuer' ? 'internal' : 'secret');
const classifyPublishing = (key) => (key === 'chrome.client_id' || key === 'firefox.jwt_issuer' ? 'internal' : 'secret');
/** The environment variable each key falls back to (only the keys Codes reads today have one). */
/** The environment variable each key falls back to (only the keys Codes reads today have one). */
const ENV_FALLBACK = Object.freeze({
const ENV_FALLBACK = Object.freeze({
- 'forge.client_id': 'CODES_FORGE_CLIENT_ID',
- 'forge.client_secret': 'CODES_FORGE_CLIENT_SECRET',
'github.token': 'CODES_GITHUB_TOKEN',
'github.token': 'CODES_GITHUB_TOKEN',
});
});
@@ -33,7 +33,7 @@ async function oauthApp(name, url, clientId, clientSecret, fetchImpl) {
@@ -33,7 +33,7 @@ async function oauthApp(name, url, clientId, clientSecret, fetchImpl) {
return { ok: false, message: `${name} answered ${r.status}${said(r.json) ? `: ${said(r.json)}` : ''}; the app could not be checked.` };
return { ok: false, message: `${name} answered ${r.status}${said(r.json) ? `: ${said(r.json)}` : ''}; the app could not be checked.` };
}
}
-function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fetch }) {
+function createOwnerInputTests({ ownerInputs, fetchImpl = globalThis.fetch }) {
const github = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
const github = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
const tests = {
const tests = {
'codes.accounts': {
'codes.accounts': {
@@ -47,10 +47,6 @@ function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fet
@@ -47,10 +47,6 @@ function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fet
return { ok: false, message: `GitHub ${r.status === 401 ? 'refused the token (401)' : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };
return { ok: false, message: `GitHub ${r.status === 401 ? 'refused the token (401)' : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };
},
},
'github.client_secret': async (secret) => oauthApp('GitHub', 'https://github.com/login/oauth/access_token', ownerInputs.value('codes.accounts', 'github.client_id').value, secret, fetchImpl),
'github.client_secret': async (secret) => oauthApp('GitHub', 'https://github.com/login/oauth/access_token', ownerInputs.value('codes.accounts', 'github.client_id').value, secret, fetchImpl),
- 'forge.client_secret': async (secret) => {
- const base = String((config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');
- return oauthApp('OpenVibe Git', `${base}/login/oauth/access_token`, ownerInputs.value('codes.accounts', 'forge.client_id').value, secret, fetchImpl);
- },
},
},
'codes.publishing': {
'codes.publishing': {
'npm.token': async (token) => {
'npm.token': async (token) => {
@@ -1,77 +1,70 @@
@@ -1,77 +1,70 @@
'use strict';
'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.
+ * OpenVibe Git through Codes' service token (server/domain/forge.js, server/http/git.js): Codes names the person
+ * signed in in X-OV-Subject, OpenVibe.Git makes a repository in their name and mints their clone token. Nothing is
+ * linked and no person's password is involved. A stand-in Git answers the three calls Codes makes.
*/
*/
const assert = require('assert');
const assert = require('assert');
-const crypto = require('crypto');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
const http = require('http');
const http = require('http');
const { check, done, boot } = require('./helpers/boot');
const { check, done, boot } = require('./helpers/boot');
-const { createSeal } = require('../server/domain/seal');
const job = require('../harness/job');
const job = require('../harness/job');
-const SEAL_KEY = crypto.randomBytes(32).toString('base64');
-const TOKEN = 'forge-access-0123456789abcdef';
+const TOKEN = 'git_test_0123456789abcdef';
-function fakeForge() {
- const st = { challenges: new Map(), repos: [], codes: new Map(), refreshes: 0 };
+function claimsOf(header) {
+ const raw = String(header || '').replace(/^Bearer\s+/i, '');
+ try { return JSON.parse(Buffer.from(raw.split('.')[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function fakeGit() {
+ const st = { repos: [], tokens: [], calls: [] };
const srv = http.createServer((req, res) => {
const srv = http.createServer((req, res) => {
- let body = '';
- req.on('data', (c) => { body += c; });
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
req.on('end', () => {
req.on('end', () => {
const u = new URL(req.url, 'http://x');
const u = new URL(req.url, 'http://x');
+ const body = raw ? JSON.parse(raw) : null;
const send = (status, doc) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(doc)); };
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' });
+ const subject = String(req.headers['x-ov-subject'] || '');
+ st.calls.push({ method: req.method, path: u.pathname, subject, body, claims: claimsOf(req.headers.authorization) });
+ if (req.method === 'POST' && u.pathname === '/api/v1/repos') {
+ if (!req.headers.authorization || !subject) return send(401, { code: 'auth.subject_required', detail: 'name the person' });
+ if (st.repos.some((r) => r.full_name === `${body.owner_username}/${body.name}`)) return send(409, { code: 'repo.exists', detail: 'That repository already exists.' });
+ const row = {
+ name: body.name, full_name: `${body.owner_username}/${body.name}`, visibility: body.visibility,
+ html_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}`,
+ clone_url: `https://git.openvibe.codes/${body.owner_username}/${body.name}.git`,
+ default_branch: 'main', owner: { username: body.owner_username, subject },
+ };
+ st.repos.push(row);
+ return send(201, row);
+ }
+ const repoMatch = u.pathname.match(/^\/api\/v1\/repos\/([^/]+)\/([^/]+)$/);
+ if (req.method === 'GET' && repoMatch) {
+ const row = st.repos.find((r) => r.full_name === `${repoMatch[1]}/${repoMatch[2]}`);
+ return row ? send(200, row) : send(404, { code: 'repo.not_found' });
}
}
- 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);
+ const who = u.pathname.match(/^\/api\/v1\/profile\/([^/]+)$/);
+ if (req.method === 'GET' && who) {
+ if (!req.headers.authorization || !subject) return send(401, { code: 'auth.required' });
+ return send(200, { username: who[1], repositories: st.repos.filter((r) => r.full_name.startsWith(`${who[1]}/`)) });
}
}
- return send(404, { message: 'not found' });
+ if (req.method === 'POST' && u.pathname === '/api/v1/tokens') {
+ if (!req.headers.authorization || !subject) return send(401, { code: 'auth.subject_required' });
+ st.tokens.push({ subject, body });
+ return send(201, { token: TOKEN, token_type: 'GitToken', scopes: body.scopes, expires_at: new Date(Date.now() + 7200_000).toISOString() });
+ }
+ return send(404, { code: 'route.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)) })));
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 () => {
(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 git = await fakeGit();
const submits = [];
const submits = [];
const run = {
const run = {
enabled: true,
enabled: true,
@@ -80,78 +73,74 @@ function fakeForge() {
@@ -80,78 +73,74 @@ function fakeForge() {
async cancel(id) { return { id, state: 'cancelled' }; },
async cancel(id) { return { id, state: 'cancelled' }; },
async streamUrl() { throw new Error('not used'); },
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 dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-git-data-'));
+ const t = await boot({ run, env: { CODES_GIT_INTERNAL_URL: git.url, CODES_GIT_DATA_DIR: dataDir } });
const ada = t.network.addUser('ada');
const ada = t.network.addUser('ada');
- const cookieOf = (r) => String(r.headers.get('set-cookie') || '').split(';')[0];
+ const nameless = t.network.addUser('');
try {
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('the page asks for no link: Codes acts with its service token for the person signed in', async () => {
+ const page = await t.get('/git', { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.doesNotMatch(page.text, /Link OpenVibe Git/);
+ assert.match(page.text, /A new repository/);
+ assert.match(page.text, /No repositories yet/);
+ const call = git.st.calls.find((c) => c.path === '/api/v1/profile/ada');
+ assert.ok(call, 'the page read ada\'s repositories');
+ assert.strictEqual(call.subject, ada.subject, 'named in X-OV-Subject');
+ assert.strictEqual(call.claims.sub, 'svc:codes', 'the service token, not a person\'s');
+ assert.ok(call.claims.cap.includes('git.repo.write'), 'with git.repo.write');
+ assert.ok(call.claims.aud.includes('openvibe.git'));
});
});
- 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 () => {
+ await check('a person with no username is told where to choose one', async () => {
+ const page = await t.get('/git', { as: nameless });
+ assert.match(page.text, /One more step on your account/);
+ const refused = await t.get('/git/repos', { as: nameless, form: { name: 'x' } });
+ assert.strictEqual(refused.status, 303);
+ assert.match(decodeURIComponent(refused.headers.get('location')), /no username/);
+ });
+
+ await check('a new repository is made in their name, private, and opens a new chat', async () => {
const made = await t.get('/git/repos', { as: ada, form: { name: 'my-site', description: 'A site for my bakery', private: '1' } });
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.status, 303);
assert.strictEqual(made.headers.get('location'), `/?mode=code&repo=${encodeURIComponent('https://git.openvibe.codes/ada/my-site')}`);
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 call = git.st.calls.filter((c) => c.method === 'POST' && c.path === '/api/v1/repos').pop();
+ assert.strictEqual(call.subject, ada.subject);
+ assert.deepStrictEqual([call.body.name, call.body.owner_username, call.body.visibility, call.body.starter], ['my-site', 'ada', 'private', 'empty']);
+ });
+
+ await check('their repositories are listed, private ones too, with a way to work on each', async () => {
const page = await t.get('/git', { as: ada });
const page = await t.get('/git', { as: ada });
assert.match(page.text, /ada\/my-site<\/a>[\s\S]*?Private[\s\S]*?href="\/\?mode=code&repo=https%3A%2F%2Fgit\.openvibe\.codes%2Fada%2Fmy-site">Work on it/);
assert.match(page.text, /ada\/my-site<\/a>[\s\S]*?Private[\s\S]*?href="\/\?mode=code&repo=https%3A%2F%2Fgit\.openvibe\.codes%2Fada%2Fmy-site">Work on it/);
assert.ok(!page.text.includes(TOKEN), 'no token on a page');
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 () => {
+ await check('a task on the repository gets a Git token for the person, and the local disk when Codes shares it', async () => {
+ fs.mkdirSync(path.join(dataDir, 'repos', 'ada', 'my-site.git'), { recursive: true });
const start = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://git.openvibe.codes/ada/my-site', text: 'Add a menu page' } });
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'));
assert.match(start.headers.get('location'), /^\/tasks\/tsk_/, start.headers.get('location'));
const sent = submits[submits.length - 1];
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');
+ assert.strictEqual(sent.args.env.OPENVIBE_GIT_CREDENTIALS, `https://x-access-token:${TOKEN}@git.openvibe.codes`);
+ assert.strictEqual(sent.args.env.OPENVIBE_GIT_LOCAL_ROOT, dataDir, 'clones from the disk, not the network');
+ const minted = git.st.tokens.pop();
+ assert.strictEqual(minted.subject, ada.subject, 'the token is the person\'s');
+ assert.deepStrictEqual(minted.body.scopes, ['git.repo.read', 'git.repo.write']);
+ assert.ok(job.ENV_NAMES.has('OPENVIBE_GIT_LOCAL_ROOT'), 'the job accepts the local root');
const header = job.gitAuthFor('https://git.openvibe.codes/ada/my-site', { OPENVIBE_GIT_CREDENTIALS: sent.args.env.OPENVIBE_GIT_CREDENTIALS });
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.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/);
+ assert.strictEqual(Buffer.from(header.env.GIT_CONFIG_VALUE_0.replace(/^Authorization: Basic /, ''), 'base64').toString(), `x-access-token:${TOKEN}`);
+ await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://github.com/ada/elsewhere', text: 'Fix it' } });
+ assert.ok(!(submits[submits.length - 1].args.env && submits[submits.length - 1].args.env.OPENVIBE_GIT_LOCAL_ROOT), 'no local root for another host');
});
});
- 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/);
+ await check('a repository on another host takes no OpenVibe Git token', async () => {
+ assert.strictEqual(await t.ctx.forge.jobEnv(ada.subject, 'https://code.example.org/ada/site'), null);
+ assert.strictEqual(await t.ctx.forge.jobEnv(ada.subject, 'not a url'), null);
});
});
} finally {
} finally {
await t.close();
await t.close();
- await forge.close();
+ await git.close();
+ fs.rmSync(dataDir, { recursive: true, force: true });
}
}
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,110 +0,0 @@
@@ -1,110 +0,0 @@
-'use strict';
-/**
- * OpenVibe Git / Forgejo client (server/domain/forgejo.js): branches, pull requests, reviews and webhooks.
- * The client uses node:http / node:https (not fetch) so it can reach Forgejo on 127.0.0.1:5060, and it refuses to do
- * anything without a token. A stand-in Forgejo answers the same paths so no real forge is contacted.
- */
-const assert = require('assert');
-const http = require('http');
-const { createForgejoClient } = require('../server/domain/forgejo');
-const { check, done } = require('./helpers/boot');
-
-function mockForgejo() {
- const calls = [];
- const repoBase = '/api/v1/repos/OpenVibers/OpenVibe.Codes';
- const server = http.createServer((req, res) => {
- let raw = '';
- req.on('data', (c) => { raw += c; });
- req.on('end', () => {
- const auth = req.headers.authorization || '';
- const body = raw ? JSON.parse(raw) : null;
- const pathname = new URL(req.url, 'http://x').pathname;
- calls.push({ method: req.method, path: req.url, auth, body });
- res.writeHead(200, { 'Content-Type': 'application/json' });
- if (pathname === repoBase) {
- return res.end(JSON.stringify({ full_name: 'OpenVibers/OpenVibe.Codes', default_branch: 'main' }));
- }
- if (pathname === `${repoBase}/branches` && req.method === 'POST') {
- return res.end(JSON.stringify({ name: body.new_branch_name }));
- }
- if (pathname === `${repoBase}/branches`) {
- return res.end(JSON.stringify([{ name: 'main' }, { name: 'feature' }]));
- }
- if (pathname === `${repoBase}/pulls` && req.method === 'POST') {
- return res.end(JSON.stringify({ number: 5, title: body.title }));
- }
- if (pathname === `${repoBase}/pulls`) {
- return res.end(JSON.stringify([{ number: 5, title: 'A PR' }]));
- }
- if (pathname === `${repoBase}/pulls/5`) {
- return res.end(JSON.stringify({ number: 5, title: 'PR' }));
- }
- if (pathname === `${repoBase}/pulls/5/reviews`) {
- return res.end(JSON.stringify([{ id: 1, state: 'APPROVED' }]));
- }
- if (pathname === `${repoBase}/hooks` && req.method === 'POST') {
- return res.end(JSON.stringify({ id: 9 }));
- }
- if (pathname === `${repoBase}/hooks/9` && req.method === 'DELETE') {
- return res.end('{}');
- }
- res.end('{}');
- });
- });
- return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, calls, url: `http://127.0.0.1:${server.address().port}` })));
-}
-
-(async () => {
- const forgejo = await mockForgejo();
- const old = process.env.FORGEJO_TOKEN;
- process.env.FORGEJO_TOKEN = 'f-token';
- const client = createForgejoClient({ config: { git: { internalUrl: forgejo.url, publicUrl: 'https://git.openvibe.codes' } }, log: { warn() {} } });
-
- try {
- await check('without a token the client is unconfigured', async () => {
- delete process.env.FORGEJO_TOKEN;
- delete process.env.CODES_GIT_TOKEN;
- const noToken = createForgejoClient({ config: { git: { internalUrl: forgejo.url } }, log: { warn() {} } });
- const c = noToken.configured();
- assert.strictEqual(c.ok, false);
- assert.strictEqual(c.code, 'forgejo.unconfigured');
- await assert.rejects(noToken.getRepo('OpenVibers', 'OpenVibe.Codes'), (e) => e.code === 'forgejo.unconfigured');
- process.env.FORGEJO_TOKEN = 'f-token';
- });
-
- await check('getRepo, listBranches and createBranch call the right Forgejo paths', async () => {
- const repo = await client.getRepo('OpenVibers', 'OpenVibe.Codes');
- assert.strictEqual(repo.default_branch, 'main');
- const branches = await client.listBranches('OpenVibers', 'OpenVibe.Codes');
- assert.strictEqual(branches.length, 2);
- const created = await client.createBranch('OpenVibers', 'OpenVibe.Codes', { base_branch: 'main', new_branch_name: 'feature' });
- assert.strictEqual(created.name, 'feature');
- assert.ok(forgejo.calls.some((c) => c.method === 'GET' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/branches'));
- assert.ok(forgejo.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/branches'));
- assert.ok(forgejo.calls.some((c) => c.auth === 'token f-token'));
- });
-
- await check('pull requests and reviews are read and created', async () => {
- const prs = await client.listPullRequests('OpenVibers', 'OpenVibe.Codes');
- assert.strictEqual(prs[0].number, 5);
- const pr = await client.getPullRequest('OpenVibers', 'OpenVibe.Codes', 5);
- assert.strictEqual(pr.number, 5);
- const created = await client.createPullRequest('OpenVibers', 'OpenVibe.Codes', { base: 'main', head: 'feature', title: 'Add test', body: 'Desc' });
- assert.strictEqual(created.number, 5);
- const reviews = await client.listReviews('OpenVibers', 'OpenVibe.Codes', 5);
- assert.strictEqual(reviews[0].state, 'APPROVED');
- });
-
- await check('webhooks are created and deleted', async () => {
- const hook = await client.createWebhook('OpenVibers', 'OpenVibe.Codes', { type: 'forgejo', config: { url: 'https://openvibe.codes/hooks' }, events: ['push'] });
- assert.strictEqual(hook.id, 9);
- await client.deleteWebhook('OpenVibers', 'OpenVibe.Codes', 9);
- assert.ok(forgejo.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/hooks'));
- assert.ok(forgejo.calls.some((c) => c.method === 'DELETE' && c.path === '/api/v1/repos/OpenVibers/OpenVibe.Codes/hooks/9'));
- });
- } finally {
- process.env.FORGEJO_TOKEN = old;
- await new Promise((r) => forgejo.server.close(r));
- }
- done();
-})().catch((e) => { console.error(e); process.exit(1); });
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * The home page's own sections (server/http/home.js): OpenVibe Git from the forge's public API (left out when it never
+ * The home page's own sections (server/http/home.js): OpenVibe Git from its public API (left out when it never
* answered), the quick start with the release-tag install line and the public router, and a FAQ that its FAQPage JSON-LD
* answered), the quick start with the release-tag install line and the public router, and a FAQ that its FAQPage JSON-LD
* repeats word for word. The git reader keeps only public repositories with a plain owner/name.
* repeats word for word. The git reader keeps only public repositories with a plain owner/name.
*/
*/
@@ -10,26 +10,25 @@ const home = require('../server/http/home');
@@ -10,26 +10,25 @@ const home = require('../server/http/home');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
- await check('the git reader: public repositories, newest first as the forge answers, with the total it reports', async () => {
+ await check('the git reader: public repositories, newest first as OpenVibe Git answers', async () => {
const srv = http.createServer((req, res) => {
const srv = http.createServer((req, res) => {
- assert.match(req.url, /^\/api\/v1\/repos\/search\?limit=8&sort=updated&order=desc$/);
+ assert.match(req.url, /^\/api\/v1\/repos\?limit=8&sort=pushed$/);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Type', 'application/json');
- res.setHeader('X-Total-Count', '52');
- res.end(JSON.stringify({ ok: true, data: [
- { full_name: 'OpenVibers/OpenVibe.Codes', private: false, updated_at: '2026-10-22T10:00:00Z', language: 'JavaScript' },
- { full_name: 'OpenVibers/secret', private: true },
- { full_name: 'bad name/<x>', private: false },
+ res.end(JSON.stringify({ repositories: [
+ { full_name: 'OpenVibers/OpenVibe.Codes', visibility: 'public', pushed_at: '2026-10-22T10:00:00Z', language: 'JavaScript' },
+ { full_name: 'OpenVibers/secret', visibility: 'private' },
+ { full_name: 'bad name/<x>', visibility: 'public' },
] }));
] }));
});
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
try {
try {
const reader = home.createGitReader({ internalUrl: `http://127.0.0.1:${srv.address().port}`, publicUrl: 'https://git.openvibe.codes' });
const reader = home.createGitReader({ internalUrl: `http://127.0.0.1:${srv.address().port}`, publicUrl: 'https://git.openvibe.codes' });
const v = await reader.refresh();
const v = await reader.refresh();
- assert.strictEqual(v.total, 52);
+ assert.strictEqual(v.total, null);
assert.deepStrictEqual(v.repos.map((r) => r.url), ['https://git.openvibe.codes/OpenVibers/OpenVibe.Codes']);
assert.deepStrictEqual(v.repos.map((r) => r.url), ['https://git.openvibe.codes/OpenVibers/OpenVibe.Codes']);
} finally { srv.close(); }
} finally { srv.close(); }
const down = home.createGitReader({ internalUrl: 'http://127.0.0.1:9', timeoutMs: 500 });
const down = home.createGitReader({ internalUrl: 'http://127.0.0.1:9', timeoutMs: 500 });
- assert.strictEqual(await down.refresh(), null, 'a forge that does not answer gives no list');
+ assert.strictEqual(await down.refresh(), null, 'a Git that does not answer gives no list');
assert.strictEqual(home.ago('2026-10-22T10:00:00Z', Date.parse('2026-10-22T12:00:00Z')), '2 h ago');
assert.strictEqual(home.ago('2026-10-22T10:00:00Z', Date.parse('2026-10-22T12:00:00Z')), '2 h ago');
});
});
@@ -21,13 +21,12 @@ const ENV_GH = 'github_pat_madeUpENVtokenFROMSETTINGSFILE0001';
@@ -21,13 +21,12 @@ const ENV_GH = 'github_pat_madeUpENVtokenFROMSETTINGSFILE0001';
const PANEL_GH = 'github_pat_madeUpPANELtokenNEVERSHOWN000002';
const PANEL_GH = 'github_pat_madeUpPANELtokenNEVERSHOWN000002';
const APP_SECRET = 'madeUpGitHubAppSecretNEVERSHOWN0000000003';
const APP_SECRET = 'madeUpGitHubAppSecretNEVERSHOWN0000000003';
const BAD_NPM = 'npm_madeUpBadTokenTHATNPMECHOESBACK0000004';
const BAD_NPM = 'npm_madeUpBadTokenTHATNPMECHOESBACK0000004';
-const FORGE_SECRET = 'madeUpForgeSecretNEVERSHOWN00000000000005';
-const SECRETS = [ENV_GH, PANEL_GH, APP_SECRET, BAD_NPM, FORGE_SECRET];
+const SECRETS = [ENV_GH, PANEL_GH, APP_SECRET, BAD_NPM];
const calls = [];
const calls = [];
async function fakeFetch(url, init = {}) {
async function fakeFetch(url, init = {}) {
const u = String(url);
const u = String(url);
- const outside = /^https:\/\/(api\.github\.com|github\.com|registry\.npmjs\.org)\//.test(u) || /\/login\/oauth\/access_token$/.test(u);
+ const outside = /^https:\/\/(api\.github\.com|github\.com|registry\.npmjs\.org)\//.test(u);
if (!outside) return globalThis.fetch(url, init);
if (!outside) return globalThis.fetch(url, init);
const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');
const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');
calls.push({ url: u, auth, body: init.body ? String(init.body) : '' });
calls.push({ url: u, auth, body: init.body ? String(init.body) : '' });
@@ -43,10 +42,6 @@ async function fakeFetch(url, init = {}) {
@@ -43,10 +42,6 @@ async function fakeFetch(url, init = {}) {
? json(200, { error: 'bad_verification_code', error_description: 'The code passed is incorrect or expired.' })
? json(200, { error: 'bad_verification_code', error_description: 'The code passed is incorrect or expired.' })
: json(200, { error: 'incorrect_client_credentials', error_description: 'The client_id and/or client_secret passed are incorrect.' });
: json(200, { error: 'incorrect_client_credentials', error_description: 'The client_id and/or client_secret passed are incorrect.' });
}
}
- if (u.endsWith('/login/oauth/access_token')) {
- const form = new URLSearchParams(String(init.body || ''));
- return form.get('client_secret') === FORGE_SECRET ? json(400, { error: 'invalid_grant', error_description: 'client is not authorized' }) : json(400, { error: 'unauthorized_client', error_description: 'invalid client secret' });
- }
if (u === 'https://registry.npmjs.org/-/whoami') return json(401, { error: `token ${auth.replace(/^Bearer /, '')} is not valid` });
if (u === 'https://registry.npmjs.org/-/whoami') return json(401, { error: `token ${auth.replace(/^Bearer /, '')} is not valid` });
return json(404, { message: 'not here' });
return json(404, { message: 'not here' });
}
}
@@ -55,7 +50,7 @@ async function fakeFetch(url, init = {}) {
@@ -55,7 +50,7 @@ async function fakeFetch(url, init = {}) {
const consoleLines = [];
const consoleLines = [];
const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
- const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', CODES_GITHUB_TOKEN: ENV_GH, CODES_SEAL_KEY: crypto.randomBytes(32).toString('base64') }, fetchImpl: fakeFetch });
+ const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', CODES_GITHUB_TOKEN: ENV_GH }, fetchImpl: fakeFetch });
const admin = () => t.network.mintService('svc:network', 'openvibe.codes', CAP);
const admin = () => t.network.mintService('svc:network', 'openvibe.codes', CAP);
const cfg = (method, p, json) => t.get(p, { method, bearer: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });
const cfg = (method, p, json) => t.get(p, { method, bearer: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });
const test = (ns, key) => cfg('POST', `/internal/config/${ns}/test/${key}`);
const test = (ns, key) => cfg('POST', `/internal/config/${ns}/test/${key}`);
@@ -69,8 +64,7 @@ async function fakeFetch(url, init = {}) {
@@ -69,8 +64,7 @@ async function fakeFetch(url, init = {}) {
await check('the sign-in apps, the GitHub token and the publishing tokens are owner inputs in their groups', async () => {
await check('the sign-in apps, the GitHub token and the publishing tokens are owner inputs in their groups', async () => {
const a = (await cfg('GET', '/internal/config/codes.accounts')).json().schema.properties;
const a = (await cfg('GET', '/internal/config/codes.accounts')).json().schema.properties;
- assert.deepStrictEqual(['forge.client_id', 'forge.client_secret', 'github.client_id', 'github.client_secret'].map((k) => a[k]['x-owner-input'].group), ['sign-in-apps', 'sign-in-apps', 'sign-in-apps', 'sign-in-apps']);
- assert.strictEqual(a['forge.client_secret']['x-required-for'], 'Linking an OpenVibe Git account', 'Codes reads it: linking is off without it');
+ assert.deepStrictEqual(['github.client_id', 'github.client_secret'].map((k) => a[k]['x-owner-input'].group), ['sign-in-apps', 'sign-in-apps']);
assert.strictEqual(a['github.client_secret']['x-required-for'], undefined, 'nothing reads the GitHub app yet: Optional');
assert.strictEqual(a['github.client_secret']['x-required-for'], undefined, 'nothing reads the GitHub app yet: Optional');
assert.match(a['github.client_id']['x-owner-input'].unlocks, /Optional/);
assert.match(a['github.client_id']['x-owner-input'].unlocks, /Optional/);
assert.deepStrictEqual([a['github.token']['x-owner-input'].group, a['github.token']['x-owner-input'].test], ['data-sources', true]);
assert.deepStrictEqual([a['github.token']['x-owner-input'].group, a['github.token']['x-owner-input'].test], ['data-sources', true]);
@@ -106,18 +100,6 @@ async function fakeFetch(url, init = {}) {
@@ -106,18 +100,6 @@ async function fakeFetch(url, init = {}) {
assert.ok(!row.values_json.includes(PANEL_GH), 'sealed at rest');
assert.ok(!row.values_json.includes(PANEL_GH), 'sealed at rest');
});
});
- await check('the OpenVibe Git app from the panel opens linking at use time; its test checks the app with the forge', async () => {
- assert.strictEqual(t.ctx.forge.enabled, false, 'no app yet: linking is closed');
- const id = await cfg('POST', '/internal/config/codes.accounts', { values: { 'forge.client_id': 'forge-client-1234' }, merge: true, reason: 'setup' });
- assert.strictEqual(id.status, 200, id.text.slice(0, 300));
- const put = await cfg('PUT', '/internal/config/codes.accounts/secrets/forge.client_secret', { value: FORGE_SECRET, reason: 'setup' });
- assert.strictEqual(put.status, 200, put.text.slice(0, 300));
- assert.strictEqual(t.ctx.forge.enabled, true, 'the panel values opened linking without a restart');
- assert.strictEqual(t.config.git.clientId, 'forge-client-1234');
- const r = await test('codes.accounts', 'forge.client_secret');
- assert.deepStrictEqual([r.json().ok, r.json().source, r.json().message], [true, 'vault', 'OpenVibe Git accepted the app\'s client ID and secret.']);
- });
-
await check('the GitHub app is tested without signing anyone in; a wrong secret is refused in one line', async () => {
await check('the GitHub app is tested without signing anyone in; a wrong secret is refused in one line', async () => {
const none = await test('codes.accounts', 'github.client_secret');
const none = await test('codes.accounts', 'github.client_secret');
assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);
assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);
@@ -42,19 +42,18 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -42,19 +42,18 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
- 'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',
+ 'server/domain/forge.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback) with Codes\' own Network service token (audience openvibe.git); paths built from validated owner/name, the person named in X-OV-Subject',
'server/domain/agent-copilot.js': 'OpenVibe.Chat (the configured Chat base) with Codes\' own Network service token, for the owner\'s Co-pilot room: the digest, the poll and the answer line; the room id is the stream\'s own id, never a URL from a person',
'server/domain/agent-copilot.js': 'OpenVibe.Chat (the configured Chat base) with Codes\' own Network service token, for the owner\'s Co-pilot room: the digest, the poll and the answer line; the room id is the stream\'s own id, never a URL from a person',
- 'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',
- 'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',
+ 'server/http/home.js': 'OpenVibe Git\'s public repository list (CODES_GIT_INTERNAL_URL, configured loopback) for the home page; no token',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',
'server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',
'server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, pause, curtain, mute and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person',
'server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, pause, curtain, mute and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person',
'server/domain/agent-copilot.js': 'OpenVibe.Chat (OV_CHAT_URL/config, configured) with Codes\' own service token for the room digest, answer and poll, and OpenVibe.Live for the state, copilot-ready and step calls; the stream id must be ags_<ULID> and is URL-encoded; no URL from a person or a chat line',
'server/domain/agent-copilot.js': 'OpenVibe.Chat (OV_CHAT_URL/config, configured) with Codes\' own service token for the room digest, answer and poll, and OpenVibe.Live for the state, copilot-ready and step calls; the stream id must be ags_<ULID> and is URL-encoded; no URL from a person or a chat line',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
- 'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',
+ 'server/http/home.js': 'OpenVibe Git\'s public repository list (CODES_GIT_INTERNAL_URL, configured loopback) for the home page; no token',
'server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',
'server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',
'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',
'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',
- 'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
+ 'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; OpenVibe Git repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
'server/domain/ai-catalog.js': 'OpenVibe.AI (CODES_AI_URL, configured) GET /v1/models with Codes\' own Network service token: the base is the operator-set AI address and the path is fixed; no URL from a person or a developer',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with Codes\' Network service token, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
@@ -70,7 +69,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -70,7 +69,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/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) and OpenVibe Git\'s token endpoint on the configured loopback (CODES_GIT_INTERNAL_URL); 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',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');