openvibers/openvibe.actor Commits

D11 owner settings: actor.ai (#286)

OP OpenVibers committed 8 days ago · b6c1b2fc53 · 23 files, +711 −33

* AI Exchange P9: the model picker offers OpenVibe.AI's catalog (ACTOR_AI_CATALOG, off)

Read GET /v1/models with Actor's service token and cache it about five minutes; with the flag off, a failed read or AI's legacy single openvibe the field is exactly what it was. The aliases come first, marked Dynamic, then the exact ids, each with its exact retail output price (integer nano-USD to dollars, BigInt) and availability for Shared's select. The chosen id is sent unchanged as model; x_openvibe.resolved_model and its cost are the run's model and cost; an exact id AI refuses is the task's own error, never retried on another model.

* D11 owner settings: actor.ai (shared 4.110.0, contracts 0.288.0)

* D11 tests: account guard and migration list know the config tables

* D11: keep ACTOR_AI_CATALOG's true spelling as the env pin

Changes

ddiff --git a/.env.example b/.env.example
iindex aecd48e..4b59374 100644
---- a/.env.example
++++ b/.env.example
@@@ -24,6 +24,12 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback
# ACTOR_AUDIENCE=openvibe.actor
++# OpenVibe.AI (the model gateway a person's own keys live in; server/runtime/models.js). The catalog flag is off by
++# default: with it on, Actor reads GET /v1/models with its service token and offers the aliases and exact ids where a
++# person picks the model their tasks run on.
++# OV_AI_URL=https://ai.openvibe.services
++# ACTOR_AI_CATALOG=1
++
# OpenVibe.Events → Actor (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
# are created at boot (off when unset); ACTOR_EVENTS_SUBSCRIBE=0 turns that off.
ddiff --git a/migrations/0028_config.sql b/migrations/0028_config.sql
nnew file mode 100644
iindex 0000000..49d9764
---- /dev/null
++++ b/migrations/0028_config.sql
@@@ -0,0 +1,28 @@
++-- phase: expand
++-- D11 owner settings: openvibe-shared/config (config_snapshots, config_keys). Applied migrations are immutable.
++CREATE TABLE IF NOT EXISTS config_snapshots (
++ namespace text COLLATE "C" NOT NULL,
++ revision bigint NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ previous_revision bigint,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
++ values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
++ classification_json text COLLATE "C" NOT NULL,
++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ activated_at text COLLATE "C",
++ activated_by text COLLATE "C",
++ reason text COLLATE "C",
++ error text COLLATE "C",
++ copied_from bigint,
++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
++ PRIMARY KEY (namespace, revision)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
++-- The fingerprint key of each namespace. Never served, never logged.
++CREATE TABLE IF NOT EXISTS config_keys (
++ namespace text COLLATE "C" PRIMARY KEY,
++ hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
++ created_at text COLLATE "C" NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex c91a119..eb527d3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,9 +15,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1146,9 +1146,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.269.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
-- "integrity": "sha512-OCdHszaBv7sV+82JgGfJy6XeO6s2ZPpvLTZJoYihnfYghX6QFoy6PCu2efoWoBHYOdhAL9kJmSLo4kVTE9zQ9A==",
++ "version": "0.288.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1190,9 +1190,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.109.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
++ "version": "4.110.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 373d6ec..b511319 100644
---- a/package.json
++++ b/package.json
@@@ -28,9 +28,9 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/ai-catalog-config.js b/server/ai-catalog-config.js
nnew file mode 100644
iindex 0000000..5c1c032
---- /dev/null
++++ b/server/ai-catalog-config.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * The `actor.ai` namespace (D11 owner settings): the switch that lets Actor's model pickers read OpenVibe.AI's
++ * catalog. ACTOR_AI_CATALOG stays the pin: while it is set it wins, is read-only in the summary and a write
++ * answers 409 config.pinned_in_env.
++ *
++ * /internal/config is openvibe-shared/config's adminRoutes, guarded by actor.config.manage (Network's service
++ * token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write
++ * (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.
++ */
++const express = require('express');
++const shared = require('openvibe-shared/config');
++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++
++const SERVICE = 'actor';
++const NAMESPACE = 'actor.ai';
++const CAPABILITY = 'actor.config.manage';
++
++const SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'OpenVibe.AI in Actor',
++ description: 'Read OpenVibe.AI\'s model catalog so the model picker offers its aliases and exact ids.',
++ type: 'object',
++ additionalProperties: false,
++ required: ['catalog'],
++ properties: {
++ catalog: {
++ type: 'boolean', title: 'AI model catalog', 'x-group': 'AI Exchange', 'x-env': 'ACTOR_AI_CATALOG',
++ description: 'Read GET /v1/models with Actor\'s own service token and cache the answer about five minutes. Off, the model field is the plain one it was before.',
++ },
++ },
++};
++const DEFAULTS = Object.freeze({ catalog: false });
++
++function bearer(req) {
++ const h = String((req.headers && req.headers.authorization) || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++function capabilityGuard({ keys, config, log }) {
++ return async function guard(req, res, next) {
++ try {
++ const token = bearer(req);
++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a Network service token is required', ctx: req.ov });
++ const publicKey = typeof keys.pemForToken === 'function' ? await keys.pemForToken(token) : keys.get();
++ if (!publicKey) return http.sendProblem(res, 503, 'identity.unavailable', { detail: 'the Network signing key is not loaded yet', ctx: req.ov });
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) return http.sendProblem(res, 401, r.code || 'token.invalid', { detail: r.reason || 'the token does not verify', ctx: req.ov });
++ const sub = String((r.claims && r.claims.sub) || '');
++ if (!sub.startsWith('svc:')) return http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPABILITY} is first-party; an app may not change Actor configuration`, ctx: req.ov });
++ const decision = capabilities.check(r.claims, CAPABILITY);
++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${CAPABILITY} not granted`, ctx: req.ov });
++ req.configClaims = r.claims;
++ return next();
++ } catch (err) {
++ log.error(`[config] actor.ai guard failed: ${(err && err.message) || err}`);
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'the configuration request failed', ctx: req.ov });
++ }
++ };
++}
++
++function actor(req) {
++ const named = String((req.headers && req.headers['x-ov-subject']) || '').trim();
++ return ids.isSubjectId('user', named) ? { type: 'user', id: named } : null;
++}
++
++const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
++
++async function create({ db, keys, config, env = process.env, log = console, onActivate = null }) {
++ // ACTOR_AI_CATALOG has always accepted 1/true; the shared store's boolean pin reads true/1.
++ const storeEnv = { ...env };
++ if (['1', 'true'].includes(String(storeEnv.ACTOR_AI_CATALOG || '').trim().toLowerCase())) storeEnv.ACTOR_AI_CATALOG = '1';
++ const store = await shared.createConfigStore({
++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, defaults: DEFAULTS, env: storeEnv, log,
++ onActivate: async (values) => { if (onActivate) await onActivate(values); },
++ onChange: ({ namespace, keys: changed, actor: who, revision }) => {
++ log.info(`[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${changed.join(', ')}`);
++ },
++ });
++ if (env.OV_CONFIG_KEKS) {
++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
++ }
++ const router = shared.adminRoutes([store], {
++ basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,
++ });
++ return {
++ store, router,
++ get: (key) => store.get(key),
++ mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
++ };
++}
++
++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
ddiff --git a/server/app.js b/server/app.js
iindex fc7e597..c4377b9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -24,6 +24,7 @@ const { createSso } = require('./auth/sso');
const catalog = require('./agents/catalog');
const { createAdapters } = require('./agents/adapters');
const { createModels } = require('./runtime/models');
++const createAiCatalogConfig = require('./ai-catalog-config').create;
const { createRegistry } = require('./runtime/tools');
const { createLoop } = require('./runtime/loop');
const { createStats } = require('./runtime/stats');
@@@ -85,6 +86,9 @@ async function createApp(opts = {}) {
const s = opts.store || await openStore(config, { now: opts.now, log });
const keys = createKeyStore({ config, fetchImpl, log });
++ // D11 owner settings (actor.ai): the catalog switch reads through the store, env pins winning.
++ const aiCatalogConfig = await createAiCatalogConfig({ db: s.db, keys, config, env: opts.env || process.env, log });
++ Object.defineProperty(config.ai, 'catalog', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
// A self-hosted Actor signs its one owner in itself (auth/local.js); otherwise OpenVibe.Network does (auth/sso.js).
if (config.selfHosted.on) checkSelfHosted(config);
const sso = config.selfHosted.on ? createLocalSignIn({ config, now: s.now }) : createSso({ config, keys, fetchImpl, now: s.now, log });
@@@ -135,7 +139,7 @@ async function createApp(opts = {}) {
createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });
// Each personal agent's run that passed its check also goes to its owner's Vibe chat (agents/vibe-deliver.js).
const vibeDelivery = require('./agents/vibe-deliver').createVibeDelivery({ s, config, chat: chatClient, log });
-- const ctx = { config, s, keys, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };
++ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@@ -150,6 +154,7 @@ async function createApp(opts = {}) {
ctx.accountSend = accountSend;
const app = express();
++ aiCatalogConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
app.get('/openapi.json', (_req, res) => {
ddiff --git a/server/config.js b/server/config.js
iindex e5200fc..cd36189 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -93,6 +93,10 @@ function load(env = process.env) {
clientId: env.OV_OAUTH_CLIENT_ID || 'actor',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
timeoutMs: Math.max(5000, int(env.ACTOR_AI_TIMEOUT_MS, 180_000)),
++ // The AI Exchange catalog (PLAN G-P9), off by default: GET /v1/models from OpenVibe.AI, cached, to offer
++ // its aliases and exact ids where a person picks the model their tasks run on. Off, the model field is
++ // the plain one it was before.
++ catalog: env.ACTOR_AI_CATALOG === '1' || env.ACTOR_AI_CATALOG === 'true',
},
// The model providers behind the agent systems (server/agents/catalog.js). A provider without its key is
// listed as unavailable, with the reason; nothing is invented. Keys are the operator's.
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 5e965f1..51c801f 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -224,7 +224,7 @@ function createApi(ctx) {
if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);
if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });
}
-- const out = await modelPrefs.put(s, req.principal.requester, body);
++ const out = await modelPrefs.put(s, req.principal.requester, body, { exactIds: Boolean(config.ai && config.ai.catalog) });
if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
res.json({ ...modelPrefs.wire(out.row), keys: null });
});
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 73474b9..31423eb 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -31,6 +31,7 @@ const { sameOrigin } = require('./principal');
const looks = require('../agents/looks');
const inputs = require('../tasks/inputs');
const modelPrefs = require('../runtime/model-prefs');
++const aiCatalog = require('../runtime/ai-catalog');
// The model a person's tasks run on (plan L55 C3; the owner, 11-01: "use your own API keys, or OpenVibe's"). Auto is
// OpenVibe's own; "own:<provider>" is the person's key, sealed in OpenVibe.AI, which Actor never sees.
@@@ -317,6 +318,31 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</div></details>`;
}
++ /**
++ * The model field of /chat/model (PLAN G-P9; ACTOR_AI_CATALOG, off by default). With OpenVibe.AI's catalog read,
++ * the dynamic aliases come first (marked Dynamic) and then the exact ids, each with its retail output price and
++ * availability in the option's data-desc/data-state for Shared's select; the chosen id is posted unchanged. With
++ * no catalog (the flag off, a failed read, or AI's legacy single model) it is the plain input it always was.
++ */
++ function modelField(models, current) {
++ if (!Array.isArray(models) || !models.length) {
++ return html`<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${current || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>`;
++ }
++ const option = (e) => {
++ const desc = e.output_nano_usd_per_m ? aiCatalog.priceDescription(e.output_nano_usd_per_m)
++ : (e.dynamic ? 'Dynamic: the router picks the model per request; the price follows it' : 'Output price not listed');
++ const label = e.dynamic ? `${e.id} · ${e.name}` : e.id;
++ return html`<option value="${e.id}"${e.id === current ? raw(' selected') : ''}${e.dynamic ? raw(' data-badge="Dynamic"') : ''} data-desc="${desc}" data-state="${e.state}"${e.available ? '' : raw(' disabled')}>${label}</option>`;
++ };
++ const aliases = models.filter((e) => e.dynamic);
++ const exact = models.filter((e) => !e.dynamic);
++ const saved = current && !models.some((e) => e.id === current);
++ return html`<label class="cm-modelid"><span>A model id, if you want one <small>(an alias lets OpenVibe.AI pick per request; an exact id pins one build)</small></span>
++<select name="model" data-ov-title="A model for OpenVibe.AI">
++<option value="">OpenVibe.AI picks the provider's best fit</option>${saved ? html`<option value="${current}" selected>${current}</option>` : ''}${aliases.length ? html`<optgroup label="Dynamic aliases">${aliases.map(option)}</optgroup>` : ''}${exact.length ? html`<optgroup label="Exact ids">${exact.map(option)}</optgroup>` : ''}
++</select></label>`;
++ }
++
const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {
@@@ -389,6 +415,9 @@ ${modelChip(model)}
if (!signedIn(req)) return res.redirect(303, loginTo(req));
const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));
const current = model.provider ? `own:${model.provider}` : 'auto';
++ // OpenVibe.AI's aliases and exact ids (PLAN G-P9; ACTOR_AI_CATALOG, off by default). No catalog (the flag
++ // off, a failed read, or AI's legacy `openvibe`) leaves the plain model input below exactly as it was.
++ const aiModels = ctx.models && typeof ctx.models.aiCatalog === 'function' ? await ctx.models.aiCatalog().catch(() => null) : null;
const saved = req.query.saved === '1';
const runsSaved = req.query.saved === 'runs';
const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';
@@@ -406,7 +435,7 @@ ${radio('auto', 'Auto, OpenVibe\'s', 'Actor picks a model for each step, on Open
${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p, 'Billed by the provider to you, not counted against Actor\'s allowance.', `<a class="cm-setup" href="${allowUrl(p).replace(/&/g, '&amp;')}" target="_blank" rel="noopener">Add or allow the key</a>`))}
<p class="cm-head">Your computer</p>
${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&amp;')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
--<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>
++${modelField(aiModels, model.model || '')}
<button class="cm-save" type="submit">Save</button>
</form>
<h2 class="cm-h2" id="runs-on">Where it runs</h2>
@@@ -425,7 +454,8 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change this on openvibe.actor itself.');
const body = req.body || {};
-- const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null });
++ const out = await modelPrefs.put(s, requesterOf(req), { choice: body.choice, model: body.model ? String(body.model) : null },
++ { exactIds: Boolean(config.ai && config.ai.catalog) });
if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}`);
return res.redirect(303, '/chat/model?saved=1');
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a8cedd7..28ade40 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -124,8 +124,13 @@ const msText = (n) => {
};
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
++// An exact AI Exchange id (org/model[:quant]@revision, D3) is shown as itself: splitting it would hide the org,
++// the quant and the revision that the person or the run disclosure named.
++const EXACT_MODEL_ID = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;
function modelName(id) {
-- const m = String(id || '').split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');
++ const raw = String(id || '');
++ if (EXACT_MODEL_ID.test(raw)) return raw;
++ const m = raw.split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');
if (!m) return 'another model';
if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex cd23d37..0c43ac9 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage'];
++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/server/runtime/ai-catalog.js b/server/runtime/ai-catalog.js
nnew file mode 100644
iindex 0000000..d752f70
---- /dev/null
++++ b/server/runtime/ai-catalog.js
@@@ -0,0 +1,81 @@
++'use strict';
++
++/**
++ * OpenVibe.AI's model catalog (PLAN G-P9), behind ACTOR_AI_CATALOG (off by default): the aliases a person may name
++ * instead of a model, and the exact ids `org/model[:quant]@revision` that pin one artifact.
++ *
++ * GET /v1/models answers OpenAI's `{ object: 'list', data: [...] }`; every alias carries `dynamic: true` and every
++ * registry model carries `id`, `availability` and `retail_rate.output_nano_usd_per_m` (integer nano-USD per million
++ * tokens, D1). The list and the parser are AI Exchange P4 (OpenVibe.AI #289); with AI_MODEL_CATALOG off that route
++ * answers only the legacy `openvibe` model, which means "fall back to Actor's usual model choice", never a one-row
++ * picker. The same goes for an unreadable or empty answer: `parse()` returns null and the page keeps its plain field.
++ *
++ * Prices are converted from nano-USD to dollars with BigInt, so the description never rounds (D1: no floats).
++ */
++const CACHE_MS = 5 * 60_000;
++
++// The dynamic aliases of D3, in the order a person should see them. `openvibe` is the legacy name of `openvibe/auto`
++// and is not offered twice.
++const ALIASES = ['openvibe/auto', 'openvibe/economy', 'openvibe/balanced', 'openvibe/fast', 'openvibe/private'];
++const EXACT = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?@[A-Za-z0-9._-]+$/;
++const STATE = { available: 'ok', limited: 'warn', deprecated: 'warn', unavailable: 'off' };
++
++/** Integer nano-USD per million tokens → exact USD per million, no float: 2500000000 → "2.50". */
++function nanoToUsd(nano) {
++ const text = String(nano == null ? '' : nano).trim();
++ if (!/^\d+$/.test(text)) return null;
++ const n = BigInt(text);
++ const whole = n / 1_000_000_000n;
++ let frac = (n % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');
++ if (frac.length < 2) frac = (frac + '00').slice(0, 2);
++ return `${whole}.${frac}`;
++}
++
++/** The option's second line: the retail output price, or that the catalog does not state one. */
++function priceDescription(nano) {
++ const usd = nanoToUsd(nano);
++ return usd == null ? 'Output price not listed' : `$${usd} per million output tokens`;
++}
++
++/**
++ * Parse GET /v1/models: aliases first (all dynamic ones, in D3's order), then the exact ids. Registry models without
++ * an exact id are left out: only an id that pins one artifact is a choice this picker can post unchanged.
++ * Null means "today's behaviour": the flag is off, the answer is the legacy single `openvibe`, or it is unusable.
++ */
++function parse(body) {
++ const list = Array.isArray(body) ? body
++ : body && typeof body === 'object' && (Array.isArray(body.data) ? body.data : Array.isArray(body.models) ? body.models : null);
++ if (!list) return null;
++ const entries = [];
++ const seen = new Set();
++ for (const m of list) {
++ if (!m || typeof m !== 'object') continue;
++ const id = typeof m.id === 'string' ? m.id.trim() : '';
++ if (!id || id.length > 256 || id === 'openvibe' || seen.has(id)) continue;
++ const dynamic = m.dynamic === true || ALIASES.includes(id);
++ if (!dynamic && !EXACT.test(id)) continue;
++ const availability = typeof m.availability === 'string' && m.availability
++ ? m.availability
++ : (dynamic ? 'available' : (m.status === 'active' ? 'available' : 'unavailable'));
++ const output = m.retail_rate && typeof m.retail_rate === 'object' ? m.retail_rate.output_nano_usd_per_m : null;
++ seen.add(id);
++ entries.push({
++ id,
++ dynamic,
++ name: String(m.display_name || m.name || id),
++ description: typeof m.description === 'string' ? m.description : null,
++ policy: typeof m.policy === 'string' ? m.policy : null,
++ output_nano_usd_per_m: typeof output === 'string' && /^\d+$/.test(output) ? output : null,
++ availability,
++ state: STATE[availability] || (dynamic ? 'ok' : 'off'),
++ // A deprecated artifact still runs (with a warning); one with no deployment is not a choice.
++ available: availability !== 'unavailable',
++ });
++ }
++ if (!entries.length) return null;
++ const rank = (id) => { const i = ALIASES.indexOf(id); return i < 0 ? ALIASES.length : i; };
++ const aliases = entries.filter((e) => e.dynamic).sort((a, b) => rank(a.id) - rank(b.id));
++ return [...aliases, ...entries.filter((e) => !e.dynamic)];
++}
++
++module.exports = { CACHE_MS, ALIASES, parse, nanoToUsd, priceDescription };
ddiff --git a/server/runtime/model-prefs.js b/server/runtime/model-prefs.js
iindex 09dfc71..192fef8 100644
---- a/server/runtime/model-prefs.js
++++ b/server/runtime/model-prefs.js
@@@ -26,16 +26,21 @@ const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter'
const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];
// A model id: letters, digits and the marks providers use (gpt-5.1, claude-sonnet-4-5, deepseek/flash, org/model).
const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;
++// An exact id (D3: org/model[:quant]@revision), accepted only while the AI catalog is on: `@` and `_` are marks an
++// exact id carries and today's model names did not. 256 is AI's own limit for a requested model.
++const EXACT_MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;
/**
* Read a choice from a request body. Returns { choice, provider, model } or { error } (never throws: a bad choice
-- * is a 422, not a crash).
++ * is a 422, not a crash). `exactIds` (ACTOR_AI_CATALOG) lets an exact id through unchanged.
*/
--function parse(body) {
++function parse(body, { exactIds = false } = {}) {
const raw = body && typeof body === 'object' ? body : {};
const choice = String(raw.choice == null ? 'auto' : raw.choice).trim();
const model = raw.model == null ? null : String(raw.model).trim();
-- if (model && !MODEL_RE.test(model)) return { error: 'model: a model id (letters, digits, and . _ : - /)' };
++ if (model && !(exactIds ? EXACT_MODEL_RE : MODEL_RE).test(model)) {
++ return { error: exactIds ? 'model: a model id, including an exact id (letters, digits, and . _ : - / @)' : 'model: a model id (letters, digits, and . _ : - /)' };
++ }
if (CHOICES.includes(choice)) return { choice, provider: null, model: model || null };
if (choice.startsWith('own:')) {
const provider = choice.slice(4).trim().toLowerCase();
@@@ -53,8 +58,8 @@ async function get(s, requester) {
}
/** Write the person's choice. Returns { row } or { error }. */
--async function put(s, requester, body, now = () => new Date().toISOString()) {
-- const parsed = parse(body);
++async function put(s, requester, body, { now = () => new Date().toISOString(), exactIds = false } = {}) {
++ const parsed = parse(body, { exactIds });
if (parsed.error) return { error: parsed.error };
if (!requester) return { error: 'no person' };
const at = now();
ddiff --git a/server/runtime/models.js b/server/runtime/models.js
iindex 70e83b3..319ad81 100644
---- a/server/runtime/models.js
++++ b/server/runtime/models.js
@@@ -25,13 +25,15 @@
* cost_usd goes back to the caller, which meters it (the task's `meter`).
*
* createModels({ config, rates, fetchImpl, log, now }) → {
-- * warm(), chat(...), resolve(tier, { avoidFamily }), tierList(), modelList(), providerList(), tierConfig
++ * warm(), chat(...), resolve(tier, { avoidFamily }), tierList(), modelList(), providerList(), tierConfig,
++ * aiCatalog() OpenVibe.AI's aliases and exact ids (PLAN G-P9; ACTOR_AI_CATALOG, cached ~5 minutes)
* }
*/
const { AsyncLocalStorage } = require('node:async_hooks');
const { serviceAuth } = require('openvibe-contracts');
const { chatClient, priceUsage, readStream, ProviderError } = require('../agents/providers');
++const aiCatalogLib = require('./ai-catalog');
class ModelError extends Error {
constructor(message, { code = 'actor.model.unavailable' } = {}) { super(message); this.code = code; }
@@@ -280,6 +282,40 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
} catch { return null; }
}
++ // ── OpenVibe.AI's model catalog (PLAN G-P9), behind ACTOR_AI_CATALOG ───────────────────────────
++ // GET /v1/models, read with the same service token, for the aliases and exact ids a person may pick. Cached for
++ // about five minutes, failures included: a failed read is today's behaviour for the next few minutes, never a
++ // broken page or a retry storm.
++ let aiCatalogCache = null; // { at, entries } — entries null when the flag is on but the read failed/legacy
++ let aiCatalogInFlight = null;
++ const aiCatalogOn = () => Boolean(ai.catalog); // D11 owner settings: read live
++
++ /** The catalog's entries (aliases first, then exact ids), or null: today's model field, unchanged. */
++ async function aiCatalog() {
++ if (!aiCatalogOn()) return null;
++ const at = now();
++ if (aiCatalogCache && at - aiCatalogCache.at < aiCatalogLib.CACHE_MS) return aiCatalogCache.entries;
++ if (aiCatalogInFlight) return aiCatalogInFlight;
++ aiCatalogInFlight = (async () => {
++ let entries = null;
++ const tokens = aiTokenClient();
++ if (tokens && ai.url) {
++ try {
++ const res = await fetchImpl(trimSlash(ai.url) + '/v1/models', {
++ headers: { accept: 'application/json', ...(await tokens.authHeaders()) },
++ signal: AbortSignal.timeout(Math.max(500, cfg.listTimeoutMs || 5000)),
++ });
++ if (res.ok) entries = aiCatalogLib.parse(await res.json());
++ } catch (err) {
++ if (log && log.warn) log.warn('[Actor] OpenVibe.AI model catalog:', (err && err.message) || err);
++ }
++ }
++ aiCatalogCache = { at: now(), entries };
++ return entries;
++ })().finally(() => { aiCatalogInFlight = null; });
++ return aiCatalogInFlight;
++ }
++
/** Run the rest of the work with this person's model choice in force. */
function withChoice(choice, fn) {
return choiceStore.run(choice || null, fn);
@@@ -356,6 +392,11 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
const said = String(msg || '').trim();
throw new ModelError(said ? said.charAt(0).toUpperCase() + said.slice(1) : 'This service may not use your key yet', { code: 'actor.model.not_allowed' });
}
++ // An exact id AI refuses (PLAN G-P9): shown as that refusal, never retried on another model. AI's own
++ // message says why (no such artifact, or one that cannot run this request).
++ if ((res.status === 404 && code === 'ai.model_not_found') || (res.status === 400 && code === 'ai.model_unavailable')) {
++ throw new ModelError(clip(msg, 300), { code });
++ }
throw new ProviderError('HTTP ' + res.status + ': ' + clip(msg, 300), { status: res.status, code: res.status === 429 ? 'actor.provider.rate_limited' : 'actor.provider.error' });
}
if (onDelta) answer = await readStream(res, onDelta, { signal, who: 'OpenVibe.AI' });
@@@ -364,13 +405,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
const message = first.message || { role: 'assistant', content: '' };
const u = answer.usage || {};
const usage = { input: u.prompt_tokens || 0, cached: 0, output: u.completion_tokens || 0 };
-- const x = answer.x_openvibe || {};
++ // The run disclosure (D3', PLAN G-P9): x_openvibe already carries the cost; resolved_model names the artifact
++ // that answered, so this is the model every existing model/cost record and page shows. Absent (the catalog is
++ // off on AI), the upstream model name is kept exactly as before.
++ const x = answer.x_openvibe && typeof answer.x_openvibe === 'object' ? answer.x_openvibe : {};
++ const resolved = typeof x.resolved_model === 'string' && x.resolved_model.trim() ? x.resolved_model.trim() : null;
return {
message,
text: typeof message.content === 'string' ? message.content : '',
toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
finish: first.finish_reason || null,
-- model: answer.model || choice.model || 'openvibe',
++ model: resolved || answer.model || choice.model || 'openvibe',
++ resolved_model: resolved,
usage,
// AI prices the call (it knows the provider, and whether the person's own key paid for it).
cost_usd: Number(x.cost_usd) || 0,
@@@ -428,7 +474,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
}
return {
-- warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys,
++ warm, chat, resolve, tierList, modelList, resolveAll, withChoice, currentChoice, allowedKeys, aiCatalog,
tierConfig: cfg, PREFERRED, PREFERRED_CHECKED, TIERS,
get warmed() { return warmed; },
listErrors: () => ({ ...listErrors }),
ddiff --git a/server/runtime/trace.js b/server/runtime/trace.js
iindex 8f29edf..731300d 100644
---- a/server/runtime/trace.js
++++ b/server/runtime/trace.js
@@@ -17,13 +17,15 @@
*/
const KINDS = new Set(['model', 'tool', 'check', 'judge', 'delegate']);
--const MAX_NAME = 120;
++// 256 is OpenVibe.AI's own limit for a requested model, so an exact id (org/model[:quant]@revision) is kept whole.
++const MAX_NAME = 256;
const MAX_SHORT = 64;
const clip = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? t.slice(0, n) : t; };
// A name is an identifier (a model id or a tool name), never a sentence: anything that is not identifier-shaped becomes
--// an underscore, so a provider echoing a prompt in its model id cannot put the person's words in a span.
--const safeName = (s) => clip(String(s == null ? '' : s).replace(/[^a-zA-Z0-9._:/-]+/g, '_'), MAX_NAME);
++// an underscore, so a provider echoing a prompt in its model id cannot put the person's words in a span. `@` is the
++// revision mark of an exact AI Exchange id (D3), so it is kept as itself.
++const safeName = (s) => clip(String(s == null ? '' : s).replace(/[^a-zA-Z0-9._:@/-]+/g, '_'), MAX_NAME);
const short = (v, n = MAX_SHORT) => (typeof v === 'string' && /^[a-zA-Z0-9_.:-]+$/.test(v) ? v.slice(0, n) : null);
const intOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null));
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 32dd625..56706c9 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -397,7 +397,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
: err && err.code === 'actor.budget.exceeded' ? 'actor.budget.exceeded'
: err && err.code === 'actor.checkpoint.invalid' ? 'actor.checkpoint.invalid'
: err && err.code === 'actor.acting.unresolved' ? 'actor.acting.unresolved'
-- : 'actor.agent.failed';
++ // An exact AI Exchange id AI refused (PLAN G-P9): the person sees AI's own error.
++ : err && (err.code === 'ai.model_not_found' || err.code === 'ai.model_unavailable') ? err.code
++ : 'actor.agent.failed';
return await setState(id, 'failed', { error_code: code, error_detail: detail.slice(0, 2000), finished_at: iso() });
}
try {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 3fc913c..923544c 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -20,6 +20,7 @@ const { testDb } = require('./helpers/db');
const EXEMPT = {
account_data_events: 'the ledger of requests itself: which export and deletion Actor applied',
actor_brain_outcomes: 'counts of outcomes by task kind, tier and tool: no person column and no task text',
++ config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
};
const A = 'usr_01JZ0000000000000000000AAA';
ddiff --git a/test/ai-catalog.test.js b/test/ai-catalog.test.js
nnew file mode 100644
iindex 0000000..c612b74
---- /dev/null
++++ b/test/ai-catalog.test.js
@@@ -0,0 +1,275 @@
++'use strict';
++/**
++ * PLAN G-P9: Actor uses OpenVibe.AI's catalog through what it already has, behind ACTOR_AI_CATALOG (off).
++ *
++ * What is proved here:
++ * the catalog parses aliases first and then exact ids, with the output price exact in nano-USD → dollars;
++ * the flag off, a failed read, and AI's legacy single `openvibe` all leave today's model field/behaviour unchanged;
++ * an exact id is sent unchanged as `model` (never rewritten or substituted);
++ * x_openvibe.resolved_model and the cost are the model and cost the run records and shows;
++ * an exact id AI refuses is that refusal, with one call and no retry on another model.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createModels } = require('../server/runtime/models');
++const aiCatalog = require('../server/runtime/ai-catalog');
++require('../server/tasks/engine'); // the end-to-end check below: an exact id AI refuses becomes the task's error
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const PERSON = 'usr_01JZ0000000000000000000AAA';
++const EXACT = 'acme/m1:q4_k_m@rev1';
++const LOG = { log() {}, warn() {}, error() {} };
++const reply = (status, obj, headers = {}) => ({
++ ok: status >= 200 && status < 300,
++ status,
++ headers: new Headers({ 'content-type': 'application/json', ...headers }),
++ json: async () => obj,
++ text: async () => JSON.stringify(obj),
++});
++
++/** A config with OpenVibe.AI (and no operator providers). */
++function aiConfig(catalog = true) {
++ return {
++ models: { tiers: {}, providerOrder: [], listTimeoutMs: 200 },
++ providers: {},
++ ai: { url: 'https://ai.test', audience: 'openvibe.ai', scope: 'ai.run.create', clientId: 'actor', clientSecret: 'actor-secret', timeoutMs: 5000, catalog },
++ networkInternalUrl: 'https://network.test', networkUrl: 'https://openvibe.network',
++ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
++ };
++}
++
++/** The token endpoint, then whatever the test wants for GET /v1/models and POST /v1/chat/completions. */
++function fakeFetch({ models = null, chat = null } = {}) {
++ const calls = [];
++ const fetchImpl = async (url, opts = {}) => {
++ const u = String(url);
++ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_service_token', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });
++ const call = { url: u, method: opts.method || 'GET', headers: opts.headers || {}, body: opts.body ? JSON.parse(opts.body) : null };
++ calls.push(call);
++ if (u.endsWith('/v1/models')) return models(call);
++ return chat(call);
++ };
++ return { calls, fetchImpl };
++}
++
++// The ai.model@1 catalog as AI serves it (OpenVibe.AI #289): aliases carry dynamic:true and no price; a registry
++// model carries an exact id, its availability and integer nano-USD prices. The legacy `openvibe` is the alias name.
++const CATALOG = {
++ object: 'list',
++ data: [
++ { id: 'acme/m1:q4_k_m@rev1', object: 'model', dynamic: false, display_name: 'm1', availability: 'available', retail_rate: { input_nano_usd_per_m: '1000000000', output_nano_usd_per_m: '2500000000' }, capabilities: { tools: true } },
++ { id: 'openvibe/fast', object: 'model', dynamic: true, policy: 'fastest', display_name: 'Fast', availability: 'available' },
++ { id: 'acme/no-revision', object: 'model', dynamic: false, display_name: 'no exact id' },
++ { id: 'openvibe', object: 'model', dynamic: true, display_name: 'OpenVibe AI' },
++ { id: 'openvibe/auto', object: 'model', dynamic: true, policy: 'auto', display_name: 'Auto', availability: 'available' },
++ { id: 'openvibe/private', object: 'model', dynamic: true, policy: 'private', display_name: 'Private', availability: 'available' },
++ { id: 'acme/m2@rev2', object: 'model', dynamic: false, display_name: 'm2', availability: 'unavailable', retail_rate: { output_nano_usd_per_m: '123456789' } },
++ ],
++};
++
++/** The OpenAI chat answer AI gives, with the route disclosure in x_openvibe. */
++function answer({ status = 200, resolved = EXACT, cost = 0.0012, body = null } = {}) {
++ if (body) return reply(status, body);
++ return reply(status, {
++ model: 'm1',
++ choices: [{ index: 0, message: { role: 'assistant', content: 'hello' }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 10, completion_tokens: 5 },
++ x_openvibe: { resolved_model: resolved, revision: 'rev1', provider_access: 'OWN_DEPLOYMENT', rate_version: 'rr-1', policy: 'exact', fallback_reason: null, cost_usd: cost },
++ });
++}
++
++(async () => {
++ let t = null;
++ try {
++ await check('the catalog parses aliases first, then exact ids, with the output price exact', async () => {
++ const entries = aiCatalog.parse(CATALOG);
++ assert.ok(entries, 'a catalog with aliases and exact ids is usable');
++ assert.deepStrictEqual(entries.map((e) => e.id), ['openvibe/auto', 'openvibe/fast', 'openvibe/private', 'acme/m1:q4_k_m@rev1', 'acme/m2@rev2'], 'aliases in D3 order, then exact ids; legacy openvibe and a non-exact id are left out');
++ assert.deepStrictEqual([entries[0].dynamic, entries[3].dynamic], [true, false]);
++ assert.deepStrictEqual(entries.slice(0, 3).map((e) => e.output_nano_usd_per_m), [null, null, null], 'an alias has no price of its own');
++ assert.strictEqual(aiCatalog.priceDescription(entries[3].output_nano_usd_per_m), '$2.50 per million output tokens');
++ assert.strictEqual(aiCatalog.priceDescription(entries[4].output_nano_usd_per_m), '$0.123456789 per million output tokens', 'nano-USD to dollars without a float');
++ assert.strictEqual(aiCatalog.priceDescription('0'), '$0.00 per million output tokens');
++ assert.strictEqual(aiCatalog.priceDescription('1000000000000'), '$1000.00 per million output tokens');
++ assert.strictEqual(aiCatalog.priceDescription(null), 'Output price not listed');
++ assert.deepStrictEqual(entries.map((e) => e.state), ['ok', 'ok', 'ok', 'ok', 'off'], 'availability as the select state');
++ assert.strictEqual(entries[4].available, false, 'an unavailable exact id is not a choice');
++
++ // The flag-off AI answer (one legacy model), an empty answer and rubbish are today's behaviour.
++ assert.strictEqual(aiCatalog.parse({ object: 'list', data: [{ id: 'openvibe', object: 'model' }] }), null);
++ assert.strictEqual(aiCatalog.parse({ object: 'list', data: [] }), null);
++ assert.strictEqual(aiCatalog.parse(null), null);
++ assert.strictEqual(aiCatalog.parse({ object: 'list', data: 'nope' }), null);
++ });
++
++ await check('flag off: no catalog call, and the page keeps the plain model field', async () => {
++ const offline = fakeFetch({ models: () => { throw new Error('the catalog must not be read'); } });
++ const models = createModels({ config: aiConfig(false), fetchImpl: offline.fetchImpl, log: LOG });
++ assert.strictEqual(await models.aiCatalog(), null);
++ assert.strictEqual(offline.calls.length, 0, 'no GET /v1/models and no token while the flag is off');
++
++ t = await boot();
++ const alice = t.network.addUser('catalog-off');
++ const page = await t.get('/chat/model', { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<input name="model" value="" maxlength="120"/);
++ assert.ok(!page.text.includes('<select name="model"'), 'no catalog select without the flag');
++ });
++
++ await check('flag on: GET /v1/models is read with Actor\'s service token and cached about five minutes', async () => {
++ let clock = 1_000_000;
++ const f = fakeFetch({ models: () => reply(200, CATALOG) });
++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG, now: () => clock });
++ const first = await models.aiCatalog();
++ assert.deepStrictEqual(first.map((e) => e.id).slice(0, 3), ['openvibe/auto', 'openvibe/fast', 'openvibe/private']);
++ assert.strictEqual(f.calls.length, 1);
++ assert.strictEqual(f.calls[0].url, 'https://ai.test/v1/models');
++ assert.strictEqual(f.calls[0].headers.Authorization, 'Bearer tok_service_token', 'the token Actor already uses');
++ assert.ok(!JSON.stringify(f.calls[0]).includes('actor-secret'), 'the client secret never travels');
++ const second = await models.aiCatalog();
++ assert.deepStrictEqual(second, first);
++ assert.strictEqual(f.calls.length, 1, 'a second read is cached');
++ clock += aiCatalog.CACHE_MS + 1;
++ await models.aiCatalog();
++ assert.strictEqual(f.calls.length, 2, 'after five minutes it is read again');
++ });
++
++ await check('flag on, AI catalog off or a failed read: null, and the next use is today\'s behaviour', async () => {
++ const legacy = fakeFetch({ models: () => reply(200, { object: 'list', data: [{ id: 'openvibe', object: 'model' }] }) });
++ const a = createModels({ config: aiConfig(true), fetchImpl: legacy.fetchImpl, log: LOG });
++ assert.strictEqual(await a.aiCatalog(), null, 'the legacy single model means no picker');
++
++ const down = fakeFetch({ models: () => reply(503, { error: { message: 'down' } }) });
++ const b = createModels({ config: aiConfig(true), fetchImpl: down.fetchImpl, log: LOG });
++ assert.strictEqual(await b.aiCatalog(), null);
++ assert.strictEqual(await b.aiCatalog(), null, 'a failure is cached too, not retried on every page');
++ assert.strictEqual(down.calls.length, 1);
++ });
++
++ await check('an exact id is sent unchanged as model, and its disclosure is the run\'s model and cost', async () => {
++ const f = fakeFetch({ chat: () => answer() });
++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });
++ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({
++ tier: 'smart', messages: [{ role: 'user', content: 'hello' }],
++ }));
++ assert.strictEqual(f.calls.length, 1, 'one call to AI');
++ assert.strictEqual(f.calls[0].body.model, EXACT, 'the exact id is sent unchanged');
++ assert.strictEqual(f.calls[0].body.prefer.model, EXACT, 'the soft preference names the same id');
++ assert.strictEqual(f.calls[0].headers['x-openvibe-credential'], 'openai:' + PERSON);
++ assert.strictEqual(r.model, EXACT, 'the model the run records is x_openvibe.resolved_model');
++ assert.strictEqual(r.resolved_model, EXACT);
++ assert.strictEqual(r.cost_usd, 0.0012, 'the cost is x_openvibe.cost_usd');
++ assert.strictEqual(r.text, 'hello');
++ // The same id is what the pages show and the trace records, not split at the quant or sanitised.
++ const { modelName } = require('../server/http/pages');
++ const { safeName } = require('../server/runtime/trace');
++ assert.strictEqual(modelName(EXACT), EXACT);
++ assert.strictEqual(safeName(EXACT), EXACT);
++
++ // A dynamic alias is likewise sent unchanged.
++ const g = fakeFetch({ chat: () => answer({ resolved: 'acme/m1@rev1' }) });
++ const models2 = createModels({ config: aiConfig(true), fetchImpl: g.fetchImpl, log: LOG });
++ const a = await models2.withChoice({ subject: PERSON, provider: 'openai', model: 'openvibe/economy' }, () => models2.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hi' }] }));
++ assert.strictEqual(g.calls[0].body.model, 'openvibe/economy');
++ assert.strictEqual(a.model, 'acme/m1@rev1', 'the alias says which model answered');
++ });
++
++ await check('a streamed answer records the final x_openvibe too', async () => {
++ const chunk = (o) => `data: ${JSON.stringify(o)}\n\n`;
++ const sse = chunk({ model: 'm1', choices: [{ index: 0, delta: { role: 'assistant', content: 'hel' }, finish_reason: null }] })
++ + chunk({ model: 'm1', choices: [{ index: 0, delta: { content: 'lo' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 5 } })
++ + chunk({ model: 'm1', choices: [], x_openvibe: { resolved_model: EXACT, cost_usd: 0.003 } });
++ const f = fakeFetch({ chat: () => new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) });
++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });
++ const r = await models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }], onDelta: () => {} }));
++ assert.strictEqual(r.model, EXACT);
++ assert.strictEqual(r.cost_usd, 0.003);
++ });
++
++ await check('an exact id AI refuses is that error, one call, never another model', async () => {
++ const cases = [
++ [404, 'ai.model_not_found', 'no artifact ' + EXACT, 'No artifact'],
++ [400, 'ai.model_unavailable', 'the artifact ' + EXACT + ' has no active deployment', 'no active deployment'],
++ ];
++ for (const [status, code, message] of cases) {
++ const f = fakeFetch({ chat: () => reply(status, { error: { message, type: 'invalid_request_error', code, param: null } }) });
++ const models = createModels({ config: aiConfig(true), fetchImpl: f.fetchImpl, log: LOG });
++ await assert.rejects(
++ () => models.withChoice({ subject: PERSON, provider: 'openai', model: EXACT }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'hello' }] })),
++ (err) => {
++ assert.strictEqual(err.code, code, 'AI\'s own code is shown');
++ assert.strictEqual(err.message, message, 'AI\'s own detail is shown');
++ return true;
++ },
++ );
++ assert.strictEqual(f.calls.length, 1, `${code}: no retry on another model`);
++ }
++ });
++
++ await check('a refused exact id fails the person\'s task with AI\'s error, never another model', async () => {
++ const aiCalls = [];
++ const f = fakeFetch({
++ models: () => reply(200, { data: [{ id: 'gpt-5-mini' }] }),
++ chat: (call) => {
++ aiCalls.push(call);
++ return reply(404, { error: { message: 'no artifact ' + EXACT, type: 'invalid_request_error', code: 'ai.model_not_found', param: null } });
++ },
++ });
++ // The operator's own tier must resolve for the loop to start; this checks the person's AI choice does win.
++ const cfg = aiConfig(true);
++ cfg.providers = { openai: { apiKey: 'sk-fixture-operator-key', baseUrl: 'https://api.openai.test/v1', model: 'gpt-5-mini' } };
++ const models = createModels({ config: cfg, fetchImpl: f.fetchImpl, log: LOG });
++ const tb = await boot({ env: { ACTOR_AI_CATALOG: '1' }, models });
++ try {
++ const user = tb.network.addUser('catalog-refused');
++ const put = await tb.get('/api/v1/me/model', { as: user, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });
++ assert.strictEqual(put.status, 200, put.text);
++ const read = await tb.get('/api/v1/me/model', { as: user });
++ assert.deepStrictEqual([read.json().choice, read.json().model], ['own:openai', EXACT], read.text);
++ const made = await tb.get('/api/v1/tasks', { as: user, json: { task: 'Say hello', mode: 'balanced' }, headers: SAME });
++ assert.strictEqual(made.status, 201, made.text);
++ const row = await tb.waitFor(made.json().id);
++ assert.strictEqual(row.state, 'failed');
++ assert.strictEqual(row.error_code, 'ai.model_not_found', `the task shows AI's own error code (${row.error_code}: ${row.error_detail})`);
++ assert.strictEqual(row.error_detail, 'no artifact ' + EXACT, 'and AI\'s own detail');
++ const chats = aiCalls.filter((c) => c.url.endsWith('/v1/chat/completions'));
++ assert.ok(chats.length >= 1, 'the run asked OpenVibe.AI');
++ assert.ok(chats.every((c) => c.body.model === EXACT), 'every call names the exact id; nothing is substituted');
++ } finally { await tb.close(); }
++ });
++
++ await check('the page offers aliases first, then exact ids, priced and available; a saved exact id is kept', async () => {
++ const entries = aiCatalog.parse(CATALOG);
++ const stub = { warm: async () => ({}), aiCatalog: async () => entries };
++ const tb = await boot({ env: { ACTOR_AI_CATALOG: '1' }, models: stub });
++ try {
++ const alice = tb.network.addUser('catalog-on');
++ const page = await tb.get('/chat/model', { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<select name="model" data-ov-title="A model for OpenVibe.AI">/);
++ assert.ok(page.text.indexOf('openvibe/auto') < page.text.indexOf(EXACT), 'aliases come before exact ids');
++ assert.match(page.text, /<option value="openvibe\/auto"[^>]*data-badge="Dynamic"[^>]*data-desc="Dynamic: the router picks/);
++ assert.match(page.text, new RegExp(`<option value="${EXACT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*data-desc="\\$2.50 per million output tokens"[^>]*data-state="ok"`));
++ assert.match(page.text, /<option value="acme\/m2@rev2"[^>]*data-desc="\$0.123456789 per million output tokens"[^>]*data-state="off" disabled>/, 'unavailable is shown as off and cannot be chosen');
++
++ const put = await tb.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().model, EXACT, 'the exact id is stored unchanged');
++ const after = await tb.get('/chat/model', { as: alice });
++ assert.match(after.text, new RegExp(`<option value="${EXACT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}" selected`));
++ } finally { await tb.close(); }
++ });
++
++ await check('flag off: an exact id is still refused by today\'s model rule', async () => {
++ t = t || await boot();
++ const alice = t.network.addUser('catalog-off-exact');
++ const put = await t.get('/api/v1/me/model', { as: alice, method: 'PUT', json: { choice: 'own:openai', model: EXACT }, headers: SAME });
++ assert.strictEqual(put.status, 422);
++ assert.strictEqual(put.json().code, 'actor.model.invalid');
++ });
++ } finally {
++ if (t) await t.close();
++ }
++
++ done();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 8bb8ddc..9c78b1f 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage'];
++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
@@@ -97,6 +97,7 @@ function codeRoutes() {
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
const declared = new Set();
for (const id of CAPS) {
++ if (id === 'actor.config.manage') continue; // /internal/config, openvibe-shared/config's adminRoutes
const impl = contracts.capabilities.get(id).implementedBy;
// A planned resource capability lists no routes; an active one lists exactly the index's two.
const expected = id === 'actor.resource.read' ? INDEX_ROUTES : impl;
@@@ -116,6 +117,7 @@ function codeRoutes() {
await check('each guarded route requires the capability that lists it; the agent reads are public', () => {
for (const id of CAPS) {
++ if (id === 'actor.config.manage') continue; // /internal/config is guarded by capabilityGuard, not a route in this map
const implemented = id === 'actor.resource.read' ? INDEX_ROUTES : contracts.capabilities.get(id).implementedBy;
for (const r of implemented) {
const route = routes.get(r);
ddiff --git a/test/durable-runs.test.js b/test/durable-runs.test.js
iindex 1380d70..1e4f81e 100644
---- a/test/durable-runs.test.js
++++ b/test/durable-runs.test.js
@@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {
assert.strictEqual(parts[0].content[0].task_id, id);
assert.strictEqual(parts[0].content[0].step, 1);
assert.ok(!('state_enc' in parts[0].content[0]), 'the sealed state is not exported');
-- assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only' } }), [], 'the guard passes');
++ assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' } }), [], 'the guard passes');
await s.db.query('DELETE FROM tasks WHERE id = $1', [id]);
assert.strictEqual(await cp.count(id), 0, 'erased with the task');
} finally { await d.close(); }
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 1e4f69e..8705c4d 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects, notifier: opts.notifier, models: opts.models, registry: opts.registry, loop: opts.loop, connectors: opts.connectors, guard: opts.guard });
++ const built = await createApp({ config, env, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, chatClient: opts.chatClient, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects, notifier: opts.notifier, models: opts.models, registry: opts.registry, loop: opts.loop, connectors: opts.connectors, guard: opts.guard });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/internal-config.test.js b/test/internal-config.test.js
nnew file mode 100644
iindex 0000000..e12d8fe
---- /dev/null
++++ b/test/internal-config.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * D11 owner settings: the actor.ai namespace and /internal/config.
++ * Env pins win; actor.config.manage is required; an owner change applies live; no KEK still works;
++ * a secret-class value never leaves a summary, snapshot or history.
++ */
++const assert = require('assert');
++const { serviceAuth } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const CAP = ['actor.config.manage'];
++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const SECRET = 'fixture-actor-d11-not-a-credential';
++
++function mint(t, cap = CAP) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({
++ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.actor'], cap,
++ iat: now, exp: now + 300, jti: 'tok_actor_config',
++ }, t.network.privatePem);
++}
++
++(async () => {
++ const t = await boot({ env: { ACTOR_AI_CATALOG: '1' } });
++ try {
++ await check('the summary: schema, the env pin, sealed:false without a KEK', async () => {
++ const r = await t.get('/internal/config/actor.ai', { bearer: mint(t) });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().namespace, 'actor.ai');
++ assert.strictEqual(r.json().sealed, false);
++ assert.strictEqual(r.json().values.catalog, true);
++ assert.deepStrictEqual(r.json().pins.catalog, { env: 'ACTOR_AI_CATALOG', pinned: true });
++ assert.ok(!r.text.includes(SECRET));
++ });
++
++ await check('a service token without actor.config.manage is refused', async () => {
++ const r = await t.get('/internal/config/actor.ai', { bearer: mint(t, ['actor.task.read']) });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'capability.denied');
++ });
++
++ await check('an env-pinned key is read-only', async () => {
++ const r = await t.get('/internal/config/actor.ai', {
++ method: 'POST', bearer: mint(t), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: false }, reason: 'off' },
++ });
++ assert.strictEqual(r.status, 409, r.text.slice(0, 300));
++ assert.strictEqual(r.json().code, 'config.pinned_in_env');
++ });
++ } finally { await t.close(); }
++
++ const live = await boot();
++ try {
++ await check('an owner change applies live', async () => {
++ assert.strictEqual(live.config.ai.catalog, false);
++ const r = await live.get('/internal/config/actor.ai', {
++ method: 'POST', bearer: mint(live), headers: { 'x-ov-subject': OWNER }, json: { values: { catalog: true }, reason: 'owner turned it on' },
++ });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().revision, 2);
++ assert.strictEqual(live.config.ai.catalog, true);
++ assert.ok(live.logs().includes('by user:' + OWNER));
++ assert.ok(!live.logs().includes(SECRET));
++ });
++
++ await check('with a KEK the store is sealed and boot reseals', async () => {
++ const sealed = await boot({ env: { OV_CONFIG_KEKS: 'k1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', OV_CONFIG_KEK_CURRENT: 'k1' } });
++ try {
++ const r = await sealed.get('/internal/config/actor.ai', { bearer: mint(sealed) });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().sealed, true);
++ assert.strictEqual(r.json().kek, 'k1');
++ } finally { await sealed.close(); }
++ });
++
++ await check('a secret is write-only and never appears in a summary, snapshot or history', async () => {
++ const shared = require('openvibe-shared/config');
++ const extra = await shared.createConfigStore({
++ db: live.ctx.s.db, service: 'actor', namespace: 'actor.test_secrets',
++ schema: { type: 'object', additionalProperties: false, properties: { api_key: { type: 'string' } } },
++ classify: () => 'secret',
++ });
++ const marker = await extra.setSecret('api_key', SECRET, { actor: { type: 'user', id: OWNER }, reason: 'test' });
++ assert.strictEqual(marker.redacted, true);
++ assert.ok(!JSON.stringify(marker).includes(SECRET));
++ const summary = await extra.summary();
++ assert.strictEqual(summary.values.api_key.redacted, true);
++ assert.ok(!JSON.stringify(summary).includes(SECRET));
++ assert.ok(!JSON.stringify(await extra.history()).includes(SECRET));
++ assert.ok(!JSON.stringify(await extra.snapshot(1)).includes(SECRET));
++ });
++ } finally { await live.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex 7c1cfbe..155e877 100644
---- a/test/memory-migration.test.js
++++ b/test/memory-migration.test.js
@@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027']);
++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);