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
396 lines
12 KiB
JavaScript
396 lines
12 KiB
JavaScript
/**
|
|
* Unit tests for layered .env resolution: pure parse/merge/upsert primitives,
|
|
* real-filesystem load/save against temp dirs (mode 600 asserted), and a real
|
|
* linked-worktree fixture proving an empty worktree .env falls through to the
|
|
* home-scoped layer.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
applyLayeredEnvToProcess,
|
|
listPresent,
|
|
loadLayeredEnv,
|
|
mergeEnvLayers,
|
|
parseDotenv,
|
|
saveEnvVar,
|
|
upsertEnvContent,
|
|
writeSecret,
|
|
} from "./env-layers.mjs";
|
|
|
|
const ROOT = resolve(new URL("../..", import.meta.url).pathname);
|
|
|
|
// realpath so git-canonicalized paths (macOS /var -> /private/var) compare equal.
|
|
function tempDir(prefix) {
|
|
return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
}
|
|
|
|
function git(cwd, args) {
|
|
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`git ${args.join(" ")} failed: ${result.stderr}`,
|
|
);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
// --- parseDotenv --------------------------------------------------------------
|
|
|
|
test("parseDotenv handles comments, export prefix, quotes, and CRLF", () => {
|
|
const parsed = parseDotenv(
|
|
[
|
|
"# comment",
|
|
"PLAIN=value",
|
|
"export EXPORTED=exported-value",
|
|
'DQ="double quoted"',
|
|
"SQ='single quoted'",
|
|
"SPACED = padded ",
|
|
"not a valid line",
|
|
"EMPTY=",
|
|
"1BAD=starts-with-digit",
|
|
].join("\r\n"),
|
|
);
|
|
assert.deepEqual(parsed, {
|
|
PLAIN: "value",
|
|
EXPORTED: "exported-value",
|
|
DQ: "double quoted",
|
|
SQ: "single quoted",
|
|
SPACED: "padded",
|
|
EMPTY: "",
|
|
});
|
|
});
|
|
|
|
// --- mergeEnvLayers -------------------------------------------------------------
|
|
|
|
test("mergeEnvLayers: first (highest-precedence) definition wins, sources attributed", () => {
|
|
const { values, sources } = mergeEnvLayers([
|
|
{ source: "process", values: { A: "proc", EMPTYWIN: "" } },
|
|
{ source: "repo", values: { A: "repo", B: "repo", EMPTYWIN: "file" } },
|
|
{ source: "home", values: { C: "home", D: "home", SKIPPED: undefined } },
|
|
]);
|
|
assert.deepEqual(values, {
|
|
A: "proc",
|
|
B: "repo",
|
|
C: "home",
|
|
D: "home",
|
|
EMPTYWIN: "",
|
|
});
|
|
assert.deepEqual(sources, {
|
|
A: "process",
|
|
B: "repo",
|
|
C: "home",
|
|
D: "home",
|
|
EMPTYWIN: "process",
|
|
});
|
|
});
|
|
|
|
// --- upsertEnvContent ------------------------------------------------------------
|
|
|
|
test("upsertEnvContent replaces in place, preserves comments, appends new keys", () => {
|
|
const before = [
|
|
"# keep me",
|
|
"KEEP=old-keep",
|
|
"REPLACE=old",
|
|
"",
|
|
"export ALSO=old-also",
|
|
].join("\n");
|
|
const after = upsertEnvContent(before, {
|
|
REPLACE: "new",
|
|
ALSO: "new-also",
|
|
ADDED: "fresh",
|
|
});
|
|
assert.equal(
|
|
after,
|
|
[
|
|
"# keep me",
|
|
"KEEP=old-keep",
|
|
"REPLACE=new",
|
|
"",
|
|
"ALSO=new-also",
|
|
"ADDED=fresh",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
test("upsertEnvContent on empty text emits just the entries", () => {
|
|
assert.equal(upsertEnvContent("", { A: "1" }), "A=1\n");
|
|
});
|
|
|
|
// --- loadLayeredEnv / listPresent ---------------------------------------------------
|
|
|
|
test("loadLayeredEnv merges process > repo > home and reports layers", () => {
|
|
const base = tempDir("env-layers-load-");
|
|
try {
|
|
const repoRoot = join(base, "repo");
|
|
const homeEnvPath = join(base, "home", ".eliza", ".env");
|
|
for (const dir of [repoRoot, join(base, "home", ".eliza")]) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
writeFileSync(join(repoRoot, ".env"), "A=repo\nB=repo\n");
|
|
writeFileSync(homeEnvPath, "B=home\nC=home\n");
|
|
const { values, sources, layers } = loadLayeredEnv({
|
|
processEnv: { A: "proc" },
|
|
repoRoot,
|
|
homeEnvPath,
|
|
});
|
|
assert.equal(values.A, "proc");
|
|
assert.equal(values.B, "repo");
|
|
assert.equal(values.C, "home");
|
|
assert.deepEqual(sources, {
|
|
A: "process",
|
|
B: "repo",
|
|
C: "home",
|
|
});
|
|
assert.deepEqual(
|
|
layers.map((layer) => [layer.source, layer.exists]),
|
|
[
|
|
["process", true],
|
|
["repo", true],
|
|
["home", true],
|
|
],
|
|
);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("loadLayeredEnv: missing files are graceful", () => {
|
|
const base = tempDir("env-layers-absent-");
|
|
try {
|
|
const repoRoot = join(base, "repo");
|
|
mkdirSync(repoRoot, { recursive: true });
|
|
const { values, sources, layers } = loadLayeredEnv({
|
|
processEnv: {},
|
|
repoRoot,
|
|
homeEnvPath: join(base, "nonexistent", ".env"),
|
|
});
|
|
assert.deepEqual(values, {});
|
|
assert.deepEqual(sources, {});
|
|
assert.deepEqual(
|
|
layers.map((layer) => layer.source),
|
|
["process", "repo", "home"],
|
|
);
|
|
assert.equal(
|
|
layers.every((layer) => layer.source === "process" || !layer.exists),
|
|
true,
|
|
);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("applyLayeredEnvToProcess hydrates only keys the process does not define", () => {
|
|
const base = tempDir("env-layers-apply-");
|
|
try {
|
|
const repoRoot = join(base, "repo");
|
|
mkdirSync(repoRoot, { recursive: true });
|
|
writeFileSync(join(repoRoot, ".env"), "FROM_REPO=repo\nKEPT=shadowed\n");
|
|
const homeEnvPath = join(base, "home.env");
|
|
writeFileSync(homeEnvPath, "FROM_HOME=home\nFROM_REPO=home-loses\n");
|
|
const processEnv = { KEPT: "process-wins", EMPTY: "" };
|
|
const loaded = applyLayeredEnvToProcess({
|
|
processEnv,
|
|
repoRoot,
|
|
homeEnvPath,
|
|
});
|
|
assert.equal(processEnv.FROM_REPO, "repo");
|
|
assert.equal(processEnv.FROM_HOME, "home");
|
|
assert.equal(processEnv.KEPT, "process-wins");
|
|
assert.equal(processEnv.EMPTY, "", "empty-but-defined keys stay untouched");
|
|
assert.equal(loaded.sources.FROM_REPO, "repo");
|
|
assert.equal(loaded.sources.KEPT, "process");
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("fresh linked worktree with empty repo .env uses home-scoped secrets, not main checkout .env", () => {
|
|
const base = tempDir("env-layers-worktree-home-");
|
|
try {
|
|
const mainRoot = join(base, "main");
|
|
const wtRoot = join(base, "wt");
|
|
const homeEnvPath = join(base, "home", ".eliza", ".env");
|
|
git(base, ["init", "-b", "main", "main"]);
|
|
writeFileSync(join(mainRoot, "seed.txt"), "seed\n");
|
|
writeFileSync(join(mainRoot, ".env"), "TOKEN=stale-main\n");
|
|
git(mainRoot, ["add", "seed.txt"]);
|
|
git(mainRoot, [
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"-c",
|
|
"user.name=Test",
|
|
"commit",
|
|
"-m",
|
|
"seed",
|
|
]);
|
|
git(mainRoot, ["worktree", "add", wtRoot]);
|
|
mkdirSync(dirname(homeEnvPath), { recursive: true });
|
|
writeFileSync(homeEnvPath, "TOKEN=home-secret\n");
|
|
|
|
const loaded = loadLayeredEnv({
|
|
processEnv: {},
|
|
repoRoot: wtRoot,
|
|
homeEnvPath,
|
|
});
|
|
assert.equal(loaded.values.TOKEN, "home-secret");
|
|
assert.equal(loaded.sources.TOKEN, "home");
|
|
assert.deepEqual(
|
|
loaded.layers.map((layer) => layer.source),
|
|
["process", "repo", "home"],
|
|
);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("listPresent attributes each source and never returns values", () => {
|
|
const base = tempDir("env-layers-present-");
|
|
try {
|
|
const repoRoot = join(base, "repo");
|
|
const homeEnvPath = join(base, "home.env");
|
|
mkdirSync(repoRoot, { recursive: true });
|
|
writeFileSync(join(repoRoot, ".env"), "FROM_REPO=secret-repo\nEMPTYVAL=\n");
|
|
writeFileSync(homeEnvPath, "FROM_HOME=secret-home\n");
|
|
const rows = listPresent(
|
|
["FROM_PROC", "FROM_REPO", "FROM_HOME", "EMPTYVAL", "ABSENT"],
|
|
{
|
|
processEnv: { FROM_PROC: "secret-proc" },
|
|
repoRoot,
|
|
homeEnvPath,
|
|
},
|
|
);
|
|
assert.deepEqual(rows, [
|
|
{ name: "FROM_PROC", present: true, source: "process" },
|
|
{ name: "FROM_REPO", present: true, source: "repo" },
|
|
{ name: "FROM_HOME", present: true, source: "home" },
|
|
{ name: "EMPTYVAL", present: false, source: "repo" },
|
|
{ name: "ABSENT", present: false, source: null },
|
|
]);
|
|
assert.equal(JSON.stringify(rows).includes("secret-"), false);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- saveEnvVar -----------------------------------------------------------------------
|
|
|
|
test("writeSecret creates the home file with mode 600 and upserts on re-save", () => {
|
|
const base = tempDir("env-layers-save-");
|
|
try {
|
|
const homeEnvPath = join(base, ".eliza", ".env");
|
|
const processEnv = {};
|
|
const first = writeSecret("NEW_TOKEN", "tok-1", {
|
|
scope: "home",
|
|
homeEnvPath,
|
|
processEnv,
|
|
});
|
|
assert.deepEqual(first, {
|
|
key: "NEW_TOKEN",
|
|
scope: "home",
|
|
path: homeEnvPath,
|
|
});
|
|
assert.equal(readFileSync(homeEnvPath, "utf8"), "NEW_TOKEN=tok-1\n");
|
|
assert.equal(statSync(homeEnvPath).mode & 0o777, 0o600);
|
|
assert.equal(processEnv.NEW_TOKEN, "tok-1");
|
|
|
|
writeFileSync(homeEnvPath, "# note\nNEW_TOKEN=tok-1\nOTHER=keep\n");
|
|
writeSecret("NEW_TOKEN", "tok-2", {
|
|
scope: "home",
|
|
homeEnvPath,
|
|
processEnv,
|
|
});
|
|
assert.equal(
|
|
readFileSync(homeEnvPath, "utf8"),
|
|
"# note\nNEW_TOKEN=tok-2\nOTHER=keep\n",
|
|
);
|
|
assert.equal(statSync(homeEnvPath).mode & 0o777, 0o600);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("writeSecret writes the repo layer when scoped", () => {
|
|
const base = tempDir("env-layers-save-repo-");
|
|
try {
|
|
const processEnv = {};
|
|
const result = writeSecret("REPO_ONLY", "x", {
|
|
scope: "repo",
|
|
repoRoot: base,
|
|
processEnv,
|
|
});
|
|
assert.equal(result.path, join(base, ".env"));
|
|
assert.equal(readFileSync(join(base, ".env"), "utf8"), "REPO_ONLY=x\n");
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("saveEnvVar remains a compatibility wrapper for existing dashboard callers", () => {
|
|
const base = tempDir("env-layers-save-wrapper-");
|
|
try {
|
|
const processEnv = {};
|
|
const result = saveEnvVar("WRAPPED", "x", "repo", {
|
|
repoRoot: base,
|
|
processEnv,
|
|
});
|
|
assert.deepEqual(result, {
|
|
key: "WRAPPED",
|
|
target: "repo",
|
|
path: join(base, ".env"),
|
|
});
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("writeSecret rejects invalid keys, multi-line values, and bad scopes", () => {
|
|
const base = tempDir("env-layers-save-bad-");
|
|
try {
|
|
const options = { homeEnvPath: join(base, ".env"), processEnv: {} };
|
|
assert.throws(
|
|
() => writeSecret("bad key", "v", options),
|
|
/invalid env key/,
|
|
);
|
|
assert.throws(
|
|
() => writeSecret("GOOD_KEY", "a\nb", options),
|
|
/single-line/,
|
|
);
|
|
assert.throws(
|
|
() => writeSecret("GOOD_KEY", "v", { ...options, scope: "elsewhere" }),
|
|
/scope/,
|
|
);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("HITL dashboard, lane driver, collector, and tests import the shared layered env module", () => {
|
|
const importers = [
|
|
"scripts/lifeops/hitl-credential-dashboard.mjs",
|
|
"scripts/lifeops/run-11632-live-lanes.mjs",
|
|
"scripts/lifeops/collect-11632-live-validation-status.mjs",
|
|
"scripts/lifeops/env-layers.test.mjs",
|
|
];
|
|
for (const relativePath of importers) {
|
|
const text = readFileSync(join(ROOT, relativePath), "utf8");
|
|
assert.match(
|
|
text,
|
|
/from "\.\/env-layers\.mjs"/,
|
|
`${relativePath} must import scripts/lifeops/env-layers.mjs`,
|
|
);
|
|
}
|
|
});
|