openvibers/openvibe.actor Commits

Agent runs: the one Share sheet, Only me by default (#300)

OP OpenVibers committed 6 days ago · 2dc7e7a229 · 19 files, +755 −73

* Agent runs: the one Share sheet, Only me by default

An agent's run carries openvibe-sdk/share's share row (kind run, service actor) and the one check,
canView, on its page (/chat/:id), its turns, its files and screenshots, and its Details page
(/tasks/:id). A new run is Only me; /chat offers the person's default for new runs with "Apply to
all my existing runs".

- server/sharing.js: Network name lookups and notifications, share.* through Events, the uniform
  router at /api/v1/run/:id/share, plus shared-with-me and people; a 15 s delivery worker.
- A shared conversation renders read-only: no sidebar, no composer, and a question, a retry or a
  wait shows its words without its buttons. A reader sees "this run was shared with you" and Send
  it only; the owner gets the audience part. Specific people who are not on the list get the
  sheet's gate, never the question or an answer.
- migrations 0030 (the SDK's own share tables, verbatim) and 0031 (defaults plus a backfill of
  existing runs, Only me), both expand-only. Account export covers the default; deletion erases the
  sharing rows, including a person's own share rows.

* The share gate from markup on openvibe-shared 4.123.0

The "not on the list" gate was an inline script. An inline script needs a CSP nonce on most of the
network's sites, and a value written into one with an HTML-escaping template breaks it. 4.123.0
draws the gate from [data-ov-share-gate]: the page sends markup, ov-share.js starts it when it
loads, and every value is escaped like any other attribute.

- The run gate on the conversation page and on the Details page renders
  data-kind/data-id/data-signed-in-as/data-sign-in-url on the element and keeps only the
  <script src=…ov-share.js> tag.
- openvibe-shared v4.118.0 → v4.123.0 in package.json, package-lock.json, README and STATUS.
- test/share-gate-chrome.test.js boots the app and drives headless Chrome: signed out the gate
  offers Sign in with the login link; signed in as somebody not on the list it offers Ask for
  access and names the account; no console error and no CSP refusal.

* Merge remote-tracking branch 'origin/main' into feat/visibility-step2-actor

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* The Chrome gate check skips without a browser

The headless-Chrome gate check now skips (exit 0) when Playwright or Chrome is absent, as Shared's own browser tests do: the CI runner has neither. It still runs on this machine.

* The SSRF ratchet reviews the sharing client, and the migration list reaches 0031

server/sharing.js makes the outbound calls a service must (Network's token endpoint, name lookups and one notification push, and Events), all on configured bases; it is added to the reviewed list with where it goes. The memory-migration test's pinned list of migrations that run after 0016 now includes the two sharing migrations (0030, 0031).

Changes

ddiff --git a/README.md b/README.md
iindex 6859d6a..497ecf4 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 4660e9f..479dac3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/migrations/0030_shares.sql b/migrations/0030_shares.sql
nnew file mode 100644
iindex 0000000..a0613f5
---- /dev/null
++++ b/migrations/0030_shares.sql
@@@ -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;
ddiff --git a/migrations/0031_share_defaults.sql b/migrations/0031_share_defaults.sql
nnew file mode 100644
iindex 0000000..d6bde5c
---- /dev/null
++++ b/migrations/0031_share_defaults.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 260a9f1..b36fe94 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 2f3597a..c6398d1 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex c4377b9..506b3f8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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));
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 6aaaba7..4fdb5ec 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -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 on
r.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 on
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) => {
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 });
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1d50f53..7c6b9df 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -387,7 +387,7 @@ ${[['a', cmp.a, rowA], ['b', cmp.b, rowB]].map(([side, run, row]) => html`<secti
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.
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(exist
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 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>` : ''}
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 1db3d20..db57bd1 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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 };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..c200a03
---- /dev/null
++++ b/server/sharing.js
@@@ -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 };
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
iindex 08006eb..d011ca1 100644
---- a/server/tasks/create.js
++++ b/server/tasks/create.js
@@@ -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: webhoo
return out;
}
--module.exports = { createTask, untilMidnight };
++module.exports = { createTask, untilMidnight, setSharing };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 923544c..5a381cf 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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 task
await 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 task
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'");
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(); }
});
ddiff --git a/test/durable-runs.test.js b/test/durable-runs.test.js
iindex 1e4f81e..5a288f6 100644
---- a/test/durable-runs.test.js
++++ b/test/durable-runs.test.js
@@@ -134,7 +134,7 @@ function minimalState(task, calls = 0) {
assert.strictEqual(parts[0].content[0].task_id, id);
assert.strictEqual(parts[0].content[0].step, 1);
assert.ok(!('state_enc' in parts[0].content[0]), 'the sealed state is not exported');
-- assert.deepStrictEqual(await uncovered(d.db, { tables: accountData.TABLES, exempt: { account_data_events: 'the ledger of requests itself', actor_brain_outcomes: 'counts only', 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(); }
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 91f22ea..6065090 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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);
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;
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex f2b69eb..a4f3212 100644
---- a/test/memory-migration.test.js
++++ b/test/memory-migration.test.js
@@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '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]);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 764db91..4c4e570 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..9df81de
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -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();
++ }
++})();
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..de6205e
---- /dev/null
++++ b/test/sharing.test.js
@@@ -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();
++ }
++})();