fix(node-cli): confine agent runtime paths
This commit is contained in:
1 parent
3d2dccd8d4
commit
9ae1b77e45
6 files changed
+322
-59
No files matched your search
@@ -57,6 +57,7 @@ This repository publishes the open-source server, console, integration catalog,
|
|||||||
- Preserve the minimum runtime versions declared by each package.
|
- Preserve the minimum runtime versions declared by each package.
|
||||||
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
||||||
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
||||||
|
- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ npx elydora install \
|
|||||||
--kid agent-456-key-v1
|
--kid agent-456-key-v1
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file <path>` and `--token_file <path>`. Secret values are rejected as command-line arguments because process listings and shell history can expose them.
|
The CLI reads the private key and optional API token through hidden terminal prompts. For non-interactive installation, store each secret in an owner-only file and pass `--private_key_file <path>` and `--token_file <path>`. Secret values are rejected as command-line arguments because process listings and shell history can expose them. Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical-directory checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
|
||||||
|
|
||||||
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
|
||||||
|
|
||||||
|
|||||||
+94
-45
@@ -4,7 +4,13 @@ import path from 'node:path';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { resolveInstallSecrets } from './cli-secrets.js';
|
import { resolveInstallSecrets } from './cli-secrets.js';
|
||||||
import { derivePublicKey } from './crypto.js';
|
import { derivePublicKey } from './crypto.js';
|
||||||
import { ensurePrivateDirectory, writePrivateFile } from './secure-files.js';
|
import {
|
||||||
|
ensurePrivateDirectory,
|
||||||
|
requirePhysicalDirectory,
|
||||||
|
requirePhysicalFile,
|
||||||
|
resolvePrivateChildDirectory,
|
||||||
|
} from './runtime-paths.js';
|
||||||
|
import { writePrivateFile } from './secure-files.js';
|
||||||
import { SUPPORTED_AGENTS } from './plugins/registry.js';
|
import { SUPPORTED_AGENTS } from './plugins/registry.js';
|
||||||
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
|
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
|
||||||
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
|
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
|
||||||
@@ -53,6 +59,65 @@ function die(message: string): never {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InstalledAgent {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
configPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readInstalledAgent(agentId: string): Promise<InstalledAgent | undefined> {
|
||||||
|
const agentDirectory = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||||
|
if (!(await requirePhysicalDirectory(agentDirectory))) return undefined;
|
||||||
|
|
||||||
|
const configPath = path.join(agentDirectory, 'config.json');
|
||||||
|
if (!(await requirePhysicalFile(configPath))) return undefined;
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not read agent config: ${configPath}`, { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: unknown;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Could not parse agent config: ${configPath}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isRecord(config)
|
||||||
|
|| typeof config.agent_id !== 'string'
|
||||||
|
|| typeof config.agent_name !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(`Agent config has an invalid runtime identity: ${configPath}`);
|
||||||
|
}
|
||||||
|
if (config.agent_id !== agentId) {
|
||||||
|
throw new Error(`Agent config crosses its runtime directory: ${configPath}`);
|
||||||
|
}
|
||||||
|
resolvePrivateChildDirectory(ELYDORA_DIR, config.agent_id);
|
||||||
|
return { agentId, agentName: config.agent_name, configPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverInstalledAgents(): Promise<InstalledAgent[]> {
|
||||||
|
if (!(await requirePhysicalDirectory(ELYDORA_DIR))) return [];
|
||||||
|
|
||||||
|
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
||||||
|
const installedAgents: InstalledAgent[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
throw new Error(`Agent runtime path is not a physical directory: ${path.join(ELYDORA_DIR, entry.name)}`);
|
||||||
|
}
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const installedAgent = await readInstalledAgent(entry.name);
|
||||||
|
if (installedAgent) installedAgents.push(installedAgent);
|
||||||
|
}
|
||||||
|
return installedAgents;
|
||||||
|
}
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
|
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
|
||||||
|
|
||||||
@@ -102,6 +167,7 @@ async function cmdInstall(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
const agentId = values.agent_id;
|
const agentId = values.agent_id;
|
||||||
if (!agentId) die('--agent_id is required');
|
if (!agentId) die('--agent_id is required');
|
||||||
|
const agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||||
|
|
||||||
const kid = values.kid;
|
const kid = values.kid;
|
||||||
if (!kid) die('--kid is required');
|
if (!kid) die('--kid is required');
|
||||||
@@ -123,7 +189,6 @@ async function cmdInstall(args: string[]): Promise<void> {
|
|||||||
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
|
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
|
||||||
|
|
||||||
// Create ~/.elydora/{agentId}/ directory
|
// Create ~/.elydora/{agentId}/ directory
|
||||||
const agentDir = path.join(ELYDORA_DIR, agentId);
|
|
||||||
await ensurePrivateDirectory(ELYDORA_DIR);
|
await ensurePrivateDirectory(ELYDORA_DIR);
|
||||||
await ensurePrivateDirectory(agentDir);
|
await ensurePrivateDirectory(agentDir);
|
||||||
|
|
||||||
@@ -194,31 +259,36 @@ async function cmdUninstall(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let agentId = values.agent_id;
|
let agentId = values.agent_id;
|
||||||
|
let agentDir: string;
|
||||||
|
let agentDirectoryExists: boolean;
|
||||||
|
|
||||||
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
|
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
|
||||||
if (!agentId) {
|
if (agentId) {
|
||||||
try {
|
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
const runtimeRootExists = await requirePhysicalDirectory(ELYDORA_DIR);
|
||||||
for (const entry of entries) {
|
agentDirectoryExists = runtimeRootExists
|
||||||
if (!entry.isDirectory()) continue;
|
? await requirePhysicalDirectory(agentDir)
|
||||||
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
|
: false;
|
||||||
try {
|
if (agentDirectoryExists) {
|
||||||
const raw = await fsp.readFile(cfgPath, 'utf-8');
|
const installedAgent = await readInstalledAgent(agentId);
|
||||||
const cfg = JSON.parse(raw);
|
if (installedAgent && installedAgent.agentName !== agentName) {
|
||||||
if (cfg.agent_name === agentName) {
|
throw new Error(
|
||||||
agentId = entry.name;
|
`Agent runtime ${agentId} belongs to ${installedAgent.agentName}, not ${agentName}`,
|
||||||
break;
|
);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip unreadable configs
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} else {
|
||||||
// ELYDORA_DIR may not exist
|
const matches = (await discoverInstalledAgents())
|
||||||
}
|
.filter((installedAgent) => installedAgent.agentName === agentName);
|
||||||
if (!agentId) {
|
if (matches.length === 0) {
|
||||||
die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`);
|
die(`No installed agent found for "${agentName}". Use --agent_id to specify explicitly.`);
|
||||||
}
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
die(`Multiple installed agents found for "${agentName}". Use --agent_id to specify explicitly.`);
|
||||||
|
}
|
||||||
|
agentId = matches[0].agentId;
|
||||||
|
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
|
||||||
|
agentDirectoryExists = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = PLUGINS.get(agentName)!;
|
const plugin = PLUGINS.get(agentName)!;
|
||||||
@@ -228,11 +298,8 @@ async function cmdUninstall(args: string[]): Promise<void> {
|
|||||||
await plugin.uninstall(agentId);
|
await plugin.uninstall(agentId);
|
||||||
|
|
||||||
// Remove entire agent directory
|
// Remove entire agent directory
|
||||||
const agentDir = path.join(ELYDORA_DIR, agentId);
|
if (agentDirectoryExists) {
|
||||||
try {
|
await fsp.rm(agentDir, { recursive: true });
|
||||||
await fsp.rm(agentDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Already removed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`);
|
console.log(`Elydora audit hook uninstalled for ${registryEntry.name}.`);
|
||||||
@@ -244,25 +311,7 @@ async function cmdStatus(): Promise<void> {
|
|||||||
let anyInstalled = false;
|
let anyInstalled = false;
|
||||||
|
|
||||||
// Scan ~/.elydora/*/config.json to discover installed agents
|
// Scan ~/.elydora/*/config.json to discover installed agents
|
||||||
const installedAgents: Array<{ agentId: string; agentName: string; configPath: string }> = [];
|
const installedAgents = await discoverInstalledAgents();
|
||||||
try {
|
|
||||||
const entries = await fsp.readdir(ELYDORA_DIR, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
const cfgPath = path.join(ELYDORA_DIR, entry.name, 'config.json');
|
|
||||||
try {
|
|
||||||
const raw = await fsp.readFile(cfgPath, 'utf-8');
|
|
||||||
const cfg = JSON.parse(raw);
|
|
||||||
if (cfg.agent_name && cfg.agent_id) {
|
|
||||||
installedAgents.push({ agentId: entry.name, agentName: cfg.agent_name, configPath: cfgPath });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip unreadable configs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ELYDORA_DIR may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, plugin] of PLUGINS) {
|
for (const [name, plugin] of PLUGINS) {
|
||||||
const st = await plugin.status();
|
const st = await plugin.status();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export function isMissingPath(error: unknown): boolean {
|
||||||
|
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePrivateDirectory(directory: string): Promise<void> {
|
||||||
|
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const metadata = await fsp.lstat(directory);
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`Private directory path is not a physical directory: ${directory}`);
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
await fsp.chmod(directory, 0o700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePhysicalDirectory(directory: string): Promise<boolean> {
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await fsp.lstat(directory);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingPath(error)) return false;
|
||||||
|
throw new Error(`Could not inspect agent runtime directory: ${directory}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`Agent runtime path is not a physical directory: ${directory}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePhysicalFile(filePath: string): Promise<boolean> {
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await fsp.lstat(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingPath(error)) return false;
|
||||||
|
throw new Error(`Could not inspect agent runtime file: ${filePath}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`Agent runtime config is not a physical file: ${filePath}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePrivateChildDirectory(root: string, childName: string): string {
|
||||||
|
const isWindowsDeviceName = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(childName);
|
||||||
|
if (
|
||||||
|
childName.length === 0
|
||||||
|
|| /[<>:"|?*\u0000-\u001f]/.test(childName)
|
||||||
|
|| childName.includes('/')
|
||||||
|
|| childName.includes('\\')
|
||||||
|
|| childName === '.'
|
||||||
|
|| childName === '..'
|
||||||
|
|| childName.startsWith(' ')
|
||||||
|
|| childName.endsWith('.')
|
||||||
|
|| childName.endsWith(' ')
|
||||||
|
|| isWindowsDeviceName
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid agent ID for local storage: ${JSON.stringify(childName)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedRoot = path.resolve(root);
|
||||||
|
const candidate = path.resolve(resolvedRoot, childName);
|
||||||
|
const relative = path.relative(resolvedRoot, candidate);
|
||||||
|
if (
|
||||||
|
relative.length === 0
|
||||||
|
|| path.isAbsolute(relative)
|
||||||
|
|| relative === '..'
|
||||||
|
|| relative.startsWith(`..${path.sep}`)
|
||||||
|
|| relative.includes(path.sep)
|
||||||
|
) {
|
||||||
|
throw new Error(`Agent ID escapes the local storage directory: ${JSON.stringify(childName)}`);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { ensurePrivateDirectory, isMissingPath } from './runtime-paths.js';
|
||||||
function isMissingFile(error: unknown): boolean {
|
|
||||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensurePrivateDirectory(directory: string): Promise<void> {
|
|
||||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
await fsp.chmod(directory, 0o700);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writePrivateFile(filePath: string, contents: string): Promise<void> {
|
export async function writePrivateFile(filePath: string, contents: string): Promise<void> {
|
||||||
const directory = path.dirname(filePath);
|
const directory = path.dirname(filePath);
|
||||||
@@ -41,7 +31,7 @@ export async function writePrivateFile(filePath: string, contents: string): Prom
|
|||||||
try {
|
try {
|
||||||
await fsp.unlink(temporaryPath);
|
await fsp.unlink(temporaryPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingFile(error)) throw error;
|
if (!isMissingPath(error)) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { resolveInstallSecrets } from '../dist/cli-secrets.js';
|
import { resolveInstallSecrets } from '../dist/cli-secrets.js';
|
||||||
|
import { requirePhysicalDirectory } from '../dist/runtime-paths.js';
|
||||||
|
|
||||||
const PRIVATE_KEY = Buffer.alloc(32, 7).toString('base64url');
|
const PRIVATE_KEY = Buffer.alloc(32, 7).toString('base64url');
|
||||||
const API_TOKEN = 'ely_test_token';
|
const API_TOKEN = 'ely_test_token';
|
||||||
@@ -102,6 +103,151 @@ test('CLI rejects legacy secret arguments before writing files', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CLI rejects agent IDs that escape the credential directory', async () => {
|
||||||
|
const invalidAgentIds = [
|
||||||
|
'../escape',
|
||||||
|
'..\\escape',
|
||||||
|
'C:escape',
|
||||||
|
'agent.',
|
||||||
|
'agent ',
|
||||||
|
' agent',
|
||||||
|
'CON',
|
||||||
|
'COM¹.log',
|
||||||
|
'.',
|
||||||
|
'..',
|
||||||
|
];
|
||||||
|
for (const agentId of invalidAgentIds) {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
|
try {
|
||||||
|
const result = await runCli([
|
||||||
|
'install',
|
||||||
|
'--agent', 'opencode',
|
||||||
|
'--org_id', 'org-1',
|
||||||
|
'--agent_id', agentId,
|
||||||
|
'--kid', 'key-1',
|
||||||
|
], homeDir);
|
||||||
|
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /(Invalid agent ID for local storage|Agent ID escapes)/);
|
||||||
|
await assert.rejects(stat(path.join(homeDir, '.elydora')), { code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uninstall validates the stored runtime identity before plugin changes', async () => {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
|
const runtimeDir = path.join(homeDir, '.elydora', 'stored-directory');
|
||||||
|
try {
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(runtimeDir, 'config.json'),
|
||||||
|
JSON.stringify({ agent_name: 'opencode', agent_id: 'different-agent' }),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
const result = await runCli(['uninstall', '--agent', 'opencode'], homeDir);
|
||||||
|
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /crosses its runtime directory/);
|
||||||
|
assert.equal((await stat(runtimeDir)).isDirectory(), true);
|
||||||
|
} finally {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uninstall requires an explicit ID for ambiguous agent runtimes', async () => {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
|
try {
|
||||||
|
for (const agentId of ['agent-1', 'agent-2']) {
|
||||||
|
const runtimeDir = path.join(homeDir, '.elydora', agentId);
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(runtimeDir, 'config.json'),
|
||||||
|
JSON.stringify({ agent_name: 'opencode', agent_id: agentId }),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await runCli(['uninstall', '--agent', 'opencode'], homeDir);
|
||||||
|
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /Multiple installed agents/);
|
||||||
|
assert.equal((await stat(path.join(homeDir, '.elydora', 'agent-1'))).isDirectory(), true);
|
||||||
|
assert.equal((await stat(path.join(homeDir, '.elydora', 'agent-2'))).isDirectory(), true);
|
||||||
|
} finally {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime directory checks reject symbolic-link children', async (t) => {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
|
const targetDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-target-'));
|
||||||
|
const runtimeRoot = path.join(homeDir, '.elydora');
|
||||||
|
const linkPath = path.join(runtimeRoot, 'agent-1');
|
||||||
|
try {
|
||||||
|
await mkdir(runtimeRoot, { recursive: true });
|
||||||
|
try {
|
||||||
|
await symlink(targetDir, linkPath, 'dir');
|
||||||
|
} catch (error) {
|
||||||
|
t.skip(`Directory symbolic links are unavailable: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
requirePhysicalDirectory(linkPath),
|
||||||
|
/not a physical directory/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
await rm(targetDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status surfaces malformed runtime config', async () => {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
|
const runtimeDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||||
|
try {
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(path.join(runtimeDir, 'config.json'), '{invalid-json', 'utf-8');
|
||||||
|
|
||||||
|
const result = await runCli(['status'], homeDir);
|
||||||
|
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /Could not parse agent config/);
|
||||||
|
} finally {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status rejects symbolic-link runtime configs', async (t) => {
|
||||||
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
|
const targetDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-target-'));
|
||||||
|
const runtimeDir = path.join(homeDir, '.elydora', 'agent-1');
|
||||||
|
const targetConfig = path.join(targetDir, 'config.json');
|
||||||
|
try {
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
targetConfig,
|
||||||
|
JSON.stringify({ agent_name: 'opencode', agent_id: 'agent-1' }),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await symlink(targetConfig, path.join(runtimeDir, 'config.json'), 'file');
|
||||||
|
} catch (error) {
|
||||||
|
t.skip(`File symbolic links are unavailable: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runCli(['status'], homeDir);
|
||||||
|
|
||||||
|
assert.equal(result.code, 1);
|
||||||
|
assert.match(result.stderr, /runtime config is not a physical file/);
|
||||||
|
} finally {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
await rm(targetDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('CLI installs from owner-only files and protects persisted credentials', async () => {
|
test('CLI installs from owner-only files and protects persisted credentials', async () => {
|
||||||
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'elydora-node-cli-'));
|
||||||
const privateKeyFile = path.join(homeDir, 'private-key');
|
const privateKeyFile = path.join(homeDir, 'private-key');
|
||||||
|
|||||||
Reference in new issue
Block a user