openvibers/openvibe.codes Commits
Run tasks: the one Share sheet, Only me by default (#291)
Changes
@@@ -178,5 +178,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.codes","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.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.","works": [nnew file mode 100644@@@ -0,0 +1,72 @@++-- phase: expand++-- The one sharing system (openvibe-sdk/share) on a run task: 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 task's own rows are run_tasks; there is one share row per task, kind 'task'.++++++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 run tasks, and one share row for++-- every task that already exists, Only me. New tasks are registered as they are made (server/sharing.js); an old++-- task 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 codes_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 'codes', 'task', id, jsonb_build_object('type', 'user', 'id', subject), 'private'++FROM run_tasks++WHERE subject IS NOT NULL++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"},"bin": {@@@ -1193,9 +1193,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",@@@ -32,7 +32,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": {@@@ -48,6 +48,8 @@ const TABLES = [// Know K5: the lessons and facts the person's runs taught. Theirs to take and to erase; a row already shared// with a team leaves with its owner's account (the team's other rows stay).{ table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },++ // The person's default audience for new run tasks (design 2026-11-06): what their next task starts as.++ { table: 'codes_share_defaults', subject: 'subject', file: 'share-defaults.json', order: 'updated_at' },];// The rows that hang under a person's tasks: exported under the task, erased before it.@@@ -99,8 +101,25 @@ function create({ db, log = console }) {}delete retained.run_tasks;add(erased, 'run_tasks', await t.exec('DELETE FROM run_tasks WHERE subject = ANY($1::text[])', [subjects]));++ // The sharing system's own rows (openvibe-sdk/share): a member or an access request is this person, and++ // the share rows they own carry their subject too. The subject column is jsonb, so the table map cannot++ // express it and they are erased here.++ 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 = 'codes' AND owner_subject = ANY($1::jsonb[])", [forms]);++ if (Number(members) || Number(requests) || Number(owned)) {++ add(erased, 'share_rows', Number(members) + Number(requests) + Number(owned));++ }},});}--module.exports = { create, TABLES, TASK_KEYED, TOPICS };++// 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, TASK_KEYED, TOPICS, EXEMPT };@@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createHarnesses } = require('./domain/harnesses');const { createPageRoutes } = require('./http/pages');++const { createSharing } = require('./sharing');const { createRunRoutes } = require('./http/run');const { createTasksApi } = require('./http/tasks-api');const { createConnectionsApi } = require('./http/connections-api');@@@ -115,8 +116,10 @@ async function createApp(opts = {}) {const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });// Know K5: the lessons and facts a person's runs teach, private by default and shareable with a team.const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });-- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge };++ // The one sharing system (design 2026-11-06): a run task is its owner's until the Share sheet says otherwise.++ const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });++ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing };ctx.forgejo = createForgejoClient({ config, log });// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });@@@ -254,8 +257,28 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────// Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).++ // ── /api/v1/task/: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: 'codes',++ 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;++ }app.use('/api/v1', createApi(ctx));-- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));++ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));@@@ -295,7 +318,7 @@ async function createApp(opts = {}) {c4Routes = require('./http/c4').createC4Routes(ctx);app.use(c4Routes);app.use(pageRoutes);-- app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, fetchImpl: fetchImpl || globalThis.fetch, log }));++ app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, sharing, fetchImpl: fetchImpl || globalThis.fetch, log }));app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));// eslint-disable-next-line no-unused-vars@@@ -147,7 +147,7 @@ function usageOf(result) {};}--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null }) {++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null }) {const db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).@@@ -582,6 +582,17 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouawait taskOutbox.emitTask(inserted, null);return inserted;});++ // Sharing (design 2026-11-06): the task is its owner's. The person's default audience for new tasks, then the++ // Share sheet changes just this one. A sharing system that is unavailable leaves the task unreadable to++ // others: every read asks canView, and a missing row answers no.++ if (sharing) {++ try {++ const audience = await sharing.defaultOf(subject).catch(() => 'private');++ await sharing.register({ subject }, inserted.id, audience);++ } catch (e) {++ log.warn(`[Codes] task ${id}: not registered with the sharing system: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ }// The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.// The budget of the attempt: the task's limits (a benchmark's per-task limits) or the person's default.const budget = budgetFor(runArgs.limits);@@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge// their own agents read and add (Know K5).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);@@@ -37,7 +37,7 @@ function repoName(url) {return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;}--function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null }) {++function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null }) {const r = asyncRouter();const db = store.db;const form = express.urlencoded({ extended: false, limit: '32kb' });@@@ -65,11 +65,50 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globaconst row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);return row && row.subject === req.viewer.subject ? row : null;};++ /** A task by its id, owner not applied: the sharing check decides who may read it. */++ const anyTask = async (id) => (/^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))++ ? await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]) : null);++ /** May this reader open the task? Only me is its owner; the other audiences are the sharing system's. */++ const mayView = async (req, id) => {++ if (!sharing) return false;++ try { return await sharing.canView(req.viewer, String(id)); } catch { return false; }++ };++ const shareHead = `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('ov-share.css')}"><script src="${require('openvibe-shared/serve').url('ov-share.js')}" defer></script>`;++ const shareButton = (row) => html`<button type="button" class="run-btn" data-ov-share-sheet data-kind="task" data-id="${row.id}" data-title="${String(row.prompt || 'A task').split('\n')[0].slice(0, 90)}">Share</button>`;++ /** Specific people, and this reader is not on the list: the audience, never the task's text. */++ const gatePage = (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', index: false, head: shareHead,++ // 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`<h1>This task is shared with specific people</h1>++<div id="share-gate" data-ov-share-gate data-kind="task" data-id="${String(id)}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`,++ }, 404);++ };// A task reads Run only as the 10-minute fallback; its state now arrives from run.job.* events.const taskStore = tasks || createRunTasks({ store, run, config, log });const sessionStore = sessions || createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });const { who, refresh } = taskStore;++ const AUDIENCE_TEXT = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };++ /**++ * "New tasks: Only me", with "Apply to all my existing tasks" (design rule 1 and 2). The Share sheet on a task++ * changes just that one; this is only what the next one starts as.++ */++ async function defaultsHtml(subject) {++ const current = await sharing.defaultOf(subject).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="run-card run-share-defaults" method="post" action="/run/defaults">++<label class="run-field"><span>New tasks</span><select name="audience">${options}</select></label>++<label class="run-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing tasks</label>++<button class="run-btn" type="submit">Save</button>++<p class="muted">Only me unless you choose otherwise. A task's Share button changes just that task.</p>++</form>`;++ }++function formHtml({ projects, values = {}, error = null }) {const agents = harnesses.list();const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;@@@ -108,8 +147,10 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`projectsOf(req, res),db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),]);++ const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';const body = html`<h1>Run on your machine</h1><p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.bot/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>++${defaults}${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>@@@ -124,6 +165,17 @@ ${tasksHtml(recent)}`;r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));++ r.post('/run/defaults', form, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!sharing) return res.redirect(303, '/run');++ const body = req.body || {};++ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';++ await sharing.setDefault(req.viewer.subject, audience, { applyAll: String(body.apply_all || '') === '1' })++ .catch((e) => log.warn('[Codes] the task default was not saved:', e.message));++ return res.redirect(303, '/run');++ });++r.post('/run/project', form, async (req, res) => {if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');@@@ -235,25 +287,39 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<</section>`;}++ /**++ * A task is its owner's. Someone it was shared with reads the same page without Cancel; anybody else gets the++ * gate for Specific people, or "not found" when it is Only me (the task is never named to them).++ */r.get('/run/tasks/:id', async (req, res) => {-- if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);-- let row = await own(req, req.params.id);-- if (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);-- row = await refresh(row);++ const base = await anyTask(req.params.id);++ if (!base) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task. <a href="/run">Your tasks</a></p>` }, 404);++ const mine = signedIn(req) && base.subject === req.viewer.subject;++ if (!mine) {++ const allowed = await mayView(req, base.id);++ if (!allowed) {++ const audience = sharing ? await sharing.audienceOf(base.id).catch(() => null) : null;++ if (audience === 'people') return gatePage(req, res, base.id);++ return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such task. <a href="/run">Run on your machine</a></p>` }, 404);++ }++ }++ let row = base;++ if (mine) row = await refresh(row);const live = !TERMINAL.has(row.state);// The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and// after the end from codes_session_lines; a task from before this change falls back to the result's old log.const lines = live ? null : await sessionStore.linesForRow(row);const title = String(row.prompt || '').split('\n')[0].slice(0, 90);const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';-- const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p>++ const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run on your machine</a></p><h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1><p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>++${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}<pre tabindex="0" class="run-prompt">${row.prompt}</pre>${await timelineHtml(row)}${await proofHtml(row)}${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>--<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;++${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });});@@@ -283,8 +349,8 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events}r.get('/run/tasks/:id/events', async (req, res) => {-- if (!signedIn(req)) return res.status(401).end();-- const row = await own(req, req.params.id);++ const row = signedIn(req) ? (await own(req, req.params.id) || ((await mayView(req, req.params.id)) ? await anyTask(req.params.id) : null))++ : ((await mayView(req, req.params.id)) ? await anyTask(req.params.id) : null);if (!row || !row.job_id) return res.status(404).end();let url;try { url = await run.streamUrl(row.job_id, who(row)); } catch (e) { return res.status(e.status === 404 ? 404 : 503).end(); }@@@ -324,10 +390,13 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/eventslet row = null;if (signedIn(req)) {row = await own(req, req.params.id);++ // Somebody the task was shared with reads the same replay: canView is the grant.++ if (!row && await mayView(req, req.params.id)) row = await anyTask(req.params.id);} else {const out = await serviceOwn(req, req.params.id);-- if (!out.row) return http.sendProblem(res, out.status, out.code, { detail: out.detail, ctx: req.ov });-- row = out.row;++ if (out.row) row = out.row;++ else if (await mayView(req, req.params.id)) row = await anyTask(req.params.id);++ else return http.sendProblem(res, out.status, out.code, { detail: out.detail, ctx: req.ov });}if (!row) return http.sendProblem(res, 404, 'codes.task_not_found', { detail: 'no such task of yours', ctx: req.ov });const raw = String(req.query.after == null ? '0' : req.query.after);@@@ -39,7 +39,7 @@ const { expandMatrix } = require('../domain/bench');const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;--function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, log = console }) {++function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, log = console }) {const r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });@@@ -78,6 +78,20 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task};const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via IS NOT DISTINCT FROM $2 AND idempotency_key = $3', [p.subject, p.via, key]);++ /**++ * A read of a task: the caller's own (as before), or one shared with them (design 2026-11-06, canView). A service++ * token stays scoped to the tasks it started for the person; sharing is between people.++ */++ const viewable = async (req) => {++ const own = await mine(req);++ if (own) return own;++ if (!sharing || !req.principal.owner || !TASK.test(String(req.params.id))) return null;++ const any = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [req.params.id]);++ if (!any) return null;++ const allowed = await sharing.canView({ subject: req.principal.subject }, any.id).catch(() => false);++ return allowed ? any : null;++ };++/** The owner (signed in, or their own access token as a Bearer), or a service token with the capability acting* for the person in X-OV-Subject. */const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;@@@ -306,14 +320,14 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task});r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {-- const row = await mine(req);++ const row = await viewable(req);if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');return res.json(await timeline(await taskStore.refresh(row)));});/** The task's event stream: the session's lines in order, resumable with Last-Event-ID, plus the attempts. */r.get('/tasks/:id/events', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {-- const row = await mine(req);++ const row = await viewable(req);if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');return await taskEvents.stream(req, res, row);});@@@ -143,7 +143,8 @@ ${(o.css || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="codes@${esc(RELEASE)}">--<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>++${o.head || ''}`,body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div><noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++++/**++ * Sharing on OpenVibe.Codes (design 2026-11-06, visibility step 2), through openvibe-sdk/share.++ *++ * A run task is Only me until its owner says otherwise. The Share sheet changes just that task; the person's default++ * for new tasks lives on /run ("New tasks: Only me"). Nothing else moves: the task's row, its attempts and its++ * session are read through the one check, canView, on the page, the live stream, session.json and the task API.++ *++ * Why a task and not a project: Codes holds tasks, not projects. A run belongs to a Network project (prj_…), and the++ * /projects page is a permanent redirect to OpenVibe.Services; a shared project is Network's to model. A task's page++ * is the run page, so sharing the task shares what a teammate can help with.++ *++ * 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 = 'codes';++const KIND = 'task';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const DEFAULT_AUDIENCE = 'private';++++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Codes' 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 task's text. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A task was shared with you', 'Open it on OpenVibe.Codes.'],++ 'share.access_requested': ['Someone asked for access to your task', 'Review the request on OpenVibe.Codes.'],++ 'share.transfer.requested': ['Someone wants to give you a task', 'Accept or decline it on OpenVibe.Codes.'],++ 'share.transfer.answered': ['A task transfer was answered', 'See what happened on OpenVibe.Codes.'],++ }[n.kind] || ['Sharing on OpenVibe.Codes', '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: 'codes', url: n.ref && n.ref.id ? `${config.baseUrl}/run/tasks/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/run`,++ category: 'social',++ },++ });++ } catch (err) {++ log.warn('[Sharing] notification not sent:', err.message);++ }++ }++++ /**++ * OpenVibe.Events. Codes' own outbox carries codes.task.* envelopes; a share envelope is written straight out,++ * and a refusal only loses the notification's 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,++ shareViewer,++ networkJson,++ /** Register a task, Only me until its owner says otherwise. */++ register: async (viewer, id, audience = DEFAULT_AUDIENCE) => await shares.register(refOf(id), {++ owner_subject: { type: 'user', id: String(viewer.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 (subject) => {++ if (!subject) return DEFAULT_AUDIENCE;++ const row = await db.maybe('SELECT audience FROM codes_share_defaults WHERE subject = $1', [String(subject)]);++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;++ },++ /** Set the default, and with `applyAll` move the person's existing tasks too (rule 2). */++ setDefault: async (subject, audience, { applyAll = false } = {}) => {++ if (!subject) return false;++ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;++ await db.exec(`INSERT INTO codes_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())++ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(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, String(subject)]);++ }++ return true;++ },++ /** The uniform API's describe(ref): the task's first line and its page. */++ describe: async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const row = await db.maybe('SELECT prompt FROM run_tasks WHERE id = $1', [String(ref.id)]);++ if (!row) return {};++ return {++ title: String(row.prompt || 'A task').split('\n')[0].slice(0, 90),++ link: `${config.baseUrl}/run/tasks/${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, AUDIENCES, SERVICE, KIND, DEFAULT_AUDIENCE };@@@ -10,7 +10,7 @@ const http = require('http');const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');const { signDeliveryHeaders } = require('openvibe-sdk/events');const { ids } = require('openvibe-contracts');--const { TABLES, TASK_KEYED } = require('../server/account-data');++const { TABLES, TASK_KEYED, EXEMPT } = require('../server/account-data');const { boot, check, done } = require('./helpers/boot');const SECRET = `whsec_${'fixture'.repeat(6)}`;@@@ -77,6 +77,7 @@ async function startNetworkStub() {account_data_events: 'the ledger of requests itself: which export and deletion Codes applied',event_outbox: 'no person column (the SDK outbox keeps envelopes); dropped with the console in 0002 anyway',config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',++ ...EXEMPT,},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);@@@ -374,6 +374,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;@@@ -58,6 +58,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person','server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person','server/domain/knowledge.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, to read the teams a knowledge row may be shared with; the path is fixed and no URL from a person is fetched',++ '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 (CODES_EVENTS_URL); share refs are the sharing system\'s own kind/id strings, never a URL from a person (test/sharing.test.js)','server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path','server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person','server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the task++ * 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 Codes'.++ */++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;++++(async () => {++ const run = {++ enabled: true,++ async submit() { return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },++ async read(jobId) { return { id: jobId, state: 'queued', result: null, error: null }; },++ async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },++ async streamUrl() { return 'http://127.0.0.1:1/stream'; },++ };++ const t = await boot({ run });++ const ada = t.network.addUser('ada');++ const cy = t.network.addUser('cy');++ const post = (p, as, form) => t.get(p, { as, form, method: 'POST' });++ 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 task shared with Specific people, with nobody on the list yet', async () => {++ const made = await post('/run/project', ada, { name: 'My code', prompt: 'Fix the typo', repo: 'https://github.com/OpenVibers/OpenVibe.Codes' });++ assert.strictEqual(made.status, 303, made.text);++ const page = await t.get(made.headers.get('location'), { as: ada });++ const project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];++ const started = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Fix the typo in README', project });++ assert.strictEqual(started.status, 303, started.text);++ taskId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(started.headers.get('location'))[1];++ await t.ctx.sharing.shares.setAudience(++ { subject: { type: 'user', id: ada.subject } },++ { service: 'codes', kind: 'task', 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}/run/tasks/${taskId}`, { waitUntil: 'load', timeout: 15000 });++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the task');++ 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}/run/tasks/${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,116 @@++'use strict';++/**++ * Sharing a run task (design 2026-11-06, visibility step 2): the one Share sheet (openvibe-sdk/share) and the one++ * check, canView, on the task page, its live stream and session.json.++ *++ * - a new task is Only me; the Share button is the owner's;++ * - Anyone with the link reads the page and the session, without Cancel;++ * - Specific people shows the gate, never the task's text, to somebody not on the list;++ * - the person's default for new tasks, and "apply to all", work.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const run = {++ enabled: true,++ async submit() { return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },++ async read(jobId) { return { id: jobId, state: 'succeeded', result: { ok: true, summary: 'The typo is fixed.', files: [], agents: ['claude-code'] }, error: null }; },++ async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },++ async streamUrl() { return 'http://127.0.0.1:1/stream'; },++ };++ const t = await boot({ run });++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const cy = t.network.addUser('cy');++ const post = (p, as, form, headers = {}) => t.get(p, { as, form, headers, method: 'POST' });++++ 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/task/${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 t.ctx.store.db.maybe(++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['codes', 'task', id]);++++ try {++ let project;++ await check('a project first, so a task can be started', async () => {++ const made = await post('/run/project', ada, { name: 'My code', prompt: 'Fix the typo', repo: 'https://github.com/OpenVibers/OpenVibe.Codes' });++ assert.strictEqual(made.status, 303, made.text);++ const page = await t.get(made.headers.get('location'), { as: ada });++ project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];++ assert.ok(project);++ });++++ let taskId;++ await check('a new task is Only me, and its page carries the Share button for its owner', async () => {++ const started = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Fix the typo in README', project });++ assert.strictEqual(started.status, 303, started.text);++ taskId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(started.headers.get('location'))[1];++ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'private' });++ const mine = await t.get(`/run/tasks/${taskId}`, { as: ada });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="task"'));++ assert.ok(mine.text.includes('ov-share.js'));++ assert.ok(mine.text.includes('Cancel'));++ const other = await t.get(`/run/tasks/${taskId}`, { as: bo });++ assert.strictEqual(other.status, 404);++ assert.ok(!other.text.includes('Fix the typo in README'), 'the task is never named to somebody else');++ const guest = await t.get(`/run/tasks/${taskId}`);++ assert.strictEqual(guest.status, 404);++ });++++ await check('Anyone with the link reads the page and the session, and cannot Cancel', async () => {++ const saved = await setShare(taskId, { audience: 'link' }, ada);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'link');++ const guest = await t.get(`/run/tasks/${taskId}`);++ assert.strictEqual(guest.status, 200, guest.text);++ assert.ok(guest.text.includes('Fix the typo in README'));++ assert.ok(guest.text.includes('This task was shared with you'));++ assert.ok(!guest.text.includes('action="/run/tasks/' + taskId + '/cancel"'), 'no Cancel for a reader');++ const replay = await t.get(`/run/tasks/${taskId}/session.json`, { as: bo });++ assert.strictEqual(replay.status, 200, replay.text);++ assert.ok(Array.isArray(replay.json().lines));++ });++++ await check('Specific people: on the list reads it, a stranger gets the gate and no text', async () => {++ const saved = await setShare(taskId, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, ada);++ assert.strictEqual(saved.status, 200, saved.text);++ assert.strictEqual(saved.json().audience, 'people');++ const member = await t.get(`/run/tasks/${taskId}`, { as: bo });++ assert.strictEqual(member.status, 200, member.text);++ assert.ok(member.text.includes('Fix the typo in README'));++ const stranger = await t.get(`/run/tasks/${taskId}`, { as: cy });++ assert.strictEqual(stranger.status, 404);++ assert.ok(stranger.text.includes('shared with specific people'));++ assert.ok(!stranger.text.includes('Fix the typo in README'));++ const replay = await t.get(`/run/tasks/${taskId}/session.json`, { as: cy });++ assert.strictEqual(replay.status, 404);++ });++++ await check('the default applies to the next task, and "apply to all" moves the existing one', async () => {++ const saved = await post('/run/defaults', ada, { audience: 'public', apply_all: '1' });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'public' });++ const guest = await t.get(`/run/tasks/${taskId}`);++ assert.strictEqual(guest.status, 200);++ const next = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Add a test', project });++ const nextId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(next.headers.get('location'))[1];++ assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });++ const start = await t.get('/run', { as: ada });++ assert.ok(start.text.includes('New tasks'), 'the default is on the product page');++ });++ } finally {++ await t.close();++ done();++ }++})();
@@ -178,5 +178,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -178,5 +178,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.codes",
"domain": "openvibe.codes",
"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.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"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 a run task: 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 task's own rows are run_tasks; there is one share row per task, kind 'task'.
+
+
+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 run tasks, and one share row for
+-- every task that already exists, Only me. New tasks are registered as they are made (server/sharing.js); an old
+-- task 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 codes_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 'codes', 'task', id, jsonb_build_object('type', 'user', 'id', subject), 'private'
+FROM run_tasks
+WHERE subject IS NOT NULL
+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"
},
},
"bin": {
"bin": {
@@ -1193,9 +1193,9 @@
@@ -1193,9 +1193,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",
@@ -32,7 +32,7 @@
@@ -32,7 +32,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": {
@@ -48,6 +48,8 @@ const TABLES = [
@@ -48,6 +48,8 @@ const TABLES = [
// Know K5: the lessons and facts the person's runs taught. Theirs to take and to erase; a row already shared
// Know K5: the lessons and facts the person's runs taught. Theirs to take and to erase; a row already shared
// with a team leaves with its owner's account (the team's other rows stay).
// with a team leaves with its owner's account (the team's other rows stay).
{ table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },
{ table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },
+ // The person's default audience for new run tasks (design 2026-11-06): what their next task starts as.
+ { table: 'codes_share_defaults', subject: 'subject', file: 'share-defaults.json', order: 'updated_at' },
];
];
// The rows that hang under a person's tasks: exported under the task, erased before it.
// The rows that hang under a person's tasks: exported under the task, erased before it.
@@ -99,8 +101,25 @@ function create({ db, log = console }) {
@@ -99,8 +101,25 @@ function create({ db, log = console }) {
}
}
delete retained.run_tasks;
delete retained.run_tasks;
add(erased, 'run_tasks', await t.exec('DELETE FROM run_tasks WHERE subject = ANY($1::text[])', [subjects]));
add(erased, 'run_tasks', await t.exec('DELETE FROM run_tasks WHERE subject = ANY($1::text[])', [subjects]));
+ // The sharing system's own rows (openvibe-sdk/share): a member or an access request is this person, and
+ // the share rows they own carry their subject too. The subject column is jsonb, so the table map cannot
+ // express it and they are erased here.
+ 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 = 'codes' AND owner_subject = ANY($1::jsonb[])", [forms]);
+ if (Number(members) || Number(requests) || Number(owned)) {
+ add(erased, 'share_rows', Number(members) + Number(requests) + Number(owned));
+ }
},
},
});
});
}
}
-module.exports = { create, TABLES, TASK_KEYED, TOPICS };
+// 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, TASK_KEYED, TOPICS, EXEMPT };
@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -27,6 +27,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createHarnesses } = require('./domain/harnesses');
const { createHarnesses } = require('./domain/harnesses');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createSharing } = require('./sharing');
const { createRunRoutes } = require('./http/run');
const { createRunRoutes } = require('./http/run');
const { createTasksApi } = require('./http/tasks-api');
const { createTasksApi } = require('./http/tasks-api');
const { createConnectionsApi } = require('./http/connections-api');
const { createConnectionsApi } = require('./http/connections-api');
@@ -115,8 +116,10 @@ async function createApp(opts = {}) {
@@ -115,8 +116,10 @@ async function createApp(opts = {}) {
const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });
const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });
// Know K5: the lessons and facts a person's runs teach, private by default and shareable with a team.
// Know K5: the lessons and facts a person's runs teach, private by default and shareable with a team.
const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });
const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge };
+ // The one sharing system (design 2026-11-06): a run task is its owner's until the Share sheet says otherwise.
+ const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
+ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
+ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -254,8 +257,28 @@ async function createApp(opts = {}) {
@@ -254,8 +257,28 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
// Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
// Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
+ // ── /api/v1/task/: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: 'codes',
+ 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;
+ }
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
+ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
@@ -295,7 +318,7 @@ async function createApp(opts = {}) {
@@ -295,7 +318,7 @@ async function createApp(opts = {}) {
c4Routes = require('./http/c4').createC4Routes(ctx);
c4Routes = require('./http/c4').createC4Routes(ctx);
app.use(c4Routes);
app.use(c4Routes);
app.use(pageRoutes);
app.use(pageRoutes);
- app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, fetchImpl: fetchImpl || globalThis.fetch, log }));
+ app.use(createRunRoutes({ config, store, sso, harnesses, run, tasks, sessions, keys, sharing, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
@@ -147,7 +147,7 @@ function usageOf(result) {
@@ -147,7 +147,7 @@ function usageOf(result) {
};
};
}
}
-function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null }) {
+function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@ -582,6 +582,17 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -582,6 +582,17 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
await taskOutbox.emitTask(inserted, null);
await taskOutbox.emitTask(inserted, null);
return inserted;
return inserted;
});
});
+ // Sharing (design 2026-11-06): the task is its owner's. The person's default audience for new tasks, then the
+ // Share sheet changes just this one. A sharing system that is unavailable leaves the task unreadable to
+ // others: every read asks canView, and a missing row answers no.
+ if (sharing) {
+ try {
+ const audience = await sharing.defaultOf(subject).catch(() => 'private');
+ await sharing.register({ subject }, inserted.id, audience);
+ } catch (e) {
+ log.warn(`[Codes] task ${id}: not registered with the sharing system: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ }
// The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.
// The task's first attempt: every Run job is one attempt, and this one is the task as it was asked.
// The budget of the attempt: the task's limits (a benchmark's per-task limits) or the person's default.
// The budget of the attempt: the task's limits (a benchmark's per-task limits) or the person's default.
const budget = budgetFor(runArgs.limits);
const budget = budgetFor(runArgs.limits);
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// their own agents read and add (Know K5).
// their own agents read and add (Know K5).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
@@ -37,7 +37,7 @@ function repoName(url) {
@@ -37,7 +37,7 @@ function repoName(url) {
return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;
return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;
}
}
-function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null }) {
+function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
@@ -65,11 +65,50 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
@@ -65,11 +65,50 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);
return row && row.subject === req.viewer.subject ? row : null;
return row && row.subject === req.viewer.subject ? row : null;
};
};
+ /** A task by its id, owner not applied: the sharing check decides who may read it. */
+ const anyTask = async (id) => (/^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))
+ ? await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]) : null);
+ /** May this reader open the task? Only me is its owner; the other audiences are the sharing system's. */
+ const mayView = async (req, id) => {
+ if (!sharing) return false;
+ try { return await sharing.canView(req.viewer, String(id)); } catch { return false; }
+ };
+ const shareHead = `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('ov-share.css')}"><script src="${require('openvibe-shared/serve').url('ov-share.js')}" defer></script>`;
+ const shareButton = (row) => html`<button type="button" class="run-btn" data-ov-share-sheet data-kind="task" data-id="${row.id}" data-title="${String(row.prompt || 'A task').split('\n')[0].slice(0, 90)}">Share</button>`;
+ /** Specific people, and this reader is not on the list: the audience, never the task's text. */
+ const gatePage = (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', index: false, head: shareHead,
+ // 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`<h1>This task is shared with specific people</h1>
+<div id="share-gate" data-ov-share-gate data-kind="task" data-id="${String(id)}" data-signed-in-as="${signedInAs}" data-sign-in-url="${signInUrl}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>`,
+ }, 404);
+ };
// A task reads Run only as the 10-minute fallback; its state now arrives from run.job.* events.
// A task reads Run only as the 10-minute fallback; its state now arrives from run.job.* events.
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
const sessionStore = sessions || createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });
const sessionStore = sessions || createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });
const { who, refresh } = taskStore;
const { who, refresh } = taskStore;
+ const AUDIENCE_TEXT = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };
+ /**
+ * "New tasks: Only me", with "Apply to all my existing tasks" (design rule 1 and 2). The Share sheet on a task
+ * changes just that one; this is only what the next one starts as.
+ */
+ async function defaultsHtml(subject) {
+ const current = await sharing.defaultOf(subject).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="run-card run-share-defaults" method="post" action="/run/defaults">
+<label class="run-field"><span>New tasks</span><select name="audience">${options}</select></label>
+<label class="run-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing tasks</label>
+<button class="run-btn" type="submit">Save</button>
+<p class="muted">Only me unless you choose otherwise. A task's Share button changes just that task.</p>
+</form>`;
+ }
+
function formHtml({ projects, values = {}, error = null }) {
function formHtml({ projects, values = {}, error = null }) {
const agents = harnesses.list();
const agents = harnesses.list();
const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
@@ -108,8 +147,10 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -108,8 +147,10 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
projectsOf(req, res),
projectsOf(req, res),
db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),
db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),
]);
]);
+ const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
const body = html`<h1>Run on your machine</h1>
const body = html`<h1>Run on your machine</h1>
<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.bot/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.bot/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
+${defaults}
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>
<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>
@@ -124,6 +165,17 @@ ${tasksHtml(recent)}`;
@@ -124,6 +165,17 @@ ${tasksHtml(recent)}`;
r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));
r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));
+ r.post('/run/defaults', form, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!sharing) return res.redirect(303, '/run');
+ const body = req.body || {};
+ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
+ await sharing.setDefault(req.viewer.subject, audience, { applyAll: String(body.apply_all || '') === '1' })
+ .catch((e) => log.warn('[Codes] the task default was not saved:', e.message));
+ return res.redirect(303, '/run');
+ });
+
r.post('/run/project', form, async (req, res) => {
r.post('/run/project', form, async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
@@ -235,25 +287,39 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
@@ -235,25 +287,39 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
</section>`;
</section>`;
}
}
+ /**
+ * A task is its owner's. Someone it was shared with reads the same page without Cancel; anybody else gets the
+ * gate for Specific people, or "not found" when it is Only me (the task is never named to them).
+ */
r.get('/run/tasks/:id', async (req, res) => {
r.get('/run/tasks/:id', async (req, res) => {
- if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
- let row = await own(req, req.params.id);
- if (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);
- row = await refresh(row);
+ const base = await anyTask(req.params.id);
+ if (!base) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task. <a href="/run">Your tasks</a></p>` }, 404);
+ const mine = signedIn(req) && base.subject === req.viewer.subject;
+ if (!mine) {
+ const allowed = await mayView(req, base.id);
+ if (!allowed) {
+ const audience = sharing ? await sharing.audienceOf(base.id).catch(() => null) : null;
+ if (audience === 'people') return gatePage(req, res, base.id);
+ return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such task. <a href="/run">Run on your machine</a></p>` }, 404);
+ }
+ }
+ let row = base;
+ if (mine) row = await refresh(row);
const live = !TERMINAL.has(row.state);
const live = !TERMINAL.has(row.state);
// The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and
// The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and
// after the end from codes_session_lines; a task from before this change falls back to the result's old log.
// after the end from codes_session_lines; a task from before this change falls back to the result's old log.
const lines = live ? null : await sessionStore.linesForRow(row);
const lines = live ? null : await sessionStore.linesForRow(row);
const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
- const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p>
+ const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run on your machine</a></p>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
+${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
${await timelineHtml(row)}
${await timelineHtml(row)}
${await proofHtml(row)}
${await proofHtml(row)}
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
-<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
+${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : ''}` : html`${session}${resultHtml(row, { session: !!(lines && lines.length) })}`}`;
return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
});
});
@@ -283,8 +349,8 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
@@ -283,8 +349,8 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
}
}
r.get('/run/tasks/:id/events', async (req, res) => {
r.get('/run/tasks/:id/events', async (req, res) => {
- if (!signedIn(req)) return res.status(401).end();
- const row = await own(req, req.params.id);
+ const row = signedIn(req) ? (await own(req, req.params.id) || ((await mayView(req, req.params.id)) ? await anyTask(req.params.id) : null))
+ : ((await mayView(req, req.params.id)) ? await anyTask(req.params.id) : null);
if (!row || !row.job_id) return res.status(404).end();
if (!row || !row.job_id) return res.status(404).end();
let url;
let url;
try { url = await run.streamUrl(row.job_id, who(row)); } catch (e) { return res.status(e.status === 404 ? 404 : 503).end(); }
try { url = await run.streamUrl(row.job_id, who(row)); } catch (e) { return res.status(e.status === 404 ? 404 : 503).end(); }
@@ -324,10 +390,13 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
@@ -324,10 +390,13 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
let row = null;
let row = null;
if (signedIn(req)) {
if (signedIn(req)) {
row = await own(req, req.params.id);
row = await own(req, req.params.id);
+ // Somebody the task was shared with reads the same replay: canView is the grant.
+ if (!row && await mayView(req, req.params.id)) row = await anyTask(req.params.id);
} else {
} else {
const out = await serviceOwn(req, req.params.id);
const out = await serviceOwn(req, req.params.id);
- if (!out.row) return http.sendProblem(res, out.status, out.code, { detail: out.detail, ctx: req.ov });
- row = out.row;
+ if (out.row) row = out.row;
+ else if (await mayView(req, req.params.id)) row = await anyTask(req.params.id);
+ else return http.sendProblem(res, out.status, out.code, { detail: out.detail, ctx: req.ov });
}
}
if (!row) return http.sendProblem(res, 404, 'codes.task_not_found', { detail: 'no such task of yours', ctx: req.ov });
if (!row) return http.sendProblem(res, 404, 'codes.task_not_found', { detail: 'no such task of yours', ctx: req.ov });
const raw = String(req.query.after == null ? '0' : req.query.after);
const raw = String(req.query.after == null ? '0' : req.query.after);
@@ -39,7 +39,7 @@ const { expandMatrix } = require('../domain/bench');
@@ -39,7 +39,7 @@ const { expandMatrix } = require('../domain/bench');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
-function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, log = console }) {
+function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@ -78,6 +78,20 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -78,6 +78,20 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
};
};
const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via IS NOT DISTINCT FROM $2 AND idempotency_key = $3', [p.subject, p.via, key]);
const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via IS NOT DISTINCT FROM $2 AND idempotency_key = $3', [p.subject, p.via, key]);
+ /**
+ * A read of a task: the caller's own (as before), or one shared with them (design 2026-11-06, canView). A service
+ * token stays scoped to the tasks it started for the person; sharing is between people.
+ */
+ const viewable = async (req) => {
+ const own = await mine(req);
+ if (own) return own;
+ if (!sharing || !req.principal.owner || !TASK.test(String(req.params.id))) return null;
+ const any = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [req.params.id]);
+ if (!any) return null;
+ const allowed = await sharing.canView({ subject: req.principal.subject }, any.id).catch(() => false);
+ return allowed ? any : null;
+ };
+
/** The owner (signed in, or their own access token as a Bearer), or a service token with the capability acting
/** The owner (signed in, or their own access token as a Bearer), or a service token with the capability acting
* for the person in X-OV-Subject. */
* for the person in X-OV-Subject. */
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
@@ -306,14 +320,14 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -306,14 +320,14 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
});
});
r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
r.get('/tasks/:id', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
- const row = await mine(req);
+ const row = await viewable(req);
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
return res.json(await timeline(await taskStore.refresh(row)));
return res.json(await timeline(await taskStore.refresh(row)));
});
});
/** The task's event stream: the session's lines in order, resumable with Last-Event-ID, plus the attempts. */
/** The task's event stream: the session's lines in order, resumable with Last-Event-ID, plus the attempts. */
r.get('/tasks/:id/events', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
r.get('/tasks/:id/events', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
- const row = await mine(req);
+ const row = await viewable(req);
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
return await taskEvents.stream(req, res, row);
return await taskEvents.stream(req, res, row);
});
});
@@ -143,7 +143,8 @@ ${(o.css || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`
@@ -143,7 +143,8 @@ ${(o.css || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="codes@${esc(RELEASE)}">
<meta name="ov-boost" content="codes@${esc(RELEASE)}">
-<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>
+${o.head || ''}`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Sharing on OpenVibe.Codes (design 2026-11-06, visibility step 2), through openvibe-sdk/share.
+ *
+ * A run task is Only me until its owner says otherwise. The Share sheet changes just that task; the person's default
+ * for new tasks lives on /run ("New tasks: Only me"). Nothing else moves: the task's row, its attempts and its
+ * session are read through the one check, canView, on the page, the live stream, session.json and the task API.
+ *
+ * Why a task and not a project: Codes holds tasks, not projects. A run belongs to a Network project (prj_…), and the
+ * /projects page is a permanent redirect to OpenVibe.Services; a shared project is Network's to model. A task's page
+ * is the run page, so sharing the task shares what a teammate can help with.
+ *
+ * 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 = 'codes';
+const KIND = 'task';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const DEFAULT_AUDIENCE = 'private';
+
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Codes' 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 task's text. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A task was shared with you', 'Open it on OpenVibe.Codes.'],
+ 'share.access_requested': ['Someone asked for access to your task', 'Review the request on OpenVibe.Codes.'],
+ 'share.transfer.requested': ['Someone wants to give you a task', 'Accept or decline it on OpenVibe.Codes.'],
+ 'share.transfer.answered': ['A task transfer was answered', 'See what happened on OpenVibe.Codes.'],
+ }[n.kind] || ['Sharing on OpenVibe.Codes', '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: 'codes', url: n.ref && n.ref.id ? `${config.baseUrl}/run/tasks/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/run`,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ log.warn('[Sharing] notification not sent:', err.message);
+ }
+ }
+
+ /**
+ * OpenVibe.Events. Codes' own outbox carries codes.task.* envelopes; a share envelope is written straight out,
+ * and a refusal only loses the notification's 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,
+ shareViewer,
+ networkJson,
+ /** Register a task, Only me until its owner says otherwise. */
+ register: async (viewer, id, audience = DEFAULT_AUDIENCE) => await shares.register(refOf(id), {
+ owner_subject: { type: 'user', id: String(viewer.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 (subject) => {
+ if (!subject) return DEFAULT_AUDIENCE;
+ const row = await db.maybe('SELECT audience FROM codes_share_defaults WHERE subject = $1', [String(subject)]);
+ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
+ },
+ /** Set the default, and with `applyAll` move the person's existing tasks too (rule 2). */
+ setDefault: async (subject, audience, { applyAll = false } = {}) => {
+ if (!subject) return false;
+ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;
+ await db.exec(`INSERT INTO codes_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
+ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(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, String(subject)]);
+ }
+ return true;
+ },
+ /** The uniform API's describe(ref): the task's first line and its page. */
+ describe: async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const row = await db.maybe('SELECT prompt FROM run_tasks WHERE id = $1', [String(ref.id)]);
+ if (!row) return {};
+ return {
+ title: String(row.prompt || 'A task').split('\n')[0].slice(0, 90),
+ link: `${config.baseUrl}/run/tasks/${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, AUDIENCES, SERVICE, KIND, DEFAULT_AUDIENCE };
@@ -10,7 +10,7 @@ const http = require('http');
@@ -10,7 +10,7 @@ const http = require('http');
const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const { TABLES, TASK_KEYED } = require('../server/account-data');
+const { TABLES, TASK_KEYED, EXEMPT } = require('../server/account-data');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const SECRET = `whsec_${'fixture'.repeat(6)}`;
const SECRET = `whsec_${'fixture'.repeat(6)}`;
@@ -77,6 +77,7 @@ async function startNetworkStub() {
@@ -77,6 +77,7 @@ async function startNetworkStub() {
account_data_events: 'the ledger of requests itself: which export and deletion Codes applied',
account_data_events: 'the ledger of requests itself: which export and deletion Codes applied',
event_outbox: 'no person column (the SDK outbox keeps envelopes); dropped with the console in 0002 anyway',
event_outbox: 'no person column (the SDK outbox keeps envelopes); dropped with the console in 0002 anyway',
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',
+ ...EXEMPT,
},
},
});
});
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(', ')}`);
@@ -374,6 +374,24 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -374,6 +374,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;
@@ -58,6 +58,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -58,6 +58,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
'server/domain/knowledge.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, to read the teams a knowledge row may be shared with; the path is fixed and no URL from a person is fetched',
'server/domain/knowledge.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, to read the teams a knowledge row may be shared with; the path is fixed and no URL from a person is fetched',
+ '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 (CODES_EVENTS_URL); share refs are the sharing system\'s own kind/id strings, never a URL from a person (test/sharing.test.js)',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the task
+ * 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 Codes'.
+ */
+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;
+
+(async () => {
+ const run = {
+ enabled: true,
+ async submit() { return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
+ async read(jobId) { return { id: jobId, state: 'queued', result: null, error: null }; },
+ async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },
+ async streamUrl() { return 'http://127.0.0.1:1/stream'; },
+ };
+ const t = await boot({ run });
+ const ada = t.network.addUser('ada');
+ const cy = t.network.addUser('cy');
+ const post = (p, as, form) => t.get(p, { as, form, method: 'POST' });
+ 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 task shared with Specific people, with nobody on the list yet', async () => {
+ const made = await post('/run/project', ada, { name: 'My code', prompt: 'Fix the typo', repo: 'https://github.com/OpenVibers/OpenVibe.Codes' });
+ assert.strictEqual(made.status, 303, made.text);
+ const page = await t.get(made.headers.get('location'), { as: ada });
+ const project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];
+ const started = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Fix the typo in README', project });
+ assert.strictEqual(started.status, 303, started.text);
+ taskId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(started.headers.get('location'))[1];
+ await t.ctx.sharing.shares.setAudience(
+ { subject: { type: 'user', id: ada.subject } },
+ { service: 'codes', kind: 'task', 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}/run/tasks/${taskId}`, { waitUntil: 'load', timeout: 15000 });
+ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the task');
+ 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}/run/tasks/${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,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+/**
+ * Sharing a run task (design 2026-11-06, visibility step 2): the one Share sheet (openvibe-sdk/share) and the one
+ * check, canView, on the task page, its live stream and session.json.
+ *
+ * - a new task is Only me; the Share button is the owner's;
+ * - Anyone with the link reads the page and the session, without Cancel;
+ * - Specific people shows the gate, never the task's text, to somebody not on the list;
+ * - the person's default for new tasks, and "apply to all", work.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const run = {
+ enabled: true,
+ async submit() { return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
+ async read(jobId) { return { id: jobId, state: 'succeeded', result: { ok: true, summary: 'The typo is fixed.', files: [], agents: ['claude-code'] }, error: null }; },
+ async cancel(jobId) { return { id: jobId, state: 'cancelled' }; },
+ async streamUrl() { return 'http://127.0.0.1:1/stream'; },
+ };
+ const t = await boot({ run });
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const cy = t.network.addUser('cy');
+ const post = (p, as, form, headers = {}) => t.get(p, { as, form, headers, method: 'POST' });
+
+ 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/task/${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 t.ctx.store.db.maybe(
+ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['codes', 'task', id]);
+
+ try {
+ let project;
+ await check('a project first, so a task can be started', async () => {
+ const made = await post('/run/project', ada, { name: 'My code', prompt: 'Fix the typo', repo: 'https://github.com/OpenVibers/OpenVibe.Codes' });
+ assert.strictEqual(made.status, 303, made.text);
+ const page = await t.get(made.headers.get('location'), { as: ada });
+ project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];
+ assert.ok(project);
+ });
+
+ let taskId;
+ await check('a new task is Only me, and its page carries the Share button for its owner', async () => {
+ const started = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Fix the typo in README', project });
+ assert.strictEqual(started.status, 303, started.text);
+ taskId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(started.headers.get('location'))[1];
+ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'private' });
+ const mine = await t.get(`/run/tasks/${taskId}`, { as: ada });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="task"'));
+ assert.ok(mine.text.includes('ov-share.js'));
+ assert.ok(mine.text.includes('Cancel'));
+ const other = await t.get(`/run/tasks/${taskId}`, { as: bo });
+ assert.strictEqual(other.status, 404);
+ assert.ok(!other.text.includes('Fix the typo in README'), 'the task is never named to somebody else');
+ const guest = await t.get(`/run/tasks/${taskId}`);
+ assert.strictEqual(guest.status, 404);
+ });
+
+ await check('Anyone with the link reads the page and the session, and cannot Cancel', async () => {
+ const saved = await setShare(taskId, { audience: 'link' }, ada);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'link');
+ const guest = await t.get(`/run/tasks/${taskId}`);
+ assert.strictEqual(guest.status, 200, guest.text);
+ assert.ok(guest.text.includes('Fix the typo in README'));
+ assert.ok(guest.text.includes('This task was shared with you'));
+ assert.ok(!guest.text.includes('action="/run/tasks/' + taskId + '/cancel"'), 'no Cancel for a reader');
+ const replay = await t.get(`/run/tasks/${taskId}/session.json`, { as: bo });
+ assert.strictEqual(replay.status, 200, replay.text);
+ assert.ok(Array.isArray(replay.json().lines));
+ });
+
+ await check('Specific people: on the list reads it, a stranger gets the gate and no text', async () => {
+ const saved = await setShare(taskId, { audience: 'people', members: [{ name: 'bo', role: 'viewer' }] }, ada);
+ assert.strictEqual(saved.status, 200, saved.text);
+ assert.strictEqual(saved.json().audience, 'people');
+ const member = await t.get(`/run/tasks/${taskId}`, { as: bo });
+ assert.strictEqual(member.status, 200, member.text);
+ assert.ok(member.text.includes('Fix the typo in README'));
+ const stranger = await t.get(`/run/tasks/${taskId}`, { as: cy });
+ assert.strictEqual(stranger.status, 404);
+ assert.ok(stranger.text.includes('shared with specific people'));
+ assert.ok(!stranger.text.includes('Fix the typo in README'));
+ const replay = await t.get(`/run/tasks/${taskId}/session.json`, { as: cy });
+ assert.strictEqual(replay.status, 404);
+ });
+
+ await check('the default applies to the next task, and "apply to all" moves the existing one', async () => {
+ const saved = await post('/run/defaults', ada, { audience: 'public', apply_all: '1' });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.deepStrictEqual(await audienceRow(taskId), { audience: 'public' });
+ const guest = await t.get(`/run/tasks/${taskId}`);
+ assert.strictEqual(guest.status, 200);
+ const next = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Add a test', project });
+ const nextId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(next.headers.get('location'))[1];
+ assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });
+ const start = await t.get('/run', { as: ada });
+ assert.ok(start.text.includes('New tasks'), 'the default is on the product page');
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})();