openvibers/openvibe.codes Commits

Codes C3: a person's connections, and the run token their harness carries (#163)

OP OpenVibers committed 14 days ago · 15599fe0a6 · 20 files, +856 −20

The API keys a person keeps for their own providers, and the plans on their 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; a request that names another one is refused. The key goes straight to
OpenVibe.AI, which seals it: Codes' database, its logs, its answers and its pages never see it.

A run token: the attempt's harness gets a short-lived Network token that names the person (on_behalf_of) and
where to send its requests (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_CUSTOM_HEADERS for Claude Code;
OPENAI_BASE_URL, OPENAI_API_KEY for Codex and the OpenAI-compatible harnesses). It is minted when the attempt
starts, it lives in the harness's environment and nowhere else (Codes keeps its jti and its expiry), and the
transcript normaliser redacts it from every line it keeps.

The router takes the person's connections: engine/router.js pick() never picks an agent they cannot run, and a
plan at its limit is skipped until its reset.

Rebased onto C5 (#165), which moved the engine that decides what happens next into the portal's runner: the
attempt store is gone, so the router's connection check is engine/router.js pick() itself (a runner whose
connection is missing is never picked, and the ladder is left as it was when no connections are given), and the
run token travels with the job's input to the machine's worker, which hands it to the harness.

Changes

ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex 7858782..5e2893c 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -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') {
ddiff --git a/engine/router.js b/engine/router.js
iindex 6b18958..cdeed17 100644
---- a/engine/router.js
++++ b/engine/router.js
@@@ -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 };
ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex 5b5899e..2f0f5b1 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -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 };
ddiff --git a/harness/job.js b/harness/job.js
iindex 2385d51..2cc7ad3 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -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 };
ddiff --git a/migrations/0011_run_tokens.sql b/migrations/0011_run_tokens.sql
nnew file mode 100644
iindex 0000000..2cb55d8
---- /dev/null
++++ b/migrations/0011_run_tokens.sql
@@@ -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;
ddiff --git a/server/app.js b/server/app.js
iindex bcea35e..1b119c2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/config.js b/server/config.js
iindex 4a71991..78a4659 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/domain/connections.js b/server/domain/connections.js
nnew file mode 100644
iindex 0000000..d329ef1
---- /dev/null
++++ b/server/domain/connections.js
@@@ -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 };
ddiff --git a/server/domain/redact.js b/server/domain/redact.js
nnew file mode 100644
iindex 0000000..fb59b56
---- /dev/null
++++ b/server/domain/redact.js
@@@ -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 };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex b8bc16b..25c2ffa 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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 () => {
ddiff --git a/server/domain/run-tokens.js b/server/domain/run-tokens.js
nnew file mode 100644
iindex 0000000..a5cd88e
---- /dev/null
++++ b/server/domain/run-tokens.js
@@@ -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 };
ddiff --git a/server/domain/session-lines.js b/server/domain/session-lines.js
iindex 7bc210b..c3458ff 100644
---- a/server/domain/session-lines.js
++++ b/server/domain/session-lines.js
@@@ -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) {
ddiff --git a/server/http/connections-api.js b/server/http/connections-api.js
nnew file mode 100644
iindex 0000000..614305f
---- /dev/null
++++ b/server/http/connections-api.js
@@@ -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 };
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex 1cfa8ee..8c39d39 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -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}/`);
ddiff --git a/test/connections.test.js b/test/connections.test.js
nnew file mode 100644
iindex 0000000..1deb8f0
---- /dev/null
++++ b/test/connections.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 723b4d0..62c4fad 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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');
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex d0afcfb..000aa28 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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(),
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.m
return 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.m
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(' ') });
}
++ // ── 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');
ddiff --git a/test/me-api.test.js b/test/me-api.test.js
iindex d5bbba4..ab3d81b 100644
---- a/test/me-api.test.js
++++ b/test/me-api.test.js
@@@ -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 () => {
ddiff --git a/test/run-pages.test.js b/test/run-pages.test.js
iindex b6519b3..1f288cb 100644
---- a/test/run-pages.test.js
++++ b/test/run-pages.test.js
@@@ -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' });
});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex bd88874..f37502b 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',
};