diff --git a/.github/workflows/delegation-tests.yml b/.github/workflows/delegation-tests.yml new file mode 100644 index 0000000..2d962bb --- /dev/null +++ b/.github/workflows/delegation-tests.yml @@ -0,0 +1,32 @@ +name: Trinity worker delegation tests + +on: + pull_request: + paths: + - 'workers/**' + - 'src/routes/workers.js' + - 'src/index.js' + - 'tests/delegation.test.mjs' + - '.github/workflows/delegation-tests.yml' + push: + branches: + - feature/trinity-worker-delegation + +permissions: + contents: read + +jobs: + delegation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Check syntax + run: | + node --check workers/shared/delegation.mjs + node --check workers/shared/delegated-handler.mjs + node --check workers/dispatcher/src/index.mjs + - name: Run delegation tests + run: node --test tests/delegation.test.mjs diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2284b93..464054f 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,31 +1,28 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Node.js CI +name: Trinity Worker CI on: push: - branches: [ "main" ] + branches: [main, feature/trinity-worker-delegation] pull_request: - branches: [ "main" ] + branches: [main] + workflow_dispatch: + +permissions: + contents: read jobs: build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Run automated tests + run: npm test + - name: Bundle application without deploying + run: npm run build diff --git a/.github/workflows/webpack.yml b/.github/workflows/webpack.yml index 9626ff6..a3f5a16 100644 --- a/.github/workflows/webpack.yml +++ b/.github/workflows/webpack.yml @@ -1,28 +1,26 @@ -name: NodeJS with Webpack +name: Trinity Dispatcher Build on: push: - branches: [ "main" ] + branches: [main, feature/trinity-worker-delegation] pull_request: - branches: [ "main" ] + branches: [main] + workflow_dispatch: + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - + timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Build - run: | - npm install - npx webpack + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install locked dependencies + run: npm ci + - name: Bundle dispatcher without deploying + run: npx --no-install wrangler deploy --config workers/dispatcher/wrangler.toml --dry-run --outdir .wrangler/ci-dispatcher diff --git a/package.json b/package.json index 579f3df..82384fa 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "Underground Street Collective - Tenebris API Gateway", "main": "src/index.js", "scripts": { - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "test": "node --test tests/*.test.mjs", + "build": "wrangler deploy --dry-run --outdir .wrangler/ci-app" }, "devDependencies": { "wrangler": "^3.0.0" diff --git a/src/index.js b/src/index.js index 423e2a0..cd58d4c 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import { handlePillars } from './routes/pillars.js'; import { handleSecurity } from './routes/security.js'; import { handleIntegrations } from './routes/integrations.js'; import { handleAI } from './routes/ai.js'; +import { handleWorkers } from './routes/workers.js'; import { handleShop } from './routes/api-shop.js'; import { HTML } from './frontend/app.js'; @@ -34,7 +35,7 @@ export default { if (path.startsWith('/api/auth')) return handleAuth(request, env); // Protected routes — require valid session - if (path.startsWith('/api/pillars') || path.startsWith('/api/security') || path.startsWith('/api/integrations') || path.startsWith('/api/ai')) { + if (path.startsWith('/api/pillars') || path.startsWith('/api/security') || path.startsWith('/api/integrations') || path.startsWith('/api/ai') || path.startsWith('/api/workers')) { const session = requireAuth(request); if (!session) return json({ error: 'Neautorizovaný prístup' }, 401); } @@ -43,6 +44,7 @@ export default { if (path.startsWith('/api/security')) return handleSecurity(request, env); if (path.startsWith('/api/integrations')) return handleIntegrations(request, env); if (path.startsWith('/api/ai')) return handleAI(request, env); + if (path.startsWith('/api/workers')) return handleWorkers(request, env); // --- Shop (existing) --- if (path.startsWith('/api/shop')) return handleShop(request, env); diff --git a/src/routes/workers.js b/src/routes/workers.js new file mode 100644 index 0000000..fecc55c --- /dev/null +++ b/src/routes/workers.js @@ -0,0 +1,22 @@ +// Auth for /api/workers/* is enforced by src/index.js before this route. +import { requestWorkerHelp, jsonResult, CAPABILITIES } from '../../workers/shared/delegation.mjs'; + +export async function handleWorkers(request, env) { + const { pathname } = new URL(request.url); + if (pathname === '/api/workers/capabilities' && request.method === 'GET') { + return jsonResult({ capabilities: CAPABILITIES, delegated: Boolean(env?.TRINITY_DISPATCHER) }); + } + if (pathname === '/api/workers/help' && request.method === 'POST') { + if (Number(request.headers.get('content-length') ?? 0) > 8192) { + return jsonResult({ error: 'Task too large' }, 413); + } + let input; + try { + input = await request.json(); + } catch { + return jsonResult({ error: 'Invalid JSON' }, 400); + } + return requestWorkerHelp(env, input); + } + return jsonResult({ error: 'Endpoint not found' }, 404); +} diff --git a/tests/delegation.test.mjs b/tests/delegation.test.mjs new file mode 100644 index 0000000..b78a86a --- /dev/null +++ b/tests/delegation.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import dispatcher from '../workers/dispatcher/src/index.mjs'; +import { requestWorkerHelp } from '../workers/shared/delegation.mjs'; +import { createDelegatedTaskHandler } from '../workers/shared/delegated-handler.mjs'; +import { handleWorkers } from '../src/routes/workers.js'; + +const task = { instruction: 'Summarize this public document' }; +const payload = { capability: 'research', task, mode: 'read', depth: 0, requestId: 'test-1' }; +function post(url, body, token = 'secret') { + return new Request(url, { + method: 'POST', + headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} +const dispatcherUrl = 'https://dispatcher.internal/delegate'; + +test('dispatcher rejects missing or incorrect credentials', async () => { + const r = await dispatcher.fetch(post(dispatcherUrl, payload, 'wrong'), { TRINITY_DELEGATION_SECRET: 'secret' }); + assert.equal(r.status, 401); +}); + +test('dispatcher rejects write operations', async () => { + const r = await dispatcher.fetch(post(dispatcherUrl, { ...payload, mode: 'write' }), { TRINITY_DELEGATION_SECRET: 'secret' }); + assert.equal(r.status, 403); +}); + +test('dispatcher refuses unbounded recursive delegation', async () => { + const r = await dispatcher.fetch(post(dispatcherUrl, { ...payload, depth: 2 }), { TRINITY_DELEGATION_SECRET: 'secret' }); + assert.equal(r.status, 409); +}); + +test('dispatcher reports missing helper without auto-provisioning', async () => { + const r = await dispatcher.fetch(post(dispatcherUrl, payload), { TRINITY_DELEGATION_SECRET: 'secret' }); + assert.equal(r.status, 202); + assert.equal((await r.json()).status, 'helper_not_configured'); +}); + +test('dispatcher delegates to a registered read-only helper', async () => { + let downstream; + const env = { + TRINITY_DELEGATION_SECRET: 'secret', + RESEARCH_HELPER: { + async fetch(request) { + downstream = await request.json(); + return Response.json({ summary: 'ok' }); + }, + }, + }; + const r = await dispatcher.fetch(post(dispatcherUrl, payload), env); + const body = await r.json(); + assert.equal(r.status, 200); + assert.equal(body.result.summary, 'ok'); + assert.equal(downstream.depth, 1); + assert.equal(downstream.capability, 'research'); +}); + +test('requestWorkerHelp requires configured dispatcher and secret', async () => { + const r = await requestWorkerHelp({}, payload); + assert.equal(r.status, 503); +}); + +test('requestWorkerHelp invokes dispatcher through service binding', async () => { + let path; + const env = { + TRINITY_DELEGATION_SECRET: 'secret', + TRINITY_DISPATCHER: { + async fetch(request) { + path = new URL(request.url).pathname; + return Response.json({ status: 'completed' }); + }, + }, + }; + const r = await requestWorkerHelp(env, payload); + assert.equal(r.status, 200); + assert.equal(path, '/delegate'); +}); + +test('a helper adapter verifies token, role and read-only mode', async () => { + const handler = createDelegatedTaskHandler('research', async () => ({ answer: 'done' })); + const bad = await handler(post('https://helper.internal/internal/task', payload, 'wrong'), { TRINITY_DELEGATION_SECRET: 'secret' }); + assert.equal(bad.status, 401); + const good = await handler(post('https://helper.internal/internal/task', { ...payload, depth: 1 }), { TRINITY_DELEGATION_SECRET: 'secret' }); + assert.equal(good.status, 200); + assert.equal((await good.json()).result.answer, 'done'); +}); + +test('dashboard returns capabilities without configured dispatcher', async () => { + const request = new Request('https://admin.internal/api/workers/capabilities'); + const response = await handleWorkers(request, {}); + assert.equal(response.status, 200); + assert.equal((await response.json()).delegated, false); +}); diff --git a/workers/README.md b/workers/README.md new file mode 100644 index 0000000..da72f04 --- /dev/null +++ b/workers/README.md @@ -0,0 +1,51 @@ +# Trinity worker-to-worker delegation (proposal) + +This module gives each existing Trinity Worker the ability to request a bounded, **read-only** helper. It DOES NOT create a new Cloudflare Worker automatically, deploy code, connect to the Cloudflare API, or modify an active production environment. + +## Components + +- `shared/delegation.mjs` — reusable client; validate task and delegate through `env.TRINITY_DISPATCHER.fetch` +- `dispatcher/src/index.mjs` — named-capability router for pre-configured service bindings +- `shared/delegated-handler.mjs` — authentication and validation adapter to install inside each helper +- `../src/routes/workers.js` — authenticated dashboard API (`/api/workers/capabilities` and `POST /api/workers/help`) + +Supported roles: `coding`, `browser`, `research`, `memory`, `model`, `tools`. Roles are allowlisted; unknown roles and arbitrary URLs are rejected. + +## Integration sequence — do not skip steps + +1. Identify actual Cloudflare deployed service names and each Worker's existing source. **This repo only contains the Tenebris/Trinity dashboard Worker.** No production fleet changes have been applied. +2. Add the dispatcher as an internal service (its config has `workers_dev = false`). Store the same strong `TRINITY_DELEGATION_SECRET` as an encrypted secret in the dispatcher, callers and participating helpers. Never place it in `[vars]`, Git, frontend code or client HTTP. +3. Configure a **service binding** in every calling Worker's own Wrangler config: + ```toml + [[services]] + binding = "TRINITY_DISPATCHER" + service = "trinity-dispatcher" + ``` + Also configure real downstream helpers in `dispatcher/wrangler.toml` using the supported role binding names (e.g. `RESEARCH_HELPER`) after verifying those Workers exist. +4. In each participating helper, integrate the `createDelegatedTaskHandler(capability, runReadOnly)` adapter into its server-side `fetch` router. The callback must implement actual domain logic with its existing tools and permissions, not grant new permissions. +5. In a calling Worker, use: + ```js + import { requestWorkerHelp } from './path-to/shared/delegation.mjs'; + const result = await requestWorkerHelp(env, { + capability: 'research', + mode: 'read', + task: { instruction: 'Summarize public data' }, + depth: 0, + }); + ``` +6. Test the binding and adapters in staging, then review before enabling in production. + +## Safety constraints + +- All mutations, code execution, social posting, deployments, creation/deletion of Cloudflare services and secret changes are **denied** by this delegation path and require a separate admin-approved execution workflow. +- No automatic child Worker provisioning. Missing role binding returns HTTP 202 with `helper_not_configured`, which is a **proposal/diagnostic**, not a job queued or a new service created. +- Two-hop ceiling; bounded payloads (8 KiB), 8–10 second timeouts, fixed allowlisted roles and no arbitrary destinations. +- The `requestId` is for tracing only. Durable idempotency, persistence, retries, rate limiting, workload metering, RBAC and queues must be integrated before high-volume or side-effecting workloads. +- Cloudflare service bindings are internal Worker-to-Worker `fetch()` calls; they do not provision Worker services on their own. +- The existing dashboard has a demo/in-memory authentication implementation and must be hardened separately before treating it as a secure production admin interface. + +## Verify + +Node 22+: `node --test tests/delegation.test.mjs` + +No files on the primary `main` branch or on deployed Cloudflare services are modified by this feature branch. diff --git a/workers/dispatcher/src/index.mjs b/workers/dispatcher/src/index.mjs new file mode 100644 index 0000000..2d9240a --- /dev/null +++ b/workers/dispatcher/src/index.mjs @@ -0,0 +1,95 @@ +// Trinity Dispatcher: service-bound role routing, never auto-provisions Workers. +import { CAPABILITIES, MAX_DEPTH, jsonResult, validateDelegation } from '../../shared/delegation.mjs'; + +const BINDINGS = Object.freeze({ + coding: 'CODING_HELPER', + browser: 'BROWSER_HELPER', + research: 'RESEARCH_HELPER', + memory: 'MEMORY_HELPER', + model: 'MODEL_HELPER', + tools: 'TOOLS_HELPER', +}); + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/health' && request.method === 'GET') { + // Service-level signal only, not public fleet status. + return jsonResult({ service: 'trinity-dispatcher', status: 'online' }); + } + if (url.pathname !== '/delegate' || request.method !== 'POST') { + return jsonResult({ error: 'Not found' }, 404); + } + if (!env.TRINITY_DELEGATION_SECRET || + request.headers.get('authorization') !== 'Bearer ' + env.TRINITY_DELEGATION_SECRET) { + return jsonResult({ error: 'Unauthorized' }, 401); + } + if (Number(request.headers.get('content-length') ?? 0) > 8192) { + return jsonResult({ error: 'Task too large' }, 413); + } + let data; + try { + data = await request.json(); + } catch { + return jsonResult({ error: 'Invalid JSON' }, 400); + } + const error = validateDelegation(data); + if (error) return jsonResult({ error }, 400); + if (!CAPABILITIES.includes(data.capability)) return jsonResult({ error: 'Unknown role' }, 400); + if (data.mode !== 'read') { + return jsonResult({ status: 'approval_required', reason: 'Only read-only delegation is enabled' }, 403); + } + const depth = data.depth ?? 0; + if (depth >= MAX_DEPTH) return jsonResult({ error: 'Maximum depth reached' }, 409); + + const bindingName = BINDINGS[data.capability]; + const target = env[bindingName]; + if (!target || typeof target.fetch !== 'function') { + return jsonResult({ + status: 'helper_not_configured', + capability: data.capability, + action: 'Configure an existing helper service binding; new provisioning requires admin approval', + requestId: data.requestId, + }, 202); + } + try { + const response = await target.fetch(new Request('https://helper.internal/internal/task', { + method: 'POST', + headers: { + authorization: 'Bearer ' + env.TRINITY_DELEGATION_SECRET, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + task: data.task, + mode: 'read', + depth: depth + 1, + capability: data.capability, + requestId: data.requestId ?? crypto.randomUUID(), + }), + signal: AbortSignal.timeout(8000), + })); + const body = await response.text(); + if (!response.ok) { + return jsonResult({ + status: 'helper_failed', + capability: data.capability, + requestId: data.requestId, + helperStatus: response.status, + }, 502); + } + // Helper must return JSON; do not reflect arbitrary headers. + let result; + try { result = JSON.parse(body); } catch { + return jsonResult({ error: 'Invalid helper response', requestId: data.requestId }, 502); + } + return jsonResult({ + status: 'completed', + capability: data.capability, + requestId: data.requestId, + result, + }); + } catch { + return jsonResult({ status: 'unavailable', error: 'Helper call failed', requestId: data.requestId }, 502); + } + }, +}; diff --git a/workers/dispatcher/wrangler.toml b/workers/dispatcher/wrangler.toml new file mode 100644 index 0000000..1f01592 --- /dev/null +++ b/workers/dispatcher/wrangler.toml @@ -0,0 +1,24 @@ +name = "trinity-dispatcher" +main = "src/index.mjs" +compatibility_date = "2026-10-01" +workers_dev = false + +[observability] +enabled = true + +# Required: set TRINITY_DELEGATION_SECRET as an encrypted secret. +# Each source Worker needs a service binding named TRINITY_DISPATCHER +# and the same secret. Helper bindings below must target REAL deployed services. +# Uncomment only AFTER verifying a service exists: +# +# [[services]] +# binding = "CODING_HELPER" +# service = "trinity-coding" +# +# [[services]] +# binding = "RESEARCH_HELPER" +# service = "trinity-research" +# +# [[services]] +# binding = "BROWSER_HELPER" +# service = "trinity-browser" diff --git a/workers/shared/delegated-handler.mjs b/workers/shared/delegated-handler.mjs new file mode 100644 index 0000000..8ee8151 --- /dev/null +++ b/workers/shared/delegated-handler.mjs @@ -0,0 +1,34 @@ +// Import into EACH pre-existing helper Worker and call before its normal router. +// Supply only a read-only implementation for the named capability. +import { MAX_DEPTH, jsonResult, validateDelegation } from './delegation.mjs'; + +export function createDelegatedTaskHandler(capability, runReadOnly) { + if (typeof runReadOnly !== 'function') throw new TypeError('runReadOnly handler required'); + return async function handleDelegatedTask(request, env) { + if (new URL(request.url).pathname !== '/internal/task') return null; + if (request.method !== 'POST') return jsonResult({ error: 'Method not allowed' }, 405); + if (!env.TRINITY_DELEGATION_SECRET || + request.headers.get('authorization') !== 'Bearer ' + env.TRINITY_DELEGATION_SECRET) { + return jsonResult({ error: 'Unauthorized' }, 401); + } + if (Number(request.headers.get('content-length') ?? 0) > 8192) { + return jsonResult({ error: 'Task too large' }, 413); + } + let input; + try { input = await request.json(); } catch { + return jsonResult({ error: 'Invalid JSON' }, 400); + } + const error = validateDelegation(input); + if (error) return jsonResult({ error }, 400); + if (input.capability !== capability || input.mode !== 'read') { + return jsonResult({ error: 'Capability or mode not allowed' }, 403); + } + if ((input.depth ?? 0) > MAX_DEPTH) return jsonResult({ error: 'Max depth exceeded' }, 409); + try { + const result = await runReadOnly(input.task, { requestId: input.requestId, depth: input.depth }, env); + return jsonResult({ ok: true, result, requestId: input.requestId }); + } catch { + return jsonResult({ error: 'Helper execution failed', requestId: input.requestId }, 502); + } + }; +} diff --git a/workers/shared/delegation.mjs b/workers/shared/delegation.mjs new file mode 100644 index 0000000..39a20c8 --- /dev/null +++ b/workers/shared/delegation.mjs @@ -0,0 +1,79 @@ +// Shared, bounded delegation client. Copy/import in each Trinity Worker. +// No dynamic Cloudflare deployments are performed by this module. +export const CAPABILITIES = Object.freeze([ + 'coding', 'browser', 'research', 'memory', 'model', 'tools', +]); +export const MAX_DEPTH = 2; +const MAX_TASK_BYTES = 8192; + +export function jsonResult(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); +} + +export function validateDelegation(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) return 'Request must be an object'; + if (!CAPABILITIES.includes(input.capability)) return 'Unknown capability'; + if (input.mode !== undefined && input.mode !== 'read' && input.mode !== 'write') return 'Invalid mode'; + if (!Number.isInteger(input.depth ?? 0) || (input.depth ?? 0) < 0 || (input.depth ?? 0) > MAX_DEPTH) { + return 'Invalid delegation depth'; + } + if (!input.task || typeof input.task !== 'object' || Array.isArray(input.task)) return 'Invalid task'; + if (typeof input.task.instruction !== 'string' || input.task.instruction.trim().length < 1) { + return 'Task instruction is required'; + } + if (typeof input.requestId !== 'undefined' && + (typeof input.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(input.requestId))) { + return 'Invalid requestId'; + } + try { + if (new TextEncoder().encode(JSON.stringify(input)).byteLength > MAX_TASK_BYTES) return 'Task too large'; + } catch { + return 'Invalid task'; + } + return null; +} + +export async function requestWorkerHelp(env, input) { + const error = validateDelegation(input); + if (error) return jsonResult({ error }, 400); + if (input.mode === 'write') { + return jsonResult({ status: 'approval_required', reason: 'Write/execute/deploy tasks must use a separate approved workflow' }, 403); + } + if ((input.depth ?? 0) >= MAX_DEPTH) { + return jsonResult({ error: 'Maximum delegation depth reached' }, 409); + } + if (!env?.TRINITY_DISPATCHER || !env?.TRINITY_DELEGATION_SECRET) { + return jsonResult({ status: 'not_configured', error: 'Dispatcher binding or secret is missing' }, 503); + } + + const requestId = input.requestId ?? crypto.randomUUID(); + const message = { + capability: input.capability, + task: input.task, + mode: 'read', + depth: input.depth ?? 0, + requestId, + }; + try { + const response = await env.TRINITY_DISPATCHER.fetch(new Request('https://dispatcher.internal/delegate', { + method: 'POST', + headers: { + authorization: 'Bearer ' + env.TRINITY_DELEGATION_SECRET, + 'content-type': 'application/json', + }, + body: JSON.stringify(message), + signal: AbortSignal.timeout(10000), + })); + const body = await response.text(); + // Do not pass arbitrary downstream headers back to public clients. + return new Response(body, { + status: response.status, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); + } catch { + return jsonResult({ status: 'unavailable', error: 'Dispatcher request failed', requestId }, 502); + } +} diff --git a/wrangler.toml b/wrangler.toml index 2641a22..7e997dc 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -4,3 +4,9 @@ compatibility_date = "2026-08-31" [observability] enabled = true + +# Internal Worker-to-Worker connection. The target service must exist in the +# same Cloudflare account before this caller can be deployed. +[[services]] +binding = "TRINITY_DISPATCHER" +service = "trinity-dispatcher"