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
267 lines
8.8 KiB
TypeScript
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);
|
|
});
|