Skip to content
Open
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
44 changes: 37 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,37 @@ env:
RUSTFLAGS: "-Dwarnings"

jobs:
cloud-function:
name: Cloud Function
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Test Cloud Function
run: npm run test:cf

- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high

lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f
with:
toolchain: stable
components: clippy, rustfmt

- name: Cache cargo registry
Expand Down Expand Up @@ -61,7 +83,9 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f
with:
toolchain: stable

- name: Cache cargo registry
uses: actions/cache@v4
Expand Down Expand Up @@ -89,7 +113,9 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f
with:
toolchain: stable

- name: Cache cargo registry
uses: actions/cache@v4
Expand Down Expand Up @@ -118,7 +144,9 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f
with:
toolchain: stable

- name: Install cargo-audit
run: cargo install cargo-audit
Expand Down Expand Up @@ -148,8 +176,9 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-action@stable
uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f
with:
toolchain: stable
components: llvm-tools-preview

- name: Install cargo-llvm-cov
Expand Down Expand Up @@ -190,12 +219,13 @@ jobs:
all-checks:
name: All Checks Pass
runs-on: ubuntu-latest
needs: [lint, test, build, security-audit]
needs: [cloud-function, lint, test, build, security-audit]
if: always()
steps:
- name: Check all jobs passed
run: |
if [ "${{ needs.lint.result }}" != "success" ] || \
if [ "${{ needs.cloud-function.result }}" != "success" ] || \
[ "${{ needs.lint.result }}" != "success" ] || \
[ "${{ needs.test.result }}" != "success" ] || \
[ "${{ needs.build.result }}" != "success" ] || \
[ "${{ needs.security-audit.result }}" != "success" ]; then
Expand Down
48 changes: 48 additions & 0 deletions docs/metaharness/2026-10-10-cloud-function-cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# MetaHarness evidence: Cloud Function CORS allowlist

## Scope and frozen contract

- Baseline: `main` at `7e0ec19c3035e98176db0d31cdef2d3ea9616b31`.
- Source candidate: restrict `Access-Control-Allow-Origin` to an exact configured origin, normalize comma-separated configuration, and emit `Vary: Origin` for dynamic allowlists.
- Compatibility anchors: explicit wildcard behavior, handler export, unknown-route 404, missing-backend 503, and unchanged Rust core/Cargo manifests.
- Project-source optimization and harness optimization were evaluated separately. The project candidate was eligible for Flywheel promotion; Darwin did not promote a harness mutation.

## Pinned tooling

Upstream MetaHarness revision: `e0dfd44da72b7adfb7c57fdd42d124be58ed7086`.

| Component | Version |
| --- | --- |
| `metaharness` | `0.4.17` |
| `@metaharness/darwin` | `0.10.3` |
| `@metaharness/flywheel` | `0.1.12` |
| Node.js | `24.19.0` locally; CI pins Node.js 20 |
| Rust | `1.99.0` (`stable`, 2026-10-01 release manifest) |

## Reproducible results

| Gate | Baseline | Candidate | Status |
| --- | ---: | ---: | --- |
| Cloud Function contract (`npm run test:cf`) | 2/7 | 7/7 | PASS candidate |
| npm production audit, high threshold | 0 vulnerabilities | 0 vulnerabilities | PASS |
| Flywheel CORS holdout | 1/6 (0.1667) | 6/6 (1.0000) | PROMOTED |
| Flywheel frozen anchor | 5/5 | 5/5 | PASS, no regression |
| Darwin real-sandbox score | 0.435 | 0.435 | NO_IMPROVEMENT |
| `cargo fmt --all -- --check` | Existing workspace formatting drift | Unchanged | BLOCKED |
| `cargo check --workspace` | Local environment lacks `pkg-config`/OpenSSL development metadata | Unchanged | BLOCKED locally |

The untouched implementation returned the first configured trusted origin for both a disallowed request origin and a request without an `Origin` header. The candidate withholds the allow-origin header in both cases. The test suite also covers whitespace normalization, allowed-origin reflection, wildcard compatibility, preflight behavior, and routing fail-closed anchors.

MetaHarness repository scoring completed with exit 0: harness fit 71, compile confidence 100, task coverage 100, tool safety 95, memory usefulness 47, estimated cost per run USD 0.048, and all six hard constraints satisfied.

Darwin was invoked with a fixed deterministic mutator, seed `2026101014`, one generation, two children, concurrency one, and the real sandbox. The baseline and both children scored 0.435 with test pass rate 0; the baseline remained the winner with delta 0.000. No harness mutation was copied into project source.

Flywheel used a deterministic evidence proposer with a frozen six-item CORS holdout and five-item anchor. The default conjunctive gate promoted the exact candidate with primary delta `+0.8333333333333334`, no-op rate `0.8333 -> 0`, cost per win `1 -> 0.1667`, and no regression. Gate fingerprint: `c5942e7c9bf8f28fe7af47a6ec3887d11eeb3f8c8e8601cc6a7a54969ea0b981`. Independent replay passed Ed25519 receipts, root reconstruction, contiguous parents, promotion verdicts, the full diagnostic ledger, sealed score fields, and the pinned gate fingerprint.

## CI and rollback

The CI addition runs a clean npm install, the Cloud Function contract suite, and the production dependency audit on Linux with commit-pinned checkout and Node setup actions. It is included in the existing `All Checks Pass` aggregate and does not remove or relax any Rust gate.

The first exact GitHub run proved the Cloud Function job green but found every existing Rust job failing during job setup because the workflow referenced the nonexistent `dtolnay/rust-action`. The candidate replaces those references with the official `dtolnay/rust-toolchain` action pinned to `e2a55d2ffb04f378e9626c28d38b36d230d1e12f` and explicitly requests the stable toolchain. This repairs evaluator execution without changing a Rust test, lint rule, or aggregate requirement.

If a post-merge regression is discovered, revert the PR merge commit. This restores the previous CORS behavior, test script, and CI job as one atomic rollback. No deployment or package publication is part of this change.
11 changes: 8 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const crypto = require('crypto');
const SERVICE_NAME = 'research-lab-agents';
const BACKEND_URL = process.env.RESEARCH_LAB_BACKEND_URL;
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
? process.env.ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
: ['*'];

const AGENT_ROUTES = {
Expand Down Expand Up @@ -43,12 +43,17 @@ function buildExecutionMetadata(traceId, executionId) {
function resolveOrigin(reqOrigin) {
if (ALLOWED_ORIGINS.includes('*')) return '*';
if (reqOrigin && ALLOWED_ORIGINS.includes(reqOrigin)) return reqOrigin;
return ALLOWED_ORIGINS[0];
return undefined;
}

function setCorsHeaders(req, res) {
const origin = resolveOrigin(req.headers.origin || '');
res.set('Access-Control-Allow-Origin', origin);
if (!ALLOWED_ORIGINS.includes('*')) {
res.set('Vary', 'Origin');
}
if (origin) {
res.set('Access-Control-Allow-Origin', origin);
}
res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.set(
'Access-Control-Allow-Headers',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "echo 'Cloud Function: no build step required'",
"build:rust": "cargo build --workspace",
"test": "cargo test --workspace",
"test:cf": "node -e \"const h = require('./index.js'); console.log('handler exported:', typeof h.handler === 'function')\"",
"test:cf": "node --test test/*.test.js",
"lint": "cargo clippy --workspace",
"fmt": "cargo fmt --all",
"deploy": "gcloud functions deploy research-lab-agents --runtime nodejs20 --trigger-http --region us-central1 --project agentics-dev --entry-point handler --memory 512MB --timeout 120s --no-allow-unauthenticated"
Expand Down
121 changes: 121 additions & 0 deletions test/cloud-function.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use strict';

const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');

const modulePath = path.resolve(__dirname, '..', 'index.js');

function loadHandler(allowedOrigins) {
if (allowedOrigins === undefined) {
delete process.env.ALLOWED_ORIGINS;
} else {
process.env.ALLOWED_ORIGINS = allowedOrigins;
}
delete process.env.RESEARCH_LAB_BACKEND_URL;
delete require.cache[modulePath];
return require(modulePath).handler;
}

function createResponse() {
const headers = new Map();
return {
headers,
statusCode: 200,
body: undefined,
set(name, value) {
headers.set(name.toLowerCase(), value);
return this;
},
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
},
send(value) {
this.body = value;
return this;
},
};
}

async function invoke({ allowedOrigins, origin, method = 'GET', pathName = '/missing' }) {
const handler = loadHandler(allowedOrigins);
const req = { method, path: pathName, headers: {} };
if (origin !== undefined) req.headers.origin = origin;
const res = createResponse();
await handler(req, res);
return res;
}

test.afterEach(() => {
delete process.env.ALLOWED_ORIGINS;
delete process.env.RESEARCH_LAB_BACKEND_URL;
delete require.cache[modulePath];
});

test('returns an exact configured origin', async () => {
const res = await invoke({
allowedOrigins: 'https://allowed.example,https://second.example',
origin: 'https://second.example',
});
assert.equal(res.headers.get('access-control-allow-origin'), 'https://second.example');
assert.equal(res.headers.get('vary'), 'Origin');
});

test('does not grant CORS to a disallowed origin', async () => {
const res = await invoke({
allowedOrigins: 'https://allowed.example,https://second.example',
origin: 'https://attacker.example',
});
assert.equal(res.headers.has('access-control-allow-origin'), false);
assert.equal(res.headers.get('vary'), 'Origin');
});

test('does not emit an allow-origin header without an Origin request header', async () => {
const res = await invoke({
allowedOrigins: 'https://allowed.example,https://second.example',
});
assert.equal(res.headers.has('access-control-allow-origin'), false);
});

test('trims configured origins before exact matching', async () => {
const res = await invoke({
allowedOrigins: ' https://allowed.example , https://second.example ',
origin: 'https://allowed.example',
});
assert.equal(res.headers.get('access-control-allow-origin'), 'https://allowed.example');
});

test('preserves explicitly configured wildcard behavior', async () => {
const res = await invoke({ allowedOrigins: '*', origin: 'https://any.example' });
assert.equal(res.headers.get('access-control-allow-origin'), '*');
assert.equal(res.headers.has('vary'), false);
});

test('preflight returns 204 while withholding CORS from a disallowed origin', async () => {
const res = await invoke({
allowedOrigins: 'https://allowed.example',
origin: 'https://attacker.example',
method: 'OPTIONS',
});
assert.equal(res.statusCode, 204);
assert.equal(res.headers.has('access-control-allow-origin'), false);
});

test('unknown route and missing backend remain fail-closed', async () => {
const notFound = await invoke({ allowedOrigins: '*', pathName: '/missing' });
assert.equal(notFound.statusCode, 404);
assert.equal(notFound.body.error, 'Not Found');

const unavailable = await invoke({
allowedOrigins: '*',
method: 'POST',
pathName: '/v1/research-lab/hypothesis',
});
assert.equal(unavailable.statusCode, 503);
assert.equal(unavailable.body.error, 'Service Unavailable');
});
Loading