openvibers/openvibe.network Commits

The Action Center: Cancel reaches the task that offered it (#446)

OP OpenVibers committed 13 days ago · c618d3aee3 · 3 files, +51 −7

* The Action Center: Cancel reaches the task that offered it

The item offered Cancel (common.action-item@1) and the rail refused it: the Dock's button was dead. Cancel now goes through the same rail as approve and decline, to the service's own cancel route, with that service's capability and the person's subject. The item comes back as it is now, so the Dock redraws it without a second read.

Plan A2, stage A2a: the wait's person must be able to stop a task from the Dock.

* The SSRF ratchet: the Action Center's cancel call site is reviewed too

The cancel route is the fourth outbound call in server/actions/index.js: the same configured bases, the same fixed paths and the same id rule as the decide route.

Changes

ddiff --git a/server/actions/index.js b/server/actions/index.js
iindex f78715dd..c7754654 100644
---- a/server/actions/index.js
++++ b/server/actions/index.js
@@@ -4,7 +4,7 @@
* by the ✦ beside the bell on every OpenVibe site with the person's Network token.
*
* GET /api/v1/me/actions network.me-actions-result@1
-- * POST /api/v1/me/actions/:service/:id/:action approve | decline → { item } (common.action-item@1), or a problem
++ * POST /api/v1/me/actions/:service/:id/:action approve | decline | cancel → { item } (common.action-item@1), or a problem
*
* Sources, asked in parallel for the person's subject:
* network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;
@@@ -29,6 +29,11 @@ const DECIDE = {
actor: { cap: 'actor.approval.decide', path: (id) => `/api/v1/approvals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Actor', what: 'approval' },
domains: { cap: 'domains.proposal.decide', path: (id) => `/api/v1/proposals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Domains', what: 'proposal' },
};
++// Who can cancel from the Action Center, and how: the capability Network signs for, and the route. The Dock's
++// Cancel button was dead before this: the item offered it (common.action-item@1) and the rail refused it.
++const CANCEL = {
++ actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },
++};
const ACTIONS_TIMEOUT_MS = 2500;
const CACHE_MS = 10_000;
const CACHE_MAX = 500;
@@@ -113,10 +118,11 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
return doc;
}
-- /** Approve or decline one waiting item through its authority. → common.action-item@1 */
++ /** Approve, decline or cancel one item through its authority. → common.action-item@1 */
async function act(actor, service, id, action, ctx) {
if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });
-- if (action !== 'approve' && action !== 'decline') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ if (action !== 'approve' && action !== 'decline' && action !== 'cancel') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ if (action === 'cancel') return await cancel(actor, service, id, ctx);
cache.delete(actor.subject);
if (service === 'network') {
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
@@@ -137,6 +143,32 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
return body;
}
++ /**
++ * Cancel one running item through its authority. The item leaves the running bucket and the person gets the
++ * item as it is now, so the Dock can redraw it without a second read.
++ */
++ async function cancel(actor, service, id, ctx) {
++ const c = CANCEL[service];
++ if (!c) throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ const token = urls[service] ? selfToken('openvibe.' + service, [c.cap]) : null;
++ if (!token) throw Object.assign(new Error(c.name + ' is not connected'), { status: 503, code: 'actions.not_connected' });
++ const res = await fetchImpl(String(urls[service]).replace(/\/+$/, '') + c.path(id), {
++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-ov-subject': actor.subject },
++ body: JSON.stringify({}), signal: AbortSignal.timeout(timeoutMs * 2),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (res.status === 404) throw Object.assign(new Error('no such ' + c.what), { status: 404, code: 'actions.not_found' });
++ if (!res.ok && res.status !== 202) throw Object.assign(new Error((body && (body.detail || body.title)) || (c.name + ' answered ' + res.status)), { status: 502, code: 'actions.upstream' });
++ const doc = await forPerson(actor);
++ const item = Object.values(doc.buckets || {}).flat().find((it) => it.service === service && it.id === id);
++ return item || {
++ service, id, kind: 'agent_task', title: '', summary: null, bucket: 'failed', state: 'cancelled',
++ agent: null, cost_usd: null, steps: null, error: null,
++ created_at: new Date(now()).toISOString(), updated_at: new Date(now()).toISOString(),
++ next_run_at: null, href: null, actions: ['open'],
++ };
++ }
++
return { forPerson, act, _cache: cache };
}
@@@ -168,4 +200,4 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {
return r;
}
--module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS };
++module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS, CANCEL };
ddiff --git a/test/me-actions.test.js b/test/me-actions.test.js
iindex 78dc2512..0e1df3ca 100644
---- a/test/me-actions.test.js
++++ b/test/me-actions.test.js
@@@ -30,6 +30,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v
// Fake Actor (two items, one invalid, one claiming to be Codes) and AI (slow); Codes is not deployed (404).
const calls = [];
let aiDelay = 0;
++let cancelled = false;
const svc = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
@@@ -38,7 +39,7 @@ const svc = http.createServer((req, res) => {
calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });
const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };
if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [
-- item(),
++ item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),
item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),
item({ id: 'tsk_bad', href: 'http://openvibe.actor/tasks/x' }),
item({ id: 'tsk_spoof', service: 'codes' }),
@@@ -48,6 +49,8 @@ const svc = http.createServer((req, res) => {
if (u.pathname === '/ai/api/v1/actions') return setTimeout(() => json(200, { items: [item({ service: 'ai', id: 'run_1', kind: 'ai_run', bucket: 'done', state: 'succeeded', href: 'https://ai.openvibe.services/runs/run_1', actions: ['open'] })] }), aiDelay);
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
++ if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }
++ if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });
if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
if (u.pathname === '/domains/api/v1/proposals/dpr_2/decide') return json(409, { code: 'proposal.decided', detail: 'This proposal is applied already.' });
@@@ -133,7 +136,16 @@ try {
const dd = calls.find((c) => c.path === '/domains/api/v1/proposals/dpr_1/decide');
assert.deepStrictEqual([dd.method, dd.auth, dd.body.decision, dd.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.domains:domains.proposal.decide', 'approve', true]);
assert.strictEqual((await call('POST', '/domains/dpr_2/approve')).status, 409, 'decided already, in words');
-- assert.deepStrictEqual([(await call('POST', '/codes/tsk_1/approve')).body.code, (await call('POST', '/actor/tsk_1/cancel')).body.code], ['actions.unsupported', 'actions.unsupported']);
++ // The Dock's Cancel (the item offered it and the rail refused it): it now goes through the same rail as
++ // approve and decline, to Actor's own cancel route, with Actor's own capability and this person.
++ r = await call('POST', '/actor/tsk_1/cancel');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');
++ assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);
++ assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');
++ assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');
++ // Codes has no cancel route, and Codes is not connected for it either.
++ assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');
assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');
} finally {
server.close(); svc.close();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex a4d3807b..afdd06fe 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -125,7 +125,7 @@ const REVIEWED = {
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
-- 'server/actions/index.js fetch': [3, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed, the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
++ 'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],