openvibers/openvibe.codes Commits

Run tasks: the one Share sheet, Only me by default (#291)

OP OpenVibers committed 6 days ago · d07fd61de7 · 19 files, +670 −31

* Run tasks: the one Share sheet, Only me by default

A run task carries openvibe-sdk/share's share row (kind task, service codes) and the one check,
canView, on every read: the task page, its live stream, session.json and the task API. A new task
is Only me; /run offers the person's default for new tasks with "Apply to all my existing tasks".

- server/sharing.js: Network name lookups and notifications, share.* through Events, the uniform
  router at /api/v1/task/:id/share, plus shared-with-me and people; a 15 s delivery worker.
- The page shows the owner a Share button, a reader the same run without Cancel and a plain "this
  task was shared with you". Specific people who are not on the list get the sheet's gate, never
  the prompt.
- server/http/moved.js lets the share API through (the rest of /api/v1 still redirects to
  OpenVibe.Services). Codes owns no projects (they are Network's and /projects redirects), so the
  shared thing is the task and its run page.
- migrations 0026 (the SDK's own share tables, verbatim) and 0027 (defaults plus a backfill of
  existing tasks, Only me), both expand-only.

* The share gate from markup on openvibe-shared 4.123.0

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

- The run task gate renders data-kind/data-id/data-signed-in-as/data-sign-in-url on the element and
  keeps only the <script src=…ov-share.js> tag. The layout now appends o.head, which the gate needs
  to load the sheet at all (it was silently dropped before).
- openvibe-shared v4.118.0 → v4.123.0 in package.json, package-lock.json, README and STATUS.
- test/share-gate-chrome.test.js boots the app and drives headless Chrome: signed out the gate
  offers Sign in with the login link; signed in as somebody not on the list it offers Ask for
  access and names the account; no console error and no CSP refusal.

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

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

* Account data covers the sharing tables; the Chrome gate check skips without a browser

The full test suite's account-data guard found codes_share_defaults (the person's default audience) uncovered, so the person's row now belongs to their export and deletion, and the share rows they own are erased with the rest.

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

* The SSRF ratchet reviews the sharing client

server/sharing.js makes the outbound calls a service must (Network's token endpoint, name lookups and one notification push, and Events), all on configured bases. It is added to the reviewed list with where it goes, like every other outbound site.

Changes

ddiff --git a/README.md b/README.md
iindex 0d630d4..a655774 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 94658bb..9289ec2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/migrations/0026_shares.sql b/migrations/0026_shares.sql
nnew file mode 100644
iindex 0000000..2178257
---- /dev/null
++++ b/migrations/0026_shares.sql
@@@ -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;
ddiff --git a/migrations/0027_share_defaults.sql b/migrations/0027_share_defaults.sql
nnew file mode 100644
iindex 0000000..f8c87a2
---- /dev/null
++++ b/migrations/0027_share_defaults.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex f6f87e5..7958d1e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
},
"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",
ddiff --git a/package.json b/package.json
iindex 710e03b..9510e52 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 0001d5e..21564e3 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex db38157..c5daa73 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 6ff8800..769438f 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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, ou
await 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);
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex c4d1423..61fd13f 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -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}/`);
ddiff --git a/server/http/run.js b/server/http/run.js
iindex bb35693..247dd2a 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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 = globa
const 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}/events
let 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);
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 560716a..c07a091 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -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);
});
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a7427e0..2aacfe7 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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>
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..0c7ec6b
---- /dev/null
++++ b/server/sharing.js
@@@ -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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 1cc5615..b88b84d 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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(', ')}`);
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 165d00a..7638134 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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);
return await projectsApi(req, raw, json, u, parts, url.searchParams);
}
++ // The sharing system's (openvibe-sdk/share) name lookups and notifications.
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = String(url.searchParams.get('username') || '').toLowerCase();
++ const u = st.byUsername.get(name);
++ if (!u) return problem(json, 404, 'identity.name_not_found', 'no such person');
++ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
++ }
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const ids = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];
++ const results = {};
++ for (const subjectId of ids) {
++ const u = [...st.users.values()].find((x) => x.subject === subjectId);
++ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };
++ }
++ return json(200, { results });
++ }
++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
++
return json(404, { error: 'not found' });
});
issuer = srv.url;
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5a9949f..be60b96 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..4d456a1
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -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();
++ }
++})();
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..bdbd8d4
---- /dev/null
++++ b/test/sharing.test.js
@@@ -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();
++ }
++})();