openvibers/openvibe.actor Commits
Agent runs: the one Share sheet, Only me by default (#300)
Changes
@@@ -259,5 +259,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.289.1- openvibe-sdk: v0.91.0--- openvibe-shared: v4.122.0++- openvibe-shared: v4.123.0<!-- versions:end -->@@@ -9,11 +9,11 @@"domain": "openvibe.actor","contracts": "openvibe-contracts v0.289.1","sdk": "openvibe-sdk v0.91.0",-- "shared": "openvibe-shared v4.122.0",++ "shared": "openvibe-shared v4.123.0","packages": ["openvibe-contracts v0.289.1","openvibe-sdk v0.91.0",-- "openvibe-shared v4.122.0"++ "openvibe-shared v4.123.0"],"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.","works": [nnew file mode 100644@@@ -0,0 +1,72 @@++-- phase: expand++-- The one sharing system (openvibe-sdk/share) on an agent's run: its audience, the people it is shared with, the log++-- and the delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/++-- share/schema.js at the pinned version), so its store finds the tables it expects and the test database has them.++-- The run's own row is tasks; there is one share row per run, kind 'run'.++++++CREATE TABLE IF NOT EXISTS shares (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),++ revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,++ PRIMARY KEY (service, kind, resource_id)++);++CREATE TABLE IF NOT EXISTS share_members (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,++ PRIMARY KEY (service, kind, resource_id, subject),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE TABLE IF NOT EXISTS share_transfers (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_access_requests (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_log (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;++DO $$ BEGIN++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();++ END IF;++END $$;++CREATE TABLE IF NOT EXISTS share_deliveries (++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE TABLE IF NOT EXISTS share_name_limits (++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL++);++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;nnew file mode 100644@@@ -0,0 +1,17 @@++-- phase: expand++-- Visibility step 2 (design 2026-11-06): the person's default audience for new runs, and one share row for every++-- run that already exists, Only me. New runs are registered as they are made (server/tasks/create.js); a run++-- without a row is still its owner's, because every read asks canView and a missing row answers no.++-- Repeating this file is a no-op. Expand-only.++++CREATE TABLE IF NOT EXISTS actor_share_defaults (++ subject text COLLATE "C" PRIMARY KEY,++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),++ updated_at timestamptz NOT NULL DEFAULT now()++);++++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT 'actor', 'run', id, jsonb_build_object('type', 'user', 'id', substring(requester from 6)), 'private'++FROM tasks++WHERE requester ~ '^user:usr_[0-9A-HJKMNP-TV-Z]{26}$'++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0","pg": "^8.23.0"},"devDependencies": {@@@ -1190,9 +1190,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.122.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",++ "version": "4.123.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -30,7 +30,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0","pg": "^8.23.0"},"engines": {@@@ -79,6 +79,9 @@ const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');* poster; tests only), bot ({ robots, computers }: stand-ins for the Console's OpenVibe.Bot reads; tests only),* notifier (a stand-in for Network notifications; tests only)*/++const { createSharing } = require('./sharing');++const { setSharing } = require('./tasks/create');++async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;@@@ -139,7 +142,10 @@ 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, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };++ // The one sharing system (design 2026-11-06): an agent's run is its owner's until the Share sheet says otherwise.++ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });++ setSharing(sharing);++ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing };// 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;@@@ -253,6 +259,26 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ // ── /api/v1/run/:id/share — the one Share sheet's API (openvibe-sdk/share) ──++ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people++ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only++ // its own paths.++ {++ const shareApi = express.Router();++ shareApi.use(sharing.shares.router({++ service: 'actor',++ authenticate: (req) => sharing.shareViewer(req.viewer),++ describe: sharing.describe,++ }));++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));++ }++ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.++ if (opts.startSharingWorker !== false) {++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);++ if (timer.unref) timer.unref();++ app.locals.sharingTimer = timer;++ }// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));app.use('/api/v1/resources', resourceIndex.router(ctx));@@@ -92,7 +92,7 @@ function tookOf(row) {}function createChatPages(ctx, { computers = null } = {}) {-- const { config, s, engine } = ctx;++ const { config, s, engine, sharing } = ctx;const r = asyncRouter();// Bot's computers, for the live desk in a turn (plan L55 C1b): made when first asked for.let pcsMade = computers;@@@ -105,6 +105,44 @@ function createChatPages(ctx, { computers = null } = {}) {const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;const requesterOf = (req) => `user:${req.viewer.subject}`;const now = () => (typeof s.now === 'function' ? s.now() : Date.now());++ /** The one Share sheet's button: its owner gets the audience part too; a reader gets Send it only. */++ const shareButton = (row, owner) => {++ const title = String(row.task || 'An agent run').split('\n')[0].slice(0, 90);++ return owner++ ? html`<button type="button" class="ovt-btn" data-ov-share-sheet data-kind="run" data-id="${row.id}" data-title="${title}">Share</button>`++ : html`<button type="button" class="ovt-btn" data-ov-share-sheet data-title="${title}">Share</button>`;++ };++ /** Specific people, and this reader is not on the list: the audience, never the question or an answer. */++ const sharedGate = (req, res, id) => {++ const signedInAs = signedIn(req) ? (req.viewer.username || '') : '';++ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;++ return send(res, 404, {++ viewer: req.viewer, config, path: req.originalUrl, title: 'Shared with specific people', app: true, noReferrer: true,++ styles: ['kit.css', 'ov-share.css'], sharedScripts: ['kit-web.js', 'ov-share.js'],++ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline++ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.++ body: html`<div class="ct-wrap"><div id="share-gate" class="ct-gate" data-ov-share-gate data-kind="run" data-id="${String(id)}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}"><h1>This run is shared with specific people</h1>++<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p></div></div>`,++ });++ };++++ const AUDIENCE_TEXT = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };++ /**++ * "New runs: Only me", with "Apply to all my existing runs" (design rule 1 and 2). The Share button on a run++ * changes just that one; this is only what the next one starts as.++ */++ async function defaultsHtml(requester) {++ if (!sharing) return '';++ const current = await sharing.defaultOf(requester).catch(() => 'private');++ const options = ['private', 'people', 'link', 'public']++ .map((a) => html`<option value="${a}"${a === current ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);++ return html`<form class="ct-share-defaults" method="post" action="/chat/defaults">++<label for="ct-share-default">New runs</label><select id="ct-share-default" name="audience">${options}</select>++<label class="ct-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing runs</label>++<button class="sc-btn" type="submit">Save</button>++<span class="muted small">Only me unless you choose otherwise. Each run's Share button changes just that run.</span>++</form>`;++ }async function chatsFor(requester) {const rows = await s.db.many(@@@ -224,17 +262,21 @@ function createChatPages(ctx, { computers = null } = {}) {}));}-- function questionBlock(row, events) {++ function questionBlock(row, events, readOnly = false) {const q = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');if (!q) return html`<div class="ovt-card is-wait"><p><b>Actor is waiting for you.</b></p><p><a href="/tasks/${row.id}?view=details#question">Open the question</a></p></div>`;const options = Array.isArray(q.options) ? q.options : [];const acting = q.question_kind === 'acting';++ if (readOnly) {++ return html`<div class="ovt-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p>++${options.length ? html`<p class="ct-opts">${options.map((o) => html`<span class="ct-opt">${o}</span>`)}</p>` : ''}</div>`;++ }return html`<div class="ovt-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p><form method="post" action="/tasks/${row.id}/answer" class="ct-opts">${options.map((o) => html`<button class="ct-opt" type="submit" name="answer" value="${o}">${o}</button>`)}</form>${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class="ct-own"><input name="answer" type="text" maxlength="2000" required placeholder="Or answer in your own words" aria-label="Your answer"><button class="ct-opt" type="submit">Answer</button></form>`}</div>`;}-- function failedBlock(row, t) {++ function failedBlock(row, t, readOnly = false) {const err = t.error || {};const why = {'actor.agents.exhausted': 'No answer passed its check, so nothing unchecked was shown.',@@@ -242,8 +284,8 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class='actor.task.timeout': 'It ran too long and was stopped.','actor.task.interrupted': 'Actor restarted while this was running. Nothing more was charged.',}[err.code] || err.detail || 'It stopped without saying why.';-- return html`<div class="ovt-card is-bad"><p><b>Actor couldn't finish this.</b> ${why}</p>--<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;++ const retry = readOnly ? '' : html`<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form>`;++ return html`<div class="ovt-card is-bad"><p><b>Actor couldn't finish this.</b> ${why}</p>${retry}${readOnly ? html`<a class="ct-act" href="/tasks/${row.id}?view=details">Details</a>` : ''}</div>`;}/**@@@ -251,7 +293,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=* it with no event (the run checks for itself); Stop waiting ends the wait and the run carries on without it. The* deadline is written as a <time> the page shows in the reader's own zone (js/chat.js), "in about 2 h" without it.*/-- function waitBlock(row, wait) {++ function waitBlock(row, wait, readOnly = false) {if (!wait) return '';const left = Math.max(0, Date.parse(wait.deadline_at) - now());const mins = Math.round(left / 60000);@@@ -259,7 +301,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=return html`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${row.id}/wait" data-ct-wait><p class="ovt-card-h">${raw(glyphs.svg('clock', { size: 18 }))}<span><b>Waiting</b> ${String(wait.line || 'for something to happen').replace(/^waiting\s+/i, '').replace(/[.\s]+$/, '')}. Nothing is spent while it waits.</span></p><p class="ct-wait-until">It gives up <time datetime="${new Date(wait.deadline_at).toISOString()}" data-ct-local>${about}</time>, then ${wait.on_timeout === 'fail' ? 'stops' : wait.on_timeout === 'retry' ? 'tries that step once more' : 'asks you what next'}.</p>--<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div></form>`;++${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div>`}</form>`;}/** The files beside the question (thread.files draws them). */@@@ -267,20 +309,20 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));}-- async function turnHtml(row, { events = null, files = null } = {}) {++ async function turnHtml(row, { events = null, files = null, readOnly = false } = {}) {const attached = files || (await inputs.list(s, [row.id])).get(row.id) || [];const t = store.toWire(row, { baseUrl: config.baseUrl });const evs = events || await store.eventsAfter(s, row.id, 0);const open = OPEN.includes(row.state);let reply;if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${screenBlock(row, evs, false)}${answerBlock(row, t)}`;-- else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;-- else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;++ else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs, readOnly)}`;++ else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t, readOnly)}`;else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;else {// A turn parked on a wait says what it waits for (plan A2); otherwise it is working.const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;-- reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked)}`;++ reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;}// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));@@@ -382,35 +424,58 @@ ${modelChip(model)}['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],];-- async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {++ /**++ * A run's page, or the same conversation read-only for somebody it was shared with: no sidebar, no composer and++ * no turn that asks or retries. The Share button is its owner's; a reader gets Send it only.++ */++ async function render(req, res, { row = null, problem = null, text = '', status = 200, shared = false } = {}) {const requester = requesterOf(req);-- const [chats, turns, today] = await Promise.all([chatsFor(requester), row ? chainOf(row) : [], store.spendToday(s, requester)]);++ const [chats, turns, today] = await Promise.all([++ shared ? Promise.resolve([]) : chatsFor(requester),++ row ? chainOf(row) : [],++ shared ? Promise.resolve({ usd: 0 }) : store.spendToday(s, requester),++ ]);const first = turns[0] || null;const lastTurn = turns[turns.length - 1] || null;const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));const fileMap = await inputs.list(s, turns.map((t) => t.id));-- const model = modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));-- const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [] })));++ const model = shared ? null : modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));++ const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [], readOnly: shared })));const title = first ? clip(first.task, 60) : 'New chat';++ const actions = row ? String(shareButton(row, !shared)) : '';++ const defaults = !shared && !row ? String(await defaultsHtml(requester)) : '';return send(res, status, {viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,// The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).-- css: ['css/chat.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js', 'ov-thread.js'],-- body: html`${busy ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}++ css: ['css/chat.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css'],++ sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js'],++ body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({-- sidebar: String(sidebar(chats, first ? first.id : null)),-- head: thread.head({ title, newHref: first ? '/chat' : null }),++ sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),++ head: thread.head({ title, newHref: shared ? null : (first ? '/chat' : null), actions }),turns: turnHtmls.map(String),empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p><ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),-- composer: String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })),++ composer: shared ? '' : String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })),label: first ? 'Conversation' : 'New chat',-- }))}</div>`,++ }))}${defaults}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,});}const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;++ r.post('/chat/defaults', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ 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.');++ if (!sharing) return res.redirect(303, '/chat');++ const body = req.body || {};++ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';++ await sharing.setDefault(requesterOf(req), audience, { applyAll: String(body.apply_all || '') === '1' })++ .catch((e) => ctx.log.warn('[Actor] the run default was not saved:', e.message));++ return res.redirect(303, '/chat');++ });++r.get('/chat/model', async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (!signedIn(req)) return res.redirect(303, loginTo(req));@@@ -479,19 +544,29 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs onr.get('/chat/turn/:id', async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.status(404).end();++ if (!TASK_ID.test(req.params.id)) return res.status(404).end();const row = await store.getTask(s, req.params.id);-- if (!row || row.requester !== requesterOf(req)) return res.status(404).end();-- res.type('html').send(require('../render/html').render(await turnHtml(row)));++ if (!row) return res.status(404).end();++ const mine = signedIn(req) && row.requester === requesterOf(req);++ if (!mine) {++ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;++ if (!allowed) return res.status(404).end();++ }++ res.type('html').send(require('../render/html').render(await turnHtml(row, { readOnly: !mine })));});// An attached file, to its own person only: an image shown in place; a PDF downloaded as itself; anything else// downloaded as plain text. In a sandbox, never sniffed.r.get('/chat/file/:taskId/:id', async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- if (!signedIn(req) || !TASK_ID.test(req.params.taskId) || !/^inp_[A-Za-z0-9_-]{6,20}$/.test(req.params.id)) return res.status(404).end();++ if (!TASK_ID.test(req.params.taskId) || !/^inp_[A-Za-z0-9_-]{6,20}$/.test(req.params.id)) return res.status(404).end();const row = await store.getTask(s, req.params.taskId);-- if (!row || row.requester !== requesterOf(req)) return res.status(404).end();++ if (!row) return res.status(404).end();++ const mine = signedIn(req) && row.requester === requesterOf(req);++ if (!mine) {++ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;++ if (!allowed) return res.status(404).end();++ }const f = await inputs.get(s, row.id, req.params.id);if (!f) return res.status(404).end();const bytes = Buffer.isBuffer(f.bytes) ? f.bytes : Buffer.from(f.bytes || []);@@@ -503,11 +578,25 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs onreturn res.type('text/plain; charset=utf-8').send(bytes);});++ /**++ * A run's page is its owner's. Someone it was shared with reads the same conversation without the composer or a++ * turn's moves; anybody else gets the gate for Specific people, or "not found" when it is Only me.++ */r.get('/chat/:id', async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- if (!signedIn(req)) return res.redirect(303, loginTo(req));const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;-- if (!row || row.requester !== requesterOf(req)) return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });++ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));++ if (row && !mine) {++ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;++ if (allowed) return render(req, res, { row, shared: true });++ const audience = sharing ? await sharing.audienceOf(row.id).catch(() => null) : null;++ if (audience === 'people') return sharedGate(req, res, row.id);++ return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });++ }++ if (!mine) {++ if (!signedIn(req)) return res.redirect(303, loginTo(req));++ return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });++ }return render(req, res, { row });});@@@ -387,7 +387,7 @@ ${[['a', cmp.a, rowA], ['b', cmp.b, rowB]].map(([side, run, row]) => html`<sectifunction createPageRoutes(ctx) {-- const { config, s, engine } = ctx;++ const { config, s, engine, sharing } = ctx;// Durable runs (stage V2-9): the sealed checkpoints are the engine's, read here only for their step metadata.const checkpoints = ctx.checkpoints || null;const checkpointsOn = Boolean(checkpoints && checkpoints.enabled);@@@ -396,6 +396,25 @@ function createPageRoutes(ctx) {const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;const requesterOf = (req) => `user:${req.viewer.subject}`;++ /** The one Share sheet's button: its owner gets the audience part too; a reader gets Send it only. */++ const shareButton = (row, owner) => {++ const title = String(row.task || 'An agent run').split('\n')[0].slice(0, 90);++ return owner++ ? html`<button type="button" class="sc-btn" data-ov-share-sheet data-kind="run" data-id="${row.id}" data-title="${title}">Share</button>`++ : html`<button type="button" class="sc-btn" data-ov-share-sheet data-title="${title}">Share</button>`;++ };++ /** Specific people, and this reader is not on the list: the audience, never the task or an answer. */++ const sharedGate = (req, res, id) => {++ const signedInAs = signedIn(req) ? (req.viewer.username || '') : '';++ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;++ return page(req, res, {++ title: 'Shared with specific people', styles: [showcase.STYLESHEET, 'ov-share.css'], sharedScripts: ['ov-share.js'],++ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline++ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.++ body: html`<div class="wrap"><div id="share-gate" data-ov-share-gate data-kind="run" data-id="${String(id)}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}"><h1>This run is shared with specific people</h1>++<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p></div></div>`,++ }, 404);++ };const tier = config.allowance;// ── The task box ─────────────────────────────────────────@@@ -920,8 +939,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</// What the agent last saw (agents/looks.js): its task's owner only, never cached.r.get('/tasks/:id/look', async (req, res) => {-- const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;-- const look = row && row.requester === requesterOf(req) ? looks.get(row.id) : null;++ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;++ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));++ const allowed = mine || (row && sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false);++ const look = allowed ? looks.get(row.id) : null;if (!look) return res.status(404).type('text').send('No picture');res.setHeader('Cache-Control', 'private, no-store');res.type(look.format === 'png' ? 'image/png' : 'image/jpeg').send(look.data);@@@ -934,8 +955,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</// sandbox with nothing allowed to load, and never sniffed (nosniff). Only a raster image is shown in place;// an SVG or an HTML page is a download unless it is shown with <img>.r.get('/tasks/:id/files/:artifact', async (req, res) => {-- const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;-- if (!row || row.requester !== requesterOf(req)) return res.status(404).type('text').send('No such file');++ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;++ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));++ const allowed = mine || (row && sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false);++ if (!allowed) return res.status(404).type('text').send('No such file');const found = await findArtifact(s.db, row.id, req.params.artifact);if (!found) return res.status(404).type('text').send('No such file');return sendArtifact(res, found);@@@ -1007,21 +1030,31 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist});r.get('/tasks/:id', async (req, res) => {-- if (req.get('sec-fetch-mode') === 'navigate' && req.query.view !== 'details' && TASK_ID.test(req.params.id) && signedIn(req)) {-- return res.redirect(303, `/chat/${req.params.id}#t-${req.params.id}`);-- }-- const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;-- if (!row || row.requester !== requesterOf(req)) {++ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;++ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));++ if (!row) {return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);}++ if (!mine) {++ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;++ if (!allowed) {++ const audience = sharing ? await sharing.audienceOf(row.id).catch(() => null) : null;++ if (audience === 'people') return sharedGate(req, res, row.id);++ return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>There is no task here that belongs to you.</p>` }, 404);++ }++ }++ if (mine && req.get('sec-fetch-mode') === 'navigate' && req.query.view !== 'details') {++ return res.redirect(303, `/chat/${req.params.id}#t-${req.params.id}`);++ }++ const shared = !mine;const t = store.toWire(row, { baseUrl: config.baseUrl });const events = await store.eventsAfter(s, row.id, 0);-- const deliveries = row.webhooks ? await ctx.webhooks.forTask(row.id) : [];++ const deliveries = !shared && row.webhooks ? await ctx.webhooks.forTask(row.id) : [];const live = store.OPEN.includes(row.state);const waiting = row.state === 'needs_you';const open = live || waiting;const questionEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');-- const question = waiting && questionEvent ? {++ const question = !shared && waiting && questionEvent ? {text: String(questionEvent.text || ''),options: Array.isArray(questionEvent.options) ? questionEvent.options : [],// The resume card (stage V2-9): the run was stopped inside an action, not before it started.@@@ -1033,18 +1066,18 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(existconst assumeProblem = req.query.assume === 'gone' ? 'Those assumptions could not be changed: the task is no longer running.' : null;const cls = (events.find((e) => e.step === 'plan' && !e.agent && /"(\w+)" task/.test(e.text || '')) || { text: '' }).text.match(/"(\w+)"/);const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;-- const desk = open ? store.deskFrom(events) : null;-- const look = looks.get(row.id);++ const desk = !shared && open ? store.deskFrom(events) : null;++ const look = shared ? null : looks.get(row.id);const parentRow = row.parent_id ? await store.getTask(s, row.parent_id) : null;const parentTask = parentRow && parentRow.requester === row.requester ? parentRow : null;-- const follows = t.state === 'succeeded' ? await store.followUps(s, row) : [];++ const follows = !shared && t.state === 'succeeded' ? await store.followUps(s, row) : [];// The saved steps (stage V2-9) and the runs this one can be compared with. A running task has no fork dialog.-- const saved = !open && checkpointsOn ? await checkpoints.list(row.id) : [];-- const forks = row.state === 'queued' ? [] : await store.forks(s, row);-- const others = !open && (row.state === 'succeeded' || row.state === 'failed')++ const saved = !shared && !open && checkpointsOn ? await checkpoints.list(row.id) : [];++ const forks = shared || row.state === 'queued' ? [] : await store.forks(s, row);++ const others = !shared && !open && (row.state === 'succeeded' || row.state === 'failed')? await store.recentSucceeded(s, row.requester, row.id, 4) : [];const forkProblem = req.query.fork === 'gone' ? 'That saved step is no longer there, so it could not be forked. Pick another step below.' : null;-- const offer = t.state === 'succeeded' ? await benchOffers.get(s, row.id, requesterOf(req)) : null;++ const offer = !shared && t.state === 'succeeded' ? await benchOffers.get(s, row.id, requesterOf(req)) : null;const spans = await trace.spansFor(s, row.id);const followProblem = typeof req.query.follow === 'string' ? (FOLLOW_ERROR[req.query.follow] || 'That follow-up could not start. Try again in a moment.') : null;const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));@@@ -1059,8 +1092,8 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist// How it went is the agents' session (openvibe-shared/agent-session.js, js/task.js mounts it from the events// below and, while the task runs, from its live stream); the attempts drawn here stay for a page without scripts.scripts: waiting ? [] : ['js/task-events.js', 'js/task.js'],-- sharedScripts: waiting ? [] : ['agent-session.js'],-- styles: [showcase.STYLESHEET],++ sharedScripts: [...(waiting ? [] : ['agent-session.js']), 'ov-share.js'],++ styles: [showcase.STYLESHEET, 'ov-share.css'],css: ['css/task.css'],noReferrer: true,body: html`${live ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}@@@ -1072,17 +1105,18 @@ ${parentTask ? html`<p class="follows-on">${row.fork_of_step != null: html`Following up on <a href="/tasks/${parentTask.id}">${parentTask.task.length > 90 ? `${parentTask.task.slice(0, 89)}…` : parentTask.task}</a>`}</p>` : ''}<h1 class="task-text">${row.task}</h1></header>++${shared ? html`<p class="muted">${shareButton(row, false)} <span class="small">This run was shared with you. Only its owner can change it.</span></p>` : html`<p>${shareButton(row, true)}</p>`}${answerProblem ? notice(answerProblem, 'warn') : ''}${assumeProblem ? notice(assumeProblem, 'warn') : ''}${forkProblem ? notice(forkProblem, 'warn') : ''}${question ? questionCard(row, question) : ''}${outcome(t, row, { acting: actingCard })}--${t.state === 'succeeded' ? benchBlock(row, offer) : ''}--${t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}--${savedStepsBlock(row, saved, spans)}--${relatedRunsBlock(row, { forks, others })}--${open ? planCard(plan, { editable: live || (waiting && !(question && question.kind === 'acting')), taskId: row.id }) : ''}--${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>++${!shared && t.state === 'succeeded' ? benchBlock(row, offer) : ''}++${!shared && t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}++${!shared ? savedStepsBlock(row, saved, spans) : ''}++${!shared ? relatedRunsBlock(row, { forks, others }) : ''}++${open ? planCard(plan, { editable: !shared && (live || (waiting && !(question && question.kind === 'acting'))), taskId: row.id }) : ''}++${!shared && open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}><p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p><a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a></section>` : ''}@@@ -63,6 +63,8 @@ const TABLES = [// What the person's agents asked them to approve (agents/approvals.js), and what they decided.{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },++ // The sharing default they chose for new runs (design 2026-11-06): the audience the next one starts as.++ { table: 'actor_share_defaults', subject: 'subject', value: (usr) => usr, file: 'share-defaults.json' },// The model they chose for their tasks (plan L55 C3): which provider their own key is for, and the model id// they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the// provider. It goes with the person.@@@ -158,7 +160,26 @@ async function exportExtra(db, subject) {/** The account-data handle for Actor's store (server/db.js createStore). */function create({ db, note = 'A task row holds the task the person asked for, in their own words.', log = console } = {}) {-- return createAccountData({ db, service: 'actor', tables: TABLES, note, log, extraExport: exportExtra });++ async function extraErase(t, subjects, { erased, add }) {++ // The sharing system's own rows (openvibe-sdk/share) name a person as a member or as somebody who asked for++ // access. Their subject column is jsonb (a subject object), which the table map cannot express, so they are++ // erased here. The runs' own share rows go with the runs' ids below.++ const forms = subjects.map((s) => JSON.stringify({ type: 'user', id: s }));++ const members = await t.exec('DELETE FROM share_members WHERE subject = ANY($1::jsonb[])', [forms]);++ const requests = await t.exec('DELETE FROM share_access_requests WHERE subject = ANY($1::jsonb[])', [forms]);++ const owned = await t.exec("DELETE FROM shares WHERE service = 'actor' AND owner_subject = ANY($1::jsonb[])", [forms]);++ if (Number(members) || Number(requests) || Number(owned)) {++ add(erased, 'share_rows', Number(members) + Number(requests) + Number(owned));++ }++ }++ return createAccountData({ db, service: 'actor', tables: TABLES, note, log, extraErase, extraExport: exportExtra });}--module.exports = { create, TABLES, TOPICS, exportFiles, exportSpans, exportCheckpoints, exportConnectionTools, exportExtra };++// The sharing system's own tables carry a person (a member, an access request). Their subject column is jsonb, so the++// table map cannot express them: they are erased in extraErase, with the rest of the person's rows.++const EXEMPT = {++ share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',++ share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',++};++++module.exports = { create, TABLES, TOPICS, EXEMPT, exportFiles, exportSpans, exportCheckpoints, exportConnectionTools, exportExtra };nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++++/**++ * Sharing an agent's run (design 2026-11-06, visibility step 2), through openvibe-sdk/share.++ *++ * A run is Only me until its owner says otherwise: its page is /chat/:id and its Details page++ * /tasks/:id?view=details. The Share sheet changes just that run; the person's default for new runs lives on /chat++ * ("New runs: Only me"). The one check, canView, guards the page, its turns, its attached files, the evidence and++ * the run's look; every write stays the owner's.++ *++ * The SDK owns the model and the rules (the shares row, members, requests, transfers, the log and the delivery++ * queue). This file is the wiring: Network's name lookups and notifications, Events for share.* envelopes, and the++ * person's default.++ */++const { createShares, ShareError } = require('openvibe-sdk/share');++const { serviceAuth } = require('openvibe-contracts');++++const SERVICE = 'actor';++const KIND = 'run';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const DEFAULT_AUDIENCE = 'private';++/** A task's requester as sharing says it: only a person's own run can be shared. */++const PERSON = /^(?:user:)?(usr_[0-9A-HJKMNP-TV-Z]{26})$/;++++const personOf = (requester) => {++ const m = PERSON.exec(String(requester || ''));++ return m ? m[1] : null;++};++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Actor's viewer as the SDK wants it: a subject object, and the staff bit. */++function shareViewer(v) {++ if (!v || !v.subject) return null;++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };++ return null;++}++++function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');++ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({++ tokenUrl: base + '/oauth/token',++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'identity.subject.resolve network.notifications.push',++ fetch: fetchImpl,++ }) : null;++++ async function networkJson({ path, method = 'GET', json, query } = {}) {++ if (!tokens) throw new Error('Network is not configured for this service');++ const url = new URL(base + path);++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };++ if (json !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });++ const body = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));++ err.status = res.status;++ throw err;++ }++ return body;++ }++++ /** One notification. It never carries the run's question or answer. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['An agent run was shared with you', 'Open it on OpenVibe.Actor.'],++ 'share.access_requested': ['Someone asked for access to your run', 'Review the request on OpenVibe.Actor.'],++ 'share.transfer.requested': ['Someone wants to give you a run', 'Accept or decline it on OpenVibe.Actor.'],++ 'share.transfer.answered': ['A run transfer was answered', 'See what happened on OpenVibe.Actor.'],++ }[n.kind] || ['Sharing on OpenVibe.Actor', 'There is something new for you.'];++ try {++ await networkJson({++ path: '/internal/notifications/push', method: 'POST',++ json: {++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],++ service: 'actor', url: n.ref && n.ref.id ? `${config.baseUrl}/chat/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/chat`,++ category: 'social',++ },++ });++ } catch (err) {++ log.warn('[Sharing] notification not sent:', err.message);++ }++ }++++ /** OpenVibe.Events. A refusal only loses the event copy; the setting is the owner's, decided by canView. */++ async function publish(envelope) {++ if (!config.events || !config.events.url) { log.warn('[Sharing] no events URL configured; share envelope not sent'); return; }++ const res = await fetchImpl(String(config.events.url).replace(/\/+$/, ''), {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify(envelope),++ signal: AbortSignal.timeout(8000),++ });++ if (!res.ok) throw new Error('Events answered ' + res.status);++ }++++ const shares = createShares({++ db,++ network: { json: networkJson },++ events: { publish },++ notify,++ });++++ const sv = (viewer) => shareViewer(viewer);++++ return {++ shares,++ SERVICE,++ KIND,++ AUDIENCES,++ DEFAULT_AUDIENCE,++ refOf,++ personOf,++ shareViewer,++ networkJson,++ /** Register a run, Only me until its owner says otherwise. A run no person owns is never shared. */++ register: async (requester, id, audience = DEFAULT_AUDIENCE) => {++ const subject = personOf(requester);++ if (!subject) return false;++ return await shares.register(refOf(id), {++ owner_subject: { type: 'user', id: subject },++ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,++ });++ },++ canView: async (viewer, id) => await shares.canView(sv(viewer), refOf(id)),++ canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), refOf(id)),++ /** Just the audience, for deciding between "Only me" (no hint) and "Specific people" (the gate). */++ audienceOf: async (id) => {++ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]);++ return row ? row.audience : null;++ },++ defaultOf: async (requester) => {++ const subject = personOf(requester);++ if (!subject) return DEFAULT_AUDIENCE;++ const row = await db.maybe('SELECT audience FROM actor_share_defaults WHERE subject = $1', [subject]);++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;++ },++ /** Set the default, and with `applyAll` move the person's existing runs too (rule 2). */++ setDefault: async (requester, audience, { applyAll = false } = {}) => {++ const subject = personOf(requester);++ if (!subject) return false;++ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;++ await db.exec(`INSERT INTO actor_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())++ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [subject, want]);++ if (applyAll) {++ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1++ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,++ [want, SERVICE, KIND, subject]);++ }++ return true;++ },++ /** The uniform API's describe(ref): the run's question and its page. */++ describe: async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const row = await db.maybe('SELECT task FROM tasks WHERE id = $1', [String(ref.id)]);++ if (!row) return {};++ return {++ title: String(row.task || 'An agent run').split('\n')[0].slice(0, 90),++ link: `${config.baseUrl}/chat/${encodeURIComponent(ref.id)}`,++ roles: ['viewer'],++ transferable: false,++ };++ },++ /** Expire members and transfers, then send the queued events and notifications. */++ work: async ({ flush = true } = {}) => {++ let expired = 0;++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }++ let sent = 0;++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }++ return { expired, sent };++ },++ };++}++++module.exports = { createSharing, shareViewer, refOf, personOf, AUDIENCES, SERVICE, KIND, DEFAULT_AUDIENCE };@@@ -14,6 +14,14 @@ const { checkWebhookUrl } = require('../net/webhook-post');const { mintSecret } = require('./webhooks');const learn = require('../runtime/brain/learn');++/**++ * The sharing system (design 2026-11-06), injected once by server/app.js. A run is its owner's until the Share sheet++ * says otherwise; a run no person owns is never registered, and a sharing system that is unavailable leaves the run++ * unreadable to others (every read asks canView, and a missing row answers no).++ */++let sharing = null;++function setSharing(store) { sharing = store || null; }++const usd = (n) => (n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);function untilMidnight(nowMs) {@@@ -96,6 +104,14 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo});// The engine starts the task only once its row has committed; a webhook that asked for `queued` hears it now.if (out.status === 201) {++ if (sharing) {++ try {++ const audience = await sharing.defaultOf(out.task.requester).catch(() => 'private');++ await sharing.register(out.task.requester, out.task.id, audience);++ } catch (e) {++ console.warn(`[Actor] task ${out.task.id}: not registered with the sharing system: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ }// A follow-up that says the answer was wrong teaches the router; a fork is a different run, not a correction.if (parent && forkOfStep == null) await learn.correction({ store: s, parent, text: body.task, now: () => s.now() }).catch(() => {});if (webhooks.length && webhookQueue) await webhookQueue.enqueue(out.task.id, 'queued');@@@ -104,4 +120,4 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhooreturn out;}--module.exports = { createTask, untilMidnight };++module.exports = { createTask, untilMidnight, setSharing };@@@ -12,16 +12,17 @@ const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');const { signDeliveryHeaders } = require('openvibe-sdk/events');const taskStore = require('../server/tasks/store');const personal = require('../server/agents/personal');--const { TABLES } = require('../server/identity/account-data');++const { TABLES, EXEMPT } = require('../server/identity/account-data');const { testDb } = require('./helpers/db');// Tables a person does not own. The brain's outcomes are counts keyed by task kind, tier, step kind and tool: no// person column, no task text, so deletion has nothing to erase there and the export names no one.--const EXEMPT = {++const EXEMPT_OUTSIDE_SHARING = {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 EXEMPT_ALL = { ...EXEMPT_OUTSIDE_SHARING, ...EXEMPT };const A = 'usr_01JZ0000000000000000000AAA';const B = 'usr_01JZ0000000000000000000BBB';@@@ -110,7 +111,7 @@ async function makeWait(t, subject, taskId, line = 'waiting for your Codes taskawait check('every table that names a person is covered or exempted with a reason', async () => {const d = await testDb();try {-- const missing = await uncovered(d.db, { tables: TABLES, exempt: EXEMPT });++ const missing = await uncovered(d.db, { tables: TABLES, exempt: EXEMPT_ALL });assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }});@@@ -120,7 +121,7 @@ async function makeWait(t, subject, taskId, line = 'waiting for your Codes tasktry {const cols = await d.db.many("SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'actor_brain_outcomes'");assert.deepStrictEqual(cols.map((c) => c.column_name).sort(), ['failures', 'id', 'step_kind', 'successes', 'task_kind', 'tier', 'tool', 'updated_at']);-- assert.deepStrictEqual(await uncovered(d.db, { tables: TABLES, exempt: EXEMPT }), [], 'no table with a person column is uncovered');++ assert.deepStrictEqual(await uncovered(d.db, { tables: TABLES, exempt: EXEMPT_ALL }), [], 'no table with a person column is uncovered');} finally { await d.close(); }});@@@ -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', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' } }), [], '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', ...accountData.EXEMPT } }), [], '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(); }@@@ -306,6 +306,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mconst parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);return await projectsApi(req, raw, json, u, parts, url.searchParams);}++ // The sharing system's (openvibe-sdk/share) name lookups and notifications.++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {++ const name = String(url.searchParams.get('username') || '').toLowerCase();++ const u = st.byUsername.get(name);++ if (!u) return problem(json, 404, 'identity.name_not_found', 'no such person');++ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });++ }++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const ids = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];++ const results = {};++ for (const subjectId of ids) {++ const u = [...st.users.values()].find((x) => x.subject === subjectId);++ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };++ }++ return json(200, { results });++ }++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });++return json(404, { error: 'not found' });});issuer = srv.url;@@@ -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', '0028', '0029']);++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028', '0029', '0030', '0031']);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]);@@@ -110,6 +110,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)','server/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)','server/notify/client.js': 'the configured OpenVibe.Network internal base (OV_NETWORK_INTERNAL_URL, loopback) with Actor\'s own service tokens: identity resolve and one notification push; the subject is the task\'s owner and the link is the desk page Actor built, never a URL from the task (test/notify-client.test.js)',++ 'server/sharing.js': 'the configured Network internal base (OV_NETWORK_INTERNAL_URL) for the token endpoint, identity lookups and one notification push, and the configured Events URL (ACTOR_EVENTS_URL); share refs are the sharing system\'s own kind/id strings, never a URL from a person (test/sharing.test.js)','server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)','server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)','server/waits/events.js': 'the configured OpenVibe.Events base (ACTOR_EVENTS_URL) with the service token (events.subscription.manage, events.event.read): the topic patterns and the cursors come from the wait rows, never from a task (test/waits.test.js)', 'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the run's++ * page carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in++ * person who is not on the list is offered Ask for access — with no console error and no CSP refusal.++ *++ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a++ * temporary database, so the CSP headers the browser sees are Actor's.++ */++const assert = require('assert');++const fs = require('fs');++const { boot, check, done } = require('./helpers/boot');++++let playwright = null;++try { playwright = require('playwright'); } catch {++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }++}++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;++const SAME = { 'sec-fetch-site': 'same-origin' };++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const cy = t.network.addUser('cy');++ t.config.loop.selfReflections = 0;++ const cookieOf = (user) => {++ const [name, ...rest] = t.signIn(user).split('=');++ return { name, value: rest.join('='), url: t.base };++ };++++ let browser;++ const errors = [];++ const watch = (page) => {++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });++ page.on('pageerror', (e) => errors.push(String(e)));++ };++ const settled = async (page) => {++ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });++ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());++ };++++ try {++ let taskId;++ await check('a run shared with Specific people, with nobody on the list yet', async () => {++ t.providers.state.script = [{ content: 'Hetzner CX22 at €4.51 a month.' }];++ const made = await t.get('/chat', { as: kim, form: { task: 'What is the cheapest Hetzner VPS?' }, headers: SAME });++ assert.strictEqual(made.status, 303, made.text);++ taskId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(made.headers.get('location'))[1];++ await t.waitFor(taskId, ['succeeded']);++ await t.ctx.sharing.shares.setAudience(++ { subject: { type: 'user', id: kim.subject } },++ { service: 'actor', kind: 'run', id: taskId }, 'people',++ );++ });++++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });++ const ctx = await browser.newContext();++ const page = await ctx.newPage();++ watch(page);++ const res = await page.goto(`${t.base}/chat/${taskId}`, { waitUntil: 'load', timeout: 15000 });++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the run');++ const text = await settled(page);++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);++ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));++ assert.match(href, /^\/auth\/login\?next=/);++ await ctx.close();++ });++++ await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {++ const ctx = await browser.newContext();++ await ctx.addCookies([cookieOf(cy)]);++ const page = await ctx.newPage();++ watch(page);++ await page.goto(`${t.base}/chat/${taskId}`, { waitUntil: 'load', timeout: 15000 });++ const text = await settled(page);++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);++ assert.ok(/@cy/.test(text), 'the gate names the account it is signed in as');++ await ctx.close();++ });++++ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {++ const refused = errors.filter((e) => REFUSED.test(e));++ assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);++ });++ } finally {++ if (browser) await browser.close().catch(() => {});++ await t.close();++ done();++ }++})();nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Sharing an agent's run (design 2026-11-06, visibility step 2): the one Share sheet (openvibe-sdk/share) and the++ * one check, canView, on the run's page, its turns, its details and its files.++ *++ * - a new run is Only me; the Share button is its owner's;++ * - Anyone with the link reads the conversation and its Details page, with no composer and no owner moves;++ * - Specific people shows the gate, never the question or an answer, to somebody not on the list;++ * - the person's default for new runs, and "apply to all", work.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++ t.config.loop.selfReflections = 0;++ const s = t.ctx.s;++++ const say = async (text, { as = kim, answer = 'Hetzner CX22 at €4.51 a month [E1].' } = {}) => {++ t.providers.state.script = [{ content: answer }];++ return await t.get('/chat', { as, form: { task: text }, headers: SAME });++ };++ const call = (pathname, o = {}) => {++ const { headers, ...rest } = o;++ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });++ };++ const share = (id, o = {}) => call(`/api/v1/run/${encodeURIComponent(id)}/share`, o);++ const setShare = async (id, body, as) => {++ const first = await share(id, { as });++ assert.strictEqual(first.status, 200, first.text);++ const etag = first.headers.get('etag');++ assert.ok(etag);++ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });++ };++ const audienceRow = async (id) => await s.db.maybe(++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['actor', 'run', id]);++++ try {++ let taskId;++ await check('a new run is Only me, and its page carries the Share button for its owner', async () => {++ const made = await say('What is the cheapest Hetzner VPS?');++ assert.strictEqual(made.status, 303, made.text);++ taskId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(made.headers.get('location'))[1];++ await t.waitFor(taskId, ['succeeded']);++ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'private' });++ const mine = await t.get(`/chat/${taskId}`, { as: kim });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="run"'));++ assert.ok(mine.text.includes('ov-share.js'));++ const other = await t.get(`/chat/${taskId}`, { as: bo });++ assert.strictEqual(other.status, 404);++ assert.ok(!other.text.includes('Hetzner CX22'), 'never the answer');++ });++++ await check('Anyone with the link reads the conversation and Details, with no composer and no owner moves', async () => {++ const saved = await setShare(taskId, { audience: 'link' }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'link');++ const guest = await t.get(`/chat/${taskId}`);++ assert.strictEqual(guest.status, 200, guest.text);++ assert.ok(guest.text.includes('What is the cheapest Hetzner VPS?'));++ assert.ok(guest.text.includes('Hetzner CX22'));++ assert.ok(guest.text.includes('This run was shared with you'));++ assert.ok(!guest.text.includes('ovt-composer'), 'a reader cannot carry the run on');++ assert.ok(!guest.text.includes('name="task"'), 'no send box');++ const turn = await t.get(`/chat/turn/${taskId}`, { as: bo });++ assert.strictEqual(turn.status, 200, turn.text);++ const details = await t.get(`/tasks/${taskId}?view=details`);++ assert.strictEqual(details.status, 200, details.text);++ assert.ok(details.text.includes('What is the cheapest Hetzner VPS?'));++ assert.ok(details.text.includes('This run was shared with you'));++ assert.ok(!details.text.includes('action="/tasks/' + taskId + '/answer"'), 'no answer form for a reader');++ });++++ await check('Specific people: on the list reads it, a stranger gets the gate and no question', async () => {++ const saved = await setShare(taskId, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'people');++ const member = await t.get(`/chat/${taskId}`, { as: bo });++ assert.strictEqual(member.status, 200, member.text);++ assert.ok(member.text.includes('Hetzner CX22'));++ const stranger = await t.get(`/chat/${taskId}`, { as: cy });++ assert.strictEqual(stranger.status, 404);++ assert.ok(stranger.text.includes('shared with specific people'));++ assert.ok(!stranger.text.includes('Hetzner CX22'), 'the gate never reveals an answer');++ const details = await t.get(`/tasks/${taskId}?view=details`, { as: cy });++ assert.strictEqual(details.status, 404);++ });++++ await check('the default applies to the next run, and "apply to all" moves the existing one', async () => {++ const saved = await t.get('/chat/defaults', { as: kim, form: { audience: 'public', apply_all: '1' }, headers: SAME });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'public' });++ const guest = await t.get(`/chat/${taskId}`);++ assert.strictEqual(guest.status, 200);++ const next = await say('And the second cheapest?');++ const nextId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(next.headers.get('location'))[1];++ assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });++ const start = await t.get('/chat', { as: kim });++ assert.ok(start.text.includes('New runs'), 'the default is on the product page');++ });++ } finally {++ await t.close();++ done();++ }++})();
@@ -259,5 +259,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -259,5 +259,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.actor",
"domain": "openvibe.actor",
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
- "shared": "openvibe-shared v4.122.0",
+ "shared": "openvibe-shared v4.123.0",
"packages": [
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-contracts v0.289.1",
"openvibe-sdk v0.91.0",
"openvibe-sdk v0.91.0",
- "openvibe-shared v4.122.0"
+ "openvibe-shared v4.123.0"
],
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"works": [
"works": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+-- phase: expand
+-- The one sharing system (openvibe-sdk/share) on an agent's run: its audience, the people it is shared with, the log
+-- and the delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/
+-- share/schema.js at the pinned version), so its store finds the tables it expects and the test database has them.
+-- The run's own row is tasks; there is one share row per run, kind 'run'.
+
+
+CREATE TABLE IF NOT EXISTS shares (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
+ revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
+ PRIMARY KEY (service, kind, resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_members (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
+ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
+ PRIMARY KEY (service, kind, resource_id, subject),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_transfers (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
+ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_access_requests (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_log (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
+ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
+ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS share_deliveries (
+ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
+ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE TABLE IF NOT EXISTS share_name_limits (
+ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
+);
+CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
+CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
+CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+-- phase: expand
+-- Visibility step 2 (design 2026-11-06): the person's default audience for new runs, and one share row for every
+-- run that already exists, Only me. New runs are registered as they are made (server/tasks/create.js); a run
+-- without a row is still its owner's, because every read asks canView and a missing row answers no.
+-- Repeating this file is a no-op. Expand-only.
+
+CREATE TABLE IF NOT EXISTS actor_share_defaults (
+ subject text COLLATE "C" PRIMARY KEY,
+ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT 'actor', 'run', id, jsonb_build_object('type', 'user', 'id', substring(requester from 6)), 'private'
+FROM tasks
+WHERE requester ~ '^user:usr_[0-9A-HJKMNP-TV-Z]{26}$'
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1190,9 +1190,9 @@
@@ -1190,9 +1190,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.122.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
+ "version": "4.123.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
+ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -79,6 +79,9 @@ const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
@@ -79,6 +79,9 @@ const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
* poster; tests only), bot ({ robots, computers }: stand-ins for the Console's OpenVibe.Bot reads; tests only),
* poster; tests only), bot ({ robots, computers }: stand-ins for the Console's OpenVibe.Bot reads; tests only),
* notifier (a stand-in for Network notifications; tests only)
* notifier (a stand-in for Network notifications; tests only)
*/
*/
+const { createSharing } = require('./sharing');
+const { setSharing } = require('./tasks/create');
+
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
@@ -139,7 +142,10 @@ async function createApp(opts = {}) {
@@ -139,7 +142,10 @@ async function createApp(opts = {}) {
createTask: (args) => createTask({ s, config, engine, webhooks, ...args }) });
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).
// 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 vibeDelivery = require('./agents/vibe-deliver').createVibeDelivery({ s, config, chat: chatClient, log });
- const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits };
+ // The one sharing system (design 2026-11-06): an agent's run is its owner's until the Share sheet says otherwise.
+ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
+ setSharing(sharing);
+ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// 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;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@ -253,6 +259,26 @@ async function createApp(opts = {}) {
@@ -253,6 +259,26 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ // ── /api/v1/run/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
+ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
+ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
+ // its own paths.
+ {
+ const shareApi = express.Router();
+ shareApi.use(sharing.shares.router({
+ service: 'actor',
+ authenticate: (req) => sharing.shareViewer(req.viewer),
+ describe: sharing.describe,
+ }));
+ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
+ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
+ }
+ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.
+ if (opts.startSharingWorker !== false) {
+ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
+ if (timer.unref) timer.unref();
+ app.locals.sharingTimer = timer;
+ }
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
@@ -92,7 +92,7 @@ function tookOf(row) {
@@ -92,7 +92,7 @@ function tookOf(row) {
}
}
function createChatPages(ctx, { computers = null } = {}) {
function createChatPages(ctx, { computers = null } = {}) {
- const { config, s, engine } = ctx;
+ const { config, s, engine, sharing } = ctx;
const r = asyncRouter();
const r = asyncRouter();
// Bot's computers, for the live desk in a turn (plan L55 C1b): made when first asked for.
// Bot's computers, for the live desk in a turn (plan L55 C1b): made when first asked for.
let pcsMade = computers;
let pcsMade = computers;
@@ -105,6 +105,44 @@ function createChatPages(ctx, { computers = null } = {}) {
@@ -105,6 +105,44 @@ function createChatPages(ctx, { computers = null } = {}) {
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const requesterOf = (req) => `user:${req.viewer.subject}`;
const requesterOf = (req) => `user:${req.viewer.subject}`;
const now = () => (typeof s.now === 'function' ? s.now() : Date.now());
const now = () => (typeof s.now === 'function' ? s.now() : Date.now());
+ /** The one Share sheet's button: its owner gets the audience part too; a reader gets Send it only. */
+ const shareButton = (row, owner) => {
+ const title = String(row.task || 'An agent run').split('\n')[0].slice(0, 90);
+ return owner
+ ? html`<button type="button" class="ovt-btn" data-ov-share-sheet data-kind="run" data-id="${row.id}" data-title="${title}">Share</button>`
+ : html`<button type="button" class="ovt-btn" data-ov-share-sheet data-title="${title}">Share</button>`;
+ };
+ /** Specific people, and this reader is not on the list: the audience, never the question or an answer. */
+ const sharedGate = (req, res, id) => {
+ const signedInAs = signedIn(req) ? (req.viewer.username || '') : '';
+ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
+ return send(res, 404, {
+ viewer: req.viewer, config, path: req.originalUrl, title: 'Shared with specific people', app: true, noReferrer: true,
+ styles: ['kit.css', 'ov-share.css'], sharedScripts: ['kit-web.js', 'ov-share.js'],
+ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline
+ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.
+ body: html`<div class="ct-wrap"><div id="share-gate" class="ct-gate" data-ov-share-gate data-kind="run" data-id="${String(id)}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}"><h1>This run is shared with specific people</h1>
+<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p></div></div>`,
+ });
+ };
+
+ const AUDIENCE_TEXT = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };
+ /**
+ * "New runs: Only me", with "Apply to all my existing runs" (design rule 1 and 2). The Share button on a run
+ * changes just that one; this is only what the next one starts as.
+ */
+ async function defaultsHtml(requester) {
+ if (!sharing) return '';
+ const current = await sharing.defaultOf(requester).catch(() => 'private');
+ const options = ['private', 'people', 'link', 'public']
+ .map((a) => html`<option value="${a}"${a === current ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);
+ return html`<form class="ct-share-defaults" method="post" action="/chat/defaults">
+<label for="ct-share-default">New runs</label><select id="ct-share-default" name="audience">${options}</select>
+<label class="ct-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing runs</label>
+<button class="sc-btn" type="submit">Save</button>
+<span class="muted small">Only me unless you choose otherwise. Each run's Share button changes just that run.</span>
+</form>`;
+ }
async function chatsFor(requester) {
async function chatsFor(requester) {
const rows = await s.db.many(
const rows = await s.db.many(
@@ -224,17 +262,21 @@ function createChatPages(ctx, { computers = null } = {}) {
@@ -224,17 +262,21 @@ function createChatPages(ctx, { computers = null } = {}) {
}));
}));
}
}
- function questionBlock(row, events) {
+ function questionBlock(row, events, readOnly = false) {
const q = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');
const q = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');
if (!q) return html`<div class="ovt-card is-wait"><p><b>Actor is waiting for you.</b></p><p><a href="/tasks/${row.id}?view=details#question">Open the question</a></p></div>`;
if (!q) return html`<div class="ovt-card is-wait"><p><b>Actor is waiting for you.</b></p><p><a href="/tasks/${row.id}?view=details#question">Open the question</a></p></div>`;
const options = Array.isArray(q.options) ? q.options : [];
const options = Array.isArray(q.options) ? q.options : [];
const acting = q.question_kind === 'acting';
const acting = q.question_kind === 'acting';
+ if (readOnly) {
+ return html`<div class="ovt-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p>
+${options.length ? html`<p class="ct-opts">${options.map((o) => html`<span class="ct-opt">${o}</span>`)}</p>` : ''}</div>`;
+ }
return html`<div class="ovt-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p>
return html`<div class="ovt-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p>
<form method="post" action="/tasks/${row.id}/answer" class="ct-opts">${options.map((o) => html`<button class="ct-opt" type="submit" name="answer" value="${o}">${o}</button>`)}</form>
<form method="post" action="/tasks/${row.id}/answer" class="ct-opts">${options.map((o) => html`<button class="ct-opt" type="submit" name="answer" value="${o}">${o}</button>`)}</form>
${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class="ct-own"><input name="answer" type="text" maxlength="2000" required placeholder="Or answer in your own words" aria-label="Your answer"><button class="ct-opt" type="submit">Answer</button></form>`}</div>`;
${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class="ct-own"><input name="answer" type="text" maxlength="2000" required placeholder="Or answer in your own words" aria-label="Your answer"><button class="ct-opt" type="submit">Answer</button></form>`}</div>`;
}
}
- function failedBlock(row, t) {
+ function failedBlock(row, t, readOnly = false) {
const err = t.error || {};
const err = t.error || {};
const why = {
const why = {
'actor.agents.exhausted': 'No answer passed its check, so nothing unchecked was shown.',
'actor.agents.exhausted': 'No answer passed its check, so nothing unchecked was shown.',
@@ -242,8 +284,8 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -242,8 +284,8 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
'actor.task.timeout': 'It ran too long and was stopped.',
'actor.task.timeout': 'It ran too long and was stopped.',
'actor.task.interrupted': 'Actor restarted while this was running. Nothing more was charged.',
'actor.task.interrupted': 'Actor restarted while this was running. Nothing more was charged.',
}[err.code] || err.detail || 'It stopped without saying why.';
}[err.code] || err.detail || 'It stopped without saying why.';
- return html`<div class="ovt-card is-bad"><p><b>Actor couldn't finish this.</b> ${why}</p>
-<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;
+ const retry = readOnly ? '' : html`<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form>`;
+ return html`<div class="ovt-card is-bad"><p><b>Actor couldn't finish this.</b> ${why}</p>${retry}${readOnly ? html`<a class="ct-act" href="/tasks/${row.id}?view=details">Details</a>` : ''}</div>`;
}
}
/**
/**
@@ -251,7 +293,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -251,7 +293,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
* it with no event (the run checks for itself); Stop waiting ends the wait and the run carries on without it. The
* it with no event (the run checks for itself); Stop waiting ends the wait and the run carries on without it. The
* deadline is written as a <time> the page shows in the reader's own zone (js/chat.js), "in about 2 h" without it.
* deadline is written as a <time> the page shows in the reader's own zone (js/chat.js), "in about 2 h" without it.
*/
*/
- function waitBlock(row, wait) {
+ function waitBlock(row, wait, readOnly = false) {
if (!wait) return '';
if (!wait) return '';
const left = Math.max(0, Date.parse(wait.deadline_at) - now());
const left = Math.max(0, Date.parse(wait.deadline_at) - now());
const mins = Math.round(left / 60000);
const mins = Math.round(left / 60000);
@@ -259,7 +301,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -259,7 +301,7 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
return html`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${row.id}/wait" data-ct-wait>
return html`<form class="ovt-card is-info ct-wait" method="post" action="/chat/${row.id}/wait" data-ct-wait>
<p class="ovt-card-h">${raw(glyphs.svg('clock', { size: 18 }))}<span><b>Waiting</b> ${String(wait.line || 'for something to happen').replace(/^waiting\s+/i, '').replace(/[.\s]+$/, '')}. Nothing is spent while it waits.</span></p>
<p class="ovt-card-h">${raw(glyphs.svg('clock', { size: 18 }))}<span><b>Waiting</b> ${String(wait.line || 'for something to happen').replace(/^waiting\s+/i, '').replace(/[.\s]+$/, '')}. Nothing is spent while it waits.</span></p>
<p class="ct-wait-until">It gives up <time datetime="${new Date(wait.deadline_at).toISOString()}" data-ct-local>${about}</time>, then ${wait.on_timeout === 'fail' ? 'stops' : wait.on_timeout === 'retry' ? 'tries that step once more' : 'asks you what next'}.</p>
<p class="ct-wait-until">It gives up <time datetime="${new Date(wait.deadline_at).toISOString()}" data-ct-local>${about}</time>, then ${wait.on_timeout === 'fail' ? 'stops' : wait.on_timeout === 'retry' ? 'tries that step once more' : 'asks you what next'}.</p>
-<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div></form>`;
+${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div>`}</form>`;
}
}
/** The files beside the question (thread.files draws them). */
/** The files beside the question (thread.files draws them). */
@@ -267,20 +309,20 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -267,20 +309,20 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
}
}
- async function turnHtml(row, { events = null, files = null } = {}) {
+ async function turnHtml(row, { events = null, files = null, readOnly = false } = {}) {
const attached = files || (await inputs.list(s, [row.id])).get(row.id) || [];
const attached = files || (await inputs.list(s, [row.id])).get(row.id) || [];
const t = store.toWire(row, { baseUrl: config.baseUrl });
const t = store.toWire(row, { baseUrl: config.baseUrl });
const evs = events || await store.eventsAfter(s, row.id, 0);
const evs = events || await store.eventsAfter(s, row.id, 0);
const open = OPEN.includes(row.state);
const open = OPEN.includes(row.state);
let reply;
let reply;
if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${screenBlock(row, evs, false)}${answerBlock(row, t)}`;
if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${screenBlock(row, evs, false)}${answerBlock(row, t)}`;
- else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;
- else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;
+ else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs, readOnly)}`;
+ else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t, readOnly)}`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
else {
else {
// A turn parked on a wait says what it waits for (plan A2); otherwise it is working.
// A turn parked on a wait says what it waits for (plan A2); otherwise it is working.
const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
- reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked)}`;
+ reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;
}
}
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
@@ -382,35 +424,58 @@ ${modelChip(model)}
@@ -382,35 +424,58 @@ ${modelChip(model)}
['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],
['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],
];
];
- async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
+ /**
+ * A run's page, or the same conversation read-only for somebody it was shared with: no sidebar, no composer and
+ * no turn that asks or retries. The Share button is its owner's; a reader gets Send it only.
+ */
+ async function render(req, res, { row = null, problem = null, text = '', status = 200, shared = false } = {}) {
const requester = requesterOf(req);
const requester = requesterOf(req);
- const [chats, turns, today] = await Promise.all([chatsFor(requester), row ? chainOf(row) : [], store.spendToday(s, requester)]);
+ const [chats, turns, today] = await Promise.all([
+ shared ? Promise.resolve([]) : chatsFor(requester),
+ row ? chainOf(row) : [],
+ shared ? Promise.resolve({ usd: 0 }) : store.spendToday(s, requester),
+ ]);
const first = turns[0] || null;
const first = turns[0] || null;
const lastTurn = turns[turns.length - 1] || null;
const lastTurn = turns[turns.length - 1] || null;
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
const fileMap = await inputs.list(s, turns.map((t) => t.id));
const fileMap = await inputs.list(s, turns.map((t) => t.id));
- const model = modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));
- const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [] })));
+ const model = shared ? null : modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));
+ const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [], readOnly: shared })));
const title = first ? clip(first.task, 60) : 'New chat';
const title = first ? clip(first.task, 60) : 'New chat';
+ const actions = row ? String(shareButton(row, !shared)) : '';
+ const defaults = !shared && !row ? String(await defaultsHtml(requester)) : '';
return send(res, status, {
return send(res, status, {
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
// The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).
// The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).
- css: ['css/chat.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js', 'ov-thread.js'],
- body: html`${busy ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
+ css: ['css/chat.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css'],
+ sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js'],
+ body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
- sidebar: String(sidebar(chats, first ? first.id : null)),
- head: thread.head({ title, newHref: first ? '/chat' : null }),
+ sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
+ head: thread.head({ title, newHref: shared ? null : (first ? '/chat' : null), actions }),
turns: turnHtmls.map(String),
turns: turnHtmls.map(String),
empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),
<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),
- composer: String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })),
+ composer: shared ? '' : String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })),
label: first ? 'Conversation' : 'New chat',
label: first ? 'Conversation' : 'New chat',
- }))}</div>`,
+ }))}${defaults}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,
});
});
}
}
const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
+ r.post('/chat/defaults', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ 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.');
+ if (!sharing) return res.redirect(303, '/chat');
+ const body = req.body || {};
+ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
+ await sharing.setDefault(requesterOf(req), audience, { applyAll: String(body.apply_all || '') === '1' })
+ .catch((e) => ctx.log.warn('[Actor] the run default was not saved:', e.message));
+ return res.redirect(303, '/chat');
+ });
+
r.get('/chat/model', async (req, res) => {
r.get('/chat/model', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
@@ -479,19 +544,29 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -479,19 +544,29 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
r.get('/chat/turn/:id', async (req, res) => {
r.get('/chat/turn/:id', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.status(404).end();
+ if (!TASK_ID.test(req.params.id)) return res.status(404).end();
const row = await store.getTask(s, req.params.id);
const row = await store.getTask(s, req.params.id);
- if (!row || row.requester !== requesterOf(req)) return res.status(404).end();
- res.type('html').send(require('../render/html').render(await turnHtml(row)));
+ if (!row) return res.status(404).end();
+ const mine = signedIn(req) && row.requester === requesterOf(req);
+ if (!mine) {
+ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;
+ if (!allowed) return res.status(404).end();
+ }
+ res.type('html').send(require('../render/html').render(await turnHtml(row, { readOnly: !mine })));
});
});
// An attached file, to its own person only: an image shown in place; a PDF downloaded as itself; anything else
// An attached file, to its own person only: an image shown in place; a PDF downloaded as itself; anything else
// downloaded as plain text. In a sandbox, never sniffed.
// downloaded as plain text. In a sandbox, never sniffed.
r.get('/chat/file/:taskId/:id', async (req, res) => {
r.get('/chat/file/:taskId/:id', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- if (!signedIn(req) || !TASK_ID.test(req.params.taskId) || !/^inp_[A-Za-z0-9_-]{6,20}$/.test(req.params.id)) return res.status(404).end();
+ if (!TASK_ID.test(req.params.taskId) || !/^inp_[A-Za-z0-9_-]{6,20}$/.test(req.params.id)) return res.status(404).end();
const row = await store.getTask(s, req.params.taskId);
const row = await store.getTask(s, req.params.taskId);
- if (!row || row.requester !== requesterOf(req)) return res.status(404).end();
+ if (!row) return res.status(404).end();
+ const mine = signedIn(req) && row.requester === requesterOf(req);
+ if (!mine) {
+ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;
+ if (!allowed) return res.status(404).end();
+ }
const f = await inputs.get(s, row.id, req.params.id);
const f = await inputs.get(s, row.id, req.params.id);
if (!f) return res.status(404).end();
if (!f) return res.status(404).end();
const bytes = Buffer.isBuffer(f.bytes) ? f.bytes : Buffer.from(f.bytes || []);
const bytes = Buffer.isBuffer(f.bytes) ? f.bytes : Buffer.from(f.bytes || []);
@@ -503,11 +578,25 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -503,11 +578,25 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
return res.type('text/plain; charset=utf-8').send(bytes);
return res.type('text/plain; charset=utf-8').send(bytes);
});
});
+ /**
+ * A run's page is its owner's. Someone it was shared with reads the same conversation without the composer or a
+ * turn's moves; anybody else gets the gate for Specific people, or "not found" when it is Only me.
+ */
r.get('/chat/:id', async (req, res) => {
r.get('/chat/:id', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- if (!signedIn(req)) return res.redirect(303, loginTo(req));
const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;
const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;
- if (!row || row.requester !== requesterOf(req)) return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });
+ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));
+ if (row && !mine) {
+ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;
+ if (allowed) return render(req, res, { row, shared: true });
+ const audience = sharing ? await sharing.audienceOf(row.id).catch(() => null) : null;
+ if (audience === 'people') return sharedGate(req, res, row.id);
+ return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });
+ }
+ if (!mine) {
+ if (!signedIn(req)) return res.redirect(303, loginTo(req));
+ return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });
+ }
return render(req, res, { row });
return render(req, res, { row });
});
});
@@ -387,7 +387,7 @@ ${[['a', cmp.a, rowA], ['b', cmp.b, rowB]].map(([side, run, row]) => html`<secti
@@ -387,7 +387,7 @@ ${[['a', cmp.a, rowA], ['b', cmp.b, rowB]].map(([side, run, row]) => html`<secti
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
- const { config, s, engine } = ctx;
+ const { config, s, engine, sharing } = ctx;
// Durable runs (stage V2-9): the sealed checkpoints are the engine's, read here only for their step metadata.
// Durable runs (stage V2-9): the sealed checkpoints are the engine's, read here only for their step metadata.
const checkpoints = ctx.checkpoints || null;
const checkpoints = ctx.checkpoints || null;
const checkpointsOn = Boolean(checkpoints && checkpoints.enabled);
const checkpointsOn = Boolean(checkpoints && checkpoints.enabled);
@@ -396,6 +396,25 @@ function createPageRoutes(ctx) {
@@ -396,6 +396,25 @@ function createPageRoutes(ctx) {
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const requesterOf = (req) => `user:${req.viewer.subject}`;
const requesterOf = (req) => `user:${req.viewer.subject}`;
+ /** The one Share sheet's button: its owner gets the audience part too; a reader gets Send it only. */
+ const shareButton = (row, owner) => {
+ const title = String(row.task || 'An agent run').split('\n')[0].slice(0, 90);
+ return owner
+ ? html`<button type="button" class="sc-btn" data-ov-share-sheet data-kind="run" data-id="${row.id}" data-title="${title}">Share</button>`
+ : html`<button type="button" class="sc-btn" data-ov-share-sheet data-title="${title}">Share</button>`;
+ };
+ /** Specific people, and this reader is not on the list: the audience, never the task or an answer. */
+ const sharedGate = (req, res, id) => {
+ const signedInAs = signedIn(req) ? (req.viewer.username || '') : '';
+ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
+ return page(req, res, {
+ title: 'Shared with specific people', styles: [showcase.STYLESHEET, 'ov-share.css'], sharedScripts: ['ov-share.js'],
+ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline
+ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.
+ body: html`<div class="wrap"><div id="share-gate" data-ov-share-gate data-kind="run" data-id="${String(id)}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}"><h1>This run is shared with specific people</h1>
+<p>Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p></div></div>`,
+ }, 404);
+ };
const tier = config.allowance;
const tier = config.allowance;
// ── The task box ─────────────────────────────────────────
// ── The task box ─────────────────────────────────────────
@@ -920,8 +939,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -920,8 +939,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
// What the agent last saw (agents/looks.js): its task's owner only, never cached.
// What the agent last saw (agents/looks.js): its task's owner only, never cached.
r.get('/tasks/:id/look', async (req, res) => {
r.get('/tasks/:id/look', async (req, res) => {
- const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
- const look = row && row.requester === requesterOf(req) ? looks.get(row.id) : null;
+ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;
+ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));
+ const allowed = mine || (row && sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false);
+ const look = allowed ? looks.get(row.id) : null;
if (!look) return res.status(404).type('text').send('No picture');
if (!look) return res.status(404).type('text').send('No picture');
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Cache-Control', 'private, no-store');
res.type(look.format === 'png' ? 'image/png' : 'image/jpeg').send(look.data);
res.type(look.format === 'png' ? 'image/png' : 'image/jpeg').send(look.data);
@@ -934,8 +955,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -934,8 +955,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
// sandbox with nothing allowed to load, and never sniffed (nosniff). Only a raster image is shown in place;
// sandbox with nothing allowed to load, and never sniffed (nosniff). Only a raster image is shown in place;
// an SVG or an HTML page is a download unless it is shown with <img>.
// an SVG or an HTML page is a download unless it is shown with <img>.
r.get('/tasks/:id/files/:artifact', async (req, res) => {
r.get('/tasks/:id/files/:artifact', async (req, res) => {
- const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
- if (!row || row.requester !== requesterOf(req)) return res.status(404).type('text').send('No such file');
+ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;
+ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));
+ const allowed = mine || (row && sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false);
+ if (!allowed) return res.status(404).type('text').send('No such file');
const found = await findArtifact(s.db, row.id, req.params.artifact);
const found = await findArtifact(s.db, row.id, req.params.artifact);
if (!found) return res.status(404).type('text').send('No such file');
if (!found) return res.status(404).type('text').send('No such file');
return sendArtifact(res, found);
return sendArtifact(res, found);
@@ -1007,21 +1030,31 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
@@ -1007,21 +1030,31 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
});
});
r.get('/tasks/:id', async (req, res) => {
r.get('/tasks/:id', async (req, res) => {
- if (req.get('sec-fetch-mode') === 'navigate' && req.query.view !== 'details' && TASK_ID.test(req.params.id) && signedIn(req)) {
- return res.redirect(303, `/chat/${req.params.id}#t-${req.params.id}`);
- }
- const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
- if (!row || row.requester !== requesterOf(req)) {
+ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;
+ const mine = Boolean(row && signedIn(req) && row.requester === requesterOf(req));
+ if (!row) {
return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);
return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);
}
}
+ if (!mine) {
+ const allowed = sharing ? await sharing.canView(req.viewer, row.id).catch(() => false) : false;
+ if (!allowed) {
+ const audience = sharing ? await sharing.audienceOf(row.id).catch(() => null) : null;
+ if (audience === 'people') return sharedGate(req, res, row.id);
+ return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>There is no task here that belongs to you.</p>` }, 404);
+ }
+ }
+ if (mine && req.get('sec-fetch-mode') === 'navigate' && req.query.view !== 'details') {
+ return res.redirect(303, `/chat/${req.params.id}#t-${req.params.id}`);
+ }
+ const shared = !mine;
const t = store.toWire(row, { baseUrl: config.baseUrl });
const t = store.toWire(row, { baseUrl: config.baseUrl });
const events = await store.eventsAfter(s, row.id, 0);
const events = await store.eventsAfter(s, row.id, 0);
- const deliveries = row.webhooks ? await ctx.webhooks.forTask(row.id) : [];
+ const deliveries = !shared && row.webhooks ? await ctx.webhooks.forTask(row.id) : [];
const live = store.OPEN.includes(row.state);
const live = store.OPEN.includes(row.state);
const waiting = row.state === 'needs_you';
const waiting = row.state === 'needs_you';
const open = live || waiting;
const open = live || waiting;
const questionEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');
const questionEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');
- const question = waiting && questionEvent ? {
+ const question = !shared && waiting && questionEvent ? {
text: String(questionEvent.text || ''),
text: String(questionEvent.text || ''),
options: Array.isArray(questionEvent.options) ? questionEvent.options : [],
options: Array.isArray(questionEvent.options) ? questionEvent.options : [],
// The resume card (stage V2-9): the run was stopped inside an action, not before it started.
// The resume card (stage V2-9): the run was stopped inside an action, not before it started.
@@ -1033,18 +1066,18 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
@@ -1033,18 +1066,18 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
const assumeProblem = req.query.assume === 'gone' ? 'Those assumptions could not be changed: the task is no longer running.' : null;
const assumeProblem = req.query.assume === 'gone' ? 'Those assumptions could not be changed: the task is no longer running.' : null;
const cls = (events.find((e) => e.step === 'plan' && !e.agent && /"(\w+)" task/.test(e.text || '')) || { text: '' }).text.match(/"(\w+)"/);
const cls = (events.find((e) => e.step === 'plan' && !e.agent && /"(\w+)" task/.test(e.text || '')) || { text: '' }).text.match(/"(\w+)"/);
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
- const desk = open ? store.deskFrom(events) : null;
- const look = looks.get(row.id);
+ const desk = !shared && open ? store.deskFrom(events) : null;
+ const look = shared ? null : looks.get(row.id);
const parentRow = row.parent_id ? await store.getTask(s, row.parent_id) : null;
const parentRow = row.parent_id ? await store.getTask(s, row.parent_id) : null;
const parentTask = parentRow && parentRow.requester === row.requester ? parentRow : null;
const parentTask = parentRow && parentRow.requester === row.requester ? parentRow : null;
- const follows = t.state === 'succeeded' ? await store.followUps(s, row) : [];
+ const follows = !shared && t.state === 'succeeded' ? await store.followUps(s, row) : [];
// The saved steps (stage V2-9) and the runs this one can be compared with. A running task has no fork dialog.
// The saved steps (stage V2-9) and the runs this one can be compared with. A running task has no fork dialog.
- const saved = !open && checkpointsOn ? await checkpoints.list(row.id) : [];
- const forks = row.state === 'queued' ? [] : await store.forks(s, row);
- const others = !open && (row.state === 'succeeded' || row.state === 'failed')
+ const saved = !shared && !open && checkpointsOn ? await checkpoints.list(row.id) : [];
+ const forks = shared || row.state === 'queued' ? [] : await store.forks(s, row);
+ const others = !shared && !open && (row.state === 'succeeded' || row.state === 'failed')
? await store.recentSucceeded(s, row.requester, row.id, 4) : [];
? await store.recentSucceeded(s, row.requester, row.id, 4) : [];
const forkProblem = req.query.fork === 'gone' ? 'That saved step is no longer there, so it could not be forked. Pick another step below.' : null;
const forkProblem = req.query.fork === 'gone' ? 'That saved step is no longer there, so it could not be forked. Pick another step below.' : null;
- const offer = t.state === 'succeeded' ? await benchOffers.get(s, row.id, requesterOf(req)) : null;
+ const offer = !shared && t.state === 'succeeded' ? await benchOffers.get(s, row.id, requesterOf(req)) : null;
const spans = await trace.spansFor(s, row.id);
const spans = await trace.spansFor(s, row.id);
const followProblem = typeof req.query.follow === 'string' ? (FOLLOW_ERROR[req.query.follow] || 'That follow-up could not start. Try again in a moment.') : null;
const followProblem = typeof req.query.follow === 'string' ? (FOLLOW_ERROR[req.query.follow] || 'That follow-up could not start. Try again in a moment.') : null;
const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
@@ -1059,8 +1092,8 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
@@ -1059,8 +1092,8 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
// How it went is the agents' session (openvibe-shared/agent-session.js, js/task.js mounts it from the events
// How it went is the agents' session (openvibe-shared/agent-session.js, js/task.js mounts it from the events
// below and, while the task runs, from its live stream); the attempts drawn here stay for a page without scripts.
// below and, while the task runs, from its live stream); the attempts drawn here stay for a page without scripts.
scripts: waiting ? [] : ['js/task-events.js', 'js/task.js'],
scripts: waiting ? [] : ['js/task-events.js', 'js/task.js'],
- sharedScripts: waiting ? [] : ['agent-session.js'],
- styles: [showcase.STYLESHEET],
+ sharedScripts: [...(waiting ? [] : ['agent-session.js']), 'ov-share.js'],
+ styles: [showcase.STYLESHEET, 'ov-share.css'],
css: ['css/task.css'],
css: ['css/task.css'],
noReferrer: true,
noReferrer: true,
body: html`${live ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
body: html`${live ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
@@ -1072,17 +1105,18 @@ ${parentTask ? html`<p class="follows-on">${row.fork_of_step != null
@@ -1072,17 +1105,18 @@ ${parentTask ? html`<p class="follows-on">${row.fork_of_step != null
: html`Following up on <a href="/tasks/${parentTask.id}">${parentTask.task.length > 90 ? `${parentTask.task.slice(0, 89)}…` : parentTask.task}</a>`}</p>` : ''}
: html`Following up on <a href="/tasks/${parentTask.id}">${parentTask.task.length > 90 ? `${parentTask.task.slice(0, 89)}…` : parentTask.task}</a>`}</p>` : ''}
<h1 class="task-text">${row.task}</h1>
<h1 class="task-text">${row.task}</h1>
</header>
</header>
+${shared ? html`<p class="muted">${shareButton(row, false)} <span class="small">This run was shared with you. Only its owner can change it.</span></p>` : html`<p>${shareButton(row, true)}</p>`}
${answerProblem ? notice(answerProblem, 'warn') : ''}
${answerProblem ? notice(answerProblem, 'warn') : ''}
${assumeProblem ? notice(assumeProblem, 'warn') : ''}
${assumeProblem ? notice(assumeProblem, 'warn') : ''}
${forkProblem ? notice(forkProblem, 'warn') : ''}
${forkProblem ? notice(forkProblem, 'warn') : ''}
${question ? questionCard(row, question) : ''}
${question ? questionCard(row, question) : ''}
${outcome(t, row, { acting: actingCard })}
${outcome(t, row, { acting: actingCard })}
-${t.state === 'succeeded' ? benchBlock(row, offer) : ''}
-${t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}
-${savedStepsBlock(row, saved, spans)}
-${relatedRunsBlock(row, { forks, others })}
-${open ? planCard(plan, { editable: live || (waiting && !(question && question.kind === 'acting')), taskId: row.id }) : ''}
-${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
+${!shared && t.state === 'succeeded' ? benchBlock(row, offer) : ''}
+${!shared && t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}
+${!shared ? savedStepsBlock(row, saved, spans) : ''}
+${!shared ? relatedRunsBlock(row, { forks, others }) : ''}
+${open ? planCard(plan, { editable: !shared && (live || (waiting && !(question && question.kind === 'acting'))), taskId: row.id }) : ''}
+${!shared && open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
<a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a>
<a class="sc-btn sc-primary" id="desk-link" href="${desk ? desk.url : botPages}" rel="noopener" target="_blank">Watch and take over</a>
</section>` : ''}
</section>` : ''}
@@ -63,6 +63,8 @@ const TABLES = [
@@ -63,6 +63,8 @@ const TABLES = [
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
+ // The sharing default they chose for new runs (design 2026-11-06): the audience the next one starts as.
+ { table: 'actor_share_defaults', subject: 'subject', value: (usr) => usr, file: 'share-defaults.json' },
// The model they chose for their tasks (plan L55 C3): which provider their own key is for, and the model id
// The model they chose for their tasks (plan L55 C3): which provider their own key is for, and the model id
// they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the
// they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the
// provider. It goes with the person.
// provider. It goes with the person.
@@ -158,7 +160,26 @@ async function exportExtra(db, subject) {
@@ -158,7 +160,26 @@ async function exportExtra(db, subject) {
/** The account-data handle for Actor's store (server/db.js createStore). */
/** The account-data handle for Actor's store (server/db.js createStore). */
function create({ db, note = 'A task row holds the task the person asked for, in their own words.', log = console } = {}) {
function create({ db, note = 'A task row holds the task the person asked for, in their own words.', log = console } = {}) {
- return createAccountData({ db, service: 'actor', tables: TABLES, note, log, extraExport: exportExtra });
+ async function extraErase(t, subjects, { erased, add }) {
+ // The sharing system's own rows (openvibe-sdk/share) name a person as a member or as somebody who asked for
+ // access. Their subject column is jsonb (a subject object), which the table map cannot express, so they are
+ // erased here. The runs' own share rows go with the runs' ids below.
+ const forms = subjects.map((s) => JSON.stringify({ type: 'user', id: s }));
+ const members = await t.exec('DELETE FROM share_members WHERE subject = ANY($1::jsonb[])', [forms]);
+ const requests = await t.exec('DELETE FROM share_access_requests WHERE subject = ANY($1::jsonb[])', [forms]);
+ const owned = await t.exec("DELETE FROM shares WHERE service = 'actor' AND owner_subject = ANY($1::jsonb[])", [forms]);
+ if (Number(members) || Number(requests) || Number(owned)) {
+ add(erased, 'share_rows', Number(members) + Number(requests) + Number(owned));
+ }
+ }
+ return createAccountData({ db, service: 'actor', tables: TABLES, note, log, extraErase, extraExport: exportExtra });
}
}
-module.exports = { create, TABLES, TOPICS, exportFiles, exportSpans, exportCheckpoints, exportConnectionTools, exportExtra };
+// The sharing system's own tables carry a person (a member, an access request). Their subject column is jsonb, so the
+// table map cannot express them: they are erased in extraErase, with the rest of the person's rows.
+const EXEMPT = {
+ share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',
+ share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',
+};
+
+module.exports = { create, TABLES, TOPICS, EXEMPT, exportFiles, exportSpans, exportCheckpoints, exportConnectionTools, exportExtra };
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+
+/**
+ * Sharing an agent's run (design 2026-11-06, visibility step 2), through openvibe-sdk/share.
+ *
+ * A run is Only me until its owner says otherwise: its page is /chat/:id and its Details page
+ * /tasks/:id?view=details. The Share sheet changes just that run; the person's default for new runs lives on /chat
+ * ("New runs: Only me"). The one check, canView, guards the page, its turns, its attached files, the evidence and
+ * the run's look; every write stays the owner's.
+ *
+ * The SDK owns the model and the rules (the shares row, members, requests, transfers, the log and the delivery
+ * queue). This file is the wiring: Network's name lookups and notifications, Events for share.* envelopes, and the
+ * person's default.
+ */
+const { createShares, ShareError } = require('openvibe-sdk/share');
+const { serviceAuth } = require('openvibe-contracts');
+
+const SERVICE = 'actor';
+const KIND = 'run';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const DEFAULT_AUDIENCE = 'private';
+/** A task's requester as sharing says it: only a person's own run can be shared. */
+const PERSON = /^(?:user:)?(usr_[0-9A-HJKMNP-TV-Z]{26})$/;
+
+const personOf = (requester) => {
+ const m = PERSON.exec(String(requester || ''));
+ return m ? m[1] : null;
+};
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Actor's viewer as the SDK wants it: a subject object, and the staff bit. */
+function shareViewer(v) {
+ if (!v || !v.subject) return null;
+ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
+ return null;
+}
+
+function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
+ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
+ tokenUrl: base + '/oauth/token',
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'identity.subject.resolve network.notifications.push',
+ fetch: fetchImpl,
+ }) : null;
+
+ async function networkJson({ path, method = 'GET', json, query } = {}) {
+ if (!tokens) throw new Error('Network is not configured for this service');
+ const url = new URL(base + path);
+ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
+ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
+ if (json !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
+ err.status = res.status;
+ throw err;
+ }
+ return body;
+ }
+
+ /** One notification. It never carries the run's question or answer. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['An agent run was shared with you', 'Open it on OpenVibe.Actor.'],
+ 'share.access_requested': ['Someone asked for access to your run', 'Review the request on OpenVibe.Actor.'],
+ 'share.transfer.requested': ['Someone wants to give you a run', 'Accept or decline it on OpenVibe.Actor.'],
+ 'share.transfer.answered': ['A run transfer was answered', 'See what happened on OpenVibe.Actor.'],
+ }[n.kind] || ['Sharing on OpenVibe.Actor', 'There is something new for you.'];
+ try {
+ await networkJson({
+ path: '/internal/notifications/push', method: 'POST',
+ json: {
+ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
+ service: 'actor', url: n.ref && n.ref.id ? `${config.baseUrl}/chat/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/chat`,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ log.warn('[Sharing] notification not sent:', err.message);
+ }
+ }
+
+ /** OpenVibe.Events. A refusal only loses the event copy; the setting is the owner's, decided by canView. */
+ async function publish(envelope) {
+ if (!config.events || !config.events.url) { log.warn('[Sharing] no events URL configured; share envelope not sent'); return; }
+ const res = await fetchImpl(String(config.events.url).replace(/\/+$/, ''), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(envelope),
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) throw new Error('Events answered ' + res.status);
+ }
+
+ const shares = createShares({
+ db,
+ network: { json: networkJson },
+ events: { publish },
+ notify,
+ });
+
+ const sv = (viewer) => shareViewer(viewer);
+
+ return {
+ shares,
+ SERVICE,
+ KIND,
+ AUDIENCES,
+ DEFAULT_AUDIENCE,
+ refOf,
+ personOf,
+ shareViewer,
+ networkJson,
+ /** Register a run, Only me until its owner says otherwise. A run no person owns is never shared. */
+ register: async (requester, id, audience = DEFAULT_AUDIENCE) => {
+ const subject = personOf(requester);
+ if (!subject) return false;
+ return await shares.register(refOf(id), {
+ owner_subject: { type: 'user', id: subject },
+ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,
+ });
+ },
+ canView: async (viewer, id) => await shares.canView(sv(viewer), refOf(id)),
+ canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), refOf(id)),
+ /** Just the audience, for deciding between "Only me" (no hint) and "Specific people" (the gate). */
+ audienceOf: async (id) => {
+ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]);
+ return row ? row.audience : null;
+ },
+ defaultOf: async (requester) => {
+ const subject = personOf(requester);
+ if (!subject) return DEFAULT_AUDIENCE;
+ const row = await db.maybe('SELECT audience FROM actor_share_defaults WHERE subject = $1', [subject]);
+ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
+ },
+ /** Set the default, and with `applyAll` move the person's existing runs too (rule 2). */
+ setDefault: async (requester, audience, { applyAll = false } = {}) => {
+ const subject = personOf(requester);
+ if (!subject) return false;
+ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;
+ await db.exec(`INSERT INTO actor_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
+ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [subject, want]);
+ if (applyAll) {
+ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1
+ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,
+ [want, SERVICE, KIND, subject]);
+ }
+ return true;
+ },
+ /** The uniform API's describe(ref): the run's question and its page. */
+ describe: async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const row = await db.maybe('SELECT task FROM tasks WHERE id = $1', [String(ref.id)]);
+ if (!row) return {};
+ return {
+ title: String(row.task || 'An agent run').split('\n')[0].slice(0, 90),
+ link: `${config.baseUrl}/chat/${encodeURIComponent(ref.id)}`,
+ roles: ['viewer'],
+ transferable: false,
+ };
+ },
+ /** Expire members and transfers, then send the queued events and notifications. */
+ work: async ({ flush = true } = {}) => {
+ let expired = 0;
+ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
+ let sent = 0;
+ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
+ return { expired, sent };
+ },
+ };
+}
+
+module.exports = { createSharing, shareViewer, refOf, personOf, AUDIENCES, SERVICE, KIND, DEFAULT_AUDIENCE };
@@ -14,6 +14,14 @@ const { checkWebhookUrl } = require('../net/webhook-post');
@@ -14,6 +14,14 @@ const { checkWebhookUrl } = require('../net/webhook-post');
const { mintSecret } = require('./webhooks');
const { mintSecret } = require('./webhooks');
const learn = require('../runtime/brain/learn');
const learn = require('../runtime/brain/learn');
+/**
+ * The sharing system (design 2026-11-06), injected once by server/app.js. A run is its owner's until the Share sheet
+ * says otherwise; a run no person owns is never registered, and a sharing system that is unavailable leaves the run
+ * unreadable to others (every read asks canView, and a missing row answers no).
+ */
+let sharing = null;
+function setSharing(store) { sharing = store || null; }
+
const usd = (n) => (n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
const usd = (n) => (n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
function untilMidnight(nowMs) {
function untilMidnight(nowMs) {
@@ -96,6 +104,14 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -96,6 +104,14 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
});
});
// The engine starts the task only once its row has committed; a webhook that asked for `queued` hears it now.
// The engine starts the task only once its row has committed; a webhook that asked for `queued` hears it now.
if (out.status === 201) {
if (out.status === 201) {
+ if (sharing) {
+ try {
+ const audience = await sharing.defaultOf(out.task.requester).catch(() => 'private');
+ await sharing.register(out.task.requester, out.task.id, audience);
+ } catch (e) {
+ console.warn(`[Actor] task ${out.task.id}: not registered with the sharing system: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ }
// A follow-up that says the answer was wrong teaches the router; a fork is a different run, not a correction.
// A follow-up that says the answer was wrong teaches the router; a fork is a different run, not a correction.
if (parent && forkOfStep == null) await learn.correction({ store: s, parent, text: body.task, now: () => s.now() }).catch(() => {});
if (parent && forkOfStep == null) await learn.correction({ store: s, parent, text: body.task, now: () => s.now() }).catch(() => {});
if (webhooks.length && webhookQueue) await webhookQueue.enqueue(out.task.id, 'queued');
if (webhooks.length && webhookQueue) await webhookQueue.enqueue(out.task.id, 'queued');
@@ -104,4 +120,4 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -104,4 +120,4 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
return out;
return out;
}
}
-module.exports = { createTask, untilMidnight };
+module.exports = { createTask, untilMidnight, setSharing };
@@ -12,16 +12,17 @@ const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
@@ -12,16 +12,17 @@ const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const taskStore = require('../server/tasks/store');
const taskStore = require('../server/tasks/store');
const personal = require('../server/agents/personal');
const personal = require('../server/agents/personal');
-const { TABLES } = require('../server/identity/account-data');
+const { TABLES, EXEMPT } = require('../server/identity/account-data');
const { testDb } = require('./helpers/db');
const { testDb } = require('./helpers/db');
// Tables a person does not own. The brain's outcomes are counts keyed by task kind, tier, step kind and tool: no
// Tables a person does not own. The brain's outcomes are counts keyed by task kind, tier, step kind and tool: no
// person column, no task text, so deletion has nothing to erase there and the export names no one.
// person column, no task text, so deletion has nothing to erase there and the export names no one.
-const EXEMPT = {
+const EXEMPT_OUTSIDE_SHARING = {
account_data_events: 'the ledger of requests itself: which export and deletion Actor applied',
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',
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',
config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
};
};
+const EXEMPT_ALL = { ...EXEMPT_OUTSIDE_SHARING, ...EXEMPT };
const A = 'usr_01JZ0000000000000000000AAA';
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
const B = 'usr_01JZ0000000000000000000BBB';
@@ -110,7 +111,7 @@ async function makeWait(t, subject, taskId, line = 'waiting for your Codes task
@@ -110,7 +111,7 @@ async function makeWait(t, subject, taskId, line = 'waiting for your Codes task
await check('every table that names a person is covered or exempted with a reason', async () => {
await check('every table that names a person is covered or exempted with a reason', async () => {
const d = await testDb();
const d = await testDb();
try {
try {
- const missing = await uncovered(d.db, { tables: TABLES, exempt: EXEMPT });
+ const missing = await uncovered(d.db, { tables: TABLES, exempt: EXEMPT_ALL });
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
});
});
@@ -120,7 +121,7 @@ async function makeWait(t, subject, taskId, line = 'waiting for your Codes task
@@ -120,7 +121,7 @@ async function makeWait(t, subject, taskId, line = 'waiting for your Codes task
try {
try {
const cols = await d.db.many("SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'actor_brain_outcomes'");
const cols = await d.db.many("SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'actor_brain_outcomes'");
assert.deepStrictEqual(cols.map((c) => c.column_name).sort(), ['failures', 'id', 'step_kind', 'successes', 'task_kind', 'tier', 'tool', 'updated_at']);
assert.deepStrictEqual(cols.map((c) => c.column_name).sort(), ['failures', 'id', 'step_kind', 'successes', 'task_kind', 'tier', 'tool', 'updated_at']);
- assert.deepStrictEqual(await uncovered(d.db, { tables: TABLES, exempt: EXEMPT }), [], 'no table with a person column is uncovered');
+ assert.deepStrictEqual(await uncovered(d.db, { tables: TABLES, exempt: EXEMPT_ALL }), [], 'no table with a person column is uncovered');
} finally { await d.close(); }
} finally { await d.close(); }
});
});
@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {
@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {
assert.strictEqual(parts[0].content[0].task_id, id);
assert.strictEqual(parts[0].content[0].task_id, id);
assert.strictEqual(parts[0].content[0].step, 1);
assert.strictEqual(parts[0].content[0].step, 1);
assert.ok(!('state_enc' in parts[0].content[0]), 'the sealed state is not exported');
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', config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data' } }), [], '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', ...accountData.EXEMPT } }), [], 'the guard passes');
await s.db.query('DELETE FROM tasks WHERE id = $1', [id]);
await s.db.query('DELETE FROM tasks WHERE id = $1', [id]);
assert.strictEqual(await cp.count(id), 0, 'erased with the task');
assert.strictEqual(await cp.count(id), 0, 'erased with the task');
} finally { await d.close(); }
} finally { await d.close(); }
@@ -306,6 +306,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -306,6 +306,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
}
}
+ // The sharing system's (openvibe-sdk/share) name lookups and notifications.
+ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
+ const name = String(url.searchParams.get('username') || '').toLowerCase();
+ const u = st.byUsername.get(name);
+ if (!u) return problem(json, 404, 'identity.name_not_found', 'no such person');
+ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
+ }
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const ids = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];
+ const results = {};
+ for (const subjectId of ids) {
+ const u = [...st.users.values()].find((x) => x.subject === subjectId);
+ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };
+ }
+ return json(200, { results });
+ }
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
+
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
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.
// 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', '0028', '0029']);
+ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028', '0029', '0030', '0031']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
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]);
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
@@ -110,6 +110,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -110,6 +110,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
'server/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)',
'server/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)',
'server/notify/client.js': 'the configured OpenVibe.Network internal base (OV_NETWORK_INTERNAL_URL, loopback) with Actor\'s own service tokens: identity resolve and one notification push; the subject is the task\'s owner and the link is the desk page Actor built, never a URL from the task (test/notify-client.test.js)',
'server/notify/client.js': 'the configured OpenVibe.Network internal base (OV_NETWORK_INTERNAL_URL, loopback) with Actor\'s own service tokens: identity resolve and one notification push; the subject is the task\'s owner and the link is the desk page Actor built, never a URL from the task (test/notify-client.test.js)',
+ 'server/sharing.js': 'the configured Network internal base (OV_NETWORK_INTERNAL_URL) for the token endpoint, identity lookups and one notification push, and the configured Events URL (ACTOR_EVENTS_URL); share refs are the sharing system\'s own kind/id strings, never a URL from a person (test/sharing.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/waits/events.js': 'the configured OpenVibe.Events base (ACTOR_EVENTS_URL) with the service token (events.subscription.manage, events.event.read): the topic patterns and the cursors come from the wait rows, never from a task (test/waits.test.js)', 'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/waits/events.js': 'the configured OpenVibe.Events base (ACTOR_EVENTS_URL) with the service token (events.subscription.manage, events.event.read): the topic patterns and the cursors come from the wait rows, never from a task (test/waits.test.js)', 'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the run's
+ * page carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in
+ * person who is not on the list is offered Ask for access — with no console error and no CSP refusal.
+ *
+ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a
+ * temporary database, so the CSP headers the browser sees are Actor's.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const { boot, check, done } = require('./helpers/boot');
+
+let playwright = null;
+try { playwright = require('playwright'); } catch {
+ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
+}
+const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
+if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
+ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const cy = t.network.addUser('cy');
+ t.config.loop.selfReflections = 0;
+ const cookieOf = (user) => {
+ const [name, ...rest] = t.signIn(user).split('=');
+ return { name, value: rest.join('='), url: t.base };
+ };
+
+ let browser;
+ const errors = [];
+ const watch = (page) => {
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', (e) => errors.push(String(e)));
+ };
+ const settled = async (page) => {
+ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
+ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
+ };
+
+ try {
+ let taskId;
+ await check('a run shared with Specific people, with nobody on the list yet', async () => {
+ t.providers.state.script = [{ content: 'Hetzner CX22 at €4.51 a month.' }];
+ const made = await t.get('/chat', { as: kim, form: { task: 'What is the cheapest Hetzner VPS?' }, headers: SAME });
+ assert.strictEqual(made.status, 303, made.text);
+ taskId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(made.headers.get('location'))[1];
+ await t.waitFor(taskId, ['succeeded']);
+ await t.ctx.sharing.shares.setAudience(
+ { subject: { type: 'user', id: kim.subject } },
+ { service: 'actor', kind: 'run', id: taskId }, 'people',
+ );
+ });
+
+ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
+ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ watch(page);
+ const res = await page.goto(`${t.base}/chat/${taskId}`, { waitUntil: 'load', timeout: 15000 });
+ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the run');
+ const text = await settled(page);
+ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
+ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
+ assert.match(href, /^\/auth\/login\?next=/);
+ await ctx.close();
+ });
+
+ await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {
+ const ctx = await browser.newContext();
+ await ctx.addCookies([cookieOf(cy)]);
+ const page = await ctx.newPage();
+ watch(page);
+ await page.goto(`${t.base}/chat/${taskId}`, { waitUntil: 'load', timeout: 15000 });
+ const text = await settled(page);
+ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
+ assert.ok(/@cy/.test(text), 'the gate names the account it is signed in as');
+ await ctx.close();
+ });
+
+ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {
+ const refused = errors.filter((e) => REFUSED.test(e));
+ assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);
+ });
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ await t.close();
+ done();
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Sharing an agent's run (design 2026-11-06, visibility step 2): the one Share sheet (openvibe-sdk/share) and the
+ * one check, canView, on the run's page, its turns, its details and its files.
+ *
+ * - a new run is Only me; the Share button is its owner's;
+ * - Anyone with the link reads the conversation and its Details page, with no composer and no owner moves;
+ * - Specific people shows the gate, never the question or an answer, to somebody not on the list;
+ * - the person's default for new runs, and "apply to all", work.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+ t.config.loop.selfReflections = 0;
+ const s = t.ctx.s;
+
+ const say = async (text, { as = kim, answer = 'Hetzner CX22 at €4.51 a month [E1].' } = {}) => {
+ t.providers.state.script = [{ content: answer }];
+ return await t.get('/chat', { as, form: { task: text }, headers: SAME });
+ };
+ const call = (pathname, o = {}) => {
+ const { headers, ...rest } = o;
+ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
+ };
+ const share = (id, o = {}) => call(`/api/v1/run/${encodeURIComponent(id)}/share`, o);
+ const setShare = async (id, body, as) => {
+ const first = await share(id, { as });
+ assert.strictEqual(first.status, 200, first.text);
+ const etag = first.headers.get('etag');
+ assert.ok(etag);
+ return await share(id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
+ };
+ const audienceRow = async (id) => await s.db.maybe(
+ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['actor', 'run', id]);
+
+ try {
+ let taskId;
+ await check('a new run is Only me, and its page carries the Share button for its owner', async () => {
+ const made = await say('What is the cheapest Hetzner VPS?');
+ assert.strictEqual(made.status, 303, made.text);
+ taskId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(made.headers.get('location'))[1];
+ await t.waitFor(taskId, ['succeeded']);
+ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'private' });
+ const mine = await t.get(`/chat/${taskId}`, { as: kim });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="run"'));
+ assert.ok(mine.text.includes('ov-share.js'));
+ const other = await t.get(`/chat/${taskId}`, { as: bo });
+ assert.strictEqual(other.status, 404);
+ assert.ok(!other.text.includes('Hetzner CX22'), 'never the answer');
+ });
+
+ await check('Anyone with the link reads the conversation and Details, with no composer and no owner moves', async () => {
+ const saved = await setShare(taskId, { audience: 'link' }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'link');
+ const guest = await t.get(`/chat/${taskId}`);
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.ok(guest.text.includes('What is the cheapest Hetzner VPS?'));
+ assert.ok(guest.text.includes('Hetzner CX22'));
+ assert.ok(guest.text.includes('This run was shared with you'));
+ assert.ok(!guest.text.includes('ovt-composer'), 'a reader cannot carry the run on');
+ assert.ok(!guest.text.includes('name="task"'), 'no send box');
+ const turn = await t.get(`/chat/turn/${taskId}`, { as: bo });
+ assert.strictEqual(turn.status, 200, turn.text);
+ const details = await t.get(`/tasks/${taskId}?view=details`);
+ assert.strictEqual(details.status, 200, details.text);
+ assert.ok(details.text.includes('What is the cheapest Hetzner VPS?'));
+ assert.ok(details.text.includes('This run was shared with you'));
+ assert.ok(!details.text.includes('action="/tasks/' + taskId + '/answer"'), 'no answer form for a reader');
+ });
+
+ await check('Specific people: on the list reads it, a stranger gets the gate and no question', async () => {
+ const saved = await setShare(taskId, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, kim);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'people');
+ const member = await t.get(`/chat/${taskId}`, { as: bo });
+ assert.strictEqual(member.status, 200, member.text);
+ assert.ok(member.text.includes('Hetzner CX22'));
+ const stranger = await t.get(`/chat/${taskId}`, { as: cy });
+ assert.strictEqual(stranger.status, 404);
+ assert.ok(stranger.text.includes('shared with specific people'));
+ assert.ok(!stranger.text.includes('Hetzner CX22'), 'the gate never reveals an answer');
+ const details = await t.get(`/tasks/${taskId}?view=details`, { as: cy });
+ assert.strictEqual(details.status, 404);
+ });
+
+ await check('the default applies to the next run, and "apply to all" moves the existing one', async () => {
+ const saved = await t.get('/chat/defaults', { as: kim, form: { audience: 'public', apply_all: '1' }, headers: SAME });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'public' });
+ const guest = await t.get(`/chat/${taskId}`);
+ assert.strictEqual(guest.status, 200);
+ const next = await say('And the second cheapest?');
+ const nextId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(next.headers.get('location'))[1];
+ assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });
+ const start = await t.get('/chat', { as: kim });
+ assert.ok(start.text.includes('New runs'), 'the default is on the product page');
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})();