openvibers/openvibe.bot Commits

Relay machine access under Network's grant and carry agent tasks to Link (Contracts 0.409.0) (#407)

OP OpenVibers committed 1 days ago · ca8a30a6af · 10 files, +530 −15

* Relay machine access under Network's grant and carry agent tasks to Link (Contracts 0.409.0)

* Merge remote-tracking branch 'origin/main' into machine-access-bot

# Conflicts:
#	package-lock.json
#	package.json

* Name the pinned Contracts 0.409.0 in STATUS.json

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 51bbe92..c8b4697 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
-- "contracts": "openvibe-contracts v0.408.0",
++ "contracts": "openvibe-contracts v0.409.0",
"sdk": "openvibe-sdk v0.150.0",
"shared": "openvibe-shared v4.211.0",
"packages": [
-- "openvibe-contracts v0.408.0",
++ "openvibe-contracts v0.409.0",
"openvibe-sdk v0.150.0",
"openvibe-shared v4.211.0"
],
ddiff --git a/docs/machine-access-relay.md b/docs/machine-access-relay.md
nnew file mode 100644
iindex 0000000..80f4313
---- /dev/null
++++ b/docs/machine-access-relay.md
@@@ -0,0 +1,92 @@
++# Machine access relay (Contracts 0.409.0, stage 1)
++
++Two paired machines talk through Bot's device WebSocket, and only while Network's signed grant allows it. This page is
++the wire between OpenVibe.Link (stage 2) and Bot; Network's routes are named at the end.
++
++## The grant token
++
++Network mints an RS256 JWT with the key published at `GET /api/.well-known/jwks` (`kid` `ov-network-1`). Its claims are
++`link.access-grant-token@1`:
++
++```json
++{ "iss": "https://openvibe.network", "aud": "lnk_TARGET", "sub": "lnk_ASKER", "jti": "tok_…",
++ "grant_id": "grt_…", "scopes": ["agent", "send", "screen", "desk", "computer", "worker"],
++ "iat": 1794430000, "exp": 1794430300 }
++```
++
++`aud` is the receiving machine, `sub` the asking machine, `exp` at most five minutes after `iat` and never after the
++stored grant ends. The asking Link gets it with its own Link token:
++`POST /api/v1/link/self/access-grants/:grant_id/token` → `{ grant, grant_token, expires_at }`.
++The receiving Link verifies the same token itself: signature with Network's key, `iss`, `aud` = its own `lnk_…`,
++`sub`, `scopes` and `exp`. It is the second enforcement; Bot is a relay, never the authority.
++
++## Frames on `wss://…/device`
++
++Every frame carries Bot's `v`, `seq`, `ts` like every other device frame. Only a socket authenticated with a Link
++token (a Network-paired machine) may open a relay.
++
++| out (Link → Bot) | answer | meaning |
++| --- | --- | --- |
++| `{ "type": "relay.open", "to": "lnk_B", "grant_token": "…" }` | `relay.opened` on both machines | Open one relay under that grant. Bot checks the token (RS256, issuer, `aud` = `to`, `sub` = the socket's own `lnk_…`, `exp`, scopes) and that B is online. |
++| `{ "type": "relay.frame", "relay": "rly_…", "scope": "screen", "body": { … } }` | `{ "type": "relay.frame", "relay": "rly_…", "from": "lnk_A", "scope": "screen", "body": { … } }` on the other machine | One opaque body. Bot checks the relay is open, the sender is one of its two machines, the scope is in the grant and the token is still in date. The answer comes back with the same shape (`from` is B). `body` is at most 256 KiB. |
++| `{ "type": "relay.close", "relay": "rly_…" }` | `{ "type": "relay.closed", "relay": "rly_…", "reason": "closed" }` on both | Either machine ends it. |
++
++`relay.opened` is sent to both machines and carries `relay`, `from`, `to`, the same `grant_token`, `scopes` and
++`expires_at`, so B can verify the grant before the first frame. `relay.closed` reasons are `closed`, `revoked`,
++`expired` and `offline` (one of the two sockets went away).
++
++### `computer`
++
++For `scope: "computer"` the asking machine's `body` must name the level it wants: `{ "level": "observe" | "ask" |
++"trusted" | "full", … }`. Bot refuses a level above the receiving machine's own setting (its status'
++`telemetry.sensors.computer_level`, else its computer capability's level; unknown is treated as `ask`) and refuses
++when the receiving machine does not offer its computer. The receiving Link still applies its own level, including the
++owner's approval at `ask`.
++
++### Audit
++
++On the first frame of each scope in a relay, Bot tells Network `POST /internal/link-access/grants/:grant_id/use`
++with `{ from_principal, to_principal, scope, action }`; Network stores the line both owners read
++(`GET /api/v1/me/nodes/access-audit`). A failed ledger call never stops a relay.
++
++## Agent tasks (`agent.task@1`)
++
++Network delivers a task for a Link-local agent over HTTP:
++`POST http://127.0.0.1:<bot>/api/v1/machines/:device_id/relay` with a Network service token (`cap` `bot.machine.relay`,
++`X-OV-Subject` the machine's owner) and `{ "type": "agent.task", "task": { "id": "tsk_…", "thread": "thr_…",
++"from": "agt_…", "to": "agt_…", "on_behalf_of": "usr_…", "message": { "role": "user", "parts": [{ "type": "text",
++"text": "…" }] } } }`. Bot answers `201 { machine_id, delivered: true }`, or `404` when the machine is not that
++person's, `409` when it is offline or the id is not a `tsk_…`, `422` for another type.
++
++The machine receives `{ "type": "agent.task", "task": { … } }` on its device socket. It answers:
++
++```json
++{ "type": "agent.task_result", "task_id": "tsk_…", "status": "running|completed|failed|cancelled",
++ "result": { "parts": [{ "type": "text", "text": "at most 8000 characters" }] } }
++```
++
++Bot checks the task was delivered to that socket, the status and at most 20 text parts of 8000 characters, forwards it
++to Network (`POST /api/v1/agents/tasks/:id/result`), and acknowledges with
++`{ "type": "agent.task_result_ack", "task_id": "tsk_…", "status": "…" }`. Only the machine the task reached may answer
++it; a `completed`/`failed`/`cancelled` answer closes it.
++
++## Revocation
++
++Bot subscribes at OpenVibe.Events to `network.link.access.revoked@1`. On that event every relay under the payload's
++`grant_id` is closed at once (both machines get `relay.closed` with reason `revoked`), and the relay never outlives its
++token's `exp` anyway. The same event is what the two Links should act on for their local sessions.
++
++## Network's routes (stage 2)
++
++| call | who |
++| --- | --- |
++| `POST /api/v1/link/self/access-requests` `{ to_principal \| target, scopes, duration_seconds \| null, reason, from_agent? }` | the asking machine (Link token) |
++| `GET /api/v1/link/self/access-requests`, `GET /api/v1/link/self/access-grants` | the machine (sent and received) |
++| `POST /api/v1/link/self/access-grants/:id/token`, `GET /api/v1/link/self/access-grants/:id/status` | the machine |
++| `GET/POST /api/v1/me/nodes/access-requests`, `POST /api/v1/me/nodes/access-requests/:id/answer` `{ approve, scopes?, duration_seconds? }` | the owner's session |
++| `GET /api/v1/me/nodes/access-grants`, `POST /api/v1/me/nodes/access-grants/:id/revoke`, `GET /api/v1/me/nodes/access-audit` | the owner's session |
++| `GET/PUT /api/v1/me/nodes/:id/access-settings` `{ others_may_ask, auto_agent_send, agents_talk }` | the owner's session |
++| `POST /api/v1/agents/register` `{ id?, kind, name, skills, status, owner? }` | a service token (`network.agents.register`) or the machine (Link token) |
++| `POST /api/v1/agents/:id/tasks` `{ from, thread?, message, skillId? }` | a service token or the machine |
++| `GET /api/v1/agents/tasks/:id`, `GET /api/v1/agents/inbox` | the sending/receiving agent's machine or service |
++| `GET /api/v1/me/agents`, `PUT /api/v1/me/agents/:id/allow-others`, `GET /api/v1/me/agent-threads`, `POST /api/v1/me/agent-threads/:id/stop` | the owner's session |
ddiff --git a/package-lock.json b/package-lock.json
iindex 2751de8..3fceca1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"pg": "^8.23.0",
@@@ -1201,9 +1201,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.408.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
-- "integrity": "sha512-Q2XjfuCDyMFd88CIKVwdZKR9IwZsxZB41cFKxaIWVIrmCt8YO5P2zj8beqvBanqpNgOjPB74HTU/0JCMTbyA+Q==",
++ "version": "0.409.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
++ "integrity": "sha512-Yh8XcOEKIrYG7WV8ack03hmobZwMyZIeOogIrCeIWvuHKpvjYYL+UcOj7DrNalxC5fp0EnhVwRj89DTFm/AH2g==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex e0488ed..487af75 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"pg": "^8.23.0",
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 71c7b46..d2a8dc7 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -38,6 +38,7 @@
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /claims | — (anyone) | a device asking to be added: a code |
* | POST /machines/:id/watch-links | bot.machine.watch_link | the owner, or their agent for them |
++ * | POST /machines/:id/relay | bot.machine.relay | Network: an agent.task for the machine|
* | POST /machines/:id/agent-computer, watch-probe| — (the owner or the network's hub) | as the person |
* | POST /machines/:id/screen | — (the owner or the network's hub) | the owner only |
* | GET /machines/:id/desks, /shares | — (the owner or the network's hub) | the owner only |
@@@ -74,6 +75,8 @@ const CAP = {
machine: 'bot.machine.manage',
// A watch link to a machine's desk (plan L55): the owner's agent shows the desk it works on, inside a chat.
watchLink: 'bot.machine.watch_link',
++ // Network's machine-access relay (Contracts 0.409.0): an agent task for a machine's own agent.
++ relay: 'bot.machine.relay',
};
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
@@@ -661,6 +664,20 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
});
res.json({ machine_id: device.id, ...result });
}));
++ // Network's relay of an agent task to a machine's own agent (Contracts 0.409.0, agent.task@1). Network already
++ // checked the sender's agent and any machine grant; Bot only checks the machine is the person's and hands the
++ // frame to its Link. The Link queues it for the local agent and answers with agent.task_result on the socket.
++ r.post('/machines/:id/relay', commandControl, wrap(async (req, res) => {
++ if (req.principal.kind !== 'service') fail(403, 'bot.forbidden', 'a machine relay is a service-to-service call');
++ if (!apiAuth.granted(req.principal, CAP.relay)) fail(403, 'capability.denied', `${CAP.relay} not granted`);
++ if (req.body?.type !== 'agent.task') fail(422, 'bot.invalid_input', 'only an agent.task is relayed');
++ const subject = actedFor(req);
++ const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
++ const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
++ if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
++ if (!hub.deliverAgentTask(device.id, req.body?.task)) fail(409, 'bot.device_offline', 'the machine is offline or the task has no valid id');
++ res.status(201).json({ machine_id: device.id, delivered: true });
++ }));
// Who has a desk right now (Desks plan D4): an agent that asked its person to take over a step (a sign-in, a
// CAPTCHA, a payment) waits for them to take it, then to stop. Read only; the person's own input is what moves it.
r.get('/machines/:id/desk-turn', commandControl, wrap(async (req, res) => {
ddiff --git a/server/app.js b/server/app.js
iindex 520db16..e7a4b30 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -65,7 +65,12 @@ function createApp(opts = {}) {
const outbox = opts.outbox || createBotOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
// Job usage readings → Billing (server/jobs/metering.js); started by server/index.js with the other jobs.
const usage = opts.usage || createUsageRelay({ db, config, fetchImpl: opts.billingFetch || fetchImpl, now, log });
-- const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
++ // Bot's own client-credentials voice to Network's internal routes (grant-use audit, agent task answers). null
++ // without a client secret: the relay still works and the ledger keeps the lines it already has.
++ const networkSend = opts.networkSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now, networkSend });
const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
const identity = opts.identity || createIdentity(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
@@@ -148,12 +153,20 @@ function createApp(opts = {}) {
// consumer over account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a request that
// came through a proxy. The sender posts to Network's internal routes with Bot's own token.
const accountData = accountDataLib.create({ domain, log });
-- const accountSend = opts.accountSend || (config.oauth.clientSecret
-- ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
-- : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });
++ const accountSend = opts.accountSend || networkSend
++ || (async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });
// network.link.revoked (a person's paired machine revoked on Network): its devices are revoked and a live session is
// closed at once, instead of at the machine's next reauth deadline.
const onEvent = async (ev) => {
++ // network.link.access.revoked (Contracts 0.409.0): either owner ended a machine grant. Close every relay under
++ // it at once, so neither machine can send under a token Network has already revoked.
++ if (ev && ev.event_type === 'network.link.access.revoked') {
++ const grantId = String((ev.payload && ev.payload.grant_id) || '');
++ if (!/^grt_[A-Za-z0-9_-]{8,80}$/.test(grantId)) return 'applied';
++ const closed = hub.revokeGrant(grantId);
++ if (closed) log.log(`[Bot] grant ${grantId} revoked on Network: ${closed} relay(s) closed`);
++ return 'applied';
++ }
if (ev && ev.event_type === 'network.link.revoked') {
const principal = String((ev.payload && ev.payload.principal_id) || '');
if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(principal)) return 'applied';
ddiff --git a/server/index.js b/server/index.js
iindex 1cfeed7..e9ff48a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -80,9 +80,10 @@ async function main() {
// kit's 5000 ms default is the right value.
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// BOT_EVENTS_SECRET or the client secret.
-- // Plus network.link.revoked: a machine revoked on Network is revoked here at once (app.js onEvent).
++ // Plus network.link.revoked (a machine revoked on Network) and network.link.access.revoked (either owner ended
++ // a machine grant): both act at once in app.js onEvent.
const subscriptions = startSubscriptions({
-- topics: [...TOPICS, 'network.link.revoked'],
++ topics: [...TOPICS, 'network.link.revoked', 'network.link.access.revoked'],
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
ddiff --git a/server/realtime.js b/server/realtime.js
iindex ada0a34..f3241c2 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -22,6 +22,7 @@
* first result instead of reaching the device again.
*/
const { WebSocketServer } = require('ws');
++const jwt = require('jsonwebtoken');
const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');
const { capabilities, ids, validate } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
@@@ -43,6 +44,19 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
// A machine's answer to a computer command (exec output, a file, a screen) is at most 512 KB of JSON in its ack
// (OpenVibe Link protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.
const DEVICE_MAX_PAYLOAD = 640 * 1024;
++// Machine-to-machine relay (Contracts 0.409.0, link.access-grant-token@1): Bot forwards an opaque body between two
++// paired machines only while Network's signed grant names both and the target, and only for the scopes it carries.
++// The target's Link verifies the same token itself and caps everything at its own local level.
++const RELAY_SCOPES = new Set(['agent', 'send', 'screen', 'desk', 'computer', 'worker']);
++const COMPUTER_LEVELS = Object.freeze({ observe: 0, ask: 1, trusted: 2, full: 3 });
++const RELAY_MAX_BODY = 256 * 1024;
++// The audit verb Network stores for a scope's first use in a relay: both owners read one line per use.
++const RELAY_SCOPE_ACTION = Object.freeze({ agent: 'message', send: 'send', screen: 'watch', desk: 'use', computer: 'control', worker: 'run' });
++// An agent task delivered to a machine (Network's POST /api/v1/agents/:id/tasks → Bot) and its answer.
++const AGENT_TASK_RE = /^tsk_[A-Za-z0-9_-]{8,80}$/;
++const AGENT_STATUSES = new Set(['running', 'completed', 'failed', 'cancelled']);
++const MAX_AGENT_PARTS = 20;
++const MAX_AGENT_TEXT = 8000;
// The Link's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its
// status, and the reply window (the owner's approval on the machine plus the program's own run). `desk` (contracts
// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
@@@ -130,10 +144,12 @@ function simView(sim) {
};
}
--function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
++function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now(), networkSend = null }) {
let domain = null;
const tickets = createControlTickets({ now }); // the embedded panel's Drive (server/control-tickets.js)
const deviceConns = new Map(); // device_id → conn
++ const relays = new Map(); // relay id → one open grant relay between two machines (relay.open)
++ const agentTasks = new Map(); // task id → { deviceId, at } an agent.task delivered to a machine
const subsByRobot = new Map(); // robot_id → Set<conn>
const pendingCmds = new Map(); // device-facing command id (server-minted) → pending
const results = new Map(); // operator key (who + their id) → { result, reason, at }
@@@ -379,6 +395,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (!conn || !conn.device) return;
const current = deviceConns.get(conn.device.id);
if (current === conn) deviceConns.delete(conn.device.id);
++ // A relay has two live sockets; when either goes, both ends hear it is closed instead of writing into a gap.
++ for (const session of [...relays.values()]) if (session.from === conn || session.to === conn) closeRelay(session, 'offline');
if (!conn.online) return;
conn.online = false;
if (silent) return;
@@@ -386,6 +404,185 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
++ // ── Machine-to-machine relay (stage 1: Network's grant, Bot's pipe) ───────────────────────────────
++ /**
++ * The one open socket of a machine principal (`lnk_…`), online. A relay names both machines by Network's id, not
++ * Bot's device id, because the grant token does.
++ */
++ function linkConn(principal) {
++ for (const c of deviceConns.values()) if (c.online && c.node && c.node.principal === principal) return c;
++ return null;
++ }
++ /** The level a machine's own computer plugin runs at (its owner's setting), or null when it has not said. */
++ function linkComputerLevel(conn) {
++ const status = conn && conn.status;
++ const sensors = status && status.telemetry && status.telemetry.sensors;
++ const offered = status && status.capabilities && status.capabilities.computer;
++ if (!offered && !(sensors && typeof sensors.computer_level === 'string')) return null;
++ if (sensors && typeof sensors.computer_level === 'string') return sensors.computer_level;
++ const pick = offered && typeof offered === 'object' ? (offered.files || offered.terminal || offered.screen || {}) : {};
++ return typeof pick.level === 'string' ? pick.level : null;
++ }
++ /**
++ * Verify a grant token minted by Network (`link.access-grant-token@1`): RS256 with Network's published key,
++ * issuer Network, audience the target machine. Returns the claims or null. The target's Link checks the same token
++ * again with the same public key: Bot is a relay, never the authority.
++ */
++ async function verifyGrantToken(token, audience) {
++ if (typeof token !== 'string' || token.length < 32 || token.length > 4096) return null;
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }
++ const candidates = await keys.keysFor(kid);
++ for (const k of candidates) {
++ let claims;
++ try { claims = jwt.verify(token, k.key, { algorithms: ['RS256'], issuer: config.network.issuer, audience }); } catch { continue; }
++ const v = validate('link.access-grant-token@1', claims);
++ if (!v.valid) return null;
++ return claims;
++ }
++ return null;
++ }
++ function closeRelay(session, reason) {
++ if (!session || !relays.has(session.id)) return false;
++ relays.delete(session.id);
++ for (const conn of [session.from, session.to]) sendFrame(conn, 'relay.closed', { relay: session.id, reason });
++ return true;
++ }
++ /**
++ * Tell Network one scope was used under a grant, for the audit line both owners read
++ * (`GET /api/v1/me/nodes/access-audit`). Never awaited and never fatal: a relay must not stop because the
++ * ledger is briefly down, and the token's short expiry still bounds it.
++ */
++ function auditRelayUse(session, scope) {
++ if (!networkSend) return;
++ const body = { from_principal: session.fromPrincipal, to_principal: session.toPrincipal,
++ scope, action: RELAY_SCOPE_ACTION[scope] || scope };
++ Promise.resolve().then(() => networkSend(`/internal/link-access/grants/${encodeURIComponent(session.grantId)}/use`, body))
++ .then((res) => { if (res && !res.ok) log.warn(`[Bot] relay audit ${session.grantId} ${scope}: ${res.status}`); })
++ .catch((e) => log.warn(`[Bot] relay audit ${session.grantId} ${scope}: ${e.message}`));
++ }
++ /**
++ * relay.open: { to: 'lnk_B', grant_token }. The asking machine is the socket's own Link principal; the token must
++ * name it as `sub` and the target as `aud`, in date. The target is told the relay id and the same token so its
++ * own Link verifies it too.
++ */
++ async function onRelayOpen(conn, msg) {
++ if (!conn.node) return sendError(conn, 'bot.relay_link_only', 'only a paired machine may open a relay');
++ const to = String(msg.to || '');
++ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(to)) return sendError(conn, 'bot.relay_target', 'to must be a machine principal (lnk_…)');
++ if (to === conn.node.principal) return sendError(conn, 'bot.relay_self', 'a machine cannot relay to itself');
++ const target = linkConn(to);
++ if (!target) return sendError(conn, 'bot.relay_target_offline', 'the receiving machine is not connected');
++ const claims = await verifyGrantToken(msg.grant_token, to);
++ if (!claims || claims.sub !== conn.node.principal) {
++ return sendError(conn, 'bot.relay_grant_denied', 'no live Network grant lets this machine relay to that one');
++ }
++ const relayId = prefixedId('rly', now());
++ const session = { id: relayId, grantId: claims.grant_id, scopes: new Set(claims.scopes), usedScopes: new Set(),
++ from: conn, fromPrincipal: conn.node.principal, to: target, toPrincipal: to, expiresAt: claims.exp * 1000, openedAt: now() };
++ relays.set(relayId, session);
++ const opened = { relay: relayId, to, grant_token: msg.grant_token, scopes: claims.scopes,
++ expires_at: iso(session.expiresAt), from: session.fromPrincipal };
++ sendFrame(conn, 'relay.opened', opened);
++ sendFrame(target, 'relay.opened', opened);
++ return undefined;
++ }
++ /**
++ * relay.frame: { relay, scope, body }. Checked on every frame in both directions: the relay is open, the sender is
++ * one of its two machines, the scope is in the grant and the grant is still in date. For `computer` the asking
++ * frame names the level it wants and Bot refuses one above the target's own; the target's Link caps it again.
++ */
++ function onRelayFrame(conn, msg) {
++ const session = relays.get(String(msg.relay || ''));
++ if (!session) return sendError(conn, 'bot.relay_unknown', 'the relay is closed');
++ const outbound = conn === session.from;
++ if (!outbound && conn !== session.to) return sendError(conn, 'bot.relay_forbidden', 'this machine is not part of the relay');
++ if (now() >= session.expiresAt) { closeRelay(session, 'expired'); return sendError(conn, 'bot.relay_expired', 'the grant token expired'); }
++ const scope = String(msg.scope || '');
++ if (!RELAY_SCOPES.has(scope) || !session.scopes.has(scope)) {
++ return sendError(conn, 'bot.relay_scope_denied', `the grant does not include ${scope || 'that scope'}`);
++ }
++ let body;
++ try { body = msg.body; } catch { body = undefined; }
++ let size = 0;
++ try { size = Buffer.byteLength(JSON.stringify(body === undefined ? null : body), 'utf8'); } catch { size = RELAY_MAX_BODY + 1; }
++ if (body === undefined || size > RELAY_MAX_BODY) {
++ return sendError(conn, 'bot.relay_body', `a relayed body is required and at most ${RELAY_MAX_BODY} bytes`);
++ }
++ if (outbound && scope === 'computer') {
++ const offered = session.to.status && session.to.status.capabilities && session.to.status.capabilities.computer;
++ if (session.to.computerAllowed === false || !offered) {
++ return sendError(conn, 'bot.relay_computer_off', 'the receiving machine does not offer its computer');
++ }
++ const have = linkComputerLevel(session.to);
++ const asked = body && typeof body.level === 'string' ? body.level : 'ask';
++ if (!(asked in COMPUTER_LEVELS)) return sendError(conn, 'bot.relay_computer_level', 'level must be observe, ask, trusted or full');
++ const own = COMPUTER_LEVELS[have] === undefined ? COMPUTER_LEVELS.ask : COMPUTER_LEVELS[have];
++ if (COMPUTER_LEVELS[asked] > own) {
++ return sendError(conn, 'bot.relay_computer_level', `the receiving machine allows up to ${have || 'ask'}; ${asked} is above it`);
++ }
++ }
++ sendFrame(outbound ? session.to : session.from, 'relay.frame', {
++ relay: session.id, from: outbound ? session.fromPrincipal : session.toPrincipal, scope, body,
++ });
++ if (outbound || !session.usedScopes.has(scope)) {
++ // One audit line per scope per relay, however many frames cross it; both directions use the same grant.
++ if (!session.usedScopes.has(scope)) { session.usedScopes.add(scope); auditRelayUse(session, scope); }
++ }
++ return undefined;
++ }
++ function onRelayClose(conn, msg) {
++ const session = relays.get(String(msg.relay || ''));
++ if (!session) return sendError(conn, 'bot.relay_unknown', 'the relay is already closed');
++ if (conn !== session.from && conn !== session.to) return sendError(conn, 'bot.relay_forbidden', 'this machine is not part of the relay');
++ closeRelay(session, 'closed');
++ return undefined;
++ }
++ /** Network's network.link.access.revoked@1: every relay under that grant closes now, both ends told why. */
++ function revokeGrant(grantId) {
++ const key = String(grantId || '');
++ let closed = 0;
++ for (const session of [...relays.values()]) if (session.grantId === key) { closeRelay(session, 'revoked'); closed++; }
++ return closed;
++ }
++ /** Network's POST /api/v1/machines/:id/relay → an `agent.task` frame on the machine. Never queued for an offline one. */
++ function deliverAgentTask(deviceId, task) {
++ const conn = deviceConns.get(String(deviceId || ''));
++ if (!conn || !conn.online || !conn.node) return false;
++ if (!task || typeof task !== 'object' || !AGENT_TASK_RE.test(String(task.id || ''))) return false;
++ agentTasks.set(task.id, { deviceId: conn.device.id, at: now() });
++ return sendFrame(conn, 'agent.task', { task });
++ }
++ /**
++ * agent.task_result: { task_id, status, result }. Only the machine the task was delivered to may answer, and only
++ * Network records the answer (the task's row and its event); Bot holds no task state of its own.
++ */
++ async function onAgentTaskResult(conn, msg) {
++ const taskId = String(msg.task_id || '');
++ const delivered = agentTasks.get(taskId);
++ if (!AGENT_TASK_RE.test(taskId) || !delivered || delivered.deviceId !== conn.device.id) {
++ return sendError(conn, 'bot.agent_task_unknown', 'no agent task was delivered to this machine under that id');
++ }
++ const status = String(msg.status || '');
++ if (!AGENT_STATUSES.has(status)) return sendError(conn, 'bot.bad_frame', 'status must be running, completed, failed or cancelled');
++ let result = msg.result;
++ if (result != null) {
++ const parts = result && result.parts;
++ if (!Array.isArray(parts) || parts.length < 1 || parts.length > MAX_AGENT_PARTS
++ || parts.some((p) => !p || p.type !== 'text' || typeof p.text !== 'string' || p.text.length > MAX_AGENT_TEXT)) {
++ return sendError(conn, 'bot.bad_frame', 'result must be text parts of at most 8000 characters');
++ }
++ result = { parts: parts.map((p) => ({ type: 'text', text: p.text })) };
++ }
++ if (!networkSend) return sendError(conn, 'bot.network_unavailable', 'the network cannot record the answer now');
++ let res;
++ try { res = await networkSend(`/api/v1/agents/tasks/${encodeURIComponent(taskId)}/result`, { status, ...(result ? { result } : {}) }); }
++ catch (e) { return sendError(conn, 'bot.network_unavailable', `the answer was not recorded: ${e.message}`); }
++ if (!res || !res.ok) return sendError(conn, 'bot.agent_result_refused', 'the network did not accept the answer');
++ if (status !== 'running') agentTasks.delete(taskId);
++ return sendFrame(conn, 'agent.task_result_ack', { task_id: taskId, status });
++ }
++
/**
* Keep what the machine's coding agents have left (Codes C9c), from the heartbeat's `fleet`. The reading is
* stored when it changed or is a minute old, so a machine that keeps beating keeps `fleet_at` current without a
@@@ -433,6 +630,11 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case 'ack': case 'nack': return onAck(conn, msg);
case 'estop_state': return onEstopState(conn, msg);
case 'voice_ask': return onVoiceAsk(conn, msg);
++ // Machine-to-machine relay (Contracts 0.409.0) and Network's agent tasks to a machine.
++ case 'relay.open': return onRelayOpen(conn, msg);
++ case 'relay.frame': return onRelayFrame(conn, msg);
++ case 'relay.close': return onRelayClose(conn, msg);
++ case 'agent.task_result': return onAgentTaskResult(conn, msg);
// Jobs (platform.job-frame@1). Out: job, job_cancel, job_exit_ack (server/jobs/dispatch.js); in:
case 'job_started': case 'job_stdout': case 'job_usage': case 'job_exit': return jobFrame(conn, msg);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
@@@ -1108,6 +1310,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
function checkHeartbeats() {
++ // A relay never outlives its grant token; an agent task nobody answered is forgotten after an hour.
++ const at = now();
++ for (const session of [...relays.values()]) if (at >= session.expiresAt) closeRelay(session, 'expired');
++ for (const [taskId, task] of agentTasks) if (at - task.at > 3_600_000) agentTasks.delete(taskId);
for (const conn of [...deviceConns.values()]) {
if (conn.node && now() > conn.node.reauthBy) {
// The Link token lapsed with no valid reauth: the machine must authenticate again.
@@@ -1151,6 +1357,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
machineCommand, machineScreen, computerOf, machineChanged,
++ // Network's grant relay and agent tasks (Contracts 0.409.0): close every relay under a revoked grant, and
++ // hand an agent.task to the machine it names.
++ revokeGrant, deliverAgentTask, relayCount: () => relays.size,
// Real devices only: a simulator is not a machine anyone runs.
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
protectedSessions() {
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex f6ae4a5..64f3837 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -86,6 +86,16 @@ async function startNetwork() {
const now = Math.floor(Date.now() / 1000);
return serviceAuth.signServiceToken({ iss: issuer, sub: `link:${principal}`, actor_type: 'link', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);
}
++ /** A machine-access grant token (link.access-grant-token@1): Network signs the claims the Link verifies itself. */
++ function signGrant({ from, to, scopes = ['agent'], grantId = null, expSec = 300 } = {}) {
++ const nowSec = Math.floor(Date.now() / 1000);
++ return jwt.sign({ iss: issuer, aud: to, sub: from, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ grant_id: grantId || `grt_${crypto.randomBytes(10).toString('hex')}`, scopes, iat: nowSec, exp: nowSec + expSec },
++ privatePem, { algorithm: 'RS256', header: { kid: 'test-network-1', typ: 'JWT' } });
++ }
++ // What Bot told Network: one scoped use per relayed scope (the audit line both owners read), and each agent task
++ // answer. They are asserted by test/machine-access-relay.test.js.
++ const ledger = { uses: [], results: [] };
/** The calling service (svc:<id> → <id>) of a valid network.link.manage token for openvibe.network, or null. */
function nodeManager(req) {
const h = String(req.headers.authorization || '');
@@@ -192,6 +202,16 @@ async function startNetwork() {
}
return send(res, 200, n.view);
}
++ const use = /^\/internal\/link-access\/grants\/([^/]+)\/use$/.exec(idn.pathname);
++ if (use && req.method === 'POST') {
++ ledger.uses.push({ grant_id: decodeURIComponent(use[1]), body: JSON.parse((await readBody(req)) || '{}') });
++ return send(res, 200, { grant: { id: decodeURIComponent(use[1]) } });
++ }
++ const result = /^\/api\/v1\/agents\/tasks\/([^/]+)\/result$/.exec(idn.pathname);
++ if (result && req.method === 'POST') {
++ ledger.results.push({ task_id: decodeURIComponent(result[1]), body: JSON.parse((await readBody(req)) || '{}') });
++ return send(res, 200, { task: { id: decodeURIComponent(result[1]), status: 'completed' } });
++ }
send(res, 404, { error: 'not found' });
});
const url = await listen(server);
@@@ -202,7 +222,7 @@ async function startNetwork() {
codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });
return code;
}
-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, issueCode, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, signGrant, ledger, nodes, revokes, pairings, statuses, tokenCalls, issueCode, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
}
async function startEvents() {
ddiff --git a/test/machine-access-relay.test.js b/test/machine-access-relay.test.js
nnew file mode 100644
iindex 0000000..d3b418e
---- /dev/null
++++ b/test/machine-access-relay.test.js
@@@ -0,0 +1,163 @@
++'use strict';
++// Machine access, stage 1 (Contracts 0.409.0): two paired machines relay frames only under Network's signed grant,
++// the scope is checked on every frame, a computer frame is capped by the target's own level, a revoked grant closes
++// the relay at once, and Network's agent.task reaches a machine whose answer goes back to Network. Each refusal is
++// checked, and the grant-use audit is recorded with Network once per scope.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ // The sockets in this test send no heartbeats; keep a quiet machine online for the whole run.
++ const t = await boot({ env: { BOT_OFFLINE_MISSES: '100000' } });
++ const alex = t.network.newUser('alex'), sam = t.network.newUser('sam');
++ const alexRobot = (await t.robot(alex)).robot;
++ const samRobot = (await t.robot(sam)).robot;
++ const a = t.network.addNode({ owner: alex.subject, ref: alexRobot.id, name: 'laptop' });
++ const b = t.network.addNode({ owner: sam.subject, ref: samRobot.id, name: 'desktop' });
++ const c = t.network.addNode({ owner: sam.subject, ref: samRobot.id, name: 'other' });
++ const connect = async (principal) => {
++ const ws = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ await ws.waitFor((m) => m.type === 'config');
++ return ws;
++ };
++ const ma = await connect(a), mb = await connect(b);
++ const device = async (principal) => await t.db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principal]);
++ // Frames are asynchronous: settle, then forget what the last check left so a wait can never read a stale frame.
++ const drain = async (...clients) => { await t.wait(50); for (const c of clients) c.messages = []; };
++ const nextError = async (client = ma) => {
++ const e = await client.waitFor((m) => m.type === 'error');
++ if (e) client.messages.splice(client.messages.indexOf(e), 1);
++ return e;
++ };
++ const da = await device(a), db = await device(b);
++
++ const open = async (from, to, scopes, source = ma, target = mb) => {
++ const grantId = `grt_${Math.random().toString(36).slice(2, 14)}${Math.random().toString(36).slice(2, 14)}`;
++ await drain(source, target);
++ source.send({ type: 'relay.open', to, grant_token: t.network.signGrant({ from, to, scopes, grantId }) });
++ const opened = await source.waitFor((m) => m.type === 'relay.opened');
++ assert.ok(opened, 'relay opened on the asking machine');
++ const targetOpened = await target.waitFor((m) => m.type === 'relay.opened' && m.relay === opened.relay);
++ assert.ok(targetOpened, 'the target is told the same relay and the same token');
++ assert.strictEqual(targetOpened.grant_token, opened.grant_token);
++ return { relay: opened.relay, grantId };
++ };
++
++ await check('a grant relays a frame both ways, and the use is audited with Network once per scope', async () => {
++ await drain(ma, mb);
++ const { relay, grantId } = await open(a, b, ['screen', 'agent']);
++ ma.send({ type: 'relay.frame', relay, scope: 'screen', body: { hello: 'screen' } });
++ const atB = await mb.waitFor((m) => m.type === 'relay.frame');
++ assert.strictEqual(atB.from, a);
++ assert.deepStrictEqual(atB.body, { hello: 'screen' });
++ mb.send({ type: 'relay.frame', relay, scope: 'screen', body: { answer: 1 } });
++ const atA = await ma.waitFor((m) => m.type === 'relay.frame' && m.from === b);
++ assert.deepStrictEqual(atA.body, { answer: 1 });
++ await t.wait(120);
++ const use = t.network.ledger.uses.find((u) => u.grant_id === grantId);
++ assert.ok(use, 'Network was told the grant was used');
++ assert.strictEqual(use.body.scope, 'screen');
++ assert.strictEqual(use.body.from_principal, a);
++ assert.strictEqual(use.body.to_principal, b);
++ ma.send({ type: 'relay.close', relay });
++ assert.strictEqual((await ma.waitFor((m) => m.type === 'relay.closed')).reason, 'closed');
++ assert.strictEqual((await mb.waitFor((m) => m.type === 'relay.closed')).reason, 'closed');
++ });
++
++ await check('a scope outside the grant is refused and never delivered', async () => {
++ await drain(ma, mb);
++ const { relay } = await open(a, b, ['agent']);
++ ma.send({ type: 'relay.frame', relay, scope: 'screen', body: { x: 1 } });
++ const err = await nextError();
++ assert.strictEqual(err.code, 'bot.relay_scope_denied');
++ assert.ok(!mb.messages.some((m) => m.type === 'relay.frame' && m.body && m.body.x === 1));
++ ma.send({ type: 'relay.close', relay });
++ await ma.waitFor((m) => m.type === 'relay.closed');
++ });
++
++ await check('a token for another machine, a forged requester, an expired token and an unknown relay are refused', async () => {
++ await drain(ma, mb);
++ ma.send({ type: 'relay.open', to: b, grant_token: t.network.signGrant({ from: c, to: b, scopes: ['agent'] }) });
++ assert.strictEqual((await nextError()).code, 'bot.relay_grant_denied');
++ ma.send({ type: 'relay.open', to: b, grant_token: t.network.signGrant({ from: a, to: c, scopes: ['agent'] }) });
++ assert.strictEqual((await nextError()).code, 'bot.relay_grant_denied');
++ ma.send({ type: 'relay.open', to: b, grant_token: t.network.signGrant({ from: a, to: b, scopes: ['agent'], expSec: -1 }) });
++ assert.strictEqual((await nextError()).code, 'bot.relay_grant_denied');
++ ma.send({ type: 'relay.open', to: c, grant_token: t.network.signGrant({ from: a, to: c, scopes: ['agent'] }) });
++ assert.strictEqual((await nextError()).code, 'bot.relay_target_offline');
++ ma.send({ type: 'relay.frame', relay: 'rly_nope', scope: 'agent', body: {} });
++ assert.strictEqual((await nextError()).code, 'bot.relay_unknown');
++ });
++
++ await check('a computer frame is capped by the receiving machine\'s own level', async () => {
++ await drain(ma, mb);
++ mb.send({ type: 'status', capabilities: { computer: { files: { level: 'ask' } } }, telemetry: { sensors: { computer_level: 'ask' } } });
++ await t.wait(50);
++ const { relay } = await open(a, b, ['computer']);
++ ma.send({ type: 'relay.frame', relay, scope: 'computer', body: { level: 'trusted', action: 'fs' } });
++ const capped = await nextError();
++ assert.strictEqual(capped.code, 'bot.relay_computer_level');
++ ma.send({ type: 'relay.frame', relay, scope: 'computer', body: { level: 'ask', action: 'fs' } });
++ const frame = await mb.waitFor((m) => m.type === 'relay.frame' && m.scope === 'computer');
++ assert.strictEqual(frame.body.level, 'ask');
++ ma.send({ type: 'relay.close', relay });
++ await ma.waitFor((m) => m.type === 'relay.closed');
++ });
++
++ await check('Network revoking the grant closes the relay at once and the id stops working', async () => {
++ await drain(ma, mb);
++ const { relay, grantId } = await open(a, b, ['agent']);
++ assert.strictEqual(t.hub.revokeGrant(grantId), 1);
++ assert.strictEqual((await ma.waitFor((m) => m.type === 'relay.closed')).reason, 'revoked');
++ assert.strictEqual((await mb.waitFor((m) => m.type === 'relay.closed')).reason, 'revoked');
++ ma.send({ type: 'relay.frame', relay, scope: 'agent', body: {} });
++ assert.strictEqual((await nextError()).code, 'bot.relay_unknown');
++ });
++
++ await check('Network delivers an agent.task over HTTP; only the machine it reached may answer, and the answer reaches Network', async () => {
++ await drain(ma, mb);
++ const relayToken = t.network.signService({ sub: 'svc:network', cap: ['bot.machine.relay'] });
++ const task = { id: `tsk_${'a'.repeat(24)}`, thread: `thr_${'b'.repeat(24)}`, from: `agt_${'C'.repeat(26)}`,
++ to: `agt_${'D'.repeat(26)}`, on_behalf_of: sam.subject, message: { role: 'user', parts: [{ type: 'text', text: 'hello' }] } };
++ const delivered = await t.call('POST', `/api/v1/machines/${db.id}/relay`, { token: relayToken,
++ headers: { 'X-OV-Subject': sam.subject }, body: { type: 'agent.task', task } });
++ assert.strictEqual(delivered.status, 201, delivered.text);
++ const frame = await mb.waitFor((m) => m.type === 'agent.task');
++ assert.strictEqual(frame.task.id, task.id);
++ // The other machine may not answer a task it never received.
++ ma.send({ type: 'agent.task_result', task_id: task.id, status: 'completed', result: { parts: [{ type: 'text', text: 'no' }] } });
++ assert.strictEqual((await ma.waitFor((m) => m.type === 'error')).code, 'bot.agent_task_unknown');
++ // A body over the text limit is refused before it leaves Bot (while the task is still open).
++ mb.send({ type: 'agent.task_result', task_id: task.id, status: 'completed', result: { parts: [{ type: 'text', text: 'x'.repeat(8001) }] } });
++ assert.strictEqual((await mb.waitFor((m) => m.type === 'error')).code, 'bot.bad_frame');
++ mb.send({ type: 'agent.task_result', task_id: task.id, status: 'completed', result: { parts: [{ type: 'text', text: 'done' }] } });
++ const ack = await mb.waitFor((m) => m.type === 'agent.task_result_ack');
++ assert.strictEqual(ack.status, 'completed');
++ await t.wait(120);
++ const sent = t.network.ledger.results.find((r) => r.task_id === task.id);
++ assert.ok(sent, 'Network received the answer');
++ assert.strictEqual(sent.body.status, 'completed');
++ assert.strictEqual(sent.body.result.parts[0].text, 'done');
++ });
++
++ await check('the relay route refuses another owner, a missing capability and a malformed task', async () => {
++ await drain(ma, mb);
++ const relayToken = t.network.signService({ sub: 'svc:network', cap: ['bot.machine.relay'] });
++ const task = { id: `tsk_${'e'.repeat(24)}`, thread: `thr_${'f'.repeat(24)}`, from: `agt_${'G'.repeat(26)}`,
++ to: `agt_${'H'.repeat(26)}`, on_behalf_of: alex.subject, message: { parts: [{ type: 'text', text: 'x' }] } };
++ const wrong = await t.call('POST', `/api/v1/machines/${db.id}/relay`, { token: relayToken,
++ headers: { 'X-OV-Subject': alex.subject }, body: { type: 'agent.task', task } });
++ assert.strictEqual(wrong.status, 404, wrong.text);
++ const noCap = await t.call('POST', `/api/v1/machines/${db.id}/relay`, { token: t.network.signService({ sub: 'svc:network', cap: ['bot.machine.manage'] }),
++ headers: { 'X-OV-Subject': sam.subject }, body: { type: 'agent.task', task } });
++ assert.strictEqual(noCap.status, 403, noCap.text);
++ const bad = await t.call('POST', `/api/v1/machines/${db.id}/relay`, { token: relayToken,
++ headers: { 'X-OV-Subject': sam.subject }, body: { type: 'agent.task', task: { ...task, id: 'nope' } } });
++ assert.strictEqual(bad.status, 409, bad.text);
++ const notTask = await t.call('POST', `/api/v1/machines/${db.id}/relay`, { token: relayToken,
++ headers: { 'X-OV-Subject': sam.subject }, body: { type: 'computer.command' } });
++ assert.strictEqual(notTask.status, 422, notTask.text);
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });