eigent/test/unit/electron/main/workspaceSecrets.test.ts

509 lines
16 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
safeStorage: {
isEncryptionAvailable: () => true,
getSelectedStorageBackend: () => 'kwallet6',
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString('utf8'),
},
}));
import { WorkspaceSecretBroker } from '../../../../electron/main/workspaceSecrets/broker';
import { registerWorkspaceSecretIpcHandlers } from '../../../../electron/main/workspaceSecrets/ipc';
import type {
WorkspaceSecretLookup,
WorkspaceSecretPutRequest,
} from '../../../../electron/main/workspaceSecrets/types';
import {
WorkspaceSecretBindingMismatchError,
WorkspaceSecretVault,
} from '../../../../electron/main/workspaceSecrets/vault';
function fakeCrypto(
options: {
available?: boolean;
backend?: string;
failDecrypt?: boolean;
} = {}
) {
return {
isEncryptionAvailable: () => options.available ?? true,
getSelectedStorageBackend: () => options.backend ?? 'kwallet6',
encryptString: (value: string) =>
Buffer.from(`cipher:${Buffer.from(value).toString('base64')}`, 'utf8'),
decryptString: (value: Buffer) => {
if (options.failDecrypt) throw new Error('rotated keychain');
const encoded = value.toString('utf8').replace(/^cipher:/u, '');
return Buffer.from(encoded, 'base64').toString('utf8');
},
};
}
const scope = {
account_scope_digest: 'a'.repeat(64),
space_id: 'space-1',
revision_id: 'bundle@1',
slot_id: 'mcp.github.env.GITHUB_TOKEN',
};
describe('WorkspaceSecretVault', () => {
let rootDir: string;
beforeEach(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-vault-test-'));
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(rootDir, { recursive: true, force: true });
});
function vault(
options: Parameters<typeof fakeCrypto>[0] = {},
platform: NodeJS.Platform = 'darwin'
) {
return new WorkspaceSecretVault({
rootDir: path.join(rootDir, 'secure'),
crypto: fakeCrypto(options),
platform,
now: () => new Date('2026-08-11T00:00:00.000Z'),
});
}
it('encrypts each value and writes only scoped metadata with secure permissions', () => {
const store = vault();
const fsync = vi.spyOn(fs, 'fsyncSync');
const result = store.put({ ...scope, value: 'sentinel-super-secret' });
const disk = fs.readFileSync(store.filePath, 'utf8');
expect(result.state).toBe('available');
expect(result.secret_ref).toMatch(/^wsvault_[A-Za-z0-9_-]{32}$/u);
expect(result).not.toHaveProperty('value');
expect(disk).not.toContain('sentinel-super-secret');
expect(disk).not.toContain(
Buffer.from('sentinel-super-secret').toString('base64')
);
expect(disk).toContain(scope.slot_id);
expect(fs.statSync(store.rootDir).mode & 0o777).toBe(0o700);
expect(fs.statSync(store.filePath).mode & 0o777).toBe(0o600);
expect(fs.readdirSync(store.rootDir)).toEqual([
'workspace-secret-vault.v1.json',
]);
expect(fsync).toHaveBeenCalledTimes(2);
expect(store.resolve(result)).toBe('sentinel-super-secret');
});
it('creates an immutable candidate reference when the tuple is rebound', () => {
const store = vault();
const first = store.put({ ...scope, value: 'first-value' });
const second = store.put({ ...scope, value: 'second-value' });
expect(second.secret_ref).not.toBe(first.secret_ref);
expect(store.resolve(first)).toBe('first-value');
expect(store.resolve(second)).toBe('second-value');
});
it('rejects Linux basic_text rather than creating a plaintext fallback', () => {
const store = vault({ backend: 'basic_text' }, 'linux');
expect(() => store.put({ ...scope, value: 'must-not-be-written' })).toThrow(
/basic_text/
);
expect(fs.existsSync(store.filePath)).toBe(false);
});
it('fails closed when OS encryption is unavailable', () => {
const store = vault({ available: false });
expect(() =>
store.put({ ...scope, value: 'must-remain-memory-only' })
).toThrow(/encryption is unavailable/);
expect(fs.existsSync(store.filePath)).toBe(false);
});
it('reports unreadable ciphertext as needs_rebind without returning a value', () => {
const writer = vault();
const created = writer.put({ ...scope, value: 'rotating-secret' });
const reader = vault({ failDecrypt: true });
const result = reader.status(created);
expect(result.state).toBe('needs_rebind');
expect(result).not.toHaveProperty('value');
expect(() => reader.resolve(created)).toThrow(/rebound/);
});
it('rejects a secret_ref replayed under a different binding tuple', () => {
const store = vault();
const created = store.put({ ...scope, value: 'scoped-secret' });
expect(() => store.status({ ...created, space_id: 'other-space' })).toThrow(
WorkspaceSecretBindingMismatchError
);
});
it('preserves the previous durable record if the atomic rename fails', () => {
const store = vault();
const created = store.put({ ...scope, value: 'durable-old-value' });
vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
throw new Error('simulated crash before rename');
});
expect(() =>
store.put({ ...scope, value: 'uncommitted-new-value' })
).toThrow(/simulated crash/);
expect(store.resolve(created)).toBe('durable-old-value');
expect(
fs.readdirSync(store.rootDir).filter((name) => name.endsWith('.tmp'))
).toEqual([]);
});
it('turns a malformed vault into needs_rebind and refuses destructive overwrite', () => {
const store = vault();
fs.mkdirSync(store.rootDir, { recursive: true });
fs.writeFileSync(store.filePath, '{truncated');
const lookup: WorkspaceSecretLookup = {
...scope,
secret_ref: `wsvault_${'A'.repeat(32)}`,
};
expect(store.status(lookup).state).toBe('needs_rebind');
expect(() => store.put({ ...scope, value: 'new-value' })).toThrow(
/corrupted/
);
expect(fs.readFileSync(store.filePath, 'utf8')).toBe('{truncated');
});
});
describe('workspace secret IPC', () => {
it('runs the main-renderer guard before every vault operation', () => {
const handlers = new Map<string, (...args: any[]) => unknown>();
const ipcMain = {
handle: vi.fn((channel: string, handler: (...args: any[]) => unknown) => {
handlers.set(channel, handler);
}),
};
const vault = {
put: vi.fn(),
status: vi.fn(),
delete: vi.fn(),
};
const guard = vi.fn(() => {
throw new Error('untrusted sender');
});
registerWorkspaceSecretIpcHandlers(ipcMain as any, vault as any, guard);
expect([...handlers.keys()]).toEqual([
'workspace-secret:put',
'workspace-secret:status',
'workspace-secret:delete',
]);
for (const channel of [
'workspace-secret:put',
'workspace-secret:status',
'workspace-secret:delete',
]) {
expect(() => handlers.get(channel)!({ sender: { id: 99 } }, {})).toThrow(
/untrusted sender/
);
}
expect(guard).toHaveBeenCalledTimes(3);
expect(vault.put).not.toHaveBeenCalled();
expect(vault.status).not.toHaveBeenCalled();
expect(vault.delete).not.toHaveBeenCalled();
});
});
describe('WorkspaceSecretBroker', () => {
let rootDir: string;
let broker: WorkspaceSecretBroker | null = null;
beforeEach(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-broker-test-'));
});
afterEach(async () => {
await broker?.close();
broker = null;
fs.rmSync(rootDir, { recursive: true, force: true });
});
it('requires its random capability and keeps single-item resolution closed', async () => {
const store = new WorkspaceSecretVault({
rootDir: path.join(rootDir, 'secure'),
crypto: fakeCrypto(),
});
const request: WorkspaceSecretPutRequest = {
...scope,
value: 'broker-must-not-return-this',
};
const created = store.put(request);
broker = new WorkspaceSecretBroker(store, () => Buffer.alloc(32, 7));
const runtime = await broker.start();
expect(runtime.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/u);
const unauthorized = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify`,
{
method: 'POST',
body: JSON.stringify(created),
}
);
expect(unauthorized.status).toBe(401);
const authorized = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify`,
{
method: 'POST',
headers: {
authorization: `Bearer ${runtime.capability}`,
'content-type': 'application/json',
},
body: JSON.stringify(created),
}
);
expect(authorized.status).toBe(200);
const body = await authorized.text();
expect(body).toContain('available');
expect(body).not.toContain('broker-must-not-return-this');
const resolveAttempt = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/resolve`,
{
method: 'POST',
headers: { authorization: `Bearer ${runtime.capability}` },
body: JSON.stringify(created),
}
);
expect(resolveAttempt.status).toBe(404);
});
it('resolves a batch only with the process capability and exact identity', async () => {
const sentinel = 'resolved-only-inside-brain';
const store = new WorkspaceSecretVault({
rootDir: path.join(rootDir, 'secure'),
crypto: fakeCrypto(),
});
const created = store.put({ ...scope, value: sentinel });
broker = new WorkspaceSecretBroker(store, () => Buffer.alloc(32, 9));
const runtime = await broker.start();
const endpoint = `${runtime.endpoint}/v1/workspace-secrets/resolve-batch`;
const body = JSON.stringify({ bindings: [created] });
const unauthorized = await fetch(endpoint, {
method: 'POST',
headers: {
authorization: 'Bearer wrong-process-capability',
'content-type': 'application/json',
},
body,
});
expect(unauthorized.status).toBe(401);
expect(await unauthorized.text()).not.toContain(sentinel);
const authorized = await fetch(endpoint, {
method: 'POST',
headers: {
authorization: `Bearer ${runtime.capability}`,
'content-type': 'application/json',
},
body,
});
expect(authorized.status).toBe(200);
expect(await authorized.json()).toEqual({
resolutions: [
{ ...scope, secret_ref: created.secret_ref, value: sentinel },
],
});
const mismatched = await fetch(endpoint, {
method: 'POST',
headers: {
authorization: `Bearer ${runtime.capability}`,
'content-type': 'application/json',
},
body: JSON.stringify({
bindings: [{ ...created, revision_id: 'bundle@2' }],
}),
});
expect(mismatched.status).toBe(403);
const mismatchBody = await mismatched.text();
expect(mismatchBody).toContain('binding_scope_mismatch');
expect(mismatchBody).not.toContain(sentinel);
});
it('verifies up to 100 bindings in one ordered response without values', async () => {
const lookups: WorkspaceSecretLookup[] = [
{
...scope,
secret_ref: `wsvault_${'A'.repeat(32)}`,
slot_id: 'available-slot',
},
{
...scope,
secret_ref: `wsvault_${'B'.repeat(32)}`,
slot_id: 'missing-slot',
},
{
...scope,
secret_ref: `wsvault_${'C'.repeat(32)}`,
slot_id: 'rebind-slot',
},
];
const store = {
status: vi.fn((lookup: WorkspaceSecretLookup) => ({
...lookup,
state:
lookup.slot_id === 'available-slot'
? 'available'
: lookup.slot_id === 'missing-slot'
? 'missing'
: 'needs_rebind',
})),
};
broker = new WorkspaceSecretBroker(
store as unknown as WorkspaceSecretVault
);
const runtime = await broker.start();
const response = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify-batch`,
{
method: 'POST',
headers: {
authorization: `Bearer ${runtime.capability}`,
'content-type': 'application/json',
},
body: JSON.stringify({ bindings: lookups }),
}
);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.statuses.map((item: { state: string }) => item.state)).toEqual([
'available',
'missing',
'needs_rebind',
]);
expect(JSON.stringify(body)).not.toContain('value');
expect(store.status).toHaveBeenCalledTimes(3);
});
it('rejects batches larger than 100 before consulting the vault', async () => {
const store = { status: vi.fn() };
broker = new WorkspaceSecretBroker(
store as unknown as WorkspaceSecretVault
);
const runtime = await broker.start();
const response = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify-batch`,
{
method: 'POST',
headers: {
authorization: `Bearer ${runtime.capability}`,
'content-type': 'application/json',
},
body: JSON.stringify({
bindings: Array.from({ length: 101 }, () => ({
secret_ref: 'x',
account_scope_digest: 'x',
space_id: 'x',
revision_id: 'x',
slot_id: 'x',
})),
}),
}
);
expect(response.status).toBe(400);
expect(store.status).not.toHaveBeenCalled();
});
it('rejects a valid reference presented with different metadata', async () => {
const store = new WorkspaceSecretVault({
rootDir: path.join(rootDir, 'secure'),
crypto: fakeCrypto(),
});
const created = store.put({ ...scope, value: 'tuple-bound' });
broker = new WorkspaceSecretBroker(store);
const runtime = await broker.start();
const response = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify`,
{
method: 'POST',
headers: { authorization: `Bearer ${runtime.capability}` },
body: JSON.stringify({ ...created, revision_id: 'bundle@2' }),
}
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error_code: 'binding_scope_mismatch',
});
});
it('fails the whole batch when any binding tuple is mismatched', async () => {
const store = new WorkspaceSecretVault({
rootDir: path.join(rootDir, 'secure'),
crypto: fakeCrypto(),
});
const created = store.put({ ...scope, value: 'tuple-bound-batch' });
broker = new WorkspaceSecretBroker(store);
const runtime = await broker.start();
const response = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify-batch`,
{
method: 'POST',
headers: {
authorization: `Bearer ${runtime.capability}`,
'content-type': 'application/json',
},
body: JSON.stringify({
bindings: [created, { ...created, slot_id: 'different-slot' }],
}),
}
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error_code: 'binding_scope_mismatch',
});
});
it('rejects oversized request bodies before parsing them', async () => {
const store = new WorkspaceSecretVault({
rootDir: path.join(rootDir, 'secure'),
crypto: fakeCrypto(),
});
broker = new WorkspaceSecretBroker(store);
const runtime = await broker.start();
const response = await fetch(
`${runtime.endpoint}/v1/workspace-secrets/verify`,
{
method: 'POST',
headers: { authorization: `Bearer ${runtime.capability}` },
body: JSON.stringify({ padding: 'x'.repeat(17 * 1024) }),
}
);
expect(response.status).toBe(413);
});
});