Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/delegation-tests.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 19 additions & 22 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 17 additions & 19 deletions .github/workflows/webpack.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
}
Expand All @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/routes/workers.js
Original file line number Diff line number Diff line change
@@ -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);
}
94 changes: 94 additions & 0 deletions tests/delegation.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
51 changes: 51 additions & 0 deletions workers/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading