Expand configuration encryption E2E coverage

This commit is contained in:
vorotamoroz
2026-10-13 09:56:04 +00:00
parent aa69212db9
commit 51808d95ed
6 changed files with 524 additions and 4 deletions
@@ -0,0 +1,332 @@
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const coreExpression = "app.plugins.plugins['obsidian-livesync'].core";
const firstKey = "synthetic-sync-configuration-key-a";
const secondKey = "synthetic-sync-configuration-key-b";
const dataPassphrase = "synthetic-note-encryption-passphrase";
const idKey = "cd".repeat(32);
const sourcePath = "E2E/configuration-source.md";
const replyPath = "E2E/configuration-reply.md";
const sourceContent = "# Configuration key rotation\n日本語とEnglishの内容を保持します。\n".repeat(200);
const replyContent = "# Configuration key recovery reply\n復旧した端末から送信します。\n".repeat(200);
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
async function applyKey(session: ObsidianLiveSyncSession, key: string, changeMode: boolean): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const root = await navigator.openPage("Power users");
if (changeMode) {
await root
.locator(".setting-item")
.filter({ hasText: "Encrypting sensitive configuration items" })
.getByRole("combobox")
.selectOption("LOCALSTORAGE");
}
await root.locator('input[type="password"]:visible').fill(key);
await root.getByRole("button", { name: "Apply", exact: true }).click();
await page.waitForFunction(`(()=>{
const core=${coreExpression};
const tab=core.modules.find(module=>module.settingTab)?.settingTab;
return tab && !tab.isDirty('configPassphrase') && !tab.isDirty('configPassphraseStore') &&
core.settings.configPassphraseStore==='LOCALSTORAGE' &&
localStorage.getItem('ls-setting-passphrase')===${JSON.stringify(key)};
})()`);
await navigator.close();
});
}
async function assertConnection(
cli: string,
session: ObsidianLiveSyncSession,
expected: Record<string, unknown>
): Promise<void> {
const settings = await evalObsidianJson(
cli,
`(()=>{
const s=${coreExpression}.services.setting.currentSettings();
return JSON.stringify({uri:s.couchDB_URI,user:s.couchDB_USER,password:s.couchDB_PASSWORD,
database:s.couchDB_DBNAME,encrypt:s.encrypt,passphrase:s.passphrase,idKey:s.idDerivationKey,
store:s.configPassphraseStore,key:localStorage.getItem('ls-setting-passphrase')});
})()`,
session.cliEnv
);
assert.deepEqual(settings, expected);
}
async function createNote(cli: string, session: ObsidianLiveSyncSession, path: string, content: string): Promise<void> {
await evalObsidianJson(
cli,
`(async()=>{
if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');
await app.vault.create(${JSON.stringify(path)},${JSON.stringify(content)});
return JSON.stringify(true);
})()`,
session.cliEnv
);
await waitForLocalDatabaseEntry(cli, session.cliEnv, path);
}
async function assertNote(
cli: string,
session: ObsidianLiveSyncSession,
vaultPath: string,
path: string,
content: string
): Promise<void> {
// Return only a boolean through the CLI; compare the complete Vault file below.
const matches = await evalObsidianJson<boolean>(
cli,
`(async()=>{
const path=${JSON.stringify(path)};
const expected=${JSON.stringify(content)};
const deadline=Date.now()+20000;
while(Date.now()<deadline){
const actual=await app.vault.adapter.read(path).catch(()=>null);
if(actual===expected) return JSON.stringify(true);
await new Promise(resolve=>setTimeout(resolve,250));
}
throw new Error('Timed out waiting for the received note');
})()`,
session.cliEnv
);
assert.equal(matches, true);
assert.equal(await readFile(join(vaultPath, path), "utf8"), content);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked: ${cli.checked.join(", ")}`);
const cliBinary = cli.binary;
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "configuration-encryption");
const sourceVault = await createTemporaryVault("obsidian-configuration-sync-source-");
let receiverVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let session: ObsidianLiveSyncSession | undefined;
let remoteCreated = false;
let stage = "source-startup";
const connection = {
uri: couchDb.uri,
user: couchDb.username,
password: couchDb.password,
database: dbName,
encrypt: true,
passphrase: dataPassphrase,
idKey,
store: "LOCALSTORAGE",
key: secondKey,
};
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
remoteCreated = true;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: sourceVault,
pluginData: createE2eCouchDbPluginData(
{ ...couchDb, dbName },
{
doctorProcessedVersion: "1.0.35",
usePowerUserMode: true,
idDerivationVersion: 0,
}
),
localStorageEntries: createE2eObsidianDeviceLocalState(sourceVault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
stage = "source-configuration";
await configureCouchDb(
cliBinary,
session.cliEnv,
{ ...couchDb, dbName },
{
doctorProcessedVersion: "1.0.35",
usePowerUserMode: true,
encrypt: true,
passphrase: dataPassphrase,
idDerivationVersion: 1,
idDerivationKey: idKey,
}
);
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await prepareRemote(cliBinary, session.cliEnv);
stage = "source-key-rotation";
await applyKey(session, firstKey, true);
await applyKey(session, secondKey, false);
const dataPath = join(sourceVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
const encryptedData = await readFile(dataPath, "utf8");
assert.equal(encryptedData.includes(dataPassphrase), false);
assert.equal(encryptedData.includes(idKey), false);
assert.equal(encryptedData.includes(couchDb.password), false);
const saved = JSON.parse(encryptedData) as Record<string, unknown>;
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: sourceVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
stage = "source-upload-after-restart";
await createNote(cliBinary, session, sourcePath, sourceContent);
await assertNote(cliBinary, session, sourceVault.path, sourcePath, sourceContent);
const sourceEntry = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, sourcePath);
await pushLocalChanges(cliBinary, session.cliEnv);
const docs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(sourceEntry.id) && sourceEntry.children.every((id) => ids.has(id));
});
assert.ok(sourceEntry.children.length > 0);
assert.equal(
docs.some((doc) => typeof doc.data === "string" && doc.data.includes("Configuration key rotation")),
false
);
console.log(
"Configuration encryption sync: key rotation and restart retained credentials, E2EE, and the ID key; Metadata and encrypted Chunks reached CouchDB."
);
await session.app.stop();
session = undefined;
stage = "receiver-key-recovery";
receiverVault = await createTemporaryVault("obsidian-configuration-sync-receiver-");
const receiverDataPath = join(receiverVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: receiverVault,
pluginData: saved,
localStorageEntries: createE2eObsidianDeviceLocalState(receiverVault.name),
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => {
await withObsidianPage(remoteDebuggingPort, async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Configuration passphrase");
assert.equal(await dialogue.locator('input[type="password"]').getAttribute("type"), "password");
const before = await readFile(receiverDataPath, "utf8");
await dialogue.getByRole("button", { name: "Cancel", exact: true }).click();
await dialogue.waitFor({ state: "hidden" });
const pending = await evalObsidianJson(
cliBinary,
`(()=>{
const core=${coreExpression};
return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(),
tabRegistered:core.modules.some(module=>!!module.settingTab)});
})()`,
cliEnv
);
assert.deepEqual(pending, { databaseReady: false, tabRegistered: false });
assert.equal(await readFile(receiverDataPath, "utf8"), before);
await evalObsidianJson(
cliBinary,
`(async()=>{
await app.plugins.disablePlugin('obsidian-livesync');
await app.plugins.enablePlugin('obsidian-livesync');
return JSON.stringify(true);
})()`,
cliEnv
);
const retry = await waitForVisibleObsidianDialogue(page, "Configuration passphrase");
await retry.locator('input[type="password"]').fill(secondKey);
await mkdir(diagnosticsDirectory, { recursive: true });
await retry.locator(".modal").screenshot({
path: join(diagnosticsDirectory, "configuration-sync-unlock.png"),
animations: "disabled",
caret: "hide",
});
await retry.getByRole("button", { name: "OK", exact: true }).click();
});
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: receiverVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
await pushLocalChanges(cliBinary, session.cliEnv);
stage = "receiver-transfer-after-restart";
await assertNote(cliBinary, session, receiverVault.path, sourcePath, sourceContent);
await createNote(cliBinary, session, replyPath, replyContent);
await assertNote(cliBinary, session, receiverVault.path, replyPath, replyContent);
await pushLocalChanges(cliBinary, session.cliEnv);
const replyEntry = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, replyPath);
await waitForCouchDbDocs(couchDb, dbName, (docs) => docs.some((doc) => doc._id === replyEntry.id));
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: sourceVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await assertConnection(cliBinary, session, connection);
await pushLocalChanges(cliBinary, session.cliEnv);
stage = "source-receive-reply";
await assertNote(cliBinary, session, sourceVault.path, replyPath, replyContent);
console.log(
"Configuration encryption sync: copied settings survived cancellation, key recovery, and restart; both real Obsidian Vaults exchanged exact note bytes through CouchDB."
);
} catch (error) {
await mkdir(diagnosticsDirectory, { recursive: true });
if (session) {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.screenshot({
path: join(diagnosticsDirectory, "configuration-sync.failure.png"),
fullPage: true,
});
}).catch(() => undefined);
const state = await evalObsidianJson(
cliBinary,
`(()=>{
const core=${coreExpression};
return JSON.stringify({databaseReady:core?.services.database.isDatabaseReady(),
appReady:core?.services.appLifecycle.isReady(),
mode:core?.settings?.configPassphraseStore});
})()`,
session.cliEnv
).catch(() => ({ stateReadFailed: true }));
await writeFile(
join(diagnosticsDirectory, "configuration-sync.failure.json"),
JSON.stringify({ stage, state }, null, 2) + "\n"
);
}
throw error;
} finally {
try {
if (session) await session.app.stop();
} finally {
try {
await sourceVault.dispose();
if (receiverVault) await receiverVault.dispose();
} finally {
if (remoteCreated) await deleteCouchDbDatabase(couchDb, dbName);
}
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
@@ -12,6 +12,7 @@ import { createTemporaryVault } from "../runner/vault.ts";
const coreExpression = "app.plugins.plugins['obsidian-livesync'].core";
const firstKey = "synthetic-first-configuration-key";
const secondKey = "synthetic-second-configuration-key";
const wrongSavedKey = "synthetic-wrong-saved-configuration-key";
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshots: string[] = [];
const saveFailureMessage =
@@ -100,7 +101,10 @@ async function applyCustomKey(session: ObsidianLiveSyncSession, key: string, cha
});
}
async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise<void> {
async function assertDefaultSettings(
session: ObsidianLiveSyncSession,
screenshot = "01-default-after-restart.png"
): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0);
const navigator = await openLiveSyncSettings(page);
@@ -111,7 +115,7 @@ async function assertDefaultSettings(session: ObsidianLiveSyncSession): Promise<
.getByRole("combobox");
assert.equal(await store.inputValue(), "");
assert.equal(await root.locator('input[type="password"]:visible').isDisabled(), true);
await capture(navigator.dialogue, "01-default-after-restart.png");
await capture(navigator.dialogue, screenshot);
await navigator.close();
});
}
@@ -181,6 +185,58 @@ async function applyAskMode(session: ObsidianLiveSyncSession): Promise<void> {
});
}
async function applyDefaultMode(
session: ObsidianLiveSyncSession,
screenshot = "12-ask-to-default-before-restart.png"
): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const root = await navigator.openPage("Power users");
await root
.locator(".setting-item")
.filter({ hasText: "Encrypting sensitive configuration items" })
.getByRole("combobox")
.selectOption("");
await root.getByRole("button", { name: "Apply", exact: true }).click();
await page.waitForFunction(`(()=>{
const core=${coreExpression};
const tab=core.modules.find(module=>module.settingTab)?.settingTab;
return tab && !tab.isDirty('configPassphraseStore') && core.settings.configPassphraseStore==='';
})()`);
await capture(navigator.dialogue, screenshot);
await navigator.close();
});
}
async function assertSavedConfigurationKey(cli: string, env: NodeJS.ProcessEnv, expectedKey: string): Promise<void> {
const key = await evalObsidianJson(cli, `JSON.stringify(localStorage.getItem('ls-setting-passphrase'))`, env);
assert.equal(key, expectedKey);
}
async function assertLoadedLocalStorageConfiguration(
cli: string,
env: NodeJS.ProcessEnv,
expectedKey: string
): Promise<void> {
const state = await evalObsidianJson(
cli,
`(()=>{
const core=${coreExpression};
return JSON.stringify({databaseReady:core.services.database.isDatabaseReady(),
tabRegistered:core.modules.some(module=>!!module.settingTab),
store:core.settings.configPassphraseStore,
key:localStorage.getItem('ls-setting-passphrase')});
})()`,
env
);
assert.deepEqual(state, {
databaseReady: true,
tabRegistered: true,
store: "LOCALSTORAGE",
key: expectedKey,
});
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -188,6 +244,7 @@ async function main(): Promise<void> {
const cliBinary = cli.binary;
const firstVault = await createTemporaryVault("obsidian-livesync-configuration-source-");
let secondVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let retryVault: Awaited<ReturnType<typeof createTemporaryVault>> | undefined;
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
@@ -271,6 +328,19 @@ async function main(): Promise<void> {
console.log(
"Configuration encryption: rejected empty-key Apply displayed a notice and retained the device key, saved data, and pending edit."
);
await applyDefaultMode(session, "18-custom-to-default-before-restart.png");
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: firstVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
await assertDefaultSettings(session, "19-custom-to-default-after-restart.png");
console.log(
"Configuration encryption: changing the custom key mode to Default preserved protected values without a prompt and retained the device key after restart."
);
await session.app.stop();
session = undefined;
@@ -381,11 +451,119 @@ async function main(): Promise<void> {
console.log(
"Configuration encryption: ASK input was masked and the validated key was reused for an ordinary save in this launch."
);
await applyDefaultMode(session);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: secondVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertSavedConfigurationKey(cliBinary, session.cliEnv, secondKey);
await assertDefaultSettings(session, "13-ask-to-default-after-restart.png");
console.log(
"Configuration encryption: changing ASK_AT_LAUNCH to Default through settings restored protected values without a prompt and retained the device key after restart."
);
await session.app.stop();
session = undefined;
retryVault = await createTemporaryVault("obsidian-livesync-configuration-wrong-saved-key-");
const retryDataPath = join(retryVault.path, ".obsidian/plugins/obsidian-livesync/data.json");
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault: retryVault,
pluginData: saved,
localStorageEntries: {
...createE2eObsidianDeviceLocalState(retryVault.name),
"ls-setting-passphrase": wrongSavedKey,
},
lifecycle: {
afterPluginLoad: async ({ remoteDebuggingPort, cliEnv }) => {
await withObsidianPage(remoteDebuggingPort, async (page) => {
try {
const retry = await waitForVisibleObsidianDialogue(
page,
"Configuration could not be decrypted"
);
const input = retry.locator('input[type="password"]');
assert.equal(await input.getAttribute("type"), "password");
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
const beforeCancellation = await readFile(retryDataPath, "utf8");
await capture(retry.locator(".modal"), "14-saved-wrong-key-retry-prompt.png");
await retry.getByRole("button", { name: "Cancel", exact: true }).click();
await retry.waitFor({ state: "hidden" });
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
assert.equal(await readFile(retryDataPath, "utf8"), beforeCancellation);
await evalObsidianJson(
cliBinary,
`(async()=>{
await app.plugins.disablePlugin('obsidian-livesync');
await app.plugins.enablePlugin('obsidian-livesync');
return JSON.stringify(true);
})()`,
cliEnv
);
const retryAfterCancellation = await waitForVisibleObsidianDialogue(
page,
"Configuration could not be decrypted"
);
const retryInput = retryAfterCancellation.locator('input[type="password"]');
assert.equal(await retryInput.getAttribute("type"), "password");
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
await retryInput.fill(secondKey);
await capture(
retryAfterCancellation.locator(".modal"),
"15-saved-wrong-key-correct-retry.png"
);
await assertPendingLoad(cliBinary, cliEnv, wrongSavedKey);
await retryAfterCancellation.getByRole("button", { name: "OK", exact: true }).click();
} catch (error) {
await mkdir(diagnosticsDirectory, { recursive: true });
await page.screenshot({
path: join(diagnosticsDirectory, "configuration-saved-key-retry.failure.png"),
fullPage: true,
});
throw error;
}
});
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertLoadedLocalStorageConfiguration(cliBinary, session.cliEnv, secondKey);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0);
const navigator = await openLiveSyncSettings(page);
await navigator.openPage("Power users");
await capture(navigator.dialogue, "16-saved-wrong-key-unlocked-settings.png");
await navigator.close();
});
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({ binary, cliBinary, vault: retryVault });
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
assert.deepEqual(await readSecrets(cliBinary, session), secrets);
await assertLoadedLocalStorageConfiguration(cliBinary, session.cliEnv, secondKey);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
assert.equal(await page.locator('.modal-container input[type="password"]').count(), 0);
const navigator = await openLiveSyncSettings(page);
await navigator.openPage("Power users");
await capture(navigator.dialogue, "17-validated-key-after-restart.png");
await navigator.close();
});
console.log(
"Configuration encryption: a saved wrong key remained intact after cancellation; the correct retry restored protected values, persisted the validated key, and survived another restart."
);
console.log(`Configuration encryption screenshots: ${JSON.stringify(screenshots)}`);
} finally {
if (session) await session.app.stop();
await firstVault.dispose();
if (secondVault) await secondVault.dispose();
if (retryVault) await retryVault.dispose();
}
}
+5
View File
@@ -18,6 +18,11 @@ const testSteps: Step[] = [
{ name: "conflict dialogue policy", args: ["run", "test:e2e:obsidian:conflict-dialog-policy"] },
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
{ name: "configuration encryption", args: ["run", "test:e2e:obsidian:configuration-encryption"] },
{
name: "configuration encryption synchronisation",
args: ["run", "test:e2e:obsidian:configuration-encryption-sync"],
},
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
+1
View File
@@ -14,6 +14,7 @@ const focusedScenarios = new Set([
"document-history-restore",
"settings-ui",
"configuration-encryption",
"configuration-encryption-sync",
"review-harness",
"p2p-pane",
"vault-reflection",