Files
elizaos--eliza/plugins/plugin-personal-assistant/test/approval-queue.toctou.integration.test.ts
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

267 lines
8.8 KiB
TypeScript

/**
* Approval-queue TOCTOU (issue #10721 / #10723 Bug 3).
*
* The old transition helpers were read-assert-write: the UPDATE carried no
* `AND state = <expected>` guard, so an in-flight `approve()` racing
* `purgeExpired()` could resurrect an expired request (expired -> approved, a
* forbidden transition) and e.g. a spend_money would execute after expiry.
*
* These tests drive the real `PgApprovalQueue` against PGlite and force the
* exact interleavings deterministically: a subclass hook runs the concurrent
* writer between the read and the compare-and-swap write. The loser of every
* race must get `ApprovalTransitionConflictError`, never a forbidden state.
*
* Run: bunx vitest run test/approval-queue.toctou.integration.test.ts
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentRuntime } from "@elizaos/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createRealTestRuntime } from "../../../packages/test/helpers/real-runtime.ts";
import { PgApprovalQueue } from "../src/lifeops/approval-queue.js";
import {
type ApprovalEnqueueInput,
type ApprovalRequest,
ApprovalStateTransitionError,
ApprovalTransitionConflictError,
} from "../src/lifeops/approval-queue.types.js";
import { personalAssistantPlugin } from "../src/plugin.js";
/**
* PgApprovalQueue with a one-shot hook between the transition's read and its
* compare-and-swap write — the deterministic stand-in for a concurrent writer
* landing inside the race window.
*/
class InterleavedApprovalQueue extends PgApprovalQueue {
public betweenReadAndWrite: (() => Promise<void>) | null = null;
protected override async fetchById(
id: string,
): Promise<ApprovalRequest | null> {
const row = await super.fetchById(id);
const hook = this.betweenReadAndWrite;
this.betweenReadAndWrite = null;
if (hook && row) await hook();
return row;
}
}
let runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let queue: InterleavedApprovalQueue;
let isolatedStateDir: string;
const isolatedEnvKeys = [
"ELIZA_STATE_DIR",
"ELIZA_CONFIG_PATH",
"ELIZA_PERSIST_CONFIG_PATH",
"ELIZAOS_CLOUD_API_KEY",
"ELIZAOS_CLOUD_BASE_URL",
] as const;
const previousEnv = new Map<string, string | undefined>();
function setIsolatedEnv(): void {
isolatedStateDir = mkdtempSync(join(tmpdir(), "approval-toctou-state-"));
const isolatedConfigPath = join(isolatedStateDir, "eliza.json");
writeFileSync(
isolatedConfigPath,
JSON.stringify({ logging: { level: "error" } }),
"utf8",
);
for (const key of isolatedEnvKeys) {
previousEnv.set(key, process.env[key]);
}
delete process.env.ELIZA_STATE_DIR;
delete process.env.ELIZA_CONFIG_PATH;
delete process.env.ELIZA_PERSIST_CONFIG_PATH;
delete process.env.ELIZAOS_CLOUD_API_KEY;
delete process.env.ELIZAOS_CLOUD_BASE_URL;
}
function restoreEnv(): void {
for (const key of isolatedEnvKeys) {
const value = previousEnv.get(key);
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
function spendMoneyInput(
overrides: Partial<ApprovalEnqueueInput> = {},
): ApprovalEnqueueInput {
return {
requestedBy: "agent:lifeops",
subjectUserId: "owner-toctou",
action: "spend_money",
payload: {
action: "spend_money",
vendor: "Cloud GPUs Inc",
amountCents: 250_00,
currency: "USD",
memo: "training run",
},
channel: "internal",
reason: "agent wants to spend money",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
...overrides,
};
}
beforeAll(async () => {
setIsolatedEnv();
const result = await createRealTestRuntime({
plugins: [personalAssistantPlugin],
});
runtime = result.runtime;
cleanup = result.cleanup;
queue = new InterleavedApprovalQueue(runtime, { agentId: runtime.agentId });
}, 180_000);
afterAll(async () => {
await cleanup();
restoreEnv();
rmSync(isolatedStateDir, { recursive: true, force: true });
});
describe("ApprovalQueue TOCTOU (real PGlite, controlled interleavings)", () => {
it("an expiry landing inside approve()'s window cannot be overwritten", async () => {
// Future expiresAt: the lazy expiry guard (#11092) must not preempt the
// race — this test exercises the CAS window itself, with a concurrent
// markExpired standing in for any pending -> expired transition.
const enqueued = await queue.enqueue(
spendMoneyInput({
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
}),
);
expect(enqueued.state).toBe("pending");
// approve() reads `pending`, then the row expires before the approve
// write lands — the classic race that used to resurrect the row.
queue.betweenReadAndWrite = async () => {
const expired = await queue.markExpired(enqueued.id);
expect(expired.state).toBe("expired");
};
await expect(
queue.approve(enqueued.id, {
resolvedBy: "owner-toctou",
resolutionReason: "approving too late",
}),
).rejects.toBeInstanceOf(ApprovalTransitionConflictError);
// The forbidden expired -> approved transition never happened.
const after = await queue.byId(enqueued.id);
expect(after?.state).toBe("expired");
expect(after?.resolvedBy).toBeNull();
}, 60_000);
it("a lapsed pending request is refused and expired at the boundary — no purge needed (#11092)", async () => {
// No interleaving hook: the guard itself must enforce expiry, because
// nothing runs purgeExpired periodically in production.
const enqueued = await queue.enqueue(
spendMoneyInput({
subjectUserId: "owner-lapsed",
expiresAt: new Date(Date.now() - 5 * 60 * 1000),
}),
);
expect(enqueued.state).toBe("pending");
await expect(
queue.approve(enqueued.id, {
resolvedBy: "owner-lapsed",
resolutionReason: "approving after expiry",
}),
).rejects.toBeInstanceOf(ApprovalStateTransitionError);
// The lazy guard flipped the row to expired; it never executed.
const after = await queue.byId(enqueued.id);
expect(after?.state).toBe("expired");
expect(after?.resolvedBy).toBeNull();
}, 60_000);
it("double-approve race has exactly one winner; the loser gets a typed conflict", async () => {
const enqueued = await queue.enqueue(
spendMoneyInput({ subjectUserId: "owner-double-approve" }),
);
queue.betweenReadAndWrite = async () => {
const inner = await queue.approve(enqueued.id, {
resolvedBy: "owner-a",
resolutionReason: "first approval wins",
});
expect(inner.state).toBe("approved");
};
await expect(
queue.approve(enqueued.id, {
resolvedBy: "owner-b",
resolutionReason: "second approval must lose",
}),
).rejects.toBeInstanceOf(ApprovalTransitionConflictError);
const after = await queue.byId(enqueued.id);
expect(after?.state).toBe("approved");
expect(after?.resolvedBy).toBe("owner-a");
}, 60_000);
it("reject landing inside markExecuting()'s window blocks execution", async () => {
const enqueued = await queue.enqueue(
spendMoneyInput({ subjectUserId: "owner-reject-race" }),
);
await queue.approve(enqueued.id, {
resolvedBy: "owner-reject-race",
resolutionReason: "approved, then thought better of it",
});
queue.betweenReadAndWrite = async () => {
const rejected = await queue.reject(enqueued.id, {
resolvedBy: "owner-reject-race",
resolutionReason: "changed my mind",
});
expect(rejected.state).toBe("rejected");
};
await expect(queue.markExecuting(enqueued.id)).rejects.toBeInstanceOf(
ApprovalTransitionConflictError,
);
const after = await queue.byId(enqueued.id);
expect(after?.state).toBe("rejected");
}, 60_000);
it("conflict is a distinct subclass of the transition error", async () => {
const enqueued = await queue.enqueue(
spendMoneyInput({ subjectUserId: "owner-error-shape" }),
);
queue.betweenReadAndWrite = async () => {
await queue.reject(enqueued.id, {
resolvedBy: "owner-error-shape",
resolutionReason: "rejected mid-flight",
});
};
const failure = await queue
.approve(enqueued.id, {
resolvedBy: "owner-error-shape",
resolutionReason: "late approval",
})
.then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(ApprovalTransitionConflictError);
expect(failure).toBeInstanceOf(ApprovalStateTransitionError);
if (!(failure instanceof ApprovalTransitionConflictError)) {
throw new Error("expected ApprovalTransitionConflictError");
}
expect(failure.requestId).toBe(enqueued.id);
expect(failure.from).toBe("rejected");
expect(failure.to).toBe("approved");
}, 60_000);
});