Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f602afb
feat(integration): carry verified local journal .github/workflows/rel…
groupthinking Nov 6, 2026
e8c80d6
feat(integration): carry verified local journal apps/web/src/lib/acti…
groupthinking Nov 6, 2026
51c9406
feat(integration): carry verified local journal apps/web/src/lib/acti…
groupthinking Nov 6, 2026
89130b4
feat(integration): carry verified local journal apps/web/src/lib/rela…
groupthinking Nov 6, 2026
bf0af73
feat(integration): carry verified local journal docs/relay-durable-re…
groupthinking Nov 6, 2026
0ac011a
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
90fc3cf
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
d7c4240
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
84f8190
feat(integration): carry verified local journal scripts/relay_harness…
groupthinking Nov 6, 2026
b73ff6c
feat(integration): carry verified local journal tests/relay-local-rev…
groupthinking Nov 6, 2026
b3d770d
test(integration): carry Python journal integrity regressions
groupthinking Nov 6, 2026
4f86829
feat(integration): enforce local journal policy alongside signed task…
groupthinking Nov 6, 2026
270cd1f
test(integration): guard review-only execution and exact preview pers…
groupthinking Nov 6, 2026
dfca088
test(integration): isolate review journal mode in web suite
groupthinking Nov 6, 2026
9386ffc
test(integration): exercise combined route through real local journal
groupthinking Nov 6, 2026
7136639
Fix: In local-review-only mode, a cookie-authenticated POST from anot…
vercel[bot] Nov 9, 2026
0f2e735
fix(review-journal): reject non-JSON local preview mutations
groupthinking Nov 10, 2026
ef886f6
fix(review-journal): reject nonboolean isError previews
groupthinking Nov 10, 2026
e52a745
fix(journal): sync recovered artifacts and index per-task history
groupthinking Nov 10, 2026
af037ed
fix(review-journal): validate interpreter, harness, and state before …
groupthinking Nov 10, 2026
84e47fb
test(journal): enforce strict preview types, fsync recovery, and inde…
groupthinking Nov 10, 2026
a2fde4e
test(integration): reject non-JSON journal writes and unusable host c…
groupthinking Nov 10, 2026
c449008
fix(ci): avoid reserved Next.js module binding in journal preflight
groupthinking Nov 10, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';

const state = vi.hoisted(() => ({
journal: null as null | { persist: ReturnType<typeof vi.fn> },
model: vi.fn(),
execute: vi.fn(),
identity: 'trusted@example.invalid' as string | null,
}));
vi.mock('@/lib/action-review-journal.mjs', () => ({
resolveActionReviewJournal: () => state.journal,
}));
vi.mock('@/lib/action-agent', () => ({
AVAILABLE_TOOL_NAMES: ['create_workflow_task'],
runActionAgent: state.model,
executePreparedActions: state.execute,
}));
vi.mock('@/lib/billing/billing-context', () => ({
resolveTrustedBillingEmail: async () => state.identity,
}));
vi.mock('@/lib/billing/entitlement-store', () => ({
isProSubscriber: async () => false,
}));
vi.mock('@/lib/workflow-task-store', () => ({
workflowTaskOwner: async () => null,
readWorkflowTask: vi.fn(),
}));
vi.mock('@/lib/gemini-client', () => ({ hasGeminiKey: () => false }));

import { POST } from '@/app/api/agents/actions/route';

const action = {
tool: 'create_workflow_task',
input: { title: 'Review target' },
status: 'pending' as const,
result: 'Prepared for review. No tool has been executed.',
isError: false,
};
function request(body: unknown) {
return new NextRequest('http://localhost/api/agents/actions', {
method: 'POST',
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
body: JSON.stringify(body),
});
}
describe('combined local journal and durable-task route boundary', () => {
beforeEach(() => {
state.model.mockReset();
state.execute.mockReset();
state.identity = 'trusted@example.invalid';
state.journal = { persist: vi.fn().mockResolvedValue({
version: 'relay.local-receipt.v1', taskId: 'local-task',
scope: 'local_artifact_only', state: 'complete',
artifactVerified: true, originalActionsExecuted: false,
originalTaskComplete: false,
}) };
state.model.mockResolvedValue({ provider: 'test', actions: [action] });
});
afterEach(() => { state.journal = null; });

it('persists exact preview fields and never issues an executable token in review-only mode', async () => {
const response = await POST(request({ mode: 'preview', transcript: 'A sufficiently long source transcript' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.actions).toEqual([action]);
expect(body.confirmationToken).toBeUndefined();
expect(body.localArtifactReceipt.originalActionsExecuted).toBe(false);
expect(state.journal?.persist).toHaveBeenCalledWith([action], 'trusted@example.invalid');
expect(state.model).toHaveBeenCalledWith(expect.objectContaining({ executeTools: false }));
expect(state.execute).not.toHaveBeenCalled();
});
it('denies all execute requests before invoking any action', async () => {
const response = await POST(request({ mode: 'execute', actions: [action], jobId: 'forged', confirmationToken: 'forged' }));
expect(response.status).toBe(409);
expect(state.model).not.toHaveBeenCalled();
expect(state.execute).not.toHaveBeenCalled();
expect(state.journal?.persist).not.toHaveBeenCalled();
});
it('requires trusted identity before preview and local persistence', async () => {
state.identity = null;
const response = await POST(request({ transcript: 'A sufficiently long source transcript' }));
expect(response.status).toBe(401);
expect(state.model).not.toHaveBeenCalled();
});
it('fails closed on journal persistence failure', async () => {
state.journal?.persist.mockRejectedValueOnce(new Error('disk unavailable'));
const response = await POST(request({ transcript: 'A sufficiently long source transcript' }));
const body = await response.json();
expect(response.status).toBe(502);
expect(body.success).toBe(false);
expect(body.actions).toEqual([]);
expect(state.execute).not.toHaveBeenCalled();
});
it('preserves existing preview path when local journal is disabled', async () => {
state.journal = null;
const response = await POST(request({ transcript: 'A sufficiently long source transcript' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.actions).toEqual([action]);
expect(body.localArtifactReceipt).toBeUndefined();
expect(state.execute).not.toHaveBeenCalled();
});
});
42 changes: 41 additions & 1 deletion apps/web/src/app/api/agents/actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 @@ -77,7 +78,19 @@ 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 a JSON object', actions: [] }, { status: 400 });
}
// Local-only review is a host policy, never a client-selectable mode.
// Fail closed on invalid configuration before either preview or execution.
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, confirmationToken } = 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 @@ -180,6 +193,26 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}

if (reviewJournal) {
const origin = request.headers.get('origin');
const bearerOnly = origin === null && !request.headers.has('cookie') &&
/^Bearer \S+$/.test(request.headers.get('authorization') ?? '');
if (request.headers.get('sec-fetch-site') === 'cross-site' ||
(origin !== new URL(request.url).origin && !bearerOnly)) {
return NextResponse.json({ success: false, error: 'Untrusted journal write origin', actions: [] }, { status: 403 });
}
// A same-origin text/plain form is still a write when request.json()
// accepts JSON-shaped bytes. Require JSON for every journal mutation.
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
return NextResponse.json({ success: false, error: 'Send an application/json request', actions: [] }, { status: 415 });
}
}

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 });
}
Comment thread
vercel[bot] marked this conversation as resolved.
Resolved

try {
const result = await runActionAgent({
transcript,
Expand All @@ -188,8 +221,14 @@ export async function POST(request: Request): Promise<NextResponse> {
executeTools: false,
});

// Persistence is mandatory when local-review-only is enabled. Never
// return an ephemeral success or an executable confirmation token.
const localArtifactReceipt = reviewJournal
? await reviewJournal.persist(result.actions, journalIdentity)
: undefined;

const hasTasks = result.actions.some(action => action.tool === 'create_workflow_task');
const previewOwner = hasTasks ? await workflowTaskOwner(request) : null;
const previewOwner = !reviewJournal && hasTasks ? await workflowTaskOwner(request) : null;
// Only server-generated plans can receive a receipt. Client run IDs remain
// correlation hints; confirmed task identity uses the returned server ID.
if (previewOwner && result.actions.some(action => !AVAILABLE_TOOL_NAMES.includes(action.tool))) {
Expand All @@ -202,6 +241,7 @@ export async function POST(request: Request): Promise<NextResponse> {
provider: result.provider,
actions: result.actions,
...(confirmation ?? {}),
...(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;
53 changes: 53 additions & 0 deletions apps/web/src/lib/action-review-journal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** 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');
// v1 artifacts omitted optional preview fields. Do not rewrite immutable
// evidence or reuse its receipt for the exact-plan serialization.
const result = await bridge.persistReview(actions, `review-v2-${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,
};
},
});
}
Loading
Loading