openvibers/openvibe.codes Commits
Codes C3: a person's connections, and the run token their harness carries (#163)
Changes
@@@ -8,7 +8,7 @@*/const path = require('path');const { createHarness } = require('../harness');--const { runJob, resultWriter } = require('../harness/job');++const { runJob, resultWriter, jobEnv } = require('../harness/job');const { version: VERSION } = require('../package.json');const { probe: probeLogins } = require('../engine/logins');@@@ -152,7 +152,13 @@ async function main() {if (cmd === 'job') {// The job ends 0 whenever it handed over a result, ok or not (the document says which): OpenVibe Run keeps a// result only from a job that exited 0, and the person needs the summary and the patch either way.-- await runJob({ input: await readStdin(), harness, emit: out, writeResult: resultWriter(out) });++ const input = await readStdin();++ // The job's own environment (Codes C3): the run token the harness carries and where the model APIs are. The++ // harness is built with it, so the agent's process gets the token and never the job's other input.++ let wanted = {};++ try { wanted = jobEnv(JSON.parse(input)); } catch { wanted = {}; }++ const jobHarness = createHarness({ env: { ...process.env, ...wanted } });++ await runJob({ input, harness: jobHarness, emit: out, writeResult: resultWriter(out) });return 0;}if (cmd === 'runs') {@@@ -137,6 +137,25 @@ function track(runs) {return rec;}++/**++ * Whether the person may run this runner (Codes C3): a runner whose connection is missing is never picked, so a++ * task never goes to an agent the person cannot pay for or sign in to.++ *++ * `connections` is { [runner]: { provider, connected } } — what the person has: an API key for the provider, or a++ * plan on one of their machines (the same thing the router skips while it is at its limit). A runner that is not in++ * the map needs no connection, and a map that is absent at all leaves the ladder as it was (the portal's router).++ *++ * @param {string} runner the runner's id++ * @param {object} [connections] the person's connections, per runner++ * @returns {boolean} may this runner take the job++ */++function connected(runner, connections) {++ if (!connections) return true;++ const c = connections[runner];++ if (!c) return true;++ return c.connected !== false;++}++/*** The runner, model and reasoning for a job, or null when nothing on the ladder can take it now.*@@@ -150,13 +169,14 @@ function track(runs) {* @param {string} [ctx.JOBS] the jobs folder* @param {string} [ctx.name] the job's own name* @param {object} [ctx.ladder] the ladder to use (the portal's by default)++ * @param {object} [ctx.connections] the person's connections, per runner (Codes C3)* @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}*/--function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder } = {}) {++function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, connections } = {}) {const c = classify(prompt, { JOBS, name });const rec = track(runs);const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };-- const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r);++ const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r) && connected(r, connections);for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {if (!usable(runner)) continue;// A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).@@@ -168,4 +188,4 @@ function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder }return null;}--module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS };++module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS, connected };@@@ -150,6 +150,26 @@ function shownInput(name, args) {return args;}++/**++ * The extra headers a job's environment asks for (Codes C3): OPENVIBE_CODES_HEADERS is a JSON object of header names++ * and values (the credential header that says whose key the request uses). Only header names and short values are++ * read, and a name that is not a header is refused, so nothing else can ride this way.++ */++function extraHeaders(env) {++ const raw = String((env && env.OPENVIBE_CODES_HEADERS) || '').trim();++ if (!raw || raw.length > 2000) return {};++ let parsed;++ try { parsed = JSON.parse(raw); } catch { return {}; }++ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};++ const out = {};++ for (const [name, value] of Object.entries(parsed)) {++ if (!/^[A-Za-z0-9-]{1,64}$/.test(name)) continue;++ if (typeof value !== 'string' || !value || value.length > 300) continue;++ out[name] = value;++ }++ return out;++}++/*** apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).@@@ -193,7 +213,7 @@ function apiAgent(spec) {const bearer = spec.token ? await spec.token(env, doFetch) : key;const res = await doFetch(`${base}/chat/completions`, {method: 'POST',-- headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },++ headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },body: JSON.stringify({ ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto' }),signal: controller.signal,});@@@ -257,4 +277,4 @@ function apiAgent(spec) {return adapter;}--module.exports = { apiAgent, runTool, inside, TOOL_DEFS, SYSTEM };++module.exports = { apiAgent, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };@@@ -63,6 +63,32 @@ function scrub(s) {return String(s).replace(/(https?:\/\/)[^@/\s]+@/g, '$1***@').replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, '***');}++/**++ * The environment a job may set for the harness (Codes C3): the model APIs' own names, nothing else. A run token++ * travels this way (the job's input carries it, the harness's process gets it), so only these names are read from++ * the job's input: everything else in it is ignored.++ */++const ENV_NAMES = new Set([++ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS',++ 'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS',++ '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',++]);++++/** The environment a job's input asks for: the allowlisted names only, each a short string. */++function jobEnv(input) {++ const out = {};++ const a = input && typeof input === 'object' && !Array.isArray(input) ? input.env : null;++ if (!a || typeof a !== 'object' || Array.isArray(a)) return out;++ for (const [k, v] of Object.entries(a)) {++ if (!ENV_NAMES.has(k)) continue;++ if (typeof v !== 'string' || !v || v.length > 4000) continue;++ out[k] = v;++ }++ return out;++}++/** The job's input checked: a public https repository (file:// only when OPENVIBE_CODES_ALLOW_FILE_REPO=1, for tests). */function parseArgs(raw, env = process.env) {let a;@@@ -84,8 +110,15 @@ function parseArgs(raw, env = process.env) {} : null;if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');if (push && push.branch && PROTECTED.test(push.branch)) throw new Error(`push.branch must be a new branch of the task's own, never ${push.branch}`);++ // The session a resume continues: the job's own input, or RESUME in the environment (a task carried on in the++ // same conversation). It is a harness's session id, never a path and never a credential.++ const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;++ const resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;++ const wanted = jobEnv(a);return {repo: repo.toString(), ref, prompt, permission, push,++ ...(resume ? { resume } : {}),++ ...(Object.keys(wanted).length ? { env: wanted } : {}),agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,};@@@ -313,4 +346,4 @@ function resultWriter(emit) {};}--module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };++module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL };nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- Stage C3 (openvibe.codes v1): the run tokens. A run token is the short-lived Network token an attempt's harness++-- carries, so that OpenVibe.AI knows whose own key and whose budget a request is. It is bound to one task and lives++-- at most two hours.++--++-- Codes keeps no copy of the token: this table holds its jti and its expiry, so a run can be traced and a token can++-- be matched with the attempt that carried it. The token itself is in the harness's environment and nowhere else.++--++-- id the row's id (rtk_<ULID>)++-- task_id the task the token was minted for++-- attempt_id the attempt that carried it++-- subject the person it acts for++-- provider the provider whose key it names (null when the run used AI's own providers)++-- jti the token's id (its hash, never the token)++-- expires_at when the token stops working++++CREATE TABLE IF NOT EXISTS codes_run_tokens (++ id text COLLATE "C" PRIMARY KEY, -- rtk_<ULID>++ task_id text COLLATE "C", -- tsk_<ULID>++ attempt_id text COLLATE "C", -- att_<ULID>++ subject text COLLATE "C", -- usr_<ULID>: the person the token acts for++ provider text COLLATE "C", -- anthropic, openai, deepseek, …++ jti text COLLATE "C", -- the token's id, never the token++ expires_at timestamptz,++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS codes_run_tokens_task ON codes_run_tokens (task_id) WHERE task_id IS NOT NULL;++CREATE INDEX IF NOT EXISTS codes_run_tokens_attempt ON codes_run_tokens (attempt_id) WHERE attempt_id IS NOT NULL;@@@ -29,9 +29,12 @@ const { createHarnesses } = require('./domain/harnesses');const { createPageRoutes } = require('./http/pages');const { createRunRoutes } = require('./http/run');const { createTasksApi } = require('./http/tasks-api');++const { createConnectionsApi } = require('./http/connections-api');const { createActionsApi } = require('./http/actions-api');const { createRunClient } = require('./domain/run-client');const { createRunTasks } = require('./domain/run-tasks');++const { createConnections } = require('./domain/connections');++const { createRunTokens } = require('./domain/run-tokens');const { createSessionLines } = require('./domain/session-lines');const { createOutbox, createPublisher } = require('./events/outbox');const { createEventsConsumer } = require('./events-consumer');@@@ -66,8 +69,12 @@ async function createApp(opts = {}) {const sessions = createSessionLines({ store, config, log });const outbox = createOutbox({ store, log });const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox });-- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks };++ // A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a++ // short-lived run token that names them.++ const connections = createConnections({ config, fetchImpl: fetchImpl || globalThis.fetch, log });++ const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections });++ const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens };ctx.forgejo = createForgejoClient({ config, log });const app = express();@@@ -170,6 +177,8 @@ async function createApp(opts = {}) {app.use('/api/v1', createApi(ctx));app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));++ // A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.++ app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));@@@ -63,6 +63,25 @@ function load(env = process.env) {sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',},cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++ // OpenVibe.AI (Codes C3): where a person's own provider key is sealed (it never leaves AI) and where the++ // harnesses run. Codes talks to it with its own Network token (audience openvibe.ai).++ ai: {++ url: trim(env.CODES_AI_URL || 'https://ai.openvibe.services'),++ audience: 'openvibe.ai',++ // The scopes Codes asks Network for: it stores a person's key with the first and runs a test request++ // (and the harnesses' requests) with the second.++ scope: 'ai.credential.manage ai.run.create',++ },++ // A run token (Codes C3): the short-lived Network token an attempt's harness carries, so that AI knows whose++ // key and whose budget the run is. Two hours is the longest a coding attempt may run.++ runTokens: {++ ttlSeconds: Math.min(7200, Math.max(60, int(env.CODES_RUN_TOKEN_TTL_S, 7200))),++ // The harness env variable names a job may set (harness/job.js): the model APIs' own names, nothing else.++ envNames: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS', 'OPENAI_BASE_URL',++ 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS', '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'],++ },// OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of// these; run.job.* subscriptions are created at boot. The outbox relay publishes codes.task.* with the// service's events.event.publish token.nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++++/**++ * A person's connections: the API keys they keep for their own providers, and the plans on their own machines.++ *++ * The key never comes to Codes. It is forwarded straight to OpenVibe.AI, which seals it (AES-256-GCM, bound to++ * Codes as the storing service, to the person and to the provider) and never returns it: Codes' database never++ * sees it, its logs never see it, and its answers carry only the provider, the four-character hint, today's spend,++ * the budget and when the key was last used. The owner of a key is the signed-in person; Codes is the service that++ * holds it for them.++ *++ * The plans (a CLI's own login on the person's machine: Claude Pro/Max, ChatGPT, Command Code, OpenCode Go, …) are++ * reported by the person's Node through OpenVibe.Bot and read from here; there are none yet, so the list is empty++ * until a machine reports one (stage C5).++ *++ * createConnections({ config, fetchImpl, log })++ * list(subject) → { keys: [...], plans: [...] }++ * put(subject, provider, body) → the connection as AI stored it (the hint, never the key)++ * remove(subject, provider) → true when there was one++ * test(subject, provider) → { ok, detail }: one tiny request through AI, or why not++ */++++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++/** The providers a person may keep a key for (the same list as OpenVibe.AI's own credential-put@2). */++const PROVIDERS = ['anthropic', 'openai', 'deepseek', 'minimax', 'openrouter', 'custom'];++/** The providers whose key speaks Anthropic's Messages API (the rest speak OpenAI's Chat Completions). */++const ANTHROPIC_FORMAT = ['anthropic', 'deepseek', 'minimax', 'custom'];++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** A provider id, or null when it is not one Codes keeps keys for. */++function providerOf(value) {++ const p = String(value || '').trim().toLowerCase();++ return PROVIDERS.includes(p) ? p : null;++}++++/** The connection as Codes shows it: never the key, only what the person may know about it. */++function present(row) {++ if (!row) return null;++ return {++ provider: row.provider,++ hint: row.key_hint || null,++ base_url: row.base_url || null,++ budget_usd_per_day: row.budget_usd_per_day == null ? null : Number(row.budget_usd_per_day),++ spent_usd_today: row.spent_usd_today == null ? 0 : Number(row.spent_usd_today),++ last_used_at: row.last_used_at || null,++ created_at: row.created_at || null,++ };++}++++function createConnections({ config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String((config.ai && config.ai.url) || 'https://ai.openvibe.services').replace(/\/+$/, '');++ const scope = (config.ai && config.ai.scope) || 'ai.credential.manage ai.run.create';++ // Codes' own Network token for OpenVibe.AI: the consent grant (ai.credential.manage) lets it store a person's++ // key; ai.run.create lets it make the test request. Five minutes, cached by the SDK client.++ const tokens = config.oauth && config.oauth.clientSecret++ ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', scope, fetch: fetchImpl })++ : null;++++ /** One call to AI. → { status, body }; the body is AI's own answer, never a key. */++ async function call(method, path, { body = null, headers = {} } = {}) {++ if (!tokens) throw Object.assign(new Error('Codes has no Network client secret, so it cannot reach OpenVibe.AI'), { status: 503, code: 'connections.unavailable' });++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { ...(await tokens.authHeaders()), Accept: 'application/json', ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) },++ ...(body ? { body: JSON.stringify(body) } : {}),++ signal: AbortSignal.timeout(20_000),++ });++ } catch (e) {++ throw Object.assign(new Error(`OpenVibe.AI did not answer: ${e.message}`), { status: 503, code: 'connections.unavailable' });++ }++ const doc = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error((doc && (doc.detail || doc.title)) || `OpenVibe.AI answered ${res.status}`);++ err.status = res.status; err.code = (doc && doc.code) || 'ai.error';++ throw err;++ }++ return doc;++ }++++ /**++ * The person's keys: one row per provider, read from AI. A provider with no key is left out (AI answers 404).++ * The plans are empty until a machine reports one.++ */++ async function list(subject) {++ if (!USER.test(String(subject || ''))) throw Object.assign(new Error('subject must be a usr_ subject'), { status: 400, code: 'subject.required' });++ const keys = [];++ for (const provider of PROVIDERS) {++ let row = null;++ try {++ row = await call('GET', `/api/v1/credentials/${encodeURIComponent(subject)}/${provider}`);++ } catch (e) {++ if (e.status === 404) continue; // no key for this provider++ throw e;++ }++ if (row) keys.push(present(row));++ }++ return { keys, plans: [] };++ }++++ /**++ * Store or replace the person's key for a provider. The key is forwarded to AI and never kept here: it is not++ * written to the database, not logged and not answered. The answer is the hint.++ */++ async function put(subject, provider, body = {}) {++ const p = providerOf(provider);++ if (!p) throw Object.assign(new Error(`provider is one of ${PROVIDERS.join(', ')}`), { status: 422, code: 'connections.provider_unknown' });++ const key = body.key != null ? String(body.key) : (body.api_key != null ? String(body.api_key) : null);++ if (!key || key.length < 8 || key.length > 400) {++ throw Object.assign(new Error('the key is 8-400 characters'), { status: 422, code: 'connections.key_invalid' });++ }++ const out = { provider: p, key };++ if (body.base_url != null && body.base_url !== '') out.base_url = String(body.base_url);++ if (body.budget_usd_per_day != null && body.budget_usd_per_day !== '') {++ const budget = Number(body.budget_usd_per_day);++ if (!Number.isFinite(budget) || budget < 0) throw Object.assign(new Error('the budget is a number of dollars a day'), { status: 422, code: 'connections.budget_invalid' });++ out.budget_usd_per_day = budget;++ }++ // The key is in `out` from here on: it goes straight to AI and is never logged, stored or answered.++ const row = await call('PUT', `/api/v1/credentials/${encodeURIComponent(subject)}/${p}`, { body: out });++ return present(row) || { provider: p, hint: null };++ }++++ /** Forget the person's key for a provider. → true when there was one. */++ async function remove(subject, provider) {++ const p = providerOf(provider);++ if (!p) throw Object.assign(new Error(`provider is one of ${PROVIDERS.join(', ')}`), { status: 422, code: 'connections.provider_unknown' });++ try {++ await call('DELETE', `/api/v1/credentials/${encodeURIComponent(subject)}/${p}`);++ return true;++ } catch (e) {++ if (e.status === 404) return false;++ throw e;++ }++ }++++ /**++ * One tiny request through AI with the person's own key: does it work? The answer is OK, or the reason it did++ * not (the provider's own words, without a key in it).++ */++ async function test(subject, provider) {++ const p = providerOf(provider);++ if (!p) throw Object.assign(new Error(`provider is one of ${PROVIDERS.join(', ')}`), { status: 422, code: 'connections.provider_unknown' });++ const header = { 'x-openvibe-credential': `${p}:${subject}` };++ try {++ if (ANTHROPIC_FORMAT.includes(p)) {++ await call('POST', '/v1/messages', { body: { model: 'openvibe', max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }, headers: header });++ } else {++ await call('POST', '/v1/chat/completions', { body: { model: 'openvibe', max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }, headers: header });++ }++ return { ok: true, detail: 'the key works' };++ } catch (e) {++ if (e.status === 404) return { ok: false, detail: 'there is no key for this provider' };++ return { ok: false, detail: String((e && e.message) || 'the provider did not answer').slice(0, 300) };++ }++ }++++ /** The providers a person has a key for: the router's word for "connected". */++ async function providers(subject) {++ const { keys } = await list(subject);++ return keys.map((k) => k.provider);++ }++++ return { list, put, remove, test, providers, present, PROVIDERS };++}++++module.exports = { createConnections, PROVIDERS, ANTHROPIC_FORMAT, providerOf, present };nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++++/**++ * The secrets Codes must never show again: the run tokens it minted for the harnesses.++ *++ * A run token is a Network token (a JWT) that says whose key and whose budget a run is. It lives in the harness's++ * environment for the length of an attempt and nowhere else — Codes keeps no copy, so the only place it can be++ * remembered is here, in memory, for as long as the service runs. Every line a session keeps is scrubbed with it, so++ * a token that an agent printed (or a harness echoed) never reaches the transcript, the page or the log.++ */++++const MIN_LENGTH = 12;++const secrets = new Set();++++/** Remember a secret so that it is redacted everywhere a line is written. */++function remember(secret) {++ const s = String(secret || '');++ if (s.length >= MIN_LENGTH) secrets.add(s);++ return s;++}++++/** The secrets remembered (their count, never their values). */++function count() { return secrets.size; }++++/** A text with every remembered secret replaced. */++function scrub(text) {++ let out = String(text == null ? '' : text);++ for (const s of secrets) {++ if (s && out.includes(s)) out = out.split(s).join('[redacted]');++ }++ return out;++}++++/** A JSON value with every remembered secret replaced, in the strings inside it. */++function scrubValue(value) {++ if (typeof value === 'string') return scrub(value);++ if (Array.isArray(value)) return value.map(scrubValue);++ if (value && typeof value === 'object') {++ const out = {};++ for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v);++ return out;++ }++ return value;++}++++module.exports = { remember, scrub, scrubValue, count };@@@ -4,7 +4,7 @@* Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the* service API (server/http/tasks-api.js, codes.task.*) share.*-- * createRunTasks({ store, run, config, log, sessions, outbox })++ * createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections })* start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)* refresh(row, { force }) → the row, read from Run only as a fallback after 10 quiet minutes* applyEvent(row, event) → a run.job.* delivery: state, timings, result, event, in one tx@@@ -85,13 +85,71 @@ function errorOf(job) {return `${job.error.code || 'run.error'}${job.error.detail ? `: ${job.error.detail}` : ''}`.slice(0, 1000);}--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null }) {++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null }) {const db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });++ // ── The person's connections (Codes C3) ──++ // What they may run: the providers they keep a key for and the plans on their machines. It is read from++ // OpenVibe.AI (the keys) and from the machines' reports (the plans, empty until the machines report them).++ // The answer is cached for a minute, so a task that ends does not ask again for every attempt.++ const connectionCache = new Map();++ const CONNECTION_TTL_MS = 60_000;++ let providerMap = null;++ async function personConnections(subject) {++ if (!connections || !subject) return null;++ const hit = connectionCache.get(subject);++ if (hit && hit.until > store.now()) return hit.value;++ let value = null;++ try {++ const out = await connections.list(subject);++ value = { providers: (out.keys || []).map((k) => k.provider), plans: out.plans || [] };++ } catch (e) {++ log.warn(`[Codes] the connections of ${subject} could not be read: ${String(e && e.message).slice(0, 200)}`);++ value = null;++ }++ connectionCache.set(subject, { value, until: store.now() + CONNECTION_TTL_MS });++ return value;++ }++++ /**++ * The environment the job's harness gets (Codes C3): a run token that names the person and says where to++ * send its requests. A failure to mint one is not fatal: the job runs as it did before, without the token.++ */++ async function harnessEnv({ subject, taskId, attemptId = null, agent = null }) {++ if (!runTokens) return null;++ try {++ const provider = providerFor(agent);++ const own = provider ? await hasOwnKey(subject, provider) : false;++ const minted = await runTokens.mint({ subject, task: taskId, attempt: attemptId, harness: agent, provider, ownKey: own });++ return minted.env;++ } catch (e) {++ log.warn(`[Codes] no run token for task ${taskId}: ${String(e && e.message).slice(0, 200)}`);++ return null;++ }++ }++++ /** The provider a harness runs on: the catalog's own word for it (anthropic, openai, deepseek, …). */++ function providerFor(agent) {++ if (!agent) return null;++ if (!providerMap) {++ providerMap = {};++ for (const offer of require('./harnesses').createHarnesses().list()) providerMap[offer.id] = offer.provider;++ }++ return providerMap[agent] || null;++ }++++ /** Whether the person keeps their own key for this provider. */++ async function hasOwnKey(subject, provider) {++ const conns = await personConnections(subject);++ if (!conns) return false;++ return conns.providers.includes(provider);++ }++++/*** One state change: update the row, write its codes.task.* outbox row in the same transaction, and on a terminal* state reconcile the session with the result and mark it complete. A late or out-of-order state never moves a@@@ -141,7 +199,10 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou});let job;try {-- job = await run.submit({ subject, project, args, key: id });++ // The job's harness runs with a short-lived run token that names the person (Codes C3): the environment++ // travels with the job's input and the machine's worker hands it to the harness.++ const env = await harnessEnv({ subject, taskId: id, agent: args.agent || null });++ job = await run.submit({ subject, project, args: env ? { ...args, env } : args, key: id });} catch (e) {const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;return await store.tx(async () => {nnew file mode 100644@@@ -0,0 +1,143 @@++'use strict';++++/**++ * Run tokens (Codes C3): the short-lived Network token an attempt's harness carries.++ *++ * A run token is a Network token (client credentials, audience openvibe.ai, capability ai.run.create) that names++ * the person it acts for (`on_behalf_of`) and lives at most two hours. It is bound to one task: it is minted when the++ * attempt starts and it is written into the job's environment, so OpenVibe.AI knows whose key and whose budget the++ * request is, and nobody else's. The token itself is never written to Codes' database (only its jti and its expiry++ * are, so a run can be traced), never logged and never answered; the transcript normaliser redacts it everywhere a++ * line is written (server/domain/redact.js).++ *++ * The environment the harness gets points it at OpenVibe.AI:++ * Claude Code ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_CUSTOM_HEADERS++ * Codex, OpenAI-like OPENAI_BASE_URL, OPENAI_API_KEY++ * The credential header (x-openvibe-credential) names the provider whose key the run uses, when the person has one.++ *++ * createRunTokens({ config, store, fetchImpl, log })++ * mint({ subject, task, attempt, harness, provider, ownKey }) → { token, expiresAt, jti, env }++ * record({ subject, task, attempt, provider, jti, expiresAt }) → the row (no token in it)++ */++++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** The harnesses that speak Anthropic's Messages API (the rest speak OpenAI's Chat Completions). */++const ANTHROPIC_HARNESSES = ['claude-code'];++++/** The claims of a JWT, decoded but not verified (the receiver verifies; this is for the jti and the expiry). */++function claims(token) {++ try {++ const part = String(token || '').split('.')[1];++ if (!part) return null;++ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));++ } catch { return null; }++}++++/** The base URL of OpenVibe.AI, without a trailing slash. */++function aiBase(config) {++ return String((config.ai && config.ai.url) || 'https://ai.openvibe.services').replace(/\/+$/, '');++}++++/**++ * The environment a harness of this kind gets: where to send its requests, the token to send, and (when the person++ * has a key for that provider) the header that says which key.++ */++function envFor({ config, harness, token, provider, ownKey }) {++ const base = aiBase(config);++ const credential = ownKey && provider ? `x-openvibe-credential: ${provider}` : null;++ if (ANTHROPIC_HARNESSES.includes(harness)) {++ return {++ ANTHROPIC_BASE_URL: base,++ ANTHROPIC_AUTH_TOKEN: token,++ ...(credential ? { ANTHROPIC_CUSTOM_HEADERS: credential } : {}),++ };++ }++ // The OpenAI-compatible harnesses: the CLI ones read OPENAI_BASE_URL and OPENAI_API_KEY; Codes' own adapters++ // read their own names, so they are given the same endpoint and token under those names too.++ const env = { OPENAI_BASE_URL: `${base}/v1`, OPENAI_API_KEY: token };++ if (credential) {++ env.OPENAI_CUSTOM_HEADERS = credential;++ env.OPENVIBE_CODES_HEADERS = JSON.stringify({ 'x-openvibe-credential': provider });++ }++ if (harness === 'deepseek') { env.DEEPSEEK_BASE_URL = `${base}/v1`; env.DEEPSEEK_API_KEY = token; }++ if (harness === 'openvibe-ai') { env.OPENVIBE_AI_URL = `${base}/v1`; env.OPENVIBE_AI_TOKEN = token; }++ if (harness === 'openai-compatible') { env.OPENVIBE_CODES_BASE_URL = `${base}/v1`; env.OPENVIBE_CODES_API_KEY = token; }++ return env;++}++++function createRunTokens({ config, store = null, fetchImpl = globalThis.fetch, log = console } = {}) {++ const ttl = Math.min(7200, Math.max(60, Number((config.runTokens && config.runTokens.ttlSeconds) || 7200)));++++ /**++ * Mint a run token for one attempt of one task of one person.++ * → { token, expiresAt, jti, env }; the token is in the answer and nowhere else.++ */++ async function mint({ subject, task = null, attempt = null, harness = null, provider = null, ownKey = false } = {}) {++ if (!USER.test(String(subject || ''))) throw Object.assign(new Error('subject must be a usr_ subject'), { status: 400, code: 'subject.required' });++ const clientId = config.oauth && config.oauth.clientId;++ const clientSecret = config.oauth && config.oauth.clientSecret;++ if (!clientId || !clientSecret) {++ throw Object.assign(new Error('Codes has no Network client secret, so it cannot mint a run token'), { status: 503, code: 'run_token.unavailable' });++ }++ const tokenUrl = `${config.networkInternalUrl}/oauth/token`;++ const body = new URLSearchParams({++ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret,++ audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: String(subject), ttl: String(ttl),++ });++ let res;++ try {++ res = await fetchImpl(tokenUrl, {++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body: body.toString(), signal: AbortSignal.timeout(15_000),++ });++ } catch (e) {++ throw Object.assign(new Error(`OpenVibe.Network did not answer: ${e.message}`), { status: 503, code: 'run_token.unavailable' });++ }++ const doc = await res.json().catch(() => null);++ if (!res.ok || !doc || !doc.access_token) {++ const err = new Error((doc && (doc.error_description || doc.error)) || `OpenVibe.Network answered ${res.status}`);++ err.status = res.status === 403 ? 403 : 503; err.code = 'run_token.refused';++ throw err;++ }++ const token = String(doc.access_token);++ const c = claims(token) || {};++ const out = {++ token,++ jti: c.jti || null,++ expiresAt: c.exp ? new Date(c.exp * 1000).toISOString() : new Date(Date.now() + ttl * 1000).toISOString(),++ env: envFor({ config, harness, token, provider, ownKey }),++ };++ require('./redact').remember(token);++ await record({ subject, task, attempt, provider, jti: out.jti, expiresAt: out.expiresAt });++ return out;++ }++++ /** The row that says a run token was minted for a task: its jti and its expiry, never the token. */++ async function record({ subject, task = null, attempt = null, provider = null, jti = null, expiresAt = null } = {}) {++ if (!store || !store.db) return null;++ try {++ return await store.db.one(++ `INSERT INTO codes_run_tokens (id, task_id, attempt_id, subject, provider, jti, expires_at, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz) RETURNING *`,++ [store.newId('rtk'), task || null, attempt || null, subject || null, provider || null, jti || null,++ expiresAt || null, store.iso()],++ );++ } catch (e) {++ // The row is a trace, not the point: a database that cannot take it must not stop the run.++ log.warn(`[Codes] the run token row could not be written: ${String(e && e.message).slice(0, 200)}`);++ return null;++ }++ }++++ /** The tokens minted for a task: their jtis and their expiries, never the tokens. */++ async function forTask(taskId) {++ if (!store || !store.db || !taskId) return [];++ const rows = await store.db.many('SELECT id, task_id, attempt_id, subject, provider, jti, expires_at, created_at FROM codes_run_tokens WHERE task_id = $1 ORDER BY created_at ASC', [taskId]);++ return rows.map((r) => ({ id: r.id, task_id: r.task_id, attempt_id: r.attempt_id, subject: r.subject, provider: r.provider, jti: r.jti, expires_at: r.expires_at, created_at: r.created_at }));++ }++++ return { mint, record, forTask, envFor, ttl };++}++++module.exports = { createRunTokens, envFor, ANTHROPIC_HARNESSES };@@@ -16,6 +16,7 @@* retention { runOnce, start, stop } (CODES_SESSION_RETENTION_DAYS, default 90)*/const { TERMINAL, sessionLogOf } = require('./run-tasks');++const redact = require('./redact');const MAX_BODY_BYTES = 64 * 1024;const CLIP_MARKER = '… [clipped]';@@@ -106,7 +107,9 @@ function createSessionLines({ store, config = {}, log = console }) {if (!Number.isSafeInteger(seq) || seq < 1) continue;const at = atIso(line.at) || store.iso();const kind = String(line.kind || 'text').slice(0, 64) || 'text';-- clean.push({ seq, at, kind, body: clipBody(line.body) });++ // The transcript normaliser: a run token (Codes C3) that an agent printed is redacted here, before the++ // line is kept, so it never reaches the transcript, the page or the log.++ clean.push({ seq, at, kind, body: clipBody(redact.scrubValue(line.body)) });}let inserted = 0;for (let i = 0; i < clean.length; i += 200) {nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++++/**++ * A person's connections (Codes C3, spec codes-c3-connections.md): the API keys they keep for their own providers++ * and the plans on their own machines.++ *++ * GET /api/v1/connections the keys (provider, hint, today's spend, budget, last used) and the plans++ * PUT /api/v1/connections/:provider { key, base_url?, budget_usd_per_day? } → the connection (the hint only)++ * DELETE /api/v1/connections/:provider forget the key++ * POST /api/v1/connections/:provider/test one tiny request through AI: OK, or why not++ *++ * The owner of a key is the signed-in person: only they may read, change or test their own connections, and a++ * request that names another person is refused. The key itself is forwarded to OpenVibe.AI and never kept here: it++ * is not in Codes' database, not in its logs, not in its answers. The plans are the CLI logins on the person's own++ * machines, reported by their Node through OpenVibe.Bot; there are none yet, so the list is empty until a machine++ * reports one (stage C5).++ */++++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createConnectionsApi({ config, store, connections, actorLimits, log = console } = {}) {++ const r = asyncRouter();++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const json = express.json({ limit: '8kb' });++++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++++ /**++ * The person whose connections this is: the signed-in one, and nobody else. A request that names another++ * subject (a header or a query) is refused rather than quietly answered with the caller's own.++ */++ const owner = (req, res) => {++ if (!signedIn(req)) { problem(req, res, 401, 'auth.required', 'sign in to see your connections'); return null; }++ const named = String(req.get('x-ov-subject') || req.query.subject || '').trim();++ if (named && named !== req.viewer.subject) {++ problem(req, res, 403, 'connections.forbidden', "these are your own connections; another person's are not yours to read");++ return null;++ }++ return req.viewer.subject;++ };++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++++ r.get('/connections', actorLimits.reads('codes.read'), async (req, res) => {++ const subject = owner(req, res);++ if (!subject) return undefined;++ try {++ return res.json(await connections.list(subject));++ } catch (e) {++ log.warn(`[Codes] the connections of ${subject} could not be read: ${String(e && e.message).slice(0, 200)}`);++ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.unavailable', (e && e.message) || 'the connections could not be read');++ }++ });++++ r.put('/connections/:provider', actorLimits.budget('codes.task.start'), json, async (req, res) => {++ const subject = owner(req, res);++ if (!subject) return undefined;++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');++ const body = req.body || {};++ try {++ // The key goes straight to OpenVibe.AI: it is never stored, never logged and never answered.++ const out = await connections.put(subject, req.params.provider, body);++ log.info(`[Codes] ${subject} stored a key for ${out.provider}`);++ return res.status(200).json({ connection: out });++ } catch (e) {++ log.warn(`[Codes] the key for ${req.params.provider} was refused: ${String(e && e.message).slice(0, 200)}`);++ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.refused', (e && e.message) || 'the key was refused');++ }++ });++++ r.delete('/connections/:provider', actorLimits.budget('codes.task.start'), async (req, res) => {++ const subject = owner(req, res);++ if (!subject) return undefined;++ try {++ const gone = await connections.remove(subject, req.params.provider);++ if (!gone) return problem(req, res, 404, 'connections.not_found', 'there is no key for that provider');++ return res.status(204).end();++ } catch (e) {++ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.refused', (e && e.message) || 'the key could not be forgotten');++ }++ });++++ r.post('/connections/:provider/test', actorLimits.budget('codes.task.start'), json, async (req, res) => {++ const subject = owner(req, res);++ if (!subject) return undefined;++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');++ try {++ const out = await connections.test(subject, req.params.provider);++ return res.json(out);++ } catch (e) {++ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.refused', (e && e.message) || 'the key could not be tested');++ }++ });++++ return r;++}++++module.exports = { createConnectionsApi };@@@ -18,8 +18,8 @@ const SERVICES_ORIGIN = 'https://openvibe.services';const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the user task--// API and the Action Center read (codes.actions.read).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me\/tasks)(\/|$)/;++// API, the Action Center read (codes.actions.read) and a person's own connections (Codes C3).++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me\/tasks|connections)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * A person's connections (Codes C3, spec codes-c3-connections.md): the API keys they keep for their own providers++ * and the plans on their machines.++ *++ * - the key never comes back: not in the answer, not in the logs, not in the database, not in a page;++ * - the owner of a key is the signed-in person: nobody else's connections are readable;++ * - the router never picks an agent the person cannot run;++ * - the run token is redacted by the transcript normaliser.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const router = require('../engine/router');++++(async () => {++ const KEY = 'sk-ant-0123456789abcdefXYZ';++ const t = await boot({ env: (network) => ({ CODES_AI_URL: network.url }) });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++++ try {++ await check('the connections are the signed-in person\'s own; another person\'s are refused', async () => {++ const none = await t.get('/api/v1/connections');++ assert.strictEqual(none.status, 401, 'not signed in is 401');++ const mine = await t.get('/api/v1/connections', { as: ada });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.deepStrictEqual(mine.json(), { keys: [], plans: [] }, 'no keys yet, and no plans until a machine reports one');++ const other = await t.get('/api/v1/connections', { as: ada, headers: { 'X-OV-Subject': bo.subject } });++ assert.strictEqual(other.status, 403, 'another person\'s connections are refused');++ assert.strictEqual(other.json().code, 'connections.forbidden');++ const named = await t.get(`/api/v1/connections?subject=${bo.subject}`, { as: ada });++ assert.strictEqual(named.status, 403, 'and by the query too');++ });++++ await check('a key is stored in OpenVibe.AI and answered with the hint only', async () => {++ const put = await t.get('/api/v1/connections/anthropic', { as: ada, method: 'PUT', json: { key: KEY, budget_usd_per_day: 5 } });++ assert.strictEqual(put.status, 200, put.text);++ const body = put.json();++ assert.strictEqual(body.connection.provider, 'anthropic');++ assert.ok(body.connection.hint && body.connection.hint.length >= 4, 'the hint is there');++ assert.ok(!JSON.stringify(body).includes(KEY), 'the key is not in the answer');++ // AI really has it, under this person and this provider.++ const stored = t.network.state.vault.get(`${ada.subject}:anthropic`);++ assert.ok(stored, 'OpenVibe.AI holds the key');++ assert.strictEqual(stored.key, KEY);++ // And Codes holds no copy of it: not in its database, not in its logs, not in a page.++ assert.ok(!(await t.dbDump()).includes(KEY), 'the key is not in Codes\' database');++ assert.ok(!t.logs().includes(KEY), 'the key is not in the logs');++ const page = await t.get('/run', { as: ada });++ assert.ok(!page.text.includes(KEY), 'the key is not on a page');++ });++++ await check('the connections are read back without the key', async () => {++ const list = await t.get('/api/v1/connections', { as: ada });++ assert.strictEqual(list.status, 200);++ assert.strictEqual(list.json().keys.length, 1);++ const key = list.json().keys[0];++ assert.strictEqual(key.provider, 'anthropic');++ assert.strictEqual(key.budget_usd_per_day, 5);++ assert.strictEqual(key.spent_usd_today, 0);++ assert.ok(!JSON.stringify(list.json()).includes(KEY), 'the key is not in the list either');++ });++++ await check('a test request goes through AI with the person\'s own key', async () => {++ const res = await t.get('/api/v1/connections/anthropic/test', { as: ada, method: 'POST', json: {} });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().ok, true);++ const sent = t.network.state.modelRequests[t.network.state.modelRequests.length - 1];++ assert.strictEqual(sent.path, '/v1/messages', 'Anthropic\'s own format');++ assert.strictEqual(sent.credential, `anthropic:${ada.subject}`, 'the header names the provider and the person');++ });++++ await check('a key can be forgotten', async () => {++ const del = await t.get('/api/v1/connections/anthropic', { as: ada, method: 'DELETE' });++ assert.strictEqual(del.status, 204, del.text);++ const list = await t.get('/api/v1/connections', { as: ada });++ assert.deepStrictEqual(list.json().keys, []);++ assert.strictEqual((await t.get('/api/v1/connections/anthropic', { as: ada, method: 'DELETE' })).status, 404, 'and it is gone for good');++ });++++ await check('the router never picks an agent the person cannot run', async () => {++ // The router (engine/router.js) is the portal's: a ladder of runners per tier. The person\'s connections++ // say which of them they may run — a runner whose connection is missing is never picked.++ const ladder = {};++ for (const tier of router.TIERS) ladder[tier] = [['cc', 'claude-sonnet-5-5', 'high']];++ const base = {++ cfg: { subagents: { cc: { enabled: true, max: 2 } } },++ health: { cc: { state: 'ok' } },++ counts: {}, models: { cc: ['claude-sonnet-5-5'] }, ladder,++ };++ const prompt = 'Find the root cause of a race condition in the payment worker and fix it.';++ const none = router.pick(prompt, { ...base, connections: { cc: { provider: 'anthropic', connected: false } } });++ assert.strictEqual(none, null, 'a runner the person has no connection for is never picked');++ const picked = router.pick(prompt, { ...base, connections: { cc: { provider: 'anthropic', connected: true } } });++ assert.ok(picked, 'a runner they may run is picked');++ assert.strictEqual(picked.runner, 'cc');++ assert.ok(router.pick(prompt, base), 'without a map of connections the ladder is as it was');++ });++++ await check('the run token is redacted by the transcript normaliser', async () => {++ const minted = await t.ctx.runTokens.mint({ subject: ada.subject, task: null, attempt: null, harness: 'claude-code', provider: 'anthropic', ownKey: true });++ assert.ok(minted.token, 'a run token was minted');++ assert.ok(minted.env.ANTHROPIC_AUTH_TOKEN, 'the harness gets it in its environment');++ assert.strictEqual(minted.env.ANTHROPIC_CUSTOM_HEADERS, 'x-openvibe-credential: anthropic');++ // The token is not in Codes' database (only its jti and its expiry are).++ assert.ok(!(await t.dbDump()).includes(minted.token), 'the run token is not in Codes\' database');++ // A line that carries the token is redacted before it is kept.++ const taskId = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1';++ await t.ctx.sessions.append(taskId, [{ seq: 1, at: new Date().toISOString(), kind: 'text', body: { text: `the token is ${minted.token}` } }]);++ const lines = await t.ctx.sessions.allRows(taskId);++ const kept = JSON.stringify(lines);++ assert.ok(!kept.includes(minted.token), 'the token is not in the transcript');++ assert.ok(kept.includes('[redacted]'), 'it says that it was redacted');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -27,7 +27,7 @@ async function boot(opts = {}) {NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',-- ...(opts.env || {}),++ ...(typeof opts.env === 'function' ? opts.env(network) : (opts.env || {})),};const configLib = require('../../server/config');const { createApp } = require('../../server/app');@@@ -56,6 +56,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.musers: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,++ vault: new Map(), // OpenVibe.AI's stand-in: a person's own keys, by subject and provider};let issuer = null;let nextUserId = 1;@@@ -281,8 +282,14 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mreturn json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });}if (body.grant_type === 'client_credentials') {++ // A run token (Codes C3): it may name the person it acts for and live longer than five minutes.const now = Math.floor(Date.now() / 1000);-- return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:codes', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });++ const ttl = Math.min(7200, Math.max(60, Number(body.ttl) || 300));++ const claims = { iss: issuer, sub: 'svc:codes', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + ttl, jti: crypto.randomUUID() };++ if (body.on_behalf_of) claims.on_behalf_of = String(body.on_behalf_of);++ st.tokenRequests[st.tokenRequests.length - 1].on_behalf_of = body.on_behalf_of || null;++ st.tokenRequests[st.tokenRequests.length - 1].ttl = body.ttl || null;++ return json(200, { access_token: serviceAuth.signServiceToken(claims, privatePem), token_type: 'Bearer', expires_in: ttl });}}const a = st.apps.get(body.client_id);@@@ -298,6 +305,42 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mif (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });}++ // ── OpenVibe.AI's stand-in (Codes C3): the vault of a person's own keys and the two model endpoints ──++ if (url.pathname.startsWith('/api/v1/credentials/')) {++ const parts = url.pathname.split('/').filter(Boolean); // api, v1, credentials, subject, provider++ const subject = parts[3]; const provider = parts[4];++ if (!subject || !provider) return problem(json, 404, 'route.not_found', 'no such route');++ const key = `${subject}:${provider}`;++ const view = (row) => row && ({ subject: row.subject, owner: 'codes', provider: row.provider, base_url: row.base_url || null, key_hint: `\u2026${String(row.key || '').slice(-4)}`, models: {}, budget_usd_per_day: row.budget_usd_per_day == null ? null : row.budget_usd_per_day, spent_usd_today: row.spent_usd_today || 0, created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null });++ if (req.method === 'PUT') {++ let body = {};++ try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { return problem(json, 400, 'input.invalid', 'body is not JSON'); }++ const prev = st.vault.get(key);++ const row = { subject, provider, key: body.key != null ? String(body.key) : (prev ? prev.key : ''), base_url: body.base_url != null ? body.base_url : (prev ? prev.base_url : null), budget_usd_per_day: body.budget_usd_per_day != null ? body.budget_usd_per_day : (prev ? prev.budget_usd_per_day : null), spent_usd_today: prev ? prev.spent_usd_today : 0, created_at: prev ? prev.created_at : new Date().toISOString(), updated_at: new Date().toISOString(), last_used_at: prev ? prev.last_used_at : null };++ st.vault.set(key, row);++ return json(200, view(row));++ }++ if (req.method === 'GET') {++ const row = st.vault.get(key);++ if (!row) return problem(json, 404, 'credential.not_found', 'no credential stored for that subject');++ return json(200, view(row));++ }++ if (req.method === 'DELETE') {++ if (!st.vault.has(key)) return problem(json, 404, 'credential.not_found', 'no credential stored for that subject');++ st.vault.delete(key);++ return json(204, {});++ }++ return problem(json, 404, 'route.not_found', 'no such route');++ }++ if (url.pathname === '/v1/messages' || url.pathname === '/v1/chat/completions') {++ const credential = String(req.headers['x-openvibe-credential'] || '');++ st.modelRequests = st.modelRequests || [];++ st.modelRequests.push({ path: url.pathname, credential, authorization: String(req.headers.authorization || '') });++ if (st.modelFails) return json(503, { type: 'error', error: { type: 'overloaded_error', message: st.modelFails } });++ return json(200, url.pathname === '/v1/messages'++ ? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }++ : { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });++ }if (url.pathname.startsWith('/api/v1/projects')) {const u = userFrom(req);if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');@@@ -45,7 +45,10 @@ const { boot, check, done } = require('./helpers/boot');const submit = calls.find((c) => c[0] === 'submit')[1];assert.strictEqual(submit.subject, ada.subject);assert.ok(submit.project, 'a project was created');-- assert.deepStrictEqual(submit.args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });++ // The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.++ const { env, ...args } = submit.args;++ assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });++ assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');});await check('the task is listed and readable by its owner, not by another person', async () => {@@@ -67,7 +67,11 @@ const { boot, check, done } = require('./helpers/boot');const [kind, sent] = calls.find((c) => c[0] === 'submit');assert.strictEqual(kind, 'submit');assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);-- assert.deepStrictEqual(sent.args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });++ // The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.++ const { env, ...args } = sent.args;++ assert.deepStrictEqual(args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });++ assert.deepStrictEqual(Object.keys(env || {}).sort(), ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']);++ assert.ok(env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_BASE_URL, 'the harness gets a run token and OpenVibe.AI\'s address');const row = await t.ctx.store.db.one('SELECT subject, project_id, job_id, state FROM run_tasks WHERE id = $1', [taskId]);assert.deepStrictEqual({ ...row }, { subject: ada.subject, project_id: project, job_id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' });});@@@ -47,6 +47,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'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/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; 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/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/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person','server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; 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',};
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
*/
*/
const path = require('path');
const path = require('path');
const { createHarness } = require('../harness');
const { createHarness } = require('../harness');
-const { runJob, resultWriter } = require('../harness/job');
+const { runJob, resultWriter, jobEnv } = require('../harness/job');
const { version: VERSION } = require('../package.json');
const { version: VERSION } = require('../package.json');
const { probe: probeLogins } = require('../engine/logins');
const { probe: probeLogins } = require('../engine/logins');
@@ -152,7 +152,13 @@ async function main() {
@@ -152,7 +152,13 @@ async function main() {
if (cmd === 'job') {
if (cmd === 'job') {
// The job ends 0 whenever it handed over a result, ok or not (the document says which): OpenVibe Run keeps a
// The job ends 0 whenever it handed over a result, ok or not (the document says which): OpenVibe Run keeps a
// result only from a job that exited 0, and the person needs the summary and the patch either way.
// result only from a job that exited 0, and the person needs the summary and the patch either way.
- await runJob({ input: await readStdin(), harness, emit: out, writeResult: resultWriter(out) });
+ const input = await readStdin();
+ // The job's own environment (Codes C3): the run token the harness carries and where the model APIs are. The
+ // harness is built with it, so the agent's process gets the token and never the job's other input.
+ let wanted = {};
+ try { wanted = jobEnv(JSON.parse(input)); } catch { wanted = {}; }
+ const jobHarness = createHarness({ env: { ...process.env, ...wanted } });
+ await runJob({ input, harness: jobHarness, emit: out, writeResult: resultWriter(out) });
return 0;
return 0;
}
}
if (cmd === 'runs') {
if (cmd === 'runs') {
@@ -137,6 +137,25 @@ function track(runs) {
@@ -137,6 +137,25 @@ function track(runs) {
return rec;
return rec;
}
}
+/**
+ * Whether the person may run this runner (Codes C3): a runner whose connection is missing is never picked, so a
+ * task never goes to an agent the person cannot pay for or sign in to.
+ *
+ * `connections` is { [runner]: { provider, connected } } — what the person has: an API key for the provider, or a
+ * plan on one of their machines (the same thing the router skips while it is at its limit). A runner that is not in
+ * the map needs no connection, and a map that is absent at all leaves the ladder as it was (the portal's router).
+ *
+ * @param {string} runner the runner's id
+ * @param {object} [connections] the person's connections, per runner
+ * @returns {boolean} may this runner take the job
+ */
+function connected(runner, connections) {
+ if (!connections) return true;
+ const c = connections[runner];
+ if (!c) return true;
+ return c.connected !== false;
+}
+
/**
/**
* The runner, model and reasoning for a job, or null when nothing on the ladder can take it now.
* The runner, model and reasoning for a job, or null when nothing on the ladder can take it now.
*
*
@@ -150,13 +169,14 @@ function track(runs) {
@@ -150,13 +169,14 @@ function track(runs) {
* @param {string} [ctx.JOBS] the jobs folder
* @param {string} [ctx.JOBS] the jobs folder
* @param {string} [ctx.name] the job's own name
* @param {string} [ctx.name] the job's own name
* @param {object} [ctx.ladder] the ladder to use (the portal's by default)
* @param {object} [ctx.ladder] the ladder to use (the portal's by default)
+ * @param {object} [ctx.connections] the person's connections, per runner (Codes C3)
* @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}
* @returns {{runner: string, model: string|null, effort: string|null, tier: string, brief: string|null, why: string}|null}
*/
*/
-function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder } = {}) {
+function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder, connections } = {}) {
const c = classify(prompt, { JOBS, name });
const c = classify(prompt, { JOBS, name });
const rec = track(runs);
const rec = track(runs);
const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };
const reliable = (r) => { const t = rec[r]; return !t || t.done + t.failed < 4 || t.done / (t.done + t.failed) >= 0.4; };
- const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r);
+ const usable = (r) => cfg.subagents[r] && cfg.subagents[r].enabled && (counts[r] || 0) < cfg.subagents[r].max && health[r] && health[r].state === 'ok' && reliable(r) && connected(r, connections);
for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {
for (const [runner, model, wanted] of (ladder || LADDER)[c.tier]) {
if (!usable(runner)) continue;
if (!usable(runner)) continue;
// A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).
// A model the runner no longer lists is skipped for that runner's default (lists refresh from each provider).
@@ -168,4 +188,4 @@ function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder }
@@ -168,4 +188,4 @@ function pick(prompt, { cfg, health, counts, models, runs, JOBS, name, ladder }
return null;
return null;
}
}
-module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS };
+module.exports = { LADDER, TIERS, classify, classifyText, pick, track, briefFor, forcedTier, fitEffort, stepUp, EFFORTS, connected };
@@ -150,6 +150,26 @@ function shownInput(name, args) {
@@ -150,6 +150,26 @@ function shownInput(name, args) {
return args;
return args;
}
}
+/**
+ * The extra headers a job's environment asks for (Codes C3): OPENVIBE_CODES_HEADERS is a JSON object of header names
+ * and values (the credential header that says whose key the request uses). Only header names and short values are
+ * read, and a name that is not a header is refused, so nothing else can ride this way.
+ */
+function extraHeaders(env) {
+ const raw = String((env && env.OPENVIBE_CODES_HEADERS) || '').trim();
+ if (!raw || raw.length > 2000) return {};
+ let parsed;
+ try { parsed = JSON.parse(raw); } catch { return {}; }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
+ const out = {};
+ for (const [name, value] of Object.entries(parsed)) {
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(name)) continue;
+ if (typeof value !== 'string' || !value || value.length > 300) continue;
+ out[name] = value;
+ }
+ return out;
+}
+
/**
/**
* apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
* apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).
* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).
@@ -193,7 +213,7 @@ function apiAgent(spec) {
@@ -193,7 +213,7 @@ function apiAgent(spec) {
const bearer = spec.token ? await spec.token(env, doFetch) : key;
const bearer = spec.token ? await spec.token(env, doFetch) : key;
const res = await doFetch(`${base}/chat/completions`, {
const res = await doFetch(`${base}/chat/completions`, {
method: 'POST',
method: 'POST',
- headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) },
+ headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },
body: JSON.stringify({ ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto' }),
body: JSON.stringify({ ...(spec.extra ? spec.extra(env) : {}), model, messages, tools, tool_choice: 'auto' }),
signal: controller.signal,
signal: controller.signal,
});
});
@@ -257,4 +277,4 @@ function apiAgent(spec) {
@@ -257,4 +277,4 @@ function apiAgent(spec) {
return adapter;
return adapter;
}
}
-module.exports = { apiAgent, runTool, inside, TOOL_DEFS, SYSTEM };
+module.exports = { apiAgent, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };
@@ -63,6 +63,32 @@ function scrub(s) {
@@ -63,6 +63,32 @@ function scrub(s) {
return String(s).replace(/(https?:\/\/)[^@/\s]+@/g, '$1***@').replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, '***');
return String(s).replace(/(https?:\/\/)[^@/\s]+@/g, '$1***@').replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, '***');
}
}
+/**
+ * The environment a job may set for the harness (Codes C3): the model APIs' own names, nothing else. A run token
+ * travels this way (the job's input carries it, the harness's process gets it), so only these names are read from
+ * the job's input: everything else in it is ignored.
+ */
+const ENV_NAMES = new Set([
+ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS',
+ 'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS',
+ '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',
+]);
+
+/** The environment a job's input asks for: the allowlisted names only, each a short string. */
+function jobEnv(input) {
+ const out = {};
+ const a = input && typeof input === 'object' && !Array.isArray(input) ? input.env : null;
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return out;
+ for (const [k, v] of Object.entries(a)) {
+ if (!ENV_NAMES.has(k)) continue;
+ if (typeof v !== 'string' || !v || v.length > 4000) continue;
+ out[k] = v;
+ }
+ return out;
+}
+
/** The job's input checked: a public https repository (file:// only when OPENVIBE_CODES_ALLOW_FILE_REPO=1, for tests). */
/** The job's input checked: a public https repository (file:// only when OPENVIBE_CODES_ALLOW_FILE_REPO=1, for tests). */
function parseArgs(raw, env = process.env) {
function parseArgs(raw, env = process.env) {
let a;
let a;
@@ -84,8 +110,15 @@ function parseArgs(raw, env = process.env) {
@@ -84,8 +110,15 @@ function parseArgs(raw, env = process.env) {
} : null;
} : null;
if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');
if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');
if (push && push.branch && PROTECTED.test(push.branch)) throw new Error(`push.branch must be a new branch of the task's own, never ${push.branch}`);
if (push && push.branch && PROTECTED.test(push.branch)) throw new Error(`push.branch must be a new branch of the task's own, never ${push.branch}`);
+ // The session a resume continues: the job's own input, or RESUME in the environment (a task carried on in the
+ // same conversation). It is a harness's session id, never a path and never a credential.
+ const resumeIn = a.resume == null || a.resume === '' ? env.RESUME : a.resume;
+ const resume = typeof resumeIn === 'string' && /^[A-Za-z0-9._:-]{1,200}$/.test(resumeIn) ? resumeIn : null;
+ const wanted = jobEnv(a);
return {
return {
repo: repo.toString(), ref, prompt, permission, push,
repo: repo.toString(), ref, prompt, permission, push,
+ ...(resume ? { resume } : {}),
+ ...(Object.keys(wanted).length ? { env: wanted } : {}),
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
};
};
@@ -313,4 +346,4 @@ function resultWriter(emit) {
@@ -313,4 +346,4 @@ function resultWriter(emit) {
};
};
}
}
-module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };
+module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- Stage C3 (openvibe.codes v1): the run tokens. A run token is the short-lived Network token an attempt's harness
+-- carries, so that OpenVibe.AI knows whose own key and whose budget a request is. It is bound to one task and lives
+-- at most two hours.
+--
+-- Codes keeps no copy of the token: this table holds its jti and its expiry, so a run can be traced and a token can
+-- be matched with the attempt that carried it. The token itself is in the harness's environment and nowhere else.
+--
+-- id the row's id (rtk_<ULID>)
+-- task_id the task the token was minted for
+-- attempt_id the attempt that carried it
+-- subject the person it acts for
+-- provider the provider whose key it names (null when the run used AI's own providers)
+-- jti the token's id (its hash, never the token)
+-- expires_at when the token stops working
+
+CREATE TABLE IF NOT EXISTS codes_run_tokens (
+ id text COLLATE "C" PRIMARY KEY, -- rtk_<ULID>
+ task_id text COLLATE "C", -- tsk_<ULID>
+ attempt_id text COLLATE "C", -- att_<ULID>
+ subject text COLLATE "C", -- usr_<ULID>: the person the token acts for
+ provider text COLLATE "C", -- anthropic, openai, deepseek, …
+ jti text COLLATE "C", -- the token's id, never the token
+ expires_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS codes_run_tokens_task ON codes_run_tokens (task_id) WHERE task_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS codes_run_tokens_attempt ON codes_run_tokens (attempt_id) WHERE attempt_id IS NOT NULL;
@@ -29,9 +29,12 @@ const { createHarnesses } = require('./domain/harnesses');
@@ -29,9 +29,12 @@ const { createHarnesses } = require('./domain/harnesses');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createRunRoutes } = require('./http/run');
const { createRunRoutes } = require('./http/run');
const { createTasksApi } = require('./http/tasks-api');
const { createTasksApi } = require('./http/tasks-api');
+const { createConnectionsApi } = require('./http/connections-api');
const { createActionsApi } = require('./http/actions-api');
const { createActionsApi } = require('./http/actions-api');
const { createRunClient } = require('./domain/run-client');
const { createRunClient } = require('./domain/run-client');
const { createRunTasks } = require('./domain/run-tasks');
const { createRunTasks } = require('./domain/run-tasks');
+const { createConnections } = require('./domain/connections');
+const { createRunTokens } = require('./domain/run-tokens');
const { createSessionLines } = require('./domain/session-lines');
const { createSessionLines } = require('./domain/session-lines');
const { createOutbox, createPublisher } = require('./events/outbox');
const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
@@ -66,8 +69,12 @@ async function createApp(opts = {}) {
@@ -66,8 +69,12 @@ async function createApp(opts = {}) {
const sessions = createSessionLines({ store, config, log });
const sessions = createSessionLines({ store, config, log });
const outbox = createOutbox({ store, log });
const outbox = createOutbox({ store, log });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox });
- const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks };
+ // A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a
+ // short-lived run token that names them.
+ const connections = createConnections({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
+ const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
+ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections });
+ const ctx = { config, store, keys, sso, harnesses, log, sessions, outbox, run, tasks, connections, runTokens };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
const app = express();
const app = express();
@@ -170,6 +177,8 @@ async function createApp(opts = {}) {
@@ -170,6 +177,8 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, actorLimits: ctx.actorLimits, log }));
+ // A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
+ app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -63,6 +63,25 @@ function load(env = process.env) {
@@ -63,6 +63,25 @@ function load(env = process.env) {
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+ // OpenVibe.AI (Codes C3): where a person's own provider key is sealed (it never leaves AI) and where the
+ // harnesses run. Codes talks to it with its own Network token (audience openvibe.ai).
+ ai: {
+ url: trim(env.CODES_AI_URL || 'https://ai.openvibe.services'),
+ audience: 'openvibe.ai',
+ // The scopes Codes asks Network for: it stores a person's key with the first and runs a test request
+ // (and the harnesses' requests) with the second.
+ scope: 'ai.credential.manage ai.run.create',
+ },
+ // A run token (Codes C3): the short-lived Network token an attempt's harness carries, so that AI knows whose
+ // key and whose budget the run is. Two hours is the longest a coding attempt may run.
+ runTokens: {
+ ttlSeconds: Math.min(7200, Math.max(60, int(env.CODES_RUN_TOKEN_TTL_S, 7200))),
+ // The harness env variable names a job may set (harness/job.js): the model APIs' own names, nothing else.
+ envNames: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_CUSTOM_HEADERS', 'OPENAI_BASE_URL',
+ 'OPENAI_API_KEY', 'OPENAI_CUSTOM_HEADERS', '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'],
+ },
// OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of
// OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of
// these; run.job.* subscriptions are created at boot. The outbox relay publishes codes.task.* with the
// these; run.job.* subscriptions are created at boot. The outbox relay publishes codes.task.* with the
// service's events.event.publish token.
// service's events.event.publish token.
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+
+/**
+ * A person's connections: the API keys they keep for their own providers, and the plans on their own machines.
+ *
+ * The key never comes to Codes. It is forwarded straight to OpenVibe.AI, which seals it (AES-256-GCM, bound to
+ * Codes as the storing service, to the person and to the provider) and never returns it: Codes' database never
+ * sees it, its logs never see it, and its answers carry only the provider, the four-character hint, today's spend,
+ * the budget and when the key was last used. The owner of a key is the signed-in person; Codes is the service that
+ * holds it for them.
+ *
+ * The plans (a CLI's own login on the person's machine: Claude Pro/Max, ChatGPT, Command Code, OpenCode Go, …) are
+ * reported by the person's Node through OpenVibe.Bot and read from here; there are none yet, so the list is empty
+ * until a machine reports one (stage C5).
+ *
+ * createConnections({ config, fetchImpl, log })
+ * list(subject) → { keys: [...], plans: [...] }
+ * put(subject, provider, body) → the connection as AI stored it (the hint, never the key)
+ * remove(subject, provider) → true when there was one
+ * test(subject, provider) → { ok, detail }: one tiny request through AI, or why not
+ */
+
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+/** The providers a person may keep a key for (the same list as OpenVibe.AI's own credential-put@2). */
+const PROVIDERS = ['anthropic', 'openai', 'deepseek', 'minimax', 'openrouter', 'custom'];
+/** The providers whose key speaks Anthropic's Messages API (the rest speak OpenAI's Chat Completions). */
+const ANTHROPIC_FORMAT = ['anthropic', 'deepseek', 'minimax', 'custom'];
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** A provider id, or null when it is not one Codes keeps keys for. */
+function providerOf(value) {
+ const p = String(value || '').trim().toLowerCase();
+ return PROVIDERS.includes(p) ? p : null;
+}
+
+/** The connection as Codes shows it: never the key, only what the person may know about it. */
+function present(row) {
+ if (!row) return null;
+ return {
+ provider: row.provider,
+ hint: row.key_hint || null,
+ base_url: row.base_url || null,
+ budget_usd_per_day: row.budget_usd_per_day == null ? null : Number(row.budget_usd_per_day),
+ spent_usd_today: row.spent_usd_today == null ? 0 : Number(row.spent_usd_today),
+ last_used_at: row.last_used_at || null,
+ created_at: row.created_at || null,
+ };
+}
+
+function createConnections({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String((config.ai && config.ai.url) || 'https://ai.openvibe.services').replace(/\/+$/, '');
+ const scope = (config.ai && config.ai.scope) || 'ai.credential.manage ai.run.create';
+ // Codes' own Network token for OpenVibe.AI: the consent grant (ai.credential.manage) lets it store a person's
+ // key; ai.run.create lets it make the test request. Five minutes, cached by the SDK client.
+ const tokens = config.oauth && config.oauth.clientSecret
+ ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.ai', scope, fetch: fetchImpl })
+ : null;
+
+ /** One call to AI. → { status, body }; the body is AI's own answer, never a key. */
+ async function call(method, path, { body = null, headers = {} } = {}) {
+ if (!tokens) throw Object.assign(new Error('Codes has no Network client secret, so it cannot reach OpenVibe.AI'), { status: 503, code: 'connections.unavailable' });
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { ...(await tokens.authHeaders()), Accept: 'application/json', ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ signal: AbortSignal.timeout(20_000),
+ });
+ } catch (e) {
+ throw Object.assign(new Error(`OpenVibe.AI did not answer: ${e.message}`), { status: 503, code: 'connections.unavailable' });
+ }
+ const doc = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error((doc && (doc.detail || doc.title)) || `OpenVibe.AI answered ${res.status}`);
+ err.status = res.status; err.code = (doc && doc.code) || 'ai.error';
+ throw err;
+ }
+ return doc;
+ }
+
+ /**
+ * The person's keys: one row per provider, read from AI. A provider with no key is left out (AI answers 404).
+ * The plans are empty until a machine reports one.
+ */
+ async function list(subject) {
+ if (!USER.test(String(subject || ''))) throw Object.assign(new Error('subject must be a usr_ subject'), { status: 400, code: 'subject.required' });
+ const keys = [];
+ for (const provider of PROVIDERS) {
+ let row = null;
+ try {
+ row = await call('GET', `/api/v1/credentials/${encodeURIComponent(subject)}/${provider}`);
+ } catch (e) {
+ if (e.status === 404) continue; // no key for this provider
+ throw e;
+ }
+ if (row) keys.push(present(row));
+ }
+ return { keys, plans: [] };
+ }
+
+ /**
+ * Store or replace the person's key for a provider. The key is forwarded to AI and never kept here: it is not
+ * written to the database, not logged and not answered. The answer is the hint.
+ */
+ async function put(subject, provider, body = {}) {
+ const p = providerOf(provider);
+ if (!p) throw Object.assign(new Error(`provider is one of ${PROVIDERS.join(', ')}`), { status: 422, code: 'connections.provider_unknown' });
+ const key = body.key != null ? String(body.key) : (body.api_key != null ? String(body.api_key) : null);
+ if (!key || key.length < 8 || key.length > 400) {
+ throw Object.assign(new Error('the key is 8-400 characters'), { status: 422, code: 'connections.key_invalid' });
+ }
+ const out = { provider: p, key };
+ if (body.base_url != null && body.base_url !== '') out.base_url = String(body.base_url);
+ if (body.budget_usd_per_day != null && body.budget_usd_per_day !== '') {
+ const budget = Number(body.budget_usd_per_day);
+ if (!Number.isFinite(budget) || budget < 0) throw Object.assign(new Error('the budget is a number of dollars a day'), { status: 422, code: 'connections.budget_invalid' });
+ out.budget_usd_per_day = budget;
+ }
+ // The key is in `out` from here on: it goes straight to AI and is never logged, stored or answered.
+ const row = await call('PUT', `/api/v1/credentials/${encodeURIComponent(subject)}/${p}`, { body: out });
+ return present(row) || { provider: p, hint: null };
+ }
+
+ /** Forget the person's key for a provider. → true when there was one. */
+ async function remove(subject, provider) {
+ const p = providerOf(provider);
+ if (!p) throw Object.assign(new Error(`provider is one of ${PROVIDERS.join(', ')}`), { status: 422, code: 'connections.provider_unknown' });
+ try {
+ await call('DELETE', `/api/v1/credentials/${encodeURIComponent(subject)}/${p}`);
+ return true;
+ } catch (e) {
+ if (e.status === 404) return false;
+ throw e;
+ }
+ }
+
+ /**
+ * One tiny request through AI with the person's own key: does it work? The answer is OK, or the reason it did
+ * not (the provider's own words, without a key in it).
+ */
+ async function test(subject, provider) {
+ const p = providerOf(provider);
+ if (!p) throw Object.assign(new Error(`provider is one of ${PROVIDERS.join(', ')}`), { status: 422, code: 'connections.provider_unknown' });
+ const header = { 'x-openvibe-credential': `${p}:${subject}` };
+ try {
+ if (ANTHROPIC_FORMAT.includes(p)) {
+ await call('POST', '/v1/messages', { body: { model: 'openvibe', max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }, headers: header });
+ } else {
+ await call('POST', '/v1/chat/completions', { body: { model: 'openvibe', max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }, headers: header });
+ }
+ return { ok: true, detail: 'the key works' };
+ } catch (e) {
+ if (e.status === 404) return { ok: false, detail: 'there is no key for this provider' };
+ return { ok: false, detail: String((e && e.message) || 'the provider did not answer').slice(0, 300) };
+ }
+ }
+
+ /** The providers a person has a key for: the router's word for "connected". */
+ async function providers(subject) {
+ const { keys } = await list(subject);
+ return keys.map((k) => k.provider);
+ }
+
+ return { list, put, remove, test, providers, present, PROVIDERS };
+}
+
+module.exports = { createConnections, PROVIDERS, ANTHROPIC_FORMAT, providerOf, present };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+
+/**
+ * The secrets Codes must never show again: the run tokens it minted for the harnesses.
+ *
+ * A run token is a Network token (a JWT) that says whose key and whose budget a run is. It lives in the harness's
+ * environment for the length of an attempt and nowhere else — Codes keeps no copy, so the only place it can be
+ * remembered is here, in memory, for as long as the service runs. Every line a session keeps is scrubbed with it, so
+ * a token that an agent printed (or a harness echoed) never reaches the transcript, the page or the log.
+ */
+
+const MIN_LENGTH = 12;
+const secrets = new Set();
+
+/** Remember a secret so that it is redacted everywhere a line is written. */
+function remember(secret) {
+ const s = String(secret || '');
+ if (s.length >= MIN_LENGTH) secrets.add(s);
+ return s;
+}
+
+/** The secrets remembered (their count, never their values). */
+function count() { return secrets.size; }
+
+/** A text with every remembered secret replaced. */
+function scrub(text) {
+ let out = String(text == null ? '' : text);
+ for (const s of secrets) {
+ if (s && out.includes(s)) out = out.split(s).join('[redacted]');
+ }
+ return out;
+}
+
+/** A JSON value with every remembered secret replaced, in the strings inside it. */
+function scrubValue(value) {
+ if (typeof value === 'string') return scrub(value);
+ if (Array.isArray(value)) return value.map(scrubValue);
+ if (value && typeof value === 'object') {
+ const out = {};
+ for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v);
+ return out;
+ }
+ return value;
+}
+
+module.exports = { remember, scrub, scrubValue, count };
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the
* Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the
* service API (server/http/tasks-api.js, codes.task.*) share.
* service API (server/http/tasks-api.js, codes.task.*) share.
*
*
- * createRunTasks({ store, run, config, log, sessions, outbox })
+ * createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections })
* start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)
* start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)
* refresh(row, { force }) → the row, read from Run only as a fallback after 10 quiet minutes
* refresh(row, { force }) → the row, read from Run only as a fallback after 10 quiet minutes
* applyEvent(row, event) → a run.job.* delivery: state, timings, result, event, in one tx
* applyEvent(row, event) → a run.job.* delivery: state, timings, result, event, in one tx
@@ -85,13 +85,71 @@ function errorOf(job) {
@@ -85,13 +85,71 @@ function errorOf(job) {
return `${job.error.code || 'run.error'}${job.error.detail ? `: ${job.error.detail}` : ''}`.slice(0, 1000);
return `${job.error.code || 'run.error'}${job.error.detail ? `: ${job.error.detail}` : ''}`.slice(0, 1000);
}
}
-function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null }) {
+function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });
const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
+ // ── The person's connections (Codes C3) ──
+ // What they may run: the providers they keep a key for and the plans on their machines. It is read from
+ // OpenVibe.AI (the keys) and from the machines' reports (the plans, empty until the machines report them).
+ // The answer is cached for a minute, so a task that ends does not ask again for every attempt.
+ const connectionCache = new Map();
+ const CONNECTION_TTL_MS = 60_000;
+ let providerMap = null;
+ async function personConnections(subject) {
+ if (!connections || !subject) return null;
+ const hit = connectionCache.get(subject);
+ if (hit && hit.until > store.now()) return hit.value;
+ let value = null;
+ try {
+ const out = await connections.list(subject);
+ value = { providers: (out.keys || []).map((k) => k.provider), plans: out.plans || [] };
+ } catch (e) {
+ log.warn(`[Codes] the connections of ${subject} could not be read: ${String(e && e.message).slice(0, 200)}`);
+ value = null;
+ }
+ connectionCache.set(subject, { value, until: store.now() + CONNECTION_TTL_MS });
+ return value;
+ }
+
+ /**
+ * The environment the job's harness gets (Codes C3): a run token that names the person and says where to
+ * send its requests. A failure to mint one is not fatal: the job runs as it did before, without the token.
+ */
+ async function harnessEnv({ subject, taskId, attemptId = null, agent = null }) {
+ if (!runTokens) return null;
+ try {
+ const provider = providerFor(agent);
+ const own = provider ? await hasOwnKey(subject, provider) : false;
+ const minted = await runTokens.mint({ subject, task: taskId, attempt: attemptId, harness: agent, provider, ownKey: own });
+ return minted.env;
+ } catch (e) {
+ log.warn(`[Codes] no run token for task ${taskId}: ${String(e && e.message).slice(0, 200)}`);
+ return null;
+ }
+ }
+
+ /** The provider a harness runs on: the catalog's own word for it (anthropic, openai, deepseek, …). */
+ function providerFor(agent) {
+ if (!agent) return null;
+ if (!providerMap) {
+ providerMap = {};
+ for (const offer of require('./harnesses').createHarnesses().list()) providerMap[offer.id] = offer.provider;
+ }
+ return providerMap[agent] || null;
+ }
+
+ /** Whether the person keeps their own key for this provider. */
+ async function hasOwnKey(subject, provider) {
+ const conns = await personConnections(subject);
+ if (!conns) return false;
+ return conns.providers.includes(provider);
+ }
+
+
/**
/**
* One state change: update the row, write its codes.task.* outbox row in the same transaction, and on a terminal
* One state change: update the row, write its codes.task.* outbox row in the same transaction, and on a terminal
* state reconcile the session with the result and mark it complete. A late or out-of-order state never moves a
* state reconcile the session with the result and mark it complete. A late or out-of-order state never moves a
@@ -141,7 +199,10 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -141,7 +199,10 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
});
});
let job;
let job;
try {
try {
- job = await run.submit({ subject, project, args, key: id });
+ // The job's harness runs with a short-lived run token that names the person (Codes C3): the environment
+ // travels with the job's input and the machine's worker hands it to the harness.
+ const env = await harnessEnv({ subject, taskId: id, agent: args.agent || null });
+ job = await run.submit({ subject, project, args: env ? { ...args, env } : args, key: id });
} catch (e) {
} catch (e) {
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
return await store.tx(async () => {
return await store.tx(async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+'use strict';
+
+/**
+ * Run tokens (Codes C3): the short-lived Network token an attempt's harness carries.
+ *
+ * A run token is a Network token (client credentials, audience openvibe.ai, capability ai.run.create) that names
+ * the person it acts for (`on_behalf_of`) and lives at most two hours. It is bound to one task: it is minted when the
+ * attempt starts and it is written into the job's environment, so OpenVibe.AI knows whose key and whose budget the
+ * request is, and nobody else's. The token itself is never written to Codes' database (only its jti and its expiry
+ * are, so a run can be traced), never logged and never answered; the transcript normaliser redacts it everywhere a
+ * line is written (server/domain/redact.js).
+ *
+ * The environment the harness gets points it at OpenVibe.AI:
+ * Claude Code ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_CUSTOM_HEADERS
+ * Codex, OpenAI-like OPENAI_BASE_URL, OPENAI_API_KEY
+ * The credential header (x-openvibe-credential) names the provider whose key the run uses, when the person has one.
+ *
+ * createRunTokens({ config, store, fetchImpl, log })
+ * mint({ subject, task, attempt, harness, provider, ownKey }) → { token, expiresAt, jti, env }
+ * record({ subject, task, attempt, provider, jti, expiresAt }) → the row (no token in it)
+ */
+
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** The harnesses that speak Anthropic's Messages API (the rest speak OpenAI's Chat Completions). */
+const ANTHROPIC_HARNESSES = ['claude-code'];
+
+/** The claims of a JWT, decoded but not verified (the receiver verifies; this is for the jti and the expiry). */
+function claims(token) {
+ try {
+ const part = String(token || '').split('.')[1];
+ if (!part) return null;
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
+ } catch { return null; }
+}
+
+/** The base URL of OpenVibe.AI, without a trailing slash. */
+function aiBase(config) {
+ return String((config.ai && config.ai.url) || 'https://ai.openvibe.services').replace(/\/+$/, '');
+}
+
+/**
+ * The environment a harness of this kind gets: where to send its requests, the token to send, and (when the person
+ * has a key for that provider) the header that says which key.
+ */
+function envFor({ config, harness, token, provider, ownKey }) {
+ const base = aiBase(config);
+ const credential = ownKey && provider ? `x-openvibe-credential: ${provider}` : null;
+ if (ANTHROPIC_HARNESSES.includes(harness)) {
+ return {
+ ANTHROPIC_BASE_URL: base,
+ ANTHROPIC_AUTH_TOKEN: token,
+ ...(credential ? { ANTHROPIC_CUSTOM_HEADERS: credential } : {}),
+ };
+ }
+ // The OpenAI-compatible harnesses: the CLI ones read OPENAI_BASE_URL and OPENAI_API_KEY; Codes' own adapters
+ // read their own names, so they are given the same endpoint and token under those names too.
+ const env = { OPENAI_BASE_URL: `${base}/v1`, OPENAI_API_KEY: token };
+ if (credential) {
+ env.OPENAI_CUSTOM_HEADERS = credential;
+ env.OPENVIBE_CODES_HEADERS = JSON.stringify({ 'x-openvibe-credential': provider });
+ }
+ if (harness === 'deepseek') { env.DEEPSEEK_BASE_URL = `${base}/v1`; env.DEEPSEEK_API_KEY = token; }
+ if (harness === 'openvibe-ai') { env.OPENVIBE_AI_URL = `${base}/v1`; env.OPENVIBE_AI_TOKEN = token; }
+ if (harness === 'openai-compatible') { env.OPENVIBE_CODES_BASE_URL = `${base}/v1`; env.OPENVIBE_CODES_API_KEY = token; }
+ return env;
+}
+
+function createRunTokens({ config, store = null, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const ttl = Math.min(7200, Math.max(60, Number((config.runTokens && config.runTokens.ttlSeconds) || 7200)));
+
+ /**
+ * Mint a run token for one attempt of one task of one person.
+ * → { token, expiresAt, jti, env }; the token is in the answer and nowhere else.
+ */
+ async function mint({ subject, task = null, attempt = null, harness = null, provider = null, ownKey = false } = {}) {
+ if (!USER.test(String(subject || ''))) throw Object.assign(new Error('subject must be a usr_ subject'), { status: 400, code: 'subject.required' });
+ const clientId = config.oauth && config.oauth.clientId;
+ const clientSecret = config.oauth && config.oauth.clientSecret;
+ if (!clientId || !clientSecret) {
+ throw Object.assign(new Error('Codes has no Network client secret, so it cannot mint a run token'), { status: 503, code: 'run_token.unavailable' });
+ }
+ const tokenUrl = `${config.networkInternalUrl}/oauth/token`;
+ const body = new URLSearchParams({
+ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret,
+ audience: 'openvibe.ai', scope: 'ai.run.create', on_behalf_of: String(subject), ttl: String(ttl),
+ });
+ let res;
+ try {
+ res = await fetchImpl(tokenUrl, {
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body: body.toString(), signal: AbortSignal.timeout(15_000),
+ });
+ } catch (e) {
+ throw Object.assign(new Error(`OpenVibe.Network did not answer: ${e.message}`), { status: 503, code: 'run_token.unavailable' });
+ }
+ const doc = await res.json().catch(() => null);
+ if (!res.ok || !doc || !doc.access_token) {
+ const err = new Error((doc && (doc.error_description || doc.error)) || `OpenVibe.Network answered ${res.status}`);
+ err.status = res.status === 403 ? 403 : 503; err.code = 'run_token.refused';
+ throw err;
+ }
+ const token = String(doc.access_token);
+ const c = claims(token) || {};
+ const out = {
+ token,
+ jti: c.jti || null,
+ expiresAt: c.exp ? new Date(c.exp * 1000).toISOString() : new Date(Date.now() + ttl * 1000).toISOString(),
+ env: envFor({ config, harness, token, provider, ownKey }),
+ };
+ require('./redact').remember(token);
+ await record({ subject, task, attempt, provider, jti: out.jti, expiresAt: out.expiresAt });
+ return out;
+ }
+
+ /** The row that says a run token was minted for a task: its jti and its expiry, never the token. */
+ async function record({ subject, task = null, attempt = null, provider = null, jti = null, expiresAt = null } = {}) {
+ if (!store || !store.db) return null;
+ try {
+ return await store.db.one(
+ `INSERT INTO codes_run_tokens (id, task_id, attempt_id, subject, provider, jti, expires_at, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz) RETURNING *`,
+ [store.newId('rtk'), task || null, attempt || null, subject || null, provider || null, jti || null,
+ expiresAt || null, store.iso()],
+ );
+ } catch (e) {
+ // The row is a trace, not the point: a database that cannot take it must not stop the run.
+ log.warn(`[Codes] the run token row could not be written: ${String(e && e.message).slice(0, 200)}`);
+ return null;
+ }
+ }
+
+ /** The tokens minted for a task: their jtis and their expiries, never the tokens. */
+ async function forTask(taskId) {
+ if (!store || !store.db || !taskId) return [];
+ const rows = await store.db.many('SELECT id, task_id, attempt_id, subject, provider, jti, expires_at, created_at FROM codes_run_tokens WHERE task_id = $1 ORDER BY created_at ASC', [taskId]);
+ return rows.map((r) => ({ id: r.id, task_id: r.task_id, attempt_id: r.attempt_id, subject: r.subject, provider: r.provider, jti: r.jti, expires_at: r.expires_at, created_at: r.created_at }));
+ }
+
+ return { mint, record, forTask, envFor, ttl };
+}
+
+module.exports = { createRunTokens, envFor, ANTHROPIC_HARNESSES };
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
* retention { runOnce, start, stop } (CODES_SESSION_RETENTION_DAYS, default 90)
* retention { runOnce, start, stop } (CODES_SESSION_RETENTION_DAYS, default 90)
*/
*/
const { TERMINAL, sessionLogOf } = require('./run-tasks');
const { TERMINAL, sessionLogOf } = require('./run-tasks');
+const redact = require('./redact');
const MAX_BODY_BYTES = 64 * 1024;
const MAX_BODY_BYTES = 64 * 1024;
const CLIP_MARKER = '… [clipped]';
const CLIP_MARKER = '… [clipped]';
@@ -106,7 +107,9 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -106,7 +107,9 @@ function createSessionLines({ store, config = {}, log = console }) {
if (!Number.isSafeInteger(seq) || seq < 1) continue;
if (!Number.isSafeInteger(seq) || seq < 1) continue;
const at = atIso(line.at) || store.iso();
const at = atIso(line.at) || store.iso();
const kind = String(line.kind || 'text').slice(0, 64) || 'text';
const kind = String(line.kind || 'text').slice(0, 64) || 'text';
- clean.push({ seq, at, kind, body: clipBody(line.body) });
+ // The transcript normaliser: a run token (Codes C3) that an agent printed is redacted here, before the
+ // line is kept, so it never reaches the transcript, the page or the log.
+ clean.push({ seq, at, kind, body: clipBody(redact.scrubValue(line.body)) });
}
}
let inserted = 0;
let inserted = 0;
for (let i = 0; i < clean.length; i += 200) {
for (let i = 0; i < clean.length; i += 200) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+
+/**
+ * A person's connections (Codes C3, spec codes-c3-connections.md): the API keys they keep for their own providers
+ * and the plans on their own machines.
+ *
+ * GET /api/v1/connections the keys (provider, hint, today's spend, budget, last used) and the plans
+ * PUT /api/v1/connections/:provider { key, base_url?, budget_usd_per_day? } → the connection (the hint only)
+ * DELETE /api/v1/connections/:provider forget the key
+ * POST /api/v1/connections/:provider/test one tiny request through AI: OK, or why not
+ *
+ * The owner of a key is the signed-in person: only they may read, change or test their own connections, and a
+ * request that names another person is refused. The key itself is forwarded to OpenVibe.AI and never kept here: it
+ * is not in Codes' database, not in its logs, not in its answers. The plans are the CLI logins on the person's own
+ * machines, reported by their Node through OpenVibe.Bot; there are none yet, so the list is empty until a machine
+ * reports one (stage C5).
+ */
+
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createConnectionsApi({ config, store, connections, actorLimits, log = console } = {}) {
+ const r = asyncRouter();
+ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const json = express.json({ limit: '8kb' });
+
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+
+ /**
+ * The person whose connections this is: the signed-in one, and nobody else. A request that names another
+ * subject (a header or a query) is refused rather than quietly answered with the caller's own.
+ */
+ const owner = (req, res) => {
+ if (!signedIn(req)) { problem(req, res, 401, 'auth.required', 'sign in to see your connections'); return null; }
+ const named = String(req.get('x-ov-subject') || req.query.subject || '').trim();
+ if (named && named !== req.viewer.subject) {
+ problem(req, res, 403, 'connections.forbidden', "these are your own connections; another person's are not yours to read");
+ return null;
+ }
+ return req.viewer.subject;
+ };
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+
+ r.get('/connections', actorLimits.reads('codes.read'), async (req, res) => {
+ const subject = owner(req, res);
+ if (!subject) return undefined;
+ try {
+ return res.json(await connections.list(subject));
+ } catch (e) {
+ log.warn(`[Codes] the connections of ${subject} could not be read: ${String(e && e.message).slice(0, 200)}`);
+ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.unavailable', (e && e.message) || 'the connections could not be read');
+ }
+ });
+
+ r.put('/connections/:provider', actorLimits.budget('codes.task.start'), json, async (req, res) => {
+ const subject = owner(req, res);
+ if (!subject) return undefined;
+ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
+ const body = req.body || {};
+ try {
+ // The key goes straight to OpenVibe.AI: it is never stored, never logged and never answered.
+ const out = await connections.put(subject, req.params.provider, body);
+ log.info(`[Codes] ${subject} stored a key for ${out.provider}`);
+ return res.status(200).json({ connection: out });
+ } catch (e) {
+ log.warn(`[Codes] the key for ${req.params.provider} was refused: ${String(e && e.message).slice(0, 200)}`);
+ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.refused', (e && e.message) || 'the key was refused');
+ }
+ });
+
+ r.delete('/connections/:provider', actorLimits.budget('codes.task.start'), async (req, res) => {
+ const subject = owner(req, res);
+ if (!subject) return undefined;
+ try {
+ const gone = await connections.remove(subject, req.params.provider);
+ if (!gone) return problem(req, res, 404, 'connections.not_found', 'there is no key for that provider');
+ return res.status(204).end();
+ } catch (e) {
+ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.refused', (e && e.message) || 'the key could not be forgotten');
+ }
+ });
+
+ r.post('/connections/:provider/test', actorLimits.budget('codes.task.start'), json, async (req, res) => {
+ const subject = owner(req, res);
+ if (!subject) return undefined;
+ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site JSON posts are refused');
+ try {
+ const out = await connections.test(subject, req.params.provider);
+ return res.json(out);
+ } catch (e) {
+ return problem(req, res, (e && e.status) || 503, (e && e.code) || 'connections.refused', (e && e.message) || 'the key could not be tested');
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createConnectionsApi };
@@ -18,8 +18,8 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -18,8 +18,8 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the user task
// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the user task
-// API and the Action Center read (codes.actions.read).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me\/tasks)(\/|$)/;
+// API, the Action Center read (codes.actions.read) and a person's own connections (Codes C3).
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me\/tasks|connections)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * A person's connections (Codes C3, spec codes-c3-connections.md): the API keys they keep for their own providers
+ * and the plans on their machines.
+ *
+ * - the key never comes back: not in the answer, not in the logs, not in the database, not in a page;
+ * - the owner of a key is the signed-in person: nobody else's connections are readable;
+ * - the router never picks an agent the person cannot run;
+ * - the run token is redacted by the transcript normaliser.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const router = require('../engine/router');
+
+(async () => {
+ const KEY = 'sk-ant-0123456789abcdefXYZ';
+ const t = await boot({ env: (network) => ({ CODES_AI_URL: network.url }) });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+
+ try {
+ await check('the connections are the signed-in person\'s own; another person\'s are refused', async () => {
+ const none = await t.get('/api/v1/connections');
+ assert.strictEqual(none.status, 401, 'not signed in is 401');
+ const mine = await t.get('/api/v1/connections', { as: ada });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.deepStrictEqual(mine.json(), { keys: [], plans: [] }, 'no keys yet, and no plans until a machine reports one');
+ const other = await t.get('/api/v1/connections', { as: ada, headers: { 'X-OV-Subject': bo.subject } });
+ assert.strictEqual(other.status, 403, 'another person\'s connections are refused');
+ assert.strictEqual(other.json().code, 'connections.forbidden');
+ const named = await t.get(`/api/v1/connections?subject=${bo.subject}`, { as: ada });
+ assert.strictEqual(named.status, 403, 'and by the query too');
+ });
+
+ await check('a key is stored in OpenVibe.AI and answered with the hint only', async () => {
+ const put = await t.get('/api/v1/connections/anthropic', { as: ada, method: 'PUT', json: { key: KEY, budget_usd_per_day: 5 } });
+ assert.strictEqual(put.status, 200, put.text);
+ const body = put.json();
+ assert.strictEqual(body.connection.provider, 'anthropic');
+ assert.ok(body.connection.hint && body.connection.hint.length >= 4, 'the hint is there');
+ assert.ok(!JSON.stringify(body).includes(KEY), 'the key is not in the answer');
+ // AI really has it, under this person and this provider.
+ const stored = t.network.state.vault.get(`${ada.subject}:anthropic`);
+ assert.ok(stored, 'OpenVibe.AI holds the key');
+ assert.strictEqual(stored.key, KEY);
+ // And Codes holds no copy of it: not in its database, not in its logs, not in a page.
+ assert.ok(!(await t.dbDump()).includes(KEY), 'the key is not in Codes\' database');
+ assert.ok(!t.logs().includes(KEY), 'the key is not in the logs');
+ const page = await t.get('/run', { as: ada });
+ assert.ok(!page.text.includes(KEY), 'the key is not on a page');
+ });
+
+ await check('the connections are read back without the key', async () => {
+ const list = await t.get('/api/v1/connections', { as: ada });
+ assert.strictEqual(list.status, 200);
+ assert.strictEqual(list.json().keys.length, 1);
+ const key = list.json().keys[0];
+ assert.strictEqual(key.provider, 'anthropic');
+ assert.strictEqual(key.budget_usd_per_day, 5);
+ assert.strictEqual(key.spent_usd_today, 0);
+ assert.ok(!JSON.stringify(list.json()).includes(KEY), 'the key is not in the list either');
+ });
+
+ await check('a test request goes through AI with the person\'s own key', async () => {
+ const res = await t.get('/api/v1/connections/anthropic/test', { as: ada, method: 'POST', json: {} });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().ok, true);
+ const sent = t.network.state.modelRequests[t.network.state.modelRequests.length - 1];
+ assert.strictEqual(sent.path, '/v1/messages', 'Anthropic\'s own format');
+ assert.strictEqual(sent.credential, `anthropic:${ada.subject}`, 'the header names the provider and the person');
+ });
+
+ await check('a key can be forgotten', async () => {
+ const del = await t.get('/api/v1/connections/anthropic', { as: ada, method: 'DELETE' });
+ assert.strictEqual(del.status, 204, del.text);
+ const list = await t.get('/api/v1/connections', { as: ada });
+ assert.deepStrictEqual(list.json().keys, []);
+ assert.strictEqual((await t.get('/api/v1/connections/anthropic', { as: ada, method: 'DELETE' })).status, 404, 'and it is gone for good');
+ });
+
+ await check('the router never picks an agent the person cannot run', async () => {
+ // The router (engine/router.js) is the portal's: a ladder of runners per tier. The person\'s connections
+ // say which of them they may run — a runner whose connection is missing is never picked.
+ const ladder = {};
+ for (const tier of router.TIERS) ladder[tier] = [['cc', 'claude-sonnet-5-5', 'high']];
+ const base = {
+ cfg: { subagents: { cc: { enabled: true, max: 2 } } },
+ health: { cc: { state: 'ok' } },
+ counts: {}, models: { cc: ['claude-sonnet-5-5'] }, ladder,
+ };
+ const prompt = 'Find the root cause of a race condition in the payment worker and fix it.';
+ const none = router.pick(prompt, { ...base, connections: { cc: { provider: 'anthropic', connected: false } } });
+ assert.strictEqual(none, null, 'a runner the person has no connection for is never picked');
+ const picked = router.pick(prompt, { ...base, connections: { cc: { provider: 'anthropic', connected: true } } });
+ assert.ok(picked, 'a runner they may run is picked');
+ assert.strictEqual(picked.runner, 'cc');
+ assert.ok(router.pick(prompt, base), 'without a map of connections the ladder is as it was');
+ });
+
+ await check('the run token is redacted by the transcript normaliser', async () => {
+ const minted = await t.ctx.runTokens.mint({ subject: ada.subject, task: null, attempt: null, harness: 'claude-code', provider: 'anthropic', ownKey: true });
+ assert.ok(minted.token, 'a run token was minted');
+ assert.ok(minted.env.ANTHROPIC_AUTH_TOKEN, 'the harness gets it in its environment');
+ assert.strictEqual(minted.env.ANTHROPIC_CUSTOM_HEADERS, 'x-openvibe-credential: anthropic');
+ // The token is not in Codes' database (only its jti and its expiry are).
+ assert.ok(!(await t.dbDump()).includes(minted.token), 'the run token is not in Codes\' database');
+ // A line that carries the token is redacted before it is kept.
+ const taskId = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1';
+ await t.ctx.sessions.append(taskId, [{ seq: 1, at: new Date().toISOString(), kind: 'text', body: { text: `the token is ${minted.token}` } }]);
+ const lines = await t.ctx.sessions.allRows(taskId);
+ const kept = JSON.stringify(lines);
+ assert.ok(!kept.includes(minted.token), 'the token is not in the transcript');
+ assert.ok(kept.includes('[redacted]'), 'it says that it was redacted');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -27,7 +27,7 @@ async function boot(opts = {}) {
@@ -27,7 +27,7 @@ async function boot(opts = {}) {
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',
NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
- ...(opts.env || {}),
+ ...(typeof opts.env === 'function' ? opts.env(network) : (opts.env || {})),
};
};
const configLib = require('../../server/config');
const configLib = require('../../server/config');
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
@@ -56,6 +56,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -56,6 +56,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
+ vault: new Map(), // OpenVibe.AI's stand-in: a person's own keys, by subject and provider
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -281,8 +282,14 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -281,8 +282,14 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
}
}
if (body.grant_type === 'client_credentials') {
if (body.grant_type === 'client_credentials') {
+ // A run token (Codes C3): it may name the person it acts for and live longer than five minutes.
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
- return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:codes', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });
+ const ttl = Math.min(7200, Math.max(60, Number(body.ttl) || 300));
+ const claims = { iss: issuer, sub: 'svc:codes', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + ttl, jti: crypto.randomUUID() };
+ if (body.on_behalf_of) claims.on_behalf_of = String(body.on_behalf_of);
+ st.tokenRequests[st.tokenRequests.length - 1].on_behalf_of = body.on_behalf_of || null;
+ st.tokenRequests[st.tokenRequests.length - 1].ttl = body.ttl || null;
+ return json(200, { access_token: serviceAuth.signServiceToken(claims, privatePem), token_type: 'Bearer', expires_in: ttl });
}
}
}
}
const a = st.apps.get(body.client_id);
const a = st.apps.get(body.client_id);
@@ -298,6 +305,42 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -298,6 +305,42 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
}
}
+ // ── OpenVibe.AI's stand-in (Codes C3): the vault of a person's own keys and the two model endpoints ──
+ if (url.pathname.startsWith('/api/v1/credentials/')) {
+ const parts = url.pathname.split('/').filter(Boolean); // api, v1, credentials, subject, provider
+ const subject = parts[3]; const provider = parts[4];
+ if (!subject || !provider) return problem(json, 404, 'route.not_found', 'no such route');
+ const key = `${subject}:${provider}`;
+ const view = (row) => row && ({ subject: row.subject, owner: 'codes', provider: row.provider, base_url: row.base_url || null, key_hint: `\u2026${String(row.key || '').slice(-4)}`, models: {}, budget_usd_per_day: row.budget_usd_per_day == null ? null : row.budget_usd_per_day, spent_usd_today: row.spent_usd_today || 0, created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null });
+ if (req.method === 'PUT') {
+ let body = {};
+ try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { return problem(json, 400, 'input.invalid', 'body is not JSON'); }
+ const prev = st.vault.get(key);
+ const row = { subject, provider, key: body.key != null ? String(body.key) : (prev ? prev.key : ''), base_url: body.base_url != null ? body.base_url : (prev ? prev.base_url : null), budget_usd_per_day: body.budget_usd_per_day != null ? body.budget_usd_per_day : (prev ? prev.budget_usd_per_day : null), spent_usd_today: prev ? prev.spent_usd_today : 0, created_at: prev ? prev.created_at : new Date().toISOString(), updated_at: new Date().toISOString(), last_used_at: prev ? prev.last_used_at : null };
+ st.vault.set(key, row);
+ return json(200, view(row));
+ }
+ if (req.method === 'GET') {
+ const row = st.vault.get(key);
+ if (!row) return problem(json, 404, 'credential.not_found', 'no credential stored for that subject');
+ return json(200, view(row));
+ }
+ if (req.method === 'DELETE') {
+ if (!st.vault.has(key)) return problem(json, 404, 'credential.not_found', 'no credential stored for that subject');
+ st.vault.delete(key);
+ return json(204, {});
+ }
+ return problem(json, 404, 'route.not_found', 'no such route');
+ }
+ if (url.pathname === '/v1/messages' || url.pathname === '/v1/chat/completions') {
+ const credential = String(req.headers['x-openvibe-credential'] || '');
+ st.modelRequests = st.modelRequests || [];
+ st.modelRequests.push({ path: url.pathname, credential, authorization: String(req.headers.authorization || '') });
+ if (st.modelFails) return json(503, { type: 'error', error: { type: 'overloaded_error', message: st.modelFails } });
+ return json(200, url.pathname === '/v1/messages'
+ ? { id: 'msg_1', type: 'message', role: 'assistant', content: [{ type: 'text', text: 'ok' }], model: 'openvibe', usage: { input_tokens: 1, output_tokens: 1 } }
+ : { id: 'chatcmpl_1', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], model: 'openvibe', usage: { prompt_tokens: 1, completion_tokens: 1 } });
+ }
if (url.pathname.startsWith('/api/v1/projects')) {
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
@@ -45,7 +45,10 @@ const { boot, check, done } = require('./helpers/boot');
@@ -45,7 +45,10 @@ const { boot, check, done } = require('./helpers/boot');
const submit = calls.find((c) => c[0] === 'submit')[1];
const submit = calls.find((c) => c[0] === 'submit')[1];
assert.strictEqual(submit.subject, ada.subject);
assert.strictEqual(submit.subject, ada.subject);
assert.ok(submit.project, 'a project was created');
assert.ok(submit.project, 'a project was created');
- assert.deepStrictEqual(submit.args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });
+ // The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
+ const { env, ...args } = submit.args;
+ assert.deepStrictEqual(args, { repo: ask.repo, ref: null, prompt: ask.prompt, permission: 'edit', push: null, agent: null, model: null });
+ assert.deepStrictEqual(Object.keys(env || {}).sort(), ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], 'the harness gets a run token and OpenVibe.AI\'s address');
});
});
await check('the task is listed and readable by its owner, not by another person', async () => {
await check('the task is listed and readable by its owner, not by another person', async () => {
@@ -67,7 +67,11 @@ const { boot, check, done } = require('./helpers/boot');
@@ -67,7 +67,11 @@ const { boot, check, done } = require('./helpers/boot');
const [kind, sent] = calls.find((c) => c[0] === 'submit');
const [kind, sent] = calls.find((c) => c[0] === 'submit');
assert.strictEqual(kind, 'submit');
assert.strictEqual(kind, 'submit');
assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);
assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);
- assert.deepStrictEqual(sent.args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });
+ // The harness's environment (Codes C3) is not part of the task's own arguments: it carries the run token.
+ const { env, ...args } = sent.args;
+ assert.deepStrictEqual(args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });
+ assert.deepStrictEqual(Object.keys(env || {}).sort(), ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL']);
+ assert.ok(env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_BASE_URL, 'the harness gets a run token and OpenVibe.AI\'s address');
const row = await t.ctx.store.db.one('SELECT subject, project_id, job_id, state FROM run_tasks WHERE id = $1', [taskId]);
const row = await t.ctx.store.db.one('SELECT subject, project_id, job_id, state FROM run_tasks WHERE id = $1', [taskId]);
assert.deepStrictEqual({ ...row }, { subject: ada.subject, project_id: project, job_id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' });
assert.deepStrictEqual({ ...row }, { subject: ada.subject, project_id: project, job_id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' });
});
});
@@ -47,6 +47,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -47,6 +47,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'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 search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) 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/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; Forgejo 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/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'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/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',
};
};