From 3ae7109faa29175e84ef695b296ad4db14097a37 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:03:19 +0000 Subject: [PATCH 1/2] fix(runtime): apply permission reloads atomically --- src/application/PermissionAuthority.ts | 40 +++++++++++++ src/main.ts | 47 +++++---------- tests/permissionAuthority.test.ts | 79 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 34 deletions(-) diff --git a/src/application/PermissionAuthority.ts b/src/application/PermissionAuthority.ts index 81a4bd8a83..ab0e06579f 100644 --- a/src/application/PermissionAuthority.ts +++ b/src/application/PermissionAuthority.ts @@ -232,6 +232,46 @@ export class PermissionAuthority { return ok(null); } + /** Atomically replace reloadable ceilings and persisted grants. */ + async replaceConfiguredPolicy(configuration: { + readonly ceilings: readonly PermissionCeiling[]; + readonly administratorGrants: readonly PermissionGrant[]; + readonly projectGrants: readonly PermissionGrant[]; + }): Promise> { + const ceilings = await canonicalizePermissionCeilings( + configuration.ceilings, + ); + if (!ceilings.ok) return ceilings; + const grants: PermissionGrant[] = []; + for (const grant of [ + ...configuration.administratorGrants, + ...configuration.projectGrants, + ]) { + const canonical = await canonicalizeGrant(grant); + if (!canonical.ok) return canonical; + grants.push(canonical.value); + } + let candidate: PermissionPolicy = { + ...reloadPermissionCeilings(this.policy, ceilings.value), + grants: this.policy.grants.filter( + ({ lifetime }) => + lifetime !== "administrator" && lifetime !== "project", + ), + }; + for (const grant of grants) { + const added = grantPermission(candidate, grant); + if (!added.ok) + return err( + new PermissionPathError(grant.grant_id, "io", { + cause: added.error, + }), + ); + candidate = added.value; + } + this.policy = candidate; + return ok(null); + } + /** Replace ceilings without restarting the MCP process. */ async reload( ceilings: readonly PermissionCeiling[], diff --git a/src/main.ts b/src/main.ts index 7fc4a2c7e3..5ac7b4339b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { readProjectPermissionStore } from "./application/ProjectPermissionStore import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { CdpElectronProvider } from "./browser/CdpElectronProvider.js"; +import type { PermissionGrant } from "./domain/permissionPolicy.js"; const MCP_CONNECTION_LOST = "REA lost its MCP client connection. Restart REA from your MCP client."; @@ -172,25 +173,7 @@ export const run = async ( serverLogger.error("Reloaded permission policy is invalid"); return; } - const reloaded = await permissionAuthority.value.reload( - refreshed.value.permissionCeilings, - ); - if (!reloaded.ok) { - serverLogger.error( - "Reloaded permission ceiling could not be applied", - ); - return; - } - const administrators = - await permissionAuthority.value.replaceAdministratorGrants( - refreshed.value.administratorPermissionGrants, - ); - if (!administrators.ok) { - serverLogger.error( - "Reloaded administrator grants could not be applied", - ); - return; - } + let projectGrants: readonly PermissionGrant[] = []; if ( refreshed.value.permissionProjectRoot !== undefined && refreshed.value.permissionProjectStore !== undefined @@ -203,21 +186,17 @@ export const run = async ( serverLogger.error("Reloaded project grants could not be read"); return; } - const projects = await permissionAuthority.value.replaceProjectGrants( - project.value?.grants ?? [], - ); - if (!projects.ok) { - serverLogger.error("Reloaded project grants could not be applied"); - return; - } - } else { - const projects = await permissionAuthority.value.replaceProjectGrants( - [], - ); - if (!projects.ok) { - serverLogger.error("Reloaded project grants could not be cleared"); - return; - } + projectGrants = project.value?.grants ?? []; + } + const reloaded = + await permissionAuthority.value.replaceConfiguredPolicy({ + ceilings: refreshed.value.permissionCeilings, + administratorGrants: refreshed.value.administratorPermissionGrants, + projectGrants, + }); + if (!reloaded.ok) { + serverLogger.error("Reloaded permission policy could not be applied"); + return; } currentConfig = refreshed.value; Object.assign( diff --git a/tests/permissionAuthority.test.ts b/tests/permissionAuthority.test.ts index 74f5e3fb03..775d4f9272 100644 --- a/tests/permissionAuthority.test.ts +++ b/tests/permissionAuthority.test.ts @@ -7,10 +7,14 @@ import { describe, expect, it } from "vitest"; import { canonicalizePermissionCeilings, canonicalizePermissionRequest, + createPermissionAuthority, + type PermissionAuthority, } from "../src/application/PermissionAuthority.js"; import { createPermissionPolicy, evaluatePermission, + type PermissionGrant, + type PermissionScope, } from "../src/domain/permissionPolicy.js"; describe("permission authority", () => { @@ -99,4 +103,79 @@ describe("permission authority", () => { if (result.ok) expect(result.value.roots).toEqual([join(sandbox, "new.json")]); }); + + it("commits a configured policy only after every grant validates", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-reload-")); + const oldRoot = join(sandbox, "old"); + const newRoot = join(sandbox, "new"); + const outsideRoot = join(sandbox, "outside"); + await Promise.all([mkdir(oldRoot), mkdir(newRoot), mkdir(outsideRoot)]); + const oldScope = evidenceScope(oldRoot); + const newScope = evidenceScope(newRoot); + const authority = await createPermissionAuthority( + [oldScope], + [administratorGrant(oldScope)], + ); + expect(authority.ok).toBe(true); + if (!authority.ok) return; + + const reloaded = await authority.value.replaceConfiguredPolicy({ + ceilings: [newScope], + administratorGrants: [administratorGrant(newScope)], + projectGrants: [ + { + ...evidenceScope(outsideRoot), + grant_id: "project:outside-ceiling", + lifetime: "project", + operation_identity: null, + expires_at: null, + }, + ], + }); + + expect(reloaded.ok).toBe(false); + expect(await canRead(authority.value, oldRoot)).toBe(true); + expect(await canRead(authority.value, newRoot)).toBe(false); + + expect( + await authority.value.replaceConfiguredPolicy({ + ceilings: [newScope], + administratorGrants: [administratorGrant(newScope)], + projectGrants: [], + }), + ).toEqual({ ok: true, value: null }); + expect(await canRead(authority.value, oldRoot)).toBe(false); + expect(await canRead(authority.value, newRoot)).toBe(true); + }); }); + +const evidenceScope = (root: string): PermissionScope => ({ + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, +}); + +const administratorGrant = (scope: PermissionScope): PermissionGrant => ({ + ...scope, + grant_id: "administrator:evidence_read", + lifetime: "administrator", + operation_identity: null, + expires_at: null, +}); + +const canRead = async ( + authority: PermissionAuthority, + root: string, +): Promise => + ( + await authority.explain( + { + ...evidenceScope(root), + operation_identity: `read:${root}`, + }, + "read", + ) + ).ok; From 7e53dd6ad321f9b75dbe458f9e116da7637d740c Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:32:54 +0000 Subject: [PATCH 2/2] fix(runtime): serialize permission reloads --- src/main.ts | 75 +++++++----- tests/mainReload.test.ts | 253 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 tests/mainReload.test.ts diff --git a/src/main.ts b/src/main.ts index 5ac7b4339b..19669f2e51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,8 @@ interface RuntimeDependencies { readonly setExitCode: (code: number) => void; readonly registerShutdown: (handler: () => void) => void; readonly registerReload?: (handler: () => void) => () => void; + readonly createServer?: typeof createServer; + readonly readProjectPermissionStore?: typeof readProjectPermissionStore; } const runtimeDependencies = (): RuntimeDependencies => ({ @@ -124,30 +126,35 @@ export const run = async ( try { handle = dependencies.serve( () => { - const server = createServer(session, session, { - logger, - processPolicy: runtimeProcessPolicy, - evidenceFilePolicy: runtimeEvidencePolicy, - investigationInputRoots: runtimeInvestigationRoots, - analysisSnapshotFilePolicy: runtimeSnapshotPolicy, - permissionAuthority: permissionAuthority.value, - browserObservation, - electronObservation, - artifactIntegrityContinueEnabled: () => - currentConfig.artifactIntegrityContinueEnabled, - availabilityPolicy: () => ({ - processCaptureEnabled: currentConfig.processExecutionPolicy.enabled, - evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length, - browserObservationEnabled: - currentConfig.browserObservationEnabled && - currentConfig.browserCdpEndpoints.length > 0 && - currentConfig.browserAllowedOrigins.length > 0, - electronObservationEnabled: - currentConfig.electronObservationEnabled && - currentConfig.electronCdpEndpoints.length > 0 && - currentConfig.electronFileRoots.length > 0, - }), - }); + const server = (dependencies.createServer ?? createServer)( + session, + session, + { + logger, + processPolicy: runtimeProcessPolicy, + evidenceFilePolicy: runtimeEvidencePolicy, + investigationInputRoots: runtimeInvestigationRoots, + analysisSnapshotFilePolicy: runtimeSnapshotPolicy, + permissionAuthority: permissionAuthority.value, + browserObservation, + electronObservation, + artifactIntegrityContinueEnabled: () => + currentConfig.artifactIntegrityContinueEnabled, + availabilityPolicy: () => ({ + processCaptureEnabled: + currentConfig.processExecutionPolicy.enabled, + evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length, + browserObservationEnabled: + currentConfig.browserObservationEnabled && + currentConfig.browserCdpEndpoints.length > 0 && + currentConfig.browserAllowedOrigins.length > 0, + electronObservationEnabled: + currentConfig.electronObservationEnabled && + currentConfig.electronCdpEndpoints.length > 0 && + currentConfig.electronFileRoots.length > 0, + }), + }, + ); liveServers.add(server); return server; }, @@ -165,20 +172,24 @@ export const run = async ( return 1; } + let reloadQueue = Promise.resolve(); const unregisterReload = dependencies.registerReload?.(() => { - void (async () => { - const refreshed = parseConfig(dependencies.env); - if (!refreshed.ok) { - serverLogger.error("Reloaded permission policy is invalid"); - return; - } + const refreshed = parseConfig(dependencies.env); + if (!refreshed.ok) { + serverLogger.error("Reloaded permission policy is invalid"); + return; + } + reloadQueue = reloadQueue.then(async () => { let projectGrants: readonly PermissionGrant[] = []; if ( refreshed.value.permissionProjectRoot !== undefined && refreshed.value.permissionProjectStore !== undefined ) { - const project = await readProjectPermissionStore( + const project = await ( + dependencies.readProjectPermissionStore ?? + readProjectPermissionStore + )( refreshed.value.permissionProjectStore, refreshed.value.permissionProjectRoot, ); @@ -217,7 +228,7 @@ export const run = async ( ...refreshed.value.investigationInputRoots, ); for (const server of liveServers) server.sendToolListChanged(); - })(); + }); }) ?? (() => undefined); let shutdownPromise: Promise | undefined; diff --git a/tests/mainReload.test.ts b/tests/mainReload.test.ts new file mode 100644 index 0000000000..8edacfce2d --- /dev/null +++ b/tests/mainReload.test.ts @@ -0,0 +1,253 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PermissionAuthority } from "../src/application/PermissionAuthority.js"; +import type { ProjectPermissionStore } from "../src/application/ProjectPermissionStore.js"; +import type { PermissionGrant } from "../src/domain/permissionPolicy.js"; +import { ok } from "../src/domain/result.js"; +import { run } from "../src/main.js"; +import { + createServer, + type CreateServerOptions, +} from "../src/server/createServer.js"; + +type RuntimeDependencies = NonNullable[0]>; + +const directories: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("runtime permission reload", () => { + it("keeps live state unchanged after a late grant failure, then commits together", async () => { + const fixture = await reloadFixture(); + const invalid = projectStore(fixture.projectRoot, [ + projectGrant("invalid", fixture.outsideRoot), + ]); + const valid = projectStore(fixture.projectRoot, [ + projectGrant("valid", fixture.newRoot), + ]); + const validReadStarted = deferred(); + const releaseValidRead = deferred(); + let reads = 0; + const runtime = await startRuntime(fixture.env, async () => { + reads += 1; + if (reads === 1) return ok(invalid); + validReadStarted.resolve(); + await releaseValidRead.promise; + return ok(valid); + }); + + configure(fixture.env, fixture.newRoot, true); + runtime.reload(); + runtime.reload(); + await validReadStarted.promise; + + expect(runtime.notifications()).toBe(0); + expect(runtime.options.processPolicy?.enabled).toBe(false); + expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ + fixture.oldRoot, + ]); + expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false); + expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(true); + expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false); + + releaseValidRead.resolve(); + await runtime.notificationCount(1); + + expect(runtime.options.processPolicy?.enabled).toBe(true); + expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ + fixture.newRoot, + ]); + expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(true); + expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(false); + expect(await canRead(runtime.authority, fixture.newRoot)).toBe(true); + }); + + it("serializes overlapping reloads so an older read cannot commit last", async () => { + const fixture = await reloadFixture(); + const firstReadStarted = deferred(); + const releaseFirstRead = deferred(); + const secondReadStarted = deferred(); + let secondStarted = false; + let reads = 0; + const runtime = await startRuntime(fixture.env, async () => { + reads += 1; + if (reads === 1) { + firstReadStarted.resolve(); + await releaseFirstRead.promise; + return ok(projectStore(fixture.projectRoot, [])); + } + secondStarted = true; + secondReadStarted.resolve(); + return ok(projectStore(fixture.projectRoot, [])); + }); + + configure(fixture.env, fixture.newRoot, true); + runtime.reload(); + await firstReadStarted.promise; + configure(fixture.env, fixture.latestRoot, false); + runtime.reload(); + + expect(secondStarted).toBe(false); + releaseFirstRead.resolve(); + await secondReadStarted.promise; + await runtime.notificationCount(2); + + expect(runtime.options.processPolicy?.enabled).toBe(false); + expect(runtime.options.evidenceFilePolicy?.roots).toEqual([ + fixture.latestRoot, + ]); + expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false); + expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false); + expect(await canRead(runtime.authority, fixture.latestRoot)).toBe(true); + }); +}); + +const reloadFixture = async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-main-reload-")); + directories.push(directory); + const oldRoot = join(directory, "old"); + const newRoot = join(directory, "new"); + const latestRoot = join(directory, "latest"); + const outsideRoot = join(directory, "outside"); + const projectRoot = join(directory, "project"); + await Promise.all( + [oldRoot, newRoot, latestRoot, outsideRoot, projectRoot].map((path) => + mkdir(path), + ), + ); + const env: NodeJS.ProcessEnv = { + REA_EVIDENCE_ROOTS_JSON: JSON.stringify([oldRoot]), + REA_PERMISSION_PROJECT_ROOT: projectRoot, + REA_PERMISSION_PROJECT_STORE: join(directory, "permissions.json"), + }; + return { env, oldRoot, newRoot, latestRoot, outsideRoot, projectRoot }; +}; + +const configure = ( + env: NodeJS.ProcessEnv, + root: string, + enabled: boolean, +): void => { + env.REA_EVIDENCE_ROOTS_JSON = JSON.stringify([root]); + env.REA_PROCESS_CAPTURE_ENABLED = String(enabled); + env.REA_PROCESS_EXECUTABLE_ROOTS_JSON = JSON.stringify([root]); + env.REA_PROCESS_WORKING_ROOTS_JSON = JSON.stringify([root]); + env.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED = String(enabled); +}; + +const startRuntime = async ( + env: NodeJS.ProcessEnv, + readStore: NonNullable, +) => { + let reload: (() => void) | undefined; + let options: CreateServerOptions | undefined; + let notificationCount = 0; + let notification = deferred(); + const waiters = new Map>>(); + const result = await run({ + env, + serve: (factory) => { + void factory({ era: "legacy" }); + return { close: () => Promise.resolve() }; + }, + writeStderr: () => undefined, + setExitCode: () => undefined, + registerShutdown: () => undefined, + registerReload: (handler) => { + reload = handler; + return () => undefined; + }, + readProjectPermissionStore: readStore, + createServer: (analysis, session, received) => { + options = received; + const server = createServer(analysis, session, received); + vi.spyOn(server, "sendToolListChanged").mockImplementation(() => { + notificationCount += 1; + notification.resolve(undefined); + notification = deferred(); + waiters.get(notificationCount)?.resolve(undefined); + }); + return server; + }, + }); + expect(result).toBe(0); + const authority = options?.permissionAuthority; + if (reload === undefined || options === undefined || authority === undefined) + throw new Error("Runtime reload seam was not initialized"); + const capturedOptions = options; + return { + reload, + options: capturedOptions, + authority, + notifications: () => notificationCount, + notificationCount: (count: number) => { + if (notificationCount >= count) return Promise.resolve(); + const waiter = deferred(); + waiters.set(count, waiter); + return waiter.promise; + }, + }; +}; + +const projectStore = ( + projectRoot: string, + grants: readonly ReturnType[], +): ProjectPermissionStore => ({ + schema_version: 1, + project_id: `project_${createHash("sha256").update(projectRoot).digest("hex")}`, + project_root: projectRoot, + grants: [...grants], +}); + +const projectGrant = (id: string, root: string) => + ({ + grant_id: `project:${id}`, + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, + lifetime: "project", + operation_identity: null, + expires_at: null, + }) satisfies PermissionGrant; + +const canRead = async ( + authority: PermissionAuthority, + root: string, +): Promise => + ( + await authority.explain( + { + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `read:${root}`, + }, + "read", + ) + ).ok; + +const deferred = () => { + let resolvePromise!: (value: T | PromiseLike) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: resolvePromise }; +};