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/relay-review-journal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Durable local review journal

on:
pull_request:
paths:
- 'scripts/relay_harness/**'
- 'apps/web/src/lib/*review*.mjs'
- 'apps/web/src/lib/*review*.d.mts'
- 'apps/web/src/app/api/agents/actions/route.ts'
- 'tests/*relay*'
- '.github/workflows/relay-review-journal.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
local-recovery-and-route:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: SQLite, evidence, denial and process recovery regressions
run: python3 -m unittest discover -s tests -p test_relay_local_harness.py -v
- name: Actual local bridge and review route integration
run: node --test tests/relay-local-review.test.mjs tests/relay-review-route.test.mjs
39 changes: 39 additions & 0 deletions apps/web/src/app/api/agents/actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { hasGeminiKey } from '@/lib/gemini-client';
import { dedupeAgentActions, type AgentAction } from '@/lib/action-lifecycle';
import { resolveTrustedBillingEmail } from '@/lib/billing/billing-context';
import { isProSubscriber } from '@/lib/billing/entitlement-store';
import { resolveActionReviewJournal } from '@/lib/action-review-journal.mjs';

const MAX_CONFIRMED_ACTIONS = 50;
const PRO_ACTION_TOOLS = new Set([
Expand Down Expand Up @@ -61,7 +62,30 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

if (!body || typeof body !== 'object' || Array.isArray(body)) {
return NextResponse.json(
{ success: false, error: 'Request body must be an object', actions: [] },
{ status: 400 },
);
}

let reviewJournal: ReturnType<typeof resolveActionReviewJournal>;
try {
reviewJournal = resolveActionReviewJournal();
} catch {
return NextResponse.json(
{ success: false, error: 'Local review journal is unavailable', actions: [] },
{ status: 503 },
);
}

const { mode, transcript, videoTitle, jobId, actions } = body;
if (reviewJournal && mode === 'execute') {
return NextResponse.json(
{ success: false, error: 'This host is configured for durable review only; no actions may execute', actions: [] },
{ status: 409 },
);
}
if (mode === 'execute') {
if (
!Array.isArray(actions) ||
Expand Down Expand Up @@ -135,6 +159,14 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

const journalIdentity = reviewJournal ? await resolveTrustedBillingEmail(request) : null;
if (reviewJournal && !journalIdentity) {
return NextResponse.json(
{ success: false, error: 'Sign in before persisting a local review plan', actions: [] },
{ status: 401 },
);
}

try {
const result = await runActionAgent({
transcript,
Expand All @@ -143,10 +175,17 @@ export async function POST(request: Request): Promise<NextResponse> {
executeTools: false,
});

// Persistence is part of this opt-in acceptance boundary. Never promote
// original actions to fulfilled or fall back to ephemeral success on error.
const localArtifactReceipt = reviewJournal
? await reviewJournal.persist(result.actions, journalIdentity)
: undefined;

return NextResponse.json({
success: true,
provider: result.provider,
actions: result.actions,
...(localArtifactReceipt ? { localArtifactReceipt } : {}),
});
} catch (error) {
console.error('Action agent error:', error);
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lib/action-review-journal.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { AgentAction } from './action-lifecycle';

export interface LocalArtifactReceipt {
version: 'relay.local-receipt.v1';
taskId: string;
scope: 'local_artifact_only';
state: 'complete';
artifactVerified: true;
artifactHash: string;
contractHash: string;
historyHead: string;
originalActionsExecuted: false;
originalTaskComplete: false;
cost: {
amount: null;
currency: null;
status: 'unknown';
note: string;
};
}

export interface ActionReviewJournal {
persist(actions: AgentAction[], trustedIdentity: string | null): Promise<LocalArtifactReceipt>;
}

export function resolveActionReviewJournal(
env?: NodeJS.ProcessEnv,
): ActionReviewJournal | null;
51 changes: 51 additions & 0 deletions apps/web/src/lib/action-review-journal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** Server-owned opt-in for persistent local review artifacts, never execution. */
import 'server-only';
import { createHash } from 'node:crypto';
import { createLocalReviewBridge } from './relay-local-review.mjs';

/**
* Only operator environment config may select the interpreter/state directory.
* A nonempty invalid mode fails closed rather than silently disabling a guard.
*/
export function resolveActionReviewJournal(env = process.env) {
const mode = env.RELAY_REVIEW_JOURNAL_MODE;
if (!mode) return null;
if (mode !== 'local-review-only') throw new Error('Invalid local review journal mode');
if (env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.K_SERVICE) {
throw new Error('Local review journal requires a persistent non-serverless host');
}
const bridge = createLocalReviewBridge({
stateDirectory: env.RELAY_REVIEW_STATE_DIRECTORY,
scriptsDirectory: env.RELAY_REVIEW_SCRIPTS_DIRECTORY,
pythonExecutable: env.RELAY_REVIEW_PYTHON_EXECUTABLE,
});
return Object.freeze({
async persist(actions, trustedIdentity) {
if (typeof trustedIdentity !== 'string' || !trustedIdentity.trim()) {
throw new Error('Trusted identity required for durable review plans');
}
// Do not store identity/transcript or trust a caller-supplied jobId as a
// cross-user idempotency key. Exact plans are independently actor-scoped.
const actorHash = createHash('sha256').update(trustedIdentity).digest('hex');
const key = createHash('sha256').update(JSON.stringify({ actorHash, actions })).digest('hex');
const result = await bridge.persistReview(actions, `review-v1-${key}`);
if (!result.localReceipt.complete || result.originalActionsExecuted !== false ||
result.originalTaskComplete !== false) {
throw new Error('The local review artifact did not verify');
}
return {
version: result.localReceipt.version,
taskId: result.taskId,
scope: 'local_artifact_only',
state: result.localReceipt.state,
artifactVerified: true,
artifactHash: result.localReceipt.evidence.sha256,
contractHash: result.localReceipt.contract_hash,
historyHead: result.localReceipt.history_head,
originalActionsExecuted: false,
originalTaskComplete: false,
cost: result.localReceipt.cost,
};
},
});
}
95 changes: 95 additions & 0 deletions apps/web/src/lib/relay-local-review.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Additive local-host bridge for EventRelay's existing review-only AgentAction[].
* This is NOT production route wiring, an approval service, or an action executor.
* @typedef {import('./action-lifecycle.ts').AgentAction} AgentAction
*/
import { execFile } from 'node:child_process';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, isAbsolute } from 'node:path';
import { promisify } from 'node:util';

const executeFile = promisify(execFile);

/**
* Configuration is owned by a trusted local host, NEVER model/request input.
* The host must preserve the private state directory between calls.
* @param {{stateDirectory:string, scriptsDirectory:string, pythonExecutable:string}} config
*/
export function createLocalReviewBridge(config) {
const { stateDirectory, scriptsDirectory, pythonExecutable } = config;
for (const value of [stateDirectory, scriptsDirectory, pythonExecutable]) {
if (typeof value !== 'string' || !isAbsolute(value)) {
throw new Error('Trusted host paths must be absolute');
}
}

async function invoke(args) {
let stdout;
try {
({ stdout } = await executeFile(pythonExecutable,
['-m', 'relay_harness', '--state', stateDirectory, ...args], {
shell: false,
env: { PYTHONPATH: scriptsDirectory, PYTHONUTF8: '1' },
timeout: 10_000,
maxBuffer: 2 * 1024 * 1024,
}));
} catch (error) {
// Exit 2 is an honest, structured non-completion receipt. Timeout/unknown
// process outcomes are NEVER retried here; caller must inspect receipt.
if (error.code === 2 && error.stdout) stdout = error.stdout;
else throw error;
}
return JSON.parse(stdout);
}

function checkedReceipt(receipt) {
if (receipt.version !== 'relay.local-receipt.v1' ||
receipt.scope !== 'local_artifact_only' ||
receipt.external_task_complete !== false ||
typeof receipt.complete !== 'boolean') {
throw new Error('Invalid local-only receipt envelope');
}
return receipt;
}

return Object.freeze({
/**
* The actions should come from runActionAgent({..., executeTools:false}).
* @param {AgentAction[]} actions
* @param {string} idempotencyKey
*/
async persistReview(actions, idempotencyKey) {
if (typeof idempotencyKey !== 'string' || !idempotencyKey.trim() || idempotencyKey.length > 200) {
throw new Error('Invalid idempotency key');
}
const json = JSON.stringify(actions);
if (typeof json !== 'string' || Buffer.byteLength(json) > 1024 * 1024) {
throw new Error('Review actions exceed input budget');
}
const directory = await mkdtemp(join(tmpdir(), 'relay-local-review-'));
try {
const input = join(directory, 'actions.json');
await writeFile(input, json, { mode: 0o600, flag: 'wx' });
const submitted = await invoke(['import-review', input, '--key', idempotencyKey]);
if (!/^[0-9a-f]{32}$/.test(submitted.task_id)) throw new Error('Invalid journal task id');
const localReceipt = checkedReceipt(await invoke(['run', submitted.task_id]));
return {
taskId: submitted.task_id,
localReceipt,
originalActionsExecuted: false,
originalTaskComplete: false,
};
} finally {
await rm(directory, { recursive: true, force: true });
}
},

async receipt(taskId) {
if (typeof taskId !== 'string' || !/^[0-9a-f]{32}$/.test(taskId)) {
throw new Error('Invalid journal task id');
}
return checkedReceipt(await invoke(['receipt', taskId]));
},
});
}
3 changes: 3 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default defineConfig({
VERCEL_AI_GATEWAY_API_KEY: '',
VERCEL_AI_GATEWAY_API: '',
VERCEL_API_KEY: '',
// Keep existing routes' default behavior independent of a local operator's
// opt-in journal configuration. Focused integration tests set it explicitly.
RELAY_REVIEW_JOURNAL_MODE: '',
},
},
resolve: {
Expand Down
48 changes: 48 additions & 0 deletions docs/relay-durable-review-journal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Durable review journal: opt-in local runtime integration

This named draft integration extends the existing `/api/agents/actions` **preview** path. It does not introduce another product, replace Origin G.A.T.E., authorize deployment, or execute an original model-proposed action.

## Default and opt-in behavior

Without `RELAY_REVIEW_JOURNAL_MODE`, the current preview and execute behavior is unchanged. With the exact operator-configured value `local-review-only`:

1. Resolve trusted, absolute local interpreter/scripts/state paths. Reject invalid configuration and known serverless hosts before requesting a model plan. Vercel's read-only/ephemeral filesystem is unsupported.
2. Reject `mode: execute` with HTTP 409 before any original tool runs.
3. Require the existing trusted signed identity resolution, never a request-body email. Anonymous attempts get HTTP 401 before a model call.
4. Request `runActionAgent({...options, executeTools:false})` exactly as the existing preview path does.
5. Persist the exact pending plan as a local artifact through the Node bridge and Python SQLite command journal. Scope the idempotency key by a digest of trusted identity plus exact plan. A client `jobId` cannot select this key, paths, permissions or adapters.
6. Return `localArtifactReceipt` only after observed bytes satisfy all required gates. Original actions remain pending, `originalActionsExecuted:false` and `originalTaskComplete:false`.

An empty model plan creates an honest empty review artifact. Invalid/nonpending model output or a failed/unknown journal result does not silently fall back to ephemeral success. Errors return a sanitized non-success response; no original actions are executed. Unknown subprocess outcomes are not retried automatically. Inspect the durable task receipt before any recovery.

The opt-in is operator-owned environment configuration on a persistent single host:

```text
RELAY_REVIEW_JOURNAL_MODE=local-review-only
RELAY_REVIEW_STATE_DIRECTORY=/absolute/private/state-directory
RELAY_REVIEW_SCRIPTS_DIRECTORY=/absolute/EventRelay/scripts
RELAY_REVIEW_PYTHON_EXECUTABLE=/absolute/path/to/python3
```

No environment changes are made by this PR. Do not provide these fields through model prompts or HTTP payloads. Do not enable on Vercel, Lambda, Cloud Run, network filesystems or a directory shared with untrusted users. Plan contents are plaintext; use a dedicated permission-restricted account/directory, never put credentials in the plan, and establish a retention policy before multi-user hosting. This slice is intentionally not a hosted storage service or a public receipt lookup API.

## Execution and evidence contract

The Python slice allows one fixed local artifact operation per immutable contract. It rejects model-selected paths, unknown tools, executable code and approval fields. `external_action` always blocks. SQLite holds contract hashes, durable execution intent, attempt budgets and an append-only hash-linked timeline. Writes are atomic/no-clobber, and recovery checks actual current bytes before replaying anything. A permission denial blocks rather than retries. Stale/deleted/changed artifacts invalidate receipts.

Receipts use `relay.local-receipt.v1`, **not** an Origin G.A.T.E. receipt or operational PASS. The integration never imports a deployment PASS as authority. Hash-linked history is integrity checking within a trusted OS account, not authentication against a malicious database owner. Monetary cost remains unknown because host computation is not metered. The new local journal does not replace shared runtime session storage, Upstash Video Packs, scheduler/reminder systems or any external action adapter.

## Verify

```bash
python3 -m unittest discover -s tests -p test_relay_local_harness.py -v
node --test tests/relay-local-review.test.mjs tests/relay-review-route.test.mjs
```

Node 24 executes the actual TypeScript route using built-in type stripping. Provider, auth and minimal framework dependencies are test stubs; the integration bridge, subprocesses, SQLite and artifact verification are real. The tests make no provider/network calls. Existing full web/Python suites and strict type/lint checks remain separate acceptance stages; these focused tests do not substitute for them.

## Deployment and review boundaries

The intended branch `codex/relay-durable-review-integration` has a branch-specific Vercel deployment exclusion in its first proposed tree. This is not enough to assert that every linked project's effective configuration honors it; verify effective Vercel project root/config before publishing the remote ref. Never publish an intermediate branch pointing to unguarded main. Do not weaken preview-required E2E checks merely to obtain green CI. No preview means those checks may remain blocked/red by design.

The draft remains unmerged. Activation, live model acceptance and external project completion are not proven by offline tests. No credential creation, deployment, production account change, original action execution, UVAI reconstruction or Shopify change is authorized or claimed by this integration.
5 changes: 5 additions & 0 deletions scripts/relay_harness/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Offline, fail-closed command journal for EventRelay integration exercises."""

from .core import ContractError, Harness

__all__ = ["Harness", "ContractError"]
Loading
Loading