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
176 lines
7.7 KiB
TypeScript
176 lines
7.7 KiB
TypeScript
/**
|
|
* Unit tests for the append-only sub-agent stdout log (#13775 item 3). Real
|
|
* filesystem writes to a temp trajectory dir — no mocks of the module under
|
|
* test. Covers: gate (no write when recording is off), file survival after the
|
|
* write, NDJSON line shape, and single-generation rotation past the byte cap.
|
|
*/
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
appendSubagentStdout,
|
|
subagentStdoutLogPath,
|
|
} from "../../src/services/subagent-stdout-log.ts";
|
|
|
|
let tmpDir: string;
|
|
const priorTrajDir = process.env.ELIZA_TRAJECTORY_DIR;
|
|
const priorLogging = process.env.ELIZA_TRAJECTORY_LOGGING;
|
|
const priorRecording = process.env.ELIZA_TRAJECTORY_RECORDING;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "subagent-stdout-test-"));
|
|
process.env.ELIZA_TRAJECTORY_DIR = tmpDir;
|
|
process.env.ELIZA_TRAJECTORY_LOGGING = "1";
|
|
delete process.env.ELIZA_TRAJECTORY_RECORDING;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
if (priorTrajDir === undefined) delete process.env.ELIZA_TRAJECTORY_DIR;
|
|
else process.env.ELIZA_TRAJECTORY_DIR = priorTrajDir;
|
|
if (priorLogging === undefined) delete process.env.ELIZA_TRAJECTORY_LOGGING;
|
|
else process.env.ELIZA_TRAJECTORY_LOGGING = priorLogging;
|
|
if (priorRecording === undefined)
|
|
delete process.env.ELIZA_TRAJECTORY_RECORDING;
|
|
else process.env.ELIZA_TRAJECTORY_RECORDING = priorRecording;
|
|
});
|
|
|
|
describe("appendSubagentStdout", () => {
|
|
it("writes an NDJSON record under the trajectory dir and returns the path", async () => {
|
|
const returned = await appendSubagentStdout("ses_1", "hello from agent\n");
|
|
const expected = subagentStdoutLogPath("ses_1");
|
|
expect(returned).toBe(expected);
|
|
expect(expected.startsWith(path.join(tmpDir, "subagent-stdout"))).toBe(
|
|
true,
|
|
);
|
|
|
|
const raw = await fs.readFile(expected, "utf8");
|
|
const lines = raw.trim().split("\n");
|
|
expect(lines).toHaveLength(1);
|
|
const parsed = JSON.parse(lines[0]) as { ts: string; text: string };
|
|
expect(parsed.text).toBe("hello from agent\n");
|
|
expect(typeof parsed.ts).toBe("string");
|
|
});
|
|
|
|
it("survives session close: the file persists after the write returns", async () => {
|
|
await appendSubagentStdout("ses_survive", "chunk-a");
|
|
await appendSubagentStdout("ses_survive", "chunk-b");
|
|
// The file is not owned by any in-memory session map, so a later read (the
|
|
// stand-in for post-close discovery) still finds both chunks.
|
|
const raw = await fs.readFile(subagentStdoutLogPath("ses_survive"), "utf8");
|
|
const texts = raw
|
|
.trim()
|
|
.split("\n")
|
|
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
expect(texts).toEqual(["chunk-a", "chunk-b"]);
|
|
});
|
|
|
|
it("no-ops (writes nothing, returns undefined) when recording is disabled", async () => {
|
|
delete process.env.ELIZA_TRAJECTORY_LOGGING;
|
|
process.env.ELIZA_TRAJECTORY_RECORDING = "0";
|
|
const returned = await appendSubagentStdout(
|
|
"ses_off",
|
|
"should not persist",
|
|
);
|
|
expect(returned).toBeUndefined();
|
|
await expect(
|
|
fs.readFile(subagentStdoutLogPath("ses_off"), "utf8"),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("rotates to a single .1 generation once the file crosses the byte cap", async () => {
|
|
const logPath = subagentStdoutLogPath("ses_rotate");
|
|
// Pre-seed the file past the 10 MiB cap so the next append triggers rotation.
|
|
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
await fs.writeFile(logPath, "x".repeat(10 * 1024 * 1024 + 1), "utf8");
|
|
|
|
await appendSubagentStdout("ses_rotate", "post-rotation line");
|
|
|
|
// The oversized content moved to `.1`; the live file holds only the new line.
|
|
const rolled = await fs.readFile(`${logPath}.1`, "utf8");
|
|
expect(rolled.length).toBeGreaterThan(10 * 1024 * 1024);
|
|
const current = await fs.readFile(logPath, "utf8");
|
|
const lines = current.trim().split("\n");
|
|
expect(lines).toHaveLength(1);
|
|
expect((JSON.parse(lines[0]) as { text: string }).text).toBe(
|
|
"post-rotation line",
|
|
);
|
|
// Single generation only — no `.2` accumulates.
|
|
await expect(fs.stat(`${logPath}.2`)).rejects.toThrow();
|
|
});
|
|
|
|
it("sanitizes session ids so the log stays inside the stdout dir", async () => {
|
|
await appendSubagentStdout("../escape/attempt", "x");
|
|
const dir = path.join(tmpDir, "subagent-stdout");
|
|
const p = subagentStdoutLogPath("../escape/attempt");
|
|
// Path separators are stripped, so the resolved file is a direct child of
|
|
// the stdout dir — no traversal escapes the trajectory root.
|
|
expect(path.dirname(path.resolve(p))).toBe(path.resolve(dir));
|
|
expect(path.basename(p)).not.toContain("/");
|
|
await expect(fs.readFile(p, "utf8")).resolves.toContain('"text":"x"');
|
|
});
|
|
});
|
|
|
|
describe("appendSubagentStdout secret redaction (#13775 review)", () => {
|
|
// The persisted file outlives the session, so a raw provider credential
|
|
// echoed to stdout would be a durable on-disk leak. The tee runs each chunk
|
|
// through core's canonical value-shape redactor (redactSensitiveText) at write
|
|
// time; these assert the persisted bytes never carry the live secret while
|
|
// ordinary content is left alone. Cerebras csk- prefix coverage is validated
|
|
// against the redactor directly in packages/core/src/security/redact.test.ts.
|
|
const readTexts = async (sessionId: string): Promise<string[]> => {
|
|
const raw = await fs.readFile(subagentStdoutLogPath(sessionId), "utf8");
|
|
return raw
|
|
.trim()
|
|
.split("\n")
|
|
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
};
|
|
|
|
it("redacts sk-/Bearer tokens in a MODEL-USED-style line before it lands", async () => {
|
|
const key = "sk-abcd1234EFGH5678wxyz";
|
|
const bearer = `Bearer ${"abcdef0123456789ABCDEF".repeat(2)}`;
|
|
const line = `MODEL-USED openai/gpt-5.5 key=${key} auth=${bearer}\n`;
|
|
const returned = await appendSubagentStdout("ses_secret", line);
|
|
expect(returned).toBe(subagentStdoutLogPath("ses_secret"));
|
|
|
|
const [persisted] = await readTexts("ses_secret");
|
|
// The raw file must never contain the live token bytes.
|
|
expect(persisted).not.toContain(key);
|
|
expect(persisted).not.toContain("abcdef0123456789ABCDEF".repeat(2));
|
|
// …but it is masked in place, not dropped: the redactor keeps a short
|
|
// head/tail marker (`…`) so the trace stays readable.
|
|
expect(persisted).toContain("…");
|
|
// Non-secret tokens on the same line survive.
|
|
expect(persisted).toContain("MODEL-USED openai/gpt-5.5");
|
|
});
|
|
|
|
it("leaves non-secret content byte-identical", async () => {
|
|
const clean =
|
|
"running `bun test`\n ✓ 3 passed\n path=/tmp/x done in 1.2s\n";
|
|
await appendSubagentStdout("ses_clean", clean);
|
|
const [persisted] = await readTexts("ses_clean");
|
|
// Nothing here matches a secret shape, so the chunk round-trips unchanged.
|
|
expect(persisted).toBe(clean);
|
|
});
|
|
|
|
it("redaction does not disturb rotation: the new (redacted) line is the only live content", async () => {
|
|
const logPath = subagentStdoutLogPath("ses_secret_rotate");
|
|
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
await fs.writeFile(logPath, "x".repeat(10 * 1024 * 1024 + 1), "utf8");
|
|
|
|
const bearer = `Bearer ${"deadbeefcafef00d1234".repeat(2)}`;
|
|
await appendSubagentStdout(
|
|
"ses_secret_rotate",
|
|
`post-rotation auth=${bearer}\n`,
|
|
);
|
|
|
|
const rolled = await fs.readFile(`${logPath}.1`, "utf8");
|
|
expect(rolled.length).toBeGreaterThan(10 * 1024 * 1024);
|
|
const [persisted] = await readTexts("ses_secret_rotate");
|
|
expect(persisted).toContain("post-rotation");
|
|
expect(persisted).not.toContain("deadbeefcafef00d1234".repeat(2));
|
|
await expect(fs.stat(`${logPath}.2`)).rejects.toThrow();
|
|
});
|
|
});
|