chore(oss): audit the server package and resync the SDK mirrors

Split the 657-line operation service into eor, submit, and verify
modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already
emits, and condense every multi-line comment. Mirror Node, Python, and
Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation
grace, and path-encoding fixes. Document which routes and response
fields the self-hosted server does not provide.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
雷电芽衣 committed 2026-09-03 20:48:20 -04:00
1 parent 2010175a82
commit bc6983a62e
364 files changed
+10090 -23259

No files matched your search

+6 -4
View File
@@ -207,6 +207,8 @@ Agent SDK Server
All endpoints are prefixed with the configured base URL (default: `https://api.elydora.com`). All authenticated endpoints require a `Bearer` token in the `Authorization` header. All endpoints are prefixed with the configured base URL (default: `https://api.elydora.com`). All authenticated endpoints require a `Bearer` token in the `Authorization` header.
This self-hosted server mounts `/.well-known/elydora/jwks.json`, `/api/auth/*`, `/v1/auth`, `/v1/agents`, `/v1/operations`, `/v1/audit`, `/v1/epochs`, and `/v1/exports`. The SDKs also expose `/v1/auth/rotate`, `/v1/health/deep`, `/v1/webhooks`, `/v1/members`, and `/v1/admin/events`, which only the hosted API implements; those methods return 404 here. SDK response types follow the hosted API, so fields such as `token_id`, `current_organization`, `onboarding_completed`, `capabilities`, `previous_status`, and the organization's `description` and `ba_org_id` can be absent in self-hosted responses.
Errors are returned as: Errors are returned as:
```json ```json
@@ -287,7 +289,7 @@ Register a new agent. Required role: **integration_engineer**.
"responsible_entity": "engineering-team@example.com", "responsible_entity": "engineering-team@example.com",
"keys": [ "keys": [
{ {
"kid": "my-agent-v1-key-v1", "kid": "my-agent-v1-key-1",
"public_key": "<base64url Ed25519 public key>", "public_key": "<base64url Ed25519 public key>",
"algorithm": "ed25519" "algorithm": "ed25519"
} }
@@ -353,7 +355,7 @@ Re-activate a frozen agent. Required role: **security_admin**.
Revoke a specific signing key. The agent remains registered but the key can no longer be used to sign new operations. Required role: **security_admin**. Revoke a specific signing key. The agent remains registered but the key can no longer be used to sign new operations. Required role: **security_admin**.
**Request body:** `{ "kid": "my-agent-v1-key-v1", "reason": "Key rotation" }` **Request body:** `{ "kid": "my-agent-v1-key-1", "reason": "Key rotation" }`
--- ---
@@ -571,7 +573,7 @@ await client.registerAgent({
agent_id: 'order-processor-v1', agent_id: 'order-processor-v1',
integration_type: 'sdk', integration_type: 'sdk',
display_name: 'Order Processor', display_name: 'Order Processor',
keys: [{ kid: 'order-processor-v1-key-v1', public_key: client.getPublicKey(), algorithm: 'ed25519' }], keys: [{ kid: 'order-processor-v1-key-1', public_key: client.getPublicKey(), algorithm: 'ed25519' }],
}); });
// 4. Submit an operation // 4. Submit an operation
@@ -633,7 +635,7 @@ client.register_agent({
"agent_id": "order-processor-v1", "agent_id": "order-processor-v1",
"integration_type": "sdk", "integration_type": "sdk",
"display_name": "Order Processor", "display_name": "Order Processor",
"keys": [{"kid": "order-processor-v1-key-v1", "keys": [{"kid": "order-processor-v1-key-1",
"public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]), "public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]),
"algorithm": "ed25519"}], "algorithm": "ed25519"}],
}) })
+1 -7
View File
@@ -1,10 +1,4 @@
/** /** BullMQ queue adapter implementing the MessageQueue interface. */
* BullMQ queue adapter implementing the MessageQueue interface.
*
* Wraps a BullMQ Queue instance to replace Cloudflare's Queue producer.
* Jobs are enqueued with retry-on-failure semantics (3 attempts,
* exponential back-off) matching the Cloudflare Queues default behaviour.
*/
import { Queue as BullQueue } from 'bullmq'; import { Queue as BullQueue } from 'bullmq';
import type { ConnectionOptions } from 'bullmq'; import type { ConnectionOptions } from 'bullmq';
+8 -35
View File
@@ -1,24 +1,12 @@
/** /** Adapter interfaces for ElydoraOpenSource infrastructure services. */
* Adapter interfaces for ElydoraOpenSource infrastructure services.
*
* These interfaces mirror the shape of Cloudflare's D1, R2, KV, and Queue
* APIs so that all existing service code can run unchanged against
* PostgreSQL, MinIO, Redis, and BullMQ implementations.
*/
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Database (replaces D1Database) // Database (replaces D1Database)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** A prepared (and optionally bound) SQL statement. */
* A prepared (and optionally bound) SQL statement.
* Mirrors D1PreparedStatement's interface exactly.
*/
export interface PreparedStatement { export interface PreparedStatement {
/** /** Bind positional parameters (? placeholders) to the statement. */
* Bind positional parameters (? placeholders) to the statement.
* Returns a new statement with the values set.
*/
bind(...values: unknown[]): PreparedStatement; bind(...values: unknown[]): PreparedStatement;
/** Execute and return the first row, or null if no rows match. */ /** Execute and return the first row, or null if no rows match. */
@@ -31,18 +19,12 @@ export interface PreparedStatement {
run(): Promise<{ success: boolean }>; run(): Promise<{ success: boolean }>;
} }
/** /** Relational database adapter. */
* Relational database adapter.
* Mirrors the subset of D1Database methods used by Elydora services.
*/
export interface Database { export interface Database {
/** Prepare a SQL statement for execution. */ /** Prepare a SQL statement for execution. */
prepare(sql: string): PreparedStatement; prepare(sql: string): PreparedStatement;
/** /** Execute multiple prepared statements atomically (within a transaction). */
* Execute multiple prepared statements atomically (within a transaction).
* Mirrors D1Database.batch().
*/
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>; batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
} }
@@ -73,10 +55,7 @@ export interface ObjectStoreHead {
readonly httpMetadata?: { readonly contentType?: string }; readonly httpMetadata?: { readonly contentType?: string };
} }
/** /** Object/blob storage adapter. */
* Object/blob storage adapter.
* Mirrors the subset of R2Bucket methods used by Elydora services.
*/
export interface ObjectStore { export interface ObjectStore {
/** Upload an object to the store. */ /** Upload an object to the store. */
put( put(
@@ -96,10 +75,7 @@ export interface ObjectStore {
// Cache (replaces KVNamespace) // Cache (replaces KVNamespace)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Key/value cache adapter. */
* Key/value cache adapter.
* Mirrors the subset of KVNamespace methods used by Elydora services.
*/
export interface Cache { export interface Cache {
/** Retrieve a cached value, or null if not found / expired. */ /** Retrieve a cached value, or null if not found / expired. */
get(key: string): Promise<string | null>; get(key: string): Promise<string | null>;
@@ -112,10 +88,7 @@ export interface Cache {
// Message queue (replaces Queue) // Message queue (replaces Queue)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Message queue adapter. */
* Message queue adapter.
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
*/
export interface MessageQueue { export interface MessageQueue {
/** Enqueue a message under the caller's durable message ID. */ /** Enqueue a message under the caller's durable message ID. */
send(messageId: string, body: unknown): Promise<void>; send(messageId: string, body: unknown): Promise<void>;
+2 -8
View File
@@ -1,10 +1,4 @@
/** /** MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. */
* MinIO / S3-compatible object storage adapter implementing the ObjectStore interface.
*
* Uses the AWS SDK v3 S3 client configured with a custom endpoint for MinIO.
* Responses are buffered in memory so that both streaming (body) and random
* access (json/text) are available on the returned ObjectStoreObject.
*/
import { import {
S3Client, S3Client,
@@ -64,7 +58,7 @@ export class MinioAdapter implements ObjectStore {
} else if (body instanceof Uint8Array) { } else if (body instanceof Uint8Array) {
bodyData = body; bodyData = body;
} else { } else {
// Web ReadableStream — collect into buffer // Web ReadableStream: collect into buffer
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
const reader = body.getReader(); const reader = body.getReader();
for (;;) { for (;;) {
+3 -18
View File
@@ -1,13 +1,4 @@
/** /** PostgreSQL adapter implementing the Database interface. */
* PostgreSQL adapter implementing the Database interface.
*
* Translates the D1-style API (prepare/bind/first/all/run/batch) into
* standard `pg` Pool queries. SQLite `?` placeholders are converted to
* PostgreSQL `$N` positional parameters automatically.
*
* Batch execution runs all statements inside a single transaction to
* preserve the atomicity guarantee of D1.batch().
*/
import pg from 'pg'; import pg from 'pg';
import type { Database, PreparedStatement } from './interfaces.js'; import type { Database, PreparedStatement } from './interfaces.js';
@@ -25,10 +16,7 @@ types.setTypeParser(20, (val: string) => Number(val));
// Placeholder conversion // Placeholder conversion
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. */
* Convert SQLite `?` positional placeholders to PostgreSQL `$N` style.
* Only replaces `?` outside of SQL string literals (safe for all Elydora queries).
*/
function convertPlaceholders(sql: string): string { function convertPlaceholders(sql: string): string {
let index = 0; let index = 0;
return sql.replace(/\?/g, () => `$${++index}`); return sql.replace(/\?/g, () => `$${++index}`);
@@ -38,10 +26,7 @@ function convertPlaceholders(sql: string): string {
// Statement implementation // Statement implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Internal PostgreSQL statement that implements PreparedStatement. */
* Internal PostgreSQL statement that implements PreparedStatement.
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
*/
class PostgresStatement implements PreparedStatement { class PostgresStatement implements PreparedStatement {
constructor( constructor(
private readonly pool: Pool, private readonly pool: Pool,
+1 -6
View File
@@ -1,9 +1,4 @@
/** /** Redis cache adapter implementing the Cache interface. */
* Redis cache adapter implementing the Cache interface.
*
* Uses ioredis to back the nonce-deduplication and chain-hash caching
* that was previously served by Cloudflare KVNamespace.
*/
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import type { Cache } from './interfaces.js'; import type { Cache } from './interfaces.js';
+11 -27
View File
@@ -1,15 +1,4 @@
/** /** Internationalization (i18n) translation map. */
* Internationalization (i18n) translation map.
*
* Provides bilingual (English / Chinese) translations for all user-facing
* error and validation messages returned by the Elydora API.
*
* Message keys follow a dot-separated convention:
* <domain>.<specificMessage>
*
* Some messages accept parameters via simple {param} placeholders that
* callers must replace before returning the message to the client.
*/
export type Lang = 'en' | 'zh'; export type Lang = 'en' | 'zh';
@@ -29,6 +18,14 @@ const messages: Record<string, Record<Lang, string>> = {
en: 'The signing key has been revoked.', en: 'The signing key has been revoked.',
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002', zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
}, },
'error.keyRetired': {
en: 'The signing key has been retired.',
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u9000\u5f79\u3002',
},
'error.agentRevoked': {
en: 'The agent has been revoked.',
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u540a\u9500\u3002',
},
'error.agentFrozen': { 'error.agentFrozen': {
en: 'The agent is frozen and cannot submit operations.', en: 'The agent is frozen and cannot submit operations.',
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002', zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002',
@@ -363,15 +360,7 @@ const messages: Record<string, Record<Lang, string>> = {
}, },
}; };
/** /** Retrieve a translated message by key. */
* Retrieve a translated message by key.
*
* Supports simple {param} placeholder interpolation via the optional
* `params` argument.
*
* Falls back to English if the requested language is missing, and
* ultimately falls back to the raw key if no translation exists at all.
*/
export function getMessage( export function getMessage(
key: string, key: string,
lang: Lang = 'en', lang: Lang = 'en',
@@ -386,12 +375,7 @@ export function getMessage(
return msg; return msg;
} }
/** /** Detect the preferred language from an Accept-Language header value. */
* Detect the preferred language from an Accept-Language header value.
*
* Returns 'zh' if the header contains any Chinese locale indicator,
* otherwise defaults to 'en'.
*/
export function detectLanguage(acceptLanguage: string | null | undefined): Lang { export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
if (!acceptLanguage) return 'en'; if (!acceptLanguage) return 'en';
if (acceptLanguage.includes('zh')) return 'zh'; if (acceptLanguage.includes('zh')) return 'zh';
+2 -4
View File
@@ -1,6 +1,4 @@
/** /** Elydora API: Node.js entry point. */
* Elydora API — Node.js entry point.
*/
import 'dotenv/config'; import 'dotenv/config';
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
@@ -90,7 +88,7 @@ app.use('/*', async (c, next) => {
await next(); await next();
}); });
// CORS — always restrict to the configured allowlist // CORS: always restrict to the configured allowlist
app.use( app.use(
'/*', '/*',
cors({ cors({
+3 -15
View File
@@ -1,16 +1,4 @@
/** /** Better Auth configuration for ElydoraOpenSource. */
* Better Auth configuration for ElydoraOpenSource.
*
* Creates a Better Auth instance backed by PostgreSQL. The instance is
* configured to:
*
* - Use the existing `users` table via field-mapping (no schema change)
* - Support legacy PBKDF2-SHA256 password hashes alongside Better Auth's
* default scrypt so existing users can sign in without re-hashing
* - Expose bearer-token auth for API clients
* - Provide organization support with Elydora's 5-role RBAC hierarchy
* - Provide admin user-management endpoints
*/
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto'; import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto';
@@ -60,7 +48,7 @@ async function verifyLegacyPbkdf2(password: string, stored: string): Promise<boo
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RBAC access control — maps Elydora's 5-role hierarchy to Better Auth // RBAC access control: maps Elydora's 5-role hierarchy to Better Auth
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const elydoraAc = createAccessControl({ const elydoraAc = createAccessControl({
@@ -121,7 +109,7 @@ const readonlyInvestigatorRole = elydoraAc.newRole({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Singleton — creates a Better Auth instance for the server process // Singleton: creates a Better Auth instance for the server process
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) { export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) {
+1 -8
View File
@@ -1,11 +1,4 @@
/** /** Authentication middleware for opaque API tokens and Better Auth sessions. */
* Authentication middleware for opaque API tokens and Better Auth sessions.
*
* A bearer value is resolved as a hashed API token first; anything else is
* validated as a Better Auth session (cookie or bearer session token).
* On success the org_id, role, actor, and credential kind are stored in the
* Hono context variables for downstream handlers.
*/
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js'; import type { RbacRole } from '../shared/index.js';
+10 -52
View File
@@ -1,15 +1,4 @@
/** /** Global error handler middleware. */
* Global error handler middleware.
*
* Catches all uncaught exceptions and formats them as a standardized
* ErrorResponse with the appropriate HTTP status code, error code,
* and the request's unique identifier for tracing.
*
* When an AppError carries a `messageKey`, the handler resolves it to
* the correct language using the `lang` context variable set by the
* i18n middleware. This allows services to throw errors with i18n keys
* without needing direct access to the Hono context.
*/
import type { ErrorHandler } from 'hono'; import type { ErrorHandler } from 'hono';
import type { ErrorCode, ErrorResponse } from '../shared/index.js'; import type { ErrorCode, ErrorResponse } from '../shared/index.js';
@@ -18,18 +7,14 @@ import type { Env, AppVariables } from '../types.js';
import { getMessage } from '../i18n/messages.js'; import { getMessage } from '../i18n/messages.js';
import type { Lang } from '../i18n/messages.js'; import type { Lang } from '../i18n/messages.js';
/** /** Maps each ErrorCode to its corresponding i18n message key. */
* Maps each ErrorCode to its corresponding i18n message key.
*
* This is used when an AppError is thrown with only an error code
* (no explicit message or i18n key), so the global error handler
* can still resolve a translated default message.
*/
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = { const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
INVALID_SIGNATURE: 'error.invalidSignature', INVALID_SIGNATURE: 'error.invalidSignature',
UNKNOWN_AGENT: 'error.unknownAgent', UNKNOWN_AGENT: 'error.unknownAgent',
KEY_REVOKED: 'error.keyRevoked', KEY_REVOKED: 'error.keyRevoked',
KEY_RETIRED: 'error.keyRetired',
AGENT_FROZEN: 'error.agentFrozen', AGENT_FROZEN: 'error.agentFrozen',
AGENT_REVOKED: 'error.agentRevoked',
TTL_EXPIRED: 'error.ttlExpired', TTL_EXPIRED: 'error.ttlExpired',
REPLAY_DETECTED: 'error.replayDetected', REPLAY_DETECTED: 'error.replayDetected',
PREV_HASH_MISMATCH: 'error.prevHashMismatch', PREV_HASH_MISMATCH: 'error.prevHashMismatch',
@@ -42,16 +27,7 @@ const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
VALIDATION_ERROR: 'error.validationError', VALIDATION_ERROR: 'error.validationError',
}; };
/** /** Custom error class that carries an HTTP status and Elydora error code. */
* Custom error class that carries an HTTP status and Elydora error code.
*
* Supports three modes:
* 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message
* 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` —
* the message is resolved at response time from the translation map
* 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is
* (should be avoided in new code; prefer mode 2)
*/
export class AppError extends Error { export class AppError extends Error {
public readonly statusCode: number; public readonly statusCode: number;
public readonly errorCode: ErrorCode; public readonly errorCode: ErrorCode;
@@ -68,13 +44,13 @@ export class AppError extends Error {
details?: Record<string, unknown>, details?: Record<string, unknown>,
) { ) {
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) { if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
// i18n mode — store the key; resolve later in the error handler // i18n mode: store the key; resolve later in the error handler
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params)); super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
this.messageKey = messageOrOpts.key; this.messageKey = messageOrOpts.key;
this.messageParams = messageOrOpts.params; this.messageParams = messageOrOpts.params;
this.usesDefaultMessage = false; this.usesDefaultMessage = false;
} else if (messageOrOpts === undefined) { } else if (messageOrOpts === undefined) {
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY // Code-only mode: will be resolved via ERROR_CODE_TO_I18N_KEY
super(ERROR_CODES[errorCode]); super(ERROR_CODES[errorCode]);
this.usesDefaultMessage = true; this.usesDefaultMessage = true;
} else { } else {
@@ -88,15 +64,7 @@ export class AppError extends Error {
this.details = details; this.details = details;
} }
/** /** Resolve the user-facing message for a given language. */
* Resolve the user-facing message for a given language.
*
* Resolution order:
* 1. If a messageKey was provided, use that with the translation map.
* 2. If the error used the default ErrorCode message, translate via
* the ERROR_CODE_TO_I18N_KEY mapping.
* 3. Otherwise return the raw message string.
*/
resolveMessage(lang: Lang): string { resolveMessage(lang: Lang): string {
if (this.messageKey) { if (this.messageKey) {
return getMessage(this.messageKey, lang, this.messageParams); return getMessage(this.messageKey, lang, this.messageParams);
@@ -111,12 +79,7 @@ export class AppError extends Error {
} }
} }
/** /** Build a standardised error response body. */
* Build a standardised error response body.
*
* When `lang` is provided and no explicit `message` is given, the
* ErrorCode default message is resolved from the translation map.
*/
export function buildErrorResponse( export function buildErrorResponse(
code: ErrorCode, code: ErrorCode,
requestId: string, requestId: string,
@@ -139,12 +102,7 @@ export function buildErrorResponse(
}; };
} }
/** /** Hono error handler that converts all errors into the ErrorResponse format. */
* Hono error handler that converts all errors into the ErrorResponse format.
*
* Resolves i18n message keys using the `lang` context variable set by
* the i18n middleware.
*/
export const globalErrorHandler: ErrorHandler<{ export const globalErrorHandler: ErrorHandler<{
Bindings: Env; Bindings: Env;
Variables: AppVariables; Variables: AppVariables;
+1 -8
View File
@@ -1,11 +1,4 @@
/** /** i18n middleware: language detection from Accept-Language header. */
* i18n middleware — language detection from Accept-Language header.
*
* Reads the Accept-Language request header, detects the preferred
* language (English or Chinese), and stores it in the Hono context
* so that all downstream handlers and services can access it via
* `c.get('lang')`.
*/
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
+4 -27
View File
@@ -1,25 +1,11 @@
/** /** Role-based access control (RBAC) middleware. */
* Role-based access control (RBAC) middleware.
*
* The Elydora console defines five roles with a clear privilege hierarchy:
*
* org_owner > security_admin > compliance_auditor > readonly_investigator
* integration_engineer (parallel to compliance_auditor, agent-management focused)
*
* This middleware checks whether the role extracted by the auth middleware
* satisfies the minimum required role for a given route.
*/
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js'; import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js'; import { AppError } from './error-handler.js';
/** /** Privilege level map. */
* Privilege level map. Higher number = more privilege.
* integration_engineer is a special role with limited admin capabilities
* for agent management only.
*/
const ROLE_LEVEL: Record<RbacRole, number> = { const ROLE_LEVEL: Record<RbacRole, number> = {
readonly_investigator: 10, readonly_investigator: 10,
integration_engineer: 20, integration_engineer: 20,
@@ -28,21 +14,12 @@ const ROLE_LEVEL: Record<RbacRole, number> = {
org_owner: 50, org_owner: 50,
}; };
/** /** Check if a role has at least the given privilege level. */
* Check if a role has at least the given privilege level.
*/
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean { function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole]; return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
} }
/** /** Create an RBAC middleware that requires a minimum role. */
* Create an RBAC middleware that requires a minimum role.
*
* Usage:
* app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler);
*
* @param minimumRole - The minimum RBAC role required to access the endpoint
*/
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{ export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
Bindings: Env; Bindings: Env;
Variables: AppVariables; Variables: AppVariables;
+1 -7
View File
@@ -1,10 +1,4 @@
/** /** Request ID middleware. */
* Request ID middleware.
*
* Generates a UUIDv7 for every incoming request and stores it in the
* Hono context variables. The ID is also set as the X-Request-Id
* response header so clients can reference it when reporting issues.
*/
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
+1 -3
View File
@@ -1,6 +1,4 @@
/** /** Agent routes: registration, lookup, freezing, and key revocation. */
* Agent routes — registration, lookup, freezing, and key revocation.
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
+2 -8
View File
@@ -1,10 +1,4 @@
/** /** Audit routes: query the tamper-evident audit log. */
* Audit routes — query the tamper-evident audit log.
*
* Per the OpenAPI spec, audit queries use POST /v1/audit/query with
* filter parameters in the request body. This avoids URL length limits
* and supports complex filter expressions.
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
@@ -19,7 +13,7 @@ const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
audit.use('/*', authMiddleware); audit.use('/*', authMiddleware);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/audit/query — Query the audit log // POST /v1/audit/query: Query the audit log
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
audit.post( audit.post(
'/query', '/query',
+7 -15
View File
@@ -1,12 +1,4 @@
/** /** Auth routes: registration, login, profile retrieval, and token refresh. */
* Auth routes — registration, login, profile retrieval, and token refresh.
*
* /register and /login are public (no auth required).
* /me and /refresh require a valid Better Auth session or API token; /token requires a session.
*
* Uses Better Auth for session management while preserving the existing
* API surface (endpoint paths and response formats).
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
@@ -20,7 +12,7 @@ import { getMessage } from '../i18n/messages.js';
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>(); const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/auth/register — Create a new user and organization // POST /v1/auth/register: Create a new user and organization
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
auth.post('/register', async (c) => { auth.post('/register', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
@@ -93,7 +85,7 @@ auth.post('/register', async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/auth/login — Authenticate and receive a session token // POST /v1/auth/login: Authenticate and receive a session token
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
auth.post('/login', async (c) => { auth.post('/login', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
@@ -134,7 +126,7 @@ auth.post('/login', async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/auth/me — Get current user profile (auth required) // GET /v1/auth/me: Get current user profile (auth required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
auth.get('/me', authMiddleware, async (c) => { auth.get('/me', authMiddleware, async (c) => {
const userId = c.get('actor'); const userId = c.get('actor');
@@ -144,7 +136,7 @@ auth.get('/me', authMiddleware, async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/auth/refresh — Issue a new session token (auth required) // POST /v1/auth/refresh: Issue a new session token (auth required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
auth.post('/refresh', authMiddleware, async (c) => { auth.post('/refresh', authMiddleware, async (c) => {
const betterAuthInstance = createAuth( const betterAuthInstance = createAuth(
@@ -154,7 +146,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
c.env.ALLOWED_ORIGINS, c.env.ALLOWED_ORIGINS,
); );
// Get the current session and return its token — Better Auth handles // Get the current session and return its token: Better Auth handles
// session refresh automatically based on the updateAge setting // session refresh automatically based on the updateAge setting
const session = await betterAuthInstance.api.getSession({ const session = await betterAuthInstance.api.getSession({
headers: c.req.raw.headers, headers: c.req.raw.headers,
@@ -171,7 +163,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/auth/token — Issue an opaque API token (session required) // POST /v1/auth/token: Issue an opaque API token (session required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
auth.post('/token', authMiddleware, async (c) => { auth.post('/token', authMiddleware, async (c) => {
if (c.get('auth_token_type') !== 'session') { if (c.get('auth_token_type') !== 'session') {
+3 -5
View File
@@ -1,6 +1,4 @@
/** /** Epoch routes: retrieve epoch root records with optional TSA anchors. */
* Epoch routes — retrieve epoch root records with optional TSA anchors.
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
@@ -14,7 +12,7 @@ const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
epochs.use('/*', authMiddleware); epochs.use('/*', authMiddleware);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/epochs — List all epochs for the organization // GET /v1/epochs: List all epochs for the organization
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
epochs.get('/', requireRole('readonly_investigator'), async (c) => { epochs.get('/', requireRole('readonly_investigator'), async (c) => {
const orgId = c.get('org_id'); const orgId = c.get('org_id');
@@ -24,7 +22,7 @@ epochs.get('/', requireRole('readonly_investigator'), async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/epochs/:epoch_id — Retrieve an epoch root // GET /v1/epochs/:epoch_id: Retrieve an epoch root
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
epochs.get( epochs.get(
'/:epoch_id', '/:epoch_id',
+5 -7
View File
@@ -1,6 +1,4 @@
/** /** Export routes: create and retrieve compliance export jobs. */
* Export routes — create and retrieve compliance export jobs.
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
@@ -16,7 +14,7 @@ const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
exports_.use('/*', authMiddleware); exports_.use('/*', authMiddleware);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/exports — List all exports for the organization // GET /v1/exports: List all exports for the organization
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
exports_.get('/', requireRole('compliance_auditor'), async (c) => { exports_.get('/', requireRole('compliance_auditor'), async (c) => {
const orgId = c.get('org_id'); const orgId = c.get('org_id');
@@ -26,7 +24,7 @@ exports_.get('/', requireRole('compliance_auditor'), async (c) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/exports — Create a compliance export job // POST /v1/exports: Create a compliance export job
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
exports_.post( exports_.post(
'/', '/',
@@ -55,7 +53,7 @@ exports_.post(
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/exports/:export_id — Retrieve export status + download URL // GET /v1/exports/:export_id: Retrieve export status + download URL
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
exports_.get( exports_.get(
'/:export_id', '/:export_id',
@@ -77,7 +75,7 @@ exports_.get(
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/exports/:export_id/download — Stream the export file from R2 // GET /v1/exports/:export_id/download: Stream the export file from R2
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
exports_.get( exports_.get(
'/:export_id/download', '/:export_id/download',
+2 -11
View File
@@ -1,13 +1,4 @@
/** /** JWKS route: public key discovery endpoint. */
* JWKS route — public key discovery endpoint.
*
* Returns the JSON Web Key Set containing the Elydora server's public
* keys used for signing attestation receipts (EAR) and epoch roots (EER).
* External verifiers can use these keys to independently validate
* signatures without contacting the Elydora API.
*
* This endpoint requires NO authentication (per OpenAPI spec and RFC 7517).
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
@@ -17,7 +8,7 @@ import { deriveEd25519PublicKey } from '../utils/crypto.js';
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>(); const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS // GET /.well-known/elydora/jwks.json: Retrieve the platform JWKS
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
jwks.get('/', async (c) => { jwks.get('/', async (c) => {
// Derive the public key from the server signing key // Derive the public key from the server signing key
+4 -6
View File
@@ -1,6 +1,4 @@
/** /** Operation routes: submit, retrieve, and verify signed operation records. */
* Operation routes — submit, retrieve, and verify signed operation records.
*/
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js'; import type { Env, AppVariables } from '../types.js';
@@ -13,7 +11,7 @@ import * as operationService from '../services/operation-service.js';
const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>(); const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/operations — Submit a signed EOR // POST /v1/operations: Submit a signed EOR
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Operation submission uses the EOR's embedded signature for authentication // Operation submission uses the EOR's embedded signature for authentication
// rather than a Bearer token. The agent authenticates via its Ed25519 signature. // rather than a Bearer token. The agent authenticates via its Ed25519 signature.
@@ -47,7 +45,7 @@ operations.post(
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /v1/operations/:operation_id — Retrieve an operation // GET /v1/operations/:operation_id: Retrieve an operation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
operations.get( operations.get(
'/:operation_id', '/:operation_id',
@@ -65,7 +63,7 @@ operations.get(
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /v1/operations/:operation_id/verify — Verify operation integrity // POST /v1/operations/:operation_id/verify: Verify operation integrity
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
operations.post( operations.post(
'/:operation_id/verify', '/:operation_id/verify',
+1 -5
View File
@@ -1,8 +1,4 @@
/** /** Epoch scheduler: runs every 5 minutes to create Merkle tree rollups. */
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
*
* Run as a separate Node.js process: node dist/scheduler.js
*/
import 'dotenv/config'; import 'dotenv/config';
import cron from 'node-cron'; import cron from 'node-cron';
@@ -1,9 +1,4 @@
/** /** Agent service: business logic for agent lifecycle management. */
* Agent service — business logic for agent lifecycle management.
*
* Handles registration, lookup, freezing, and key revocation. All
* administrative mutations are recorded as admin_events for audit.
*/
import type { import type {
Agent, Agent,
@@ -1,6 +1,4 @@
/** /** Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored. */
* Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored.
*/
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import type { Database } from '../adapters/interfaces.js'; import type { Database } from '../adapters/interfaces.js';
@@ -1,10 +1,4 @@
/** /** Audit service: business logic for querying the tamper-evident audit log. */
* Audit service — business logic for querying the tamper-evident audit log.
*
* Builds dynamic SQL queries with optional filters (org_id, agent_id,
* operation_type, time range) and supports cursor-based pagination for
* efficient traversal of large result sets.
*/
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js'; import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js'; import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
+1 -7
View File
@@ -1,10 +1,4 @@
/** /** Auth service: password hashing and user profile retrieval. */
* Auth service — password hashing and user profile retrieval.
*
* Authentication is handled by Better Auth (see lib/auth.ts).
* This service provides password utilities for legacy PBKDF2 hash
* verification and the getMe profile query.
*/
import { AppError } from '../middleware/error-handler.js'; import { AppError } from '../middleware/error-handler.js';
import type { RbacRole } from '../shared/index.js'; import type { RbacRole } from '../shared/index.js';
+3 -16
View File
@@ -1,11 +1,4 @@
/** /** Epoch service: business logic for retrieving epoch root records. */
* Epoch service — business logic for retrieving epoch root records.
*
* Epochs are periodic Merkle-tree rollups that anchor a set of operations
* to a single root hash. Optionally, the epoch may carry a TSA
* (Timestamping Authority) anchor that binds the root to an external
* trusted timestamp for non-repudiation.
*/
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js'; import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
import { AppError } from '../middleware/error-handler.js'; import { AppError } from '../middleware/error-handler.js';
@@ -82,13 +75,7 @@ export async function getEpoch(
// Create epoch // Create epoch
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Create a new epoch by building a Merkle tree over operations in a time window, signing the Epoch Record, and p */
* Create a new epoch by building a Merkle tree over operations in a time
* window, signing the Epoch Record, and persisting to D1 + R2.
*
* Returns null if the epoch already exists, or if no operations fall within
* the given time range.
*/
export async function createEpoch( export async function createEpoch(
db: Database, db: Database,
r2: ObjectStore, r2: ObjectStore,
@@ -97,7 +84,7 @@ export async function createEpoch(
startTime: number, startTime: number,
endTime: number, endTime: number,
): Promise<Epoch | null> { ): Promise<Epoch | null> {
// 1. Idempotency check — skip if epoch already exists for this window // 1. Idempotency check: skip if epoch already exists for this window
const existing = await db const existing = await db
.prepare( .prepare(
'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?', 'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?',
@@ -1,12 +1,4 @@
/** /** Export service: business logic for compliance export job management. */
* Export service — business logic for compliance export job management.
*
* Exports are asynchronous jobs that package operations matching specified
* criteria into a downloadable bundle (JSON or PDF). The job is enqueued
* via a message queue and processed by the queue consumer. Once complete,
* the result is stored in the object store and the database record is
* updated with a reference to the object key.
*/
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js'; import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js';
import { generateUUIDv7 } from '../utils/uuid.js'; import { generateUUIDv7 } from '../utils/uuid.js';
@@ -0,0 +1,82 @@
import type { EOR } from '../shared/index.js';
import { MAX_PAYLOAD_SIZE, MAX_TTL_MS, MIN_TTL_MS, MAX_NONCE_LENGTH } from '../shared/index.js';
import { AppError } from '../middleware/error-handler.js';
/** Genesis chain hash for an agent's first operation. */
export const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
/** Server key ID carried in receipts. */
export const ELYDORA_KID = 'elydora-server-key-v1';
/** The EOR fields covered by the agent signature. */
export function buildSignableEOR(eor: EOR): Record<string, unknown> {
return {
op_version: eor.op_version,
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
issued_at: eor.issued_at,
ttl_ms: eor.ttl_ms,
nonce: eor.nonce,
operation_type: eor.operation_type,
subject: eor.subject,
action: eor.action,
payload: eor.payload,
payload_hash: eor.payload_hash,
prev_chain_hash: eor.prev_chain_hash,
agent_pubkey_kid: eor.agent_pubkey_kid,
};
}
export function validateEORFields(eor: EOR, receivedAt: number): void {
if (eor.op_version !== '1.0') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
}
const requiredStrings: Array<[string, unknown]> = [
['operation_id', eor.operation_id],
['org_id', eor.org_id],
['agent_id', eor.agent_id],
['nonce', eor.nonce],
['operation_type', eor.operation_type],
['payload_hash', eor.payload_hash],
['prev_chain_hash', eor.prev_chain_hash],
['agent_pubkey_kid', eor.agent_pubkey_kid],
['signature', eor.signature],
];
for (const [field, value] of requiredStrings) {
if (!value || typeof value !== 'string' || value.trim().length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } });
}
}
if (eor.nonce.length > MAX_NONCE_LENGTH) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
}
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
}
if (typeof eor.ttl_ms !== 'number') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' });
}
if (eor.ttl_ms < MIN_TTL_MS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } });
}
if (eor.ttl_ms > MAX_TTL_MS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } });
}
if (eor.issued_at + eor.ttl_ms < receivedAt) {
throw new AppError(400, 'TTL_EXPIRED');
}
if (eor.payload !== null && eor.payload !== undefined) {
const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload);
if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) {
throw new AppError(400, 'PAYLOAD_TOO_LARGE');
}
}
}
@@ -1,328 +1,9 @@
/** import type { Operation, Receipt, GetOperationResponse } from '../shared/index.js';
* Operation service — business logic for EOR submission, retrieval, and verification.
*
* This is the core of the Elydora protocol implementation. It performs:
* 1. EOR field validation
* 2. Agent and key status checks
* 3. Ed25519 signature verification
* 4. Hash chain validation and computation
* 5. Persistence to D1 and R2
* 6. Queue enqueue for async processing
* 7. EAR (receipt) generation with server signature
*/
import type {
EOR,
EAR,
Agent,
AgentKey,
Operation,
Receipt,
GetOperationResponse,
VerifyOperationResponse,
} from '../shared/index.js';
import {
MAX_PAYLOAD_SIZE,
MAX_TTL_MS,
MIN_TTL_MS,
MAX_NONCE_LENGTH,
} from '../shared/index.js';
import {
base64urlDecode,
verifyEd25519Signature,
jcsCanonicalise,
computeChainHash,
computeReceiptHash,
signEd25519,
} from '../utils/crypto.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { AppError } from '../middleware/error-handler.js'; import { AppError } from '../middleware/error-handler.js';
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js'; import type { Database, ObjectStore } from '../adapters/interfaces.js';
// --------------------------------------------------------------------------- export { submitOperation } from './operation-submit.js';
// Constants export { verifyOperation } from './operation-verify.js';
// ---------------------------------------------------------------------------
/** The genesis chain hash used for an agent's first operation. */
const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
/** Elydora server key ID used in receipts. */
const ELYDORA_KID = 'elydora-server-key-v1';
// ---------------------------------------------------------------------------
// Submit operation
// ---------------------------------------------------------------------------
export async function submitOperation(
db: Database,
r2: ObjectStore,
kv: Cache,
queue: MessageQueue,
eor: EOR,
signingKey: string,
): Promise<{ receipt: EAR }> {
const receivedAt = Date.now();
// ------------------------------------------------------------------
// Step 1: Validate EOR fields
// ------------------------------------------------------------------
validateEORFields(eor, receivedAt);
// ------------------------------------------------------------------
// Step 2: Replay detection via nonce in KV
// ------------------------------------------------------------------
const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`;
const existingNonce = await kv.get(nonceKey);
if (existingNonce) {
throw new AppError(400, 'REPLAY_DETECTED');
}
// Store nonce with TTL equal to the operation's TTL (in seconds)
const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000);
await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds });
// ------------------------------------------------------------------
// Step 3: Look up agent and check status
// ------------------------------------------------------------------
const agent = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(eor.agent_id, eor.org_id)
.first<Agent>();
if (!agent) {
throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } });
}
if (agent.status === 'frozen') {
throw new AppError(403, 'AGENT_FROZEN');
}
if (agent.status === 'revoked') {
throw new AppError(403, 'AGENT_FROZEN', { key: 'operation.agentRevoked' });
}
// ------------------------------------------------------------------
// Step 4: Look up key and check status
// ------------------------------------------------------------------
const agentKey = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(eor.agent_pubkey_kid, eor.agent_id)
.first<AgentKey>();
if (!agentKey) {
throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } });
}
if (agentKey.status === 'revoked') {
throw new AppError(403, 'KEY_REVOKED');
}
if (agentKey.status === 'retired') {
throw new AppError(403, 'KEY_REVOKED', { key: 'operation.keyRetired' });
}
// ------------------------------------------------------------------
// Step 5: Verify Ed25519 signature
// ------------------------------------------------------------------
const signableEOR = buildSignableEOR(eor);
const canonicalData = jcsCanonicalise(signableEOR);
const dataBytes = new TextEncoder().encode(canonicalData);
const signatureBytes = base64urlDecode(eor.signature);
const publicKeyBytes = base64urlDecode(agentKey.public_key);
const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
if (!signatureValid) {
throw new AppError(400, 'INVALID_SIGNATURE');
}
// ------------------------------------------------------------------
// Step 6: Verify prev_chain_hash against the latest chain_hash
// ------------------------------------------------------------------
const latestOp = await db
.prepare(
'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1',
)
.bind(eor.agent_id)
.first<{ chain_hash: string; seq_no: number }>();
const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH;
const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1;
if (eor.prev_chain_hash !== expectedPrevHash) {
throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } });
}
// ------------------------------------------------------------------
// Step 7: Compute new chain_hash
// ------------------------------------------------------------------
const chainHash = await computeChainHash(
eor.prev_chain_hash,
eor.payload_hash,
eor.operation_id,
eor.issued_at,
);
// ------------------------------------------------------------------
// Step 8: Store operation in D1
// ------------------------------------------------------------------
const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`;
const operation: Operation = {
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
seq_no: nextSeqNo,
operation_type: eor.operation_type,
issued_at: eor.issued_at,
ttl_ms: eor.ttl_ms,
nonce: eor.nonce,
subject: JSON.stringify(eor.subject),
action: JSON.stringify(eor.action),
payload_hash: eor.payload_hash,
prev_chain_hash: eor.prev_chain_hash,
chain_hash: chainHash,
agent_pubkey_kid: eor.agent_pubkey_kid,
signature: eor.signature,
r2_payload_key: r2PayloadKey,
created_at: receivedAt,
};
// ------------------------------------------------------------------
// Step 9: Store the full EOR envelope in R2
// ------------------------------------------------------------------
const eorJson = JSON.stringify(eor);
await r2.put(r2PayloadKey, eorJson, {
httpMetadata: { contentType: 'application/json' },
customMetadata: {
org_id: eor.org_id,
agent_id: eor.agent_id,
operation_type: eor.operation_type,
},
});
// ------------------------------------------------------------------
// Step 10: Reserve the queue message ID; the send follows the commit
// ------------------------------------------------------------------
const queueMessageId = generateUUIDv7();
const receiptId = generateUUIDv7();
// ------------------------------------------------------------------
// Step 11: Generate EAR receipt
// ------------------------------------------------------------------
const receiptFields = {
receipt_version: '1.0',
receipt_id: receiptId,
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
server_received_at: receivedAt,
seq_no: nextSeqNo,
chain_hash: chainHash,
queue_message_id: queueMessageId,
};
const receiptHash = await computeReceiptHash(receiptFields);
// Sign the receipt hash with the server key
const receiptDataToSign = new TextEncoder().encode(receiptHash);
const elydoraSignature = await signEd25519(signingKey, receiptDataToSign);
const ear: EAR = {
...receiptFields,
receipt_hash: receiptHash,
elydora_kid: ELYDORA_KID,
elydora_signature: elydoraSignature,
};
// Store receipt in R2
const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`;
await r2.put(r2ReceiptKey, JSON.stringify(ear), {
httpMetadata: { contentType: 'application/json' },
});
// ------------------------------------------------------------------
// Step 12: Persist operation and receipt to D1
// ------------------------------------------------------------------
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare(
`INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
operation.operation_id,
operation.org_id,
operation.agent_id,
operation.seq_no,
operation.operation_type,
operation.issued_at,
operation.ttl_ms,
operation.nonce,
operation.subject,
operation.action,
operation.payload_hash,
operation.prev_chain_hash,
operation.chain_hash,
operation.agent_pubkey_kid,
operation.signature,
operation.r2_payload_key,
operation.created_at,
),
);
statements.push(
db
.prepare(
`INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at)
VALUES (?, ?, ?, ?)`,
)
.bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt),
);
try {
await db.batch(statements);
} catch (error) {
if (!isSeqNoConflict(error)) throw error;
const winner = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1')
.bind(eor.agent_id)
.first<{ chain_hash: string }>();
if (!winner) throw new Error('seq_no conflict without a stored operation.');
throw new AppError(400, 'PREV_HASH_MISMATCH', {
key: 'operation.prevHashMismatch',
params: { expected: winner.chain_hash, actual: eor.prev_chain_hash },
});
}
try {
await queue.send(queueMessageId, {
type: 'operation',
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
});
} catch (error) {
console.error('operation.enqueue_failed', {
operation_id: eor.operation_id,
queue_message_id: queueMessageId,
error: error instanceof Error ? error.message : String(error),
});
}
return { receipt: ear };
}
function isSeqNoConflict(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const { code, constraint } = error as { code?: unknown; constraint?: unknown };
return code === '23505' && constraint === 'operations_agent_id_seq_no_key';
}
// ---------------------------------------------------------------------------
// Get operation
// ---------------------------------------------------------------------------
export async function getOperation( export async function getOperation(
db: Database, db: Database,
@@ -354,7 +35,7 @@ export async function getOperation(
payload = (eor.payload as Record<string, unknown>) ?? undefined; payload = (eor.payload as Record<string, unknown>) ?? undefined;
} }
} catch { } catch {
// Payload fetch is best-effort — don't fail the request // Payload fetch is best-effort: don't fail the request
} }
} }
@@ -368,290 +49,3 @@ export async function getOperation(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Verify operation // Verify operation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function verifyOperation(
db: Database,
r2: ObjectStore,
operationId: string,
orgId: string,
): Promise<VerifyOperationResponse> {
const errors: string[] = [];
let signatureCheck = false;
let chainCheck = false;
let receiptCheck = false;
// Load the operation
const operation = await db
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
.bind(operationId, orgId)
.first<Operation>();
if (!operation) {
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
}
// Load the EOR from R2
if (!operation.r2_payload_key) {
errors.push('Operation has no R2 payload key.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
const eorObject = await r2.get(operation.r2_payload_key);
if (!eorObject) {
errors.push('EOR evidence not found in R2.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
let eor: EOR;
try {
eor = JSON.parse(await eorObject.text()) as EOR;
} catch {
errors.push('Failed to parse EOR evidence from R2.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
// ---- Signature verification ----
try {
const agentKey = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(eor.agent_pubkey_kid, eor.agent_id)
.first<AgentKey>();
if (agentKey) {
const signableEOR = buildSignableEOR(eor);
const canonicalData = jcsCanonicalise(signableEOR);
const dataBytes = new TextEncoder().encode(canonicalData);
const signatureBytes = base64urlDecode(eor.signature);
const publicKeyBytes = base64urlDecode(agentKey.public_key);
signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
if (!signatureCheck) {
errors.push('Ed25519 signature verification failed.');
}
} else {
errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`);
}
} catch (e) {
errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Chain hash verification ----
try {
const expectedChainHash = await computeChainHash(
operation.prev_chain_hash,
operation.payload_hash,
operation.operation_id,
operation.issued_at,
);
chainCheck = expectedChainHash === operation.chain_hash;
if (!chainCheck) {
errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`);
}
// Also verify the prev_chain_hash links correctly
if (operation.seq_no > 1) {
const prevOp = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?')
.bind(operation.agent_id, operation.seq_no - 1)
.first<{ chain_hash: string }>();
if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) {
chainCheck = false;
errors.push(`prev_chain_hash does not match previous operation's chain_hash.`);
}
}
} catch (e) {
errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Receipt verification ----
try {
const receiptRow = await db
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
.bind(operationId)
.first<Receipt>();
if (receiptRow) {
const receiptObject = await r2.get(receiptRow.r2_receipt_key);
if (receiptObject) {
const ear = JSON.parse(await receiptObject.text()) as EAR;
// Recompute receipt hash
const receiptFields = {
receipt_version: ear.receipt_version,
receipt_id: ear.receipt_id,
operation_id: ear.operation_id,
org_id: ear.org_id,
agent_id: ear.agent_id,
server_received_at: ear.server_received_at,
seq_no: ear.seq_no,
chain_hash: ear.chain_hash,
queue_message_id: ear.queue_message_id,
};
const expectedHash = await computeReceiptHash(receiptFields);
if (expectedHash === ear.receipt_hash) {
receiptCheck = true;
} else {
errors.push('Receipt hash does not match recomputed value.');
}
} else {
errors.push('Receipt evidence not found in R2.');
}
} else {
errors.push('No receipt found for this operation.');
}
} catch (e) {
errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Merkle inclusion verification ----
let merkleCheck: boolean | undefined;
// Find epoch by operation's created_at timestamp
const epochRecord = await db
.prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?')
.bind(orgId, operation.created_at, operation.created_at)
.first<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }>();
if (epochRecord) {
try {
const epochObj = await r2.get(epochRecord.r2_epoch_key);
if (epochObj) {
const epochData = JSON.parse(await epochObj.text()) as {
merkle: { leafOps: string[]; layers: string[][] };
};
// Check if operation's chain_hash is in the leaf layer
const leaves = epochData.merkle.layers[0];
if (leaves && leaves.includes(operation.chain_hash)) {
// Verify the tree root matches the epoch's root_hash
const { getMerkleProof, verifyMerkleProof } = await import('../utils/merkle.js');
const tree = {
root: epochRecord.root_hash,
leaves,
leafOps: epochData.merkle.leafOps,
layers: epochData.merkle.layers,
};
const proof = getMerkleProof(tree, operation.chain_hash);
if (proof) {
merkleCheck = await verifyMerkleProof(proof);
if (!merkleCheck) {
errors.push('Merkle inclusion proof verification failed.');
}
} else {
merkleCheck = false;
errors.push('Operation chain_hash found in leaves but proof generation failed.');
}
} else {
merkleCheck = false;
errors.push('Operation chain_hash not found in epoch Merkle tree leaves.');
}
}
} catch {
// If R2 read fails, leave merkle as undefined (pending)
}
}
// If no epoch found, merkleCheck remains undefined (pending)
const valid = signatureCheck && chainCheck && receiptCheck;
return {
valid,
checks: {
signature: signatureCheck,
chain: chainCheck,
receipt: receiptCheck,
...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}),
},
errors: errors.length > 0 ? errors : undefined,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build the signable portion of an EOR (excludes the signature field itself).
*/
function buildSignableEOR(eor: EOR): Record<string, unknown> {
return {
op_version: eor.op_version,
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
issued_at: eor.issued_at,
ttl_ms: eor.ttl_ms,
nonce: eor.nonce,
operation_type: eor.operation_type,
subject: eor.subject,
action: eor.action,
payload: eor.payload,
payload_hash: eor.payload_hash,
prev_chain_hash: eor.prev_chain_hash,
agent_pubkey_kid: eor.agent_pubkey_kid,
};
}
/**
* Validate all required EOR fields before processing.
*/
function validateEORFields(eor: EOR, receivedAt: number): void {
// Protocol version
if (eor.op_version !== '1.0') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
}
// Required string fields
const requiredStrings: Array<[string, unknown]> = [
['operation_id', eor.operation_id],
['org_id', eor.org_id],
['agent_id', eor.agent_id],
['nonce', eor.nonce],
['operation_type', eor.operation_type],
['payload_hash', eor.payload_hash],
['prev_chain_hash', eor.prev_chain_hash],
['agent_pubkey_kid', eor.agent_pubkey_kid],
['signature', eor.signature],
];
for (const [field, value] of requiredStrings) {
if (!value || typeof value !== 'string' || value.trim().length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } });
}
}
// Nonce length
if (eor.nonce.length > MAX_NONCE_LENGTH) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
}
// issued_at must be a number
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
}
// TTL bounds
if (typeof eor.ttl_ms !== 'number') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' });
}
if (eor.ttl_ms < MIN_TTL_MS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } });
}
if (eor.ttl_ms > MAX_TTL_MS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } });
}
// TTL expiration check
if (eor.issued_at + eor.ttl_ms < receivedAt) {
throw new AppError(400, 'TTL_EXPIRED');
}
// Payload size check (approximate via JSON serialization)
if (eor.payload !== null && eor.payload !== undefined) {
const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload);
if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) {
throw new AppError(400, 'PAYLOAD_TOO_LARGE');
}
}
}
@@ -0,0 +1,256 @@
import type { EOR, EAR, Agent, AgentKey, Operation } from '../shared/index.js';
import {
base64urlDecode,
verifyEd25519Signature,
jcsCanonicalise,
computeChainHash,
computeReceiptHash,
signEd25519,
} from '../utils/crypto.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js';
import { ELYDORA_KID, GENESIS_CHAIN_HASH, buildSignableEOR, validateEORFields } from './operation-eor.js';
export async function submitOperation(
db: Database,
r2: ObjectStore,
kv: Cache,
queue: MessageQueue,
eor: EOR,
signingKey: string,
): Promise<{ receipt: EAR }> {
const receivedAt = Date.now();
// Step 1: Validate EOR fields
validateEORFields(eor, receivedAt);
// Step 2: Replay detection via nonce in KV
const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`;
const existingNonce = await kv.get(nonceKey);
if (existingNonce) {
throw new AppError(400, 'REPLAY_DETECTED');
}
// Store nonce with TTL equal to the operation's TTL (in seconds)
const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000);
await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds });
// Step 3: Look up agent and check status
const agent = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(eor.agent_id, eor.org_id)
.first<Agent>();
if (!agent) {
throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } });
}
if (agent.status === 'frozen') {
throw new AppError(403, 'AGENT_FROZEN');
}
if (agent.status === 'revoked') {
throw new AppError(403, 'AGENT_REVOKED', { key: 'operation.agentRevoked' });
}
// Step 4: Look up key and check status
const agentKey = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(eor.agent_pubkey_kid, eor.agent_id)
.first<AgentKey>();
if (!agentKey) {
throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } });
}
if (agentKey.status === 'revoked') {
throw new AppError(403, 'KEY_REVOKED');
}
if (agentKey.status === 'retired') {
throw new AppError(403, 'KEY_RETIRED', { key: 'operation.keyRetired' });
}
// Step 5: Verify Ed25519 signature
const signableEOR = buildSignableEOR(eor);
const canonicalData = jcsCanonicalise(signableEOR);
const dataBytes = new TextEncoder().encode(canonicalData);
const signatureBytes = base64urlDecode(eor.signature);
const publicKeyBytes = base64urlDecode(agentKey.public_key);
const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
if (!signatureValid) {
throw new AppError(400, 'INVALID_SIGNATURE');
}
// Step 6: Verify prev_chain_hash against the latest chain_hash
const latestOp = await db
.prepare(
'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1',
)
.bind(eor.agent_id)
.first<{ chain_hash: string; seq_no: number }>();
const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH;
const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1;
if (eor.prev_chain_hash !== expectedPrevHash) {
throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } });
}
// Step 7: Compute new chain_hash
const chainHash = await computeChainHash(
eor.prev_chain_hash,
eor.payload_hash,
eor.operation_id,
eor.issued_at,
);
// Step 8: Store operation in D1
const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`;
const operation: Operation = {
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
seq_no: nextSeqNo,
operation_type: eor.operation_type,
issued_at: eor.issued_at,
ttl_ms: eor.ttl_ms,
nonce: eor.nonce,
subject: JSON.stringify(eor.subject),
action: JSON.stringify(eor.action),
payload_hash: eor.payload_hash,
prev_chain_hash: eor.prev_chain_hash,
chain_hash: chainHash,
agent_pubkey_kid: eor.agent_pubkey_kid,
signature: eor.signature,
r2_payload_key: r2PayloadKey,
created_at: receivedAt,
};
// Step 9: Store the full EOR envelope in R2
const eorJson = JSON.stringify(eor);
await r2.put(r2PayloadKey, eorJson, {
httpMetadata: { contentType: 'application/json' },
customMetadata: {
org_id: eor.org_id,
agent_id: eor.agent_id,
operation_type: eor.operation_type,
},
});
// Step 10: Reserve the queue message ID; the send follows the commit
const queueMessageId = generateUUIDv7();
const receiptId = generateUUIDv7();
// Step 11: Generate EAR receipt
const receiptFields = {
receipt_version: '1.0',
receipt_id: receiptId,
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
server_received_at: receivedAt,
seq_no: nextSeqNo,
chain_hash: chainHash,
queue_message_id: queueMessageId,
};
const receiptHash = await computeReceiptHash(receiptFields);
// Sign the receipt hash with the server key
const receiptDataToSign = new TextEncoder().encode(receiptHash);
const elydoraSignature = await signEd25519(signingKey, receiptDataToSign);
const ear: EAR = {
...receiptFields,
receipt_hash: receiptHash,
elydora_kid: ELYDORA_KID,
elydora_signature: elydoraSignature,
};
// Store receipt in R2
const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`;
await r2.put(r2ReceiptKey, JSON.stringify(ear), {
httpMetadata: { contentType: 'application/json' },
});
// Step 12: Persist operation and receipt to D1
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare(
`INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
operation.operation_id,
operation.org_id,
operation.agent_id,
operation.seq_no,
operation.operation_type,
operation.issued_at,
operation.ttl_ms,
operation.nonce,
operation.subject,
operation.action,
operation.payload_hash,
operation.prev_chain_hash,
operation.chain_hash,
operation.agent_pubkey_kid,
operation.signature,
operation.r2_payload_key,
operation.created_at,
),
);
statements.push(
db
.prepare(
`INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at)
VALUES (?, ?, ?, ?)`,
)
.bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt),
);
try {
await db.batch(statements);
} catch (error) {
if (!isSeqNoConflict(error)) throw error;
const winner = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1')
.bind(eor.agent_id)
.first<{ chain_hash: string }>();
if (!winner) throw new Error('seq_no conflict without a stored operation.');
throw new AppError(400, 'PREV_HASH_MISMATCH', {
key: 'operation.prevHashMismatch',
params: { expected: winner.chain_hash, actual: eor.prev_chain_hash },
});
}
try {
await queue.send(queueMessageId, {
type: 'operation',
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
});
} catch (error) {
console.error('operation.enqueue_failed', {
operation_id: eor.operation_id,
queue_message_id: queueMessageId,
error: error instanceof Error ? error.message : String(error),
});
}
return { receipt: ear };
}
function isSeqNoConflict(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const { code, constraint } = error as { code?: unknown; constraint?: unknown };
return code === '23505' && constraint === 'operations_agent_id_seq_no_key';
}
@@ -0,0 +1,209 @@
import type { EOR, EAR, AgentKey, Operation, Receipt, VerifyOperationResponse } from '../shared/index.js';
import {
base64urlDecode,
verifyEd25519Signature,
jcsCanonicalise,
computeChainHash,
computeReceiptHash,
} from '../utils/crypto.js';
import { AppError } from '../middleware/error-handler.js';
import { getMerkleProof, verifyMerkleProof } from '../utils/merkle.js';
import type { Database, ObjectStore } from '../adapters/interfaces.js';
import { buildSignableEOR } from './operation-eor.js';
export async function verifyOperation(
db: Database,
r2: ObjectStore,
operationId: string,
orgId: string,
): Promise<VerifyOperationResponse> {
const errors: string[] = [];
let signatureCheck = false;
let chainCheck = false;
let receiptCheck = false;
// Load the operation
const operation = await db
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
.bind(operationId, orgId)
.first<Operation>();
if (!operation) {
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
}
// Load the EOR from R2
if (!operation.r2_payload_key) {
errors.push('Operation has no R2 payload key.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
const eorObject = await r2.get(operation.r2_payload_key);
if (!eorObject) {
errors.push('EOR evidence not found in R2.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
let eor: EOR;
try {
eor = JSON.parse(await eorObject.text()) as EOR;
} catch {
errors.push('Failed to parse EOR evidence from R2.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
// ---- Signature verification ----
try {
const agentKey = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(eor.agent_pubkey_kid, eor.agent_id)
.first<AgentKey>();
if (agentKey) {
const signableEOR = buildSignableEOR(eor);
const canonicalData = jcsCanonicalise(signableEOR);
const dataBytes = new TextEncoder().encode(canonicalData);
const signatureBytes = base64urlDecode(eor.signature);
const publicKeyBytes = base64urlDecode(agentKey.public_key);
signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
if (!signatureCheck) {
errors.push('Ed25519 signature verification failed.');
}
} else {
errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`);
}
} catch (e) {
errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Chain hash verification ----
try {
const expectedChainHash = await computeChainHash(
operation.prev_chain_hash,
operation.payload_hash,
operation.operation_id,
operation.issued_at,
);
chainCheck = expectedChainHash === operation.chain_hash;
if (!chainCheck) {
errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`);
}
// Also verify the prev_chain_hash links correctly
if (operation.seq_no > 1) {
const prevOp = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?')
.bind(operation.agent_id, operation.seq_no - 1)
.first<{ chain_hash: string }>();
if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) {
chainCheck = false;
errors.push(`prev_chain_hash does not match previous operation's chain_hash.`);
}
}
} catch (e) {
errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Receipt verification ----
try {
const receiptRow = await db
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
.bind(operationId)
.first<Receipt>();
if (receiptRow) {
const receiptObject = await r2.get(receiptRow.r2_receipt_key);
if (receiptObject) {
const ear = JSON.parse(await receiptObject.text()) as EAR;
// Recompute receipt hash
const receiptFields = {
receipt_version: ear.receipt_version,
receipt_id: ear.receipt_id,
operation_id: ear.operation_id,
org_id: ear.org_id,
agent_id: ear.agent_id,
server_received_at: ear.server_received_at,
seq_no: ear.seq_no,
chain_hash: ear.chain_hash,
queue_message_id: ear.queue_message_id,
};
const expectedHash = await computeReceiptHash(receiptFields);
if (expectedHash === ear.receipt_hash) {
receiptCheck = true;
} else {
errors.push('Receipt hash does not match recomputed value.');
}
} else {
errors.push('Receipt evidence not found in R2.');
}
} else {
errors.push('No receipt found for this operation.');
}
} catch (e) {
errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Merkle inclusion verification ----
let merkleCheck: boolean | undefined;
// Find epoch by operation's created_at timestamp
const epochRecord = await db
.prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?')
.bind(orgId, operation.created_at, operation.created_at)
.first<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }>();
if (epochRecord) {
try {
const epochObj = await r2.get(epochRecord.r2_epoch_key);
if (epochObj) {
const epochData = JSON.parse(await epochObj.text()) as {
merkle: { leafOps: string[]; layers: string[][] };
};
// Check if operation's chain_hash is in the leaf layer
const leaves = epochData.merkle.layers[0];
if (leaves && leaves.includes(operation.chain_hash)) {
// Verify the tree root matches the epoch's root_hash
const tree = {
root: epochRecord.root_hash,
leaves,
leafOps: epochData.merkle.leafOps,
layers: epochData.merkle.layers,
};
const proof = getMerkleProof(tree, operation.chain_hash);
if (proof) {
merkleCheck = await verifyMerkleProof(proof);
if (!merkleCheck) {
errors.push('Merkle inclusion proof verification failed.');
}
} else {
merkleCheck = false;
errors.push('Operation chain_hash found in leaves but proof generation failed.');
}
} else {
merkleCheck = false;
errors.push('Operation chain_hash not found in epoch Merkle tree leaves.');
}
}
} catch {
// If R2 read fails, leave merkle as undefined (pending)
}
}
// If no epoch found, merkleCheck remains undefined (pending)
const valid = signatureCheck && chainCheck && receiptCheck;
return {
valid,
checks: {
signature: signatureCheck,
chain: chainCheck,
receipt: receiptCheck,
...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}),
},
errors: errors.length > 0 ? errors : undefined,
};
}
@@ -5,7 +5,9 @@ export const ERROR_CODES: Readonly<Record<ErrorCode, string>> = {
INVALID_SIGNATURE: 'The operation signature is invalid.', INVALID_SIGNATURE: 'The operation signature is invalid.',
UNKNOWN_AGENT: 'The specified agent does not exist.', UNKNOWN_AGENT: 'The specified agent does not exist.',
KEY_REVOKED: 'The signing key has been revoked.', KEY_REVOKED: 'The signing key has been revoked.',
KEY_RETIRED: 'The signing key has been retired.',
AGENT_FROZEN: 'The agent is frozen and cannot submit operations.', AGENT_FROZEN: 'The agent is frozen and cannot submit operations.',
AGENT_REVOKED: 'The agent has been revoked.',
TTL_EXPIRED: 'The operation TTL has expired.', TTL_EXPIRED: 'The operation TTL has expired.',
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).', REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
PREV_HASH_MISMATCH: 'The previous chain hash does not match.', PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
@@ -56,7 +56,9 @@ export type ErrorCode =
| 'INVALID_SIGNATURE' | 'INVALID_SIGNATURE'
| 'UNKNOWN_AGENT' | 'UNKNOWN_AGENT'
| 'KEY_REVOKED' | 'KEY_REVOKED'
| 'KEY_RETIRED'
| 'AGENT_FROZEN' | 'AGENT_FROZEN'
| 'AGENT_REVOKED'
| 'TTL_EXPIRED' | 'TTL_EXPIRED'
| 'REPLAY_DETECTED' | 'REPLAY_DETECTED'
| 'PREV_HASH_MISMATCH' | 'PREV_HASH_MISMATCH'
+4 -21
View File
@@ -1,9 +1,4 @@
/** /** Elydora Operation Record (EOR) The fundamental unit of auditable activity in the Elydora protocol. */
* Elydora Operation Record (EOR)
*
* The fundamental unit of auditable activity in the Elydora protocol.
* Signed by the agent's Ed25519 key and chained via prev_chain_hash.
*/
export interface EOR { export interface EOR {
/** Protocol version, always "1.0" */ /** Protocol version, always "1.0" */
readonly op_version: '1.0'; readonly op_version: '1.0';
@@ -37,11 +32,7 @@ export interface EOR {
readonly signature: string; readonly signature: string;
} }
/** /** Elydora Chain Hash (ECH) Represents the chain hash computation linking operations together. */
* Elydora Chain Hash (ECH)
*
* Represents the chain hash computation linking operations together.
*/
export interface ECH { export interface ECH {
/** Previous chain hash (base64url) */ /** Previous chain hash (base64url) */
readonly prev_ech: string; readonly prev_ech: string;
@@ -55,11 +46,7 @@ export interface ECH {
readonly chain_hash: string; readonly chain_hash: string;
} }
/** /** Elydora Acknowledgment Receipt (EAR) Server-issued receipt confirming acceptance of an operation. */
* Elydora Acknowledgment Receipt (EAR)
*
* Server-issued receipt confirming acceptance of an operation.
*/
export interface EAR { export interface EAR {
/** Receipt version */ /** Receipt version */
readonly receipt_version: string; readonly receipt_version: string;
@@ -87,11 +74,7 @@ export interface EAR {
readonly elydora_signature: string; readonly elydora_signature: string;
} }
/** /** Elydora Epoch Record (EER) Periodic Merkle-tree rollup anchoring operations to a root hash. */
* Elydora Epoch Record (EER)
*
* Periodic Merkle-tree rollup anchoring operations to a root hash.
*/
export interface EER { export interface EER {
/** Unique epoch identifier */ /** Unique epoch identifier */
readonly epoch_id: string; readonly epoch_id: string;
+2 -10
View File
@@ -1,10 +1,4 @@
/** /** Environment bindings for the ElydoraOpenSource Node.js server. */
* Environment bindings for the ElydoraOpenSource Node.js server.
*
* Replaces Cloudflare-specific bindings (D1Database, R2Bucket, KVNamespace,
* Queue) with adapter interfaces backed by PostgreSQL, MinIO, Redis, and
* BullMQ respectively.
*/
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js'; import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
@@ -46,9 +40,7 @@ export interface Env {
TSA_URL?: string; TSA_URL?: string;
} }
/** /** Variables set by middleware and available via `c.get(...)` / `c.var`. */
* Variables set by middleware and available via `c.get(...)` / `c.var`.
*/
export interface AppVariables { export interface AppVariables {
/** UUIDv7 request identifier injected by the request-id middleware */ /** UUIDv7 request identifier injected by the request-id middleware */
request_id: string; request_id: string;
+11 -71
View File
@@ -1,9 +1,4 @@
/** /** Cryptographic utilities for the Elydora API. */
* Cryptographic utilities for the Elydora API.
*
* All operations use the Web Crypto API (SubtleCrypto) which is available
* natively in Node.js 20+ with full Ed25519 support.
*/
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Base64url helpers (RFC 4648 section 5) // Base64url helpers (RFC 4648 section 5)
@@ -52,11 +47,7 @@ export async function sha256Base64url(data: BufferSource | string): Promise<stri
// Ed25519 signature verification // Ed25519 signature verification
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Import a raw Ed25519 public key (32 bytes) as a CryptoKey. */
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
*
* @param publicKeyBytes - 32-byte raw Ed25519 public key
*/
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> { export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey( return crypto.subtle.importKey(
'raw', 'raw',
@@ -67,14 +58,7 @@ export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promis
); );
} }
/** /** Verify an Ed25519 signature over data. */
* Verify an Ed25519 signature over data.
*
* @param publicKeyBytes - 32-byte raw public key
* @param signatureBytes - 64-byte Ed25519 signature
* @param data - The signed data
* @returns true if the signature is valid
*/
export async function verifyEd25519Signature( export async function verifyEd25519Signature(
publicKeyBytes: Uint8Array, publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array, signatureBytes: Uint8Array,
@@ -97,15 +81,7 @@ export async function verifyEd25519Signature(
// Ed25519 signing (server-side receipt signing) // Ed25519 signing (server-side receipt signing)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. */
* PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key.
*
* SEQUENCE {
* INTEGER 0
* SEQUENCE { OID 1.3.101.112 }
* OCTET STRING { OCTET STRING { <32 bytes> } }
* }
*/
const ED25519_PKCS8_PREFIX = new Uint8Array([ const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, // SEQUENCE (46 bytes) 0x30, 0x2e, // SEQUENCE (46 bytes)
0x02, 0x01, 0x00, // INTEGER 0 0x02, 0x01, 0x00, // INTEGER 0
@@ -123,14 +99,7 @@ function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
return pkcs8; return pkcs8;
} }
/** /** Import a raw Ed25519 private key for signing. */
* Import a raw Ed25519 private key for signing.
*
* Node.js expects PKCS8 format for Ed25519 private keys.
* We wrap the 32-byte seed in a minimal PKCS8 envelope.
*
* @param privateKeyBase64url - base64url-encoded 32-byte Ed25519 private key seed
*/
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> { export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
const seed = base64urlDecode(privateKeyBase64url); const seed = base64urlDecode(privateKeyBase64url);
const pkcs8 = wrapSeedAsPkcs8(seed); const pkcs8 = wrapSeedAsPkcs8(seed);
@@ -144,13 +113,7 @@ export async function importEd25519PrivateKey(privateKeyBase64url: string): Prom
); );
} }
/** /** Sign data with an Ed25519 private key. */
* Sign data with an Ed25519 private key.
*
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
* @param data - data to sign
* @returns base64url-encoded 64-byte signature
*/
export async function signEd25519( export async function signEd25519(
privateKeyBase64url: string, privateKeyBase64url: string,
data: Uint8Array, data: Uint8Array,
@@ -164,12 +127,7 @@ export async function signEd25519(
return base64urlEncode(new Uint8Array(signature)); return base64urlEncode(new Uint8Array(signature));
} }
/** /** Export the public key from an Ed25519 private key (for JWKS). */
* Export the public key from an Ed25519 private key (for JWKS).
*
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
* @returns base64url-encoded 32-byte public key
*/
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> { export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
const seed = base64urlDecode(privateKeyBase64url); const seed = base64urlDecode(privateKeyBase64url);
const pkcs8 = wrapSeedAsPkcs8(seed); const pkcs8 = wrapSeedAsPkcs8(seed);
@@ -178,7 +136,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
'pkcs8', 'pkcs8',
pkcs8 as unknown as Uint8Array<ArrayBuffer>, pkcs8 as unknown as Uint8Array<ArrayBuffer>,
{ name: 'Ed25519' }, { name: 'Ed25519' },
true, // extractable — needed to export the public key true, // extractable: needed to export the public key
['sign'], ['sign'],
); );
// Export as JWK to get the public 'x' parameter // Export as JWK to get the public 'x' parameter
@@ -191,15 +149,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
// JCS (JSON Canonicalization Scheme - RFC 8785) // JCS (JSON Canonicalization Scheme - RFC 8785)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Canonicalize a JSON value according to JCS (RFC 8785). */
* Canonicalize a JSON value according to JCS (RFC 8785).
*
* JCS specifies:
* - Object keys sorted lexicographically by UTF-16 code units
* - No whitespace
* - Numbers serialized using ES2015 Number serialization
* - Strings serialized with minimal escaping per JSON spec
*/
export function jcsCanonicalise(value: unknown): string { export function jcsCanonicalise(value: unknown): string {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return 'null'; return 'null';
@@ -237,13 +187,7 @@ export function jcsCanonicalise(value: unknown): string {
// Chain hash computation // Chain hash computation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Compute the chain hash for an operation. */
* Compute the chain hash for an operation.
*
* chain_hash = SHA-256(prev_chain_hash + payload_hash + operation_id + issued_at)
*
* All inputs are concatenated as UTF-8 strings separated by '|'.
*/
export async function computeChainHash( export async function computeChainHash(
prevChainHash: string, prevChainHash: string,
payloadHash: string, payloadHash: string,
@@ -258,11 +202,7 @@ export async function computeChainHash(
// Receipt hash computation // Receipt hash computation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Compute the hash of an EAR receipt for signing. */
* Compute the hash of an EAR receipt for signing.
*
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
*/
export async function computeReceiptHash(receiptFields: { export async function computeReceiptHash(receiptFields: {
receipt_version: string; receipt_version: string;
receipt_id: string; receipt_id: string;
+5 -25
View File
@@ -1,10 +1,4 @@
/** /** SHA-256 Merkle tree implementation for epoch rollups. */
* SHA-256 Merkle tree implementation for epoch rollups.
*
* Leaf hashes are sorted lexicographically before tree construction.
* Internal nodes are computed by concatenating the raw bytes (not base64url
* strings) of their children and hashing the result with SHA-256.
*/
import { sha256Base64url, base64urlDecode } from './crypto.js'; import { sha256Base64url, base64urlDecode } from './crypto.js';
@@ -38,10 +32,7 @@ export interface MerkleProof {
// Internal helper // Internal helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Compute the parent hash from two child hashes. */
* Compute the parent hash from two child hashes.
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
*/
async function hashPair(left: string, right: string): Promise<string> { async function hashPair(left: string, right: string): Promise<string> {
const leftBytes = base64urlDecode(left); const leftBytes = base64urlDecode(left);
const rightBytes = base64urlDecode(right); const rightBytes = base64urlDecode(right);
@@ -55,12 +46,7 @@ async function hashPair(left: string, right: string): Promise<string> {
// Build // Build
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Build a Merkle tree from leaf hashes and their corresponding operation IDs. */
* Build a Merkle tree from leaf hashes and their corresponding operation IDs.
*
* Leaves are sorted lexicographically (with operationIds reordered to match).
* If a layer has an odd number of nodes the last node is duplicated.
*/
export async function buildMerkleTree( export async function buildMerkleTree(
leafHashes: string[], leafHashes: string[],
operationIds: string[], operationIds: string[],
@@ -103,11 +89,7 @@ export async function buildMerkleTree(
// Proof generation // Proof generation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Generate a Merkle inclusion proof for a given leaf hash. */
* Generate a Merkle inclusion proof for a given leaf hash.
*
* Returns null if the leaf is not found in the tree.
*/
export function getMerkleProof( export function getMerkleProof(
tree: MerkleTree, tree: MerkleTree,
leafHash: string, leafHash: string,
@@ -142,9 +124,7 @@ export function getMerkleProof(
// Proof verification // Proof verification
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /** Verify a Merkle inclusion proof by recomputing the root. */
* Verify a Merkle inclusion proof by recomputing the root.
*/
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> { export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
let current = proof.leaf; let current = proof.leaf;
+3 -20
View File
@@ -1,11 +1,4 @@
/** /** Cursor-based pagination helpers. */
* Cursor-based pagination helpers.
*
* Cursors are opaque base64url-encoded JSON objects containing the
* created_at timestamp and primary key of the last item in the current
* page. This design avoids OFFSET-based pagination which degrades at
* scale and is susceptible to drift when rows are inserted concurrently.
*/
import { base64urlDecode, base64urlEncode } from './crypto.js'; import { base64urlDecode, base64urlEncode } from './crypto.js';
@@ -17,24 +10,14 @@ export interface CursorPayload {
readonly id: string; readonly id: string;
} }
/** /** Encode a cursor payload into an opaque cursor string. */
* Encode a cursor payload into an opaque cursor string.
*
* @param payload - The cursor data to encode
* @returns An opaque base64url-encoded cursor string
*/
export function encodeCursor(payload: CursorPayload): string { export function encodeCursor(payload: CursorPayload): string {
const json = JSON.stringify(payload); const json = JSON.stringify(payload);
const bytes = new TextEncoder().encode(json); const bytes = new TextEncoder().encode(json);
return base64urlEncode(bytes); return base64urlEncode(bytes);
} }
/** /** Decode an opaque cursor string back into its payload. */
* Decode an opaque cursor string back into its payload.
*
* @param cursor - The opaque cursor string
* @returns The decoded cursor payload, or null if invalid
*/
export function decodeCursor(cursor: string): CursorPayload | null { export function decodeCursor(cursor: string): CursorPayload | null {
try { try {
const bytes = base64urlDecode(cursor); const bytes = base64urlDecode(cursor);
+7 -22
View File
@@ -1,15 +1,4 @@
/** /** Branded PDF generator for Elydora compliance exports. */
* Branded PDF generator for Elydora compliance exports.
*
* Produces professional multi-page PDF/1.4 documents with:
* - Elydora logo and brand header bar
* - Structured metadata section
* - Formatted operations table with alternating row stripes
* - Branded footer with page numbers
*
* Uses only built-in PDF Type1 fonts (Helvetica, Courier) —
* no external dependencies, works entirely in the Node.js runtime.
*/
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -46,15 +35,15 @@ const CW = PW - ML - MR; // 500pt content width
// Header bar // Header bar
const HDR_H = 56; const HDR_H = 56;
const HDR_Y = PH - HDR_H; // 736 — bottom edge of header const HDR_Y = PH - HDR_H; // 736: bottom edge of header
// Footer // Footer
const FTR_LINE = 44; // y of separator line const FTR_LINE = 44; // y of separator line
const FTR_TEXT = 28; // y of footer text baseline const FTR_TEXT = 28; // y of footer text baseline
// Content area // Content area
const CT = HDR_Y - 20; // 716 — content top const CT = HDR_Y - 20; // 716: content top
const CB = FTR_LINE + 12; // 56 — content bottom const CB = FTR_LINE + 12; // 56 : content bottom
// Operations table // Operations table
const ROW_H = 12; // row height const ROW_H = 12; // row height
@@ -114,11 +103,7 @@ function txt(s: string, x: number, y: number, font: string, size: number, rgb: s
return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`; return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`;
} }
/** /** Draw the Elydora "E" logo mark. */
* Draw the Elydora "E" logo mark.
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
* Transform flips Y for PDF coordinate system.
*/
function drawLogo(x: number, y: number, size: number, rgb: string): string { function drawLogo(x: number, y: number, size: number, rgb: string): string {
const sc = size / 24; const sc = size / 24;
return [ return [
@@ -131,7 +116,7 @@ function drawLogo(x: number, y: number, size: number, rgb: string): string {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section renderers — each appends drawing instructions to the page array // Section renderers: each appends drawing instructions to the page array
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Dark header bar with logo and brand text (every page). */ /** Dark header bar with logo and brand text (every page). */
@@ -321,7 +306,7 @@ function assemblePDF(pageStreams: string[]): Uint8Array {
// Obj 1: Catalog // Obj 1: Catalog
objContents.push('<< /Type /Catalog /Pages 2 0 R >>'); objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
// Obj 2: Pages (placeholder — patched after page objects are created) // Obj 2: Pages (placeholder: patched after page objects are created)
objContents.push(''); objContents.push('');
// Obj 3–5: Built-in fonts // Obj 3–5: Built-in fonts
+7 -35
View File
@@ -1,49 +1,27 @@
/** /** RFC 3161 Timestamping Authority (TSA) client utilities. */
* RFC 3161 Timestamping Authority (TSA) client utilities.
*
* Constructs a minimal DER-encoded TimeStampReq and submits it to a
* public TSA endpoint. No ASN.1 library required — the 59-byte request
* is hand-assembled using the same DER-prefix pattern as the PKCS8
* wrapper in crypto.ts.
*/
/** Default public TSA endpoint (Sectigo / Comodo). */ /** Default public TSA endpoint (Sectigo / Comodo). */
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com'; export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
/** /** Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). */
* Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes).
*
* Structure:
* SEQUENCE {
* INTEGER 1 -- version
* SEQUENCE { -- messageImprint
* SEQUENCE { -- hashAlgorithm (SHA-256)
* OID 2.16.840.1.101.3.4.2.1
* NULL
* }
* OCTET STRING <32 bytes> -- hashedMessage
* }
* BOOLEAN TRUE -- certReq
* }
*/
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array { export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
if (sha256Hash.length !== 32) { if (sha256Hash.length !== 32) {
throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`); throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`);
} }
// DER prefix (24 bytes) — everything before the 32-byte hash // DER prefix (24 bytes): everything before the 32-byte hash
const prefix = new Uint8Array([ const prefix = new Uint8Array([
0x30, 0x39, // SEQUENCE (57 bytes) 0x30, 0x39, // SEQUENCE (57 bytes)
0x02, 0x01, 0x01, // INTEGER 1 (version v1) 0x02, 0x01, 0x01, // INTEGER 1 (version v1)
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint 0x30, 0x31, // SEQUENCE (49 bytes): MessageImprint
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier 0x30, 0x0d, // SEQUENCE (13 bytes): AlgorithmIdentifier
0x06, 0x09, // OID (9 bytes) 0x06, 0x09, // OID (9 bytes)
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1) 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1)
0x05, 0x00, // NULL (parameters) 0x05, 0x00, // NULL (parameters)
0x04, 0x20, // OCTET STRING (32 bytes) 0x04, 0x20, // OCTET STRING (32 bytes)
]); ]);
// DER suffix (3 bytes) — certReq = TRUE // DER suffix (3 bytes): certReq = TRUE
const suffix = new Uint8Array([0x01, 0x01, 0xff]); const suffix = new Uint8Array([0x01, 0x01, 0xff]);
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes // Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
@@ -55,13 +33,7 @@ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
return req; return req;
} }
/** /** Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. */
* Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp.
*
* @param sha256Hash - 32-byte SHA-256 digest to timestamp
* @param tsaUrl - TSA endpoint (defaults to Sectigo)
* @returns Raw DER-encoded TimeStampResp bytes
*/
export async function requestTimestamp( export async function requestTimestamp(
sha256Hash: Uint8Array, sha256Hash: Uint8Array,
tsaUrl: string = DEFAULT_TSA_URL, tsaUrl: string = DEFAULT_TSA_URL,
+3 -23
View File
@@ -1,24 +1,6 @@
/** /** UUIDv7 generator conforming to RFC 9562. */
* UUIDv7 generator conforming to RFC 9562.
*
* UUIDv7 embeds a Unix-millisecond timestamp in the most-significant 48 bits,
* making identifiers lexicographically sortable and eliminating the need for
* a separate created_at column in many cases.
*
* This implementation uses the Web Crypto API (crypto.getRandomValues) which
* is available natively in Node.js 20+.
*/
/** /** Generate a UUIDv7 string. */
* Generate a UUIDv7 string.
*
* Layout (128 bits total):
* bits 0-47 : Unix timestamp in milliseconds
* bits 48-51 : version (0b0111 = 7)
* bits 52-63 : random_a (12 bits)
* bits 64-65 : variant (0b10)
* bits 66-127: random_b (62 bits)
*/
export function generateUUIDv7(): string { export function generateUUIDv7(): string {
const now = Date.now(); const now = Date.now();
const bytes = new Uint8Array(16); const bytes = new Uint8Array(16);
@@ -46,9 +28,7 @@ export function generateUUIDv7(): string {
return formatUUID(bytes); return formatUUID(bytes);
} }
/** /** Format a 16-byte array as a standard UUID string (8-4-4-4-12). */
* Format a 16-byte array as a standard UUID string (8-4-4-4-12).
*/
function formatUUID(bytes: Uint8Array): string { function formatUUID(bytes: Uint8Array): string {
const hex = Array.from(bytes) const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
+1 -5
View File
@@ -1,8 +1,4 @@
/** /** BullMQ queue worker: processes operation, export, and TSA jobs. */
* BullMQ queue worker — processes operation, export, and TSA jobs.
*
* Run as a separate Node.js process: node dist/worker.js
*/
import 'dotenv/config'; import 'dotenv/config';
import { Worker, type Job } from 'bullmq'; import { Worker, type Job } from 'bullmq';
+50 -21
View File
@@ -10,6 +10,8 @@ go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration. Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
Upgrading from 2.0.x: run `elydora install` again for every agent. The generated runtimes changed, and `elydora status` does not detect stale runtimes for every adapter.
## Quick Start ## Quick Start
```go ```go
@@ -23,15 +25,12 @@ import (
) )
func main() { func main() {
// Initialize the client with your API token. // Create client with an API token issued in the Console
// Obtain an API token by signing in via the Elydora console or:
// POST /api/auth/sign-in/email -> get session token
// POST /v1/auth/token -> exchange for long-lived API token
client, err := elydora.NewClient(&elydora.Config{ client, err := elydora.NewClient(&elydora.Config{
OrgID: "org-123", OrgID: "org-123",
AgentID: "my-agent-id", AgentID: "my-agent-id",
PrivateKey: "<base64url-encoded-ed25519-seed>", PrivateKey: "<base64url-encoded-ed25519-seed>",
Token: "your-api-token", Token: "<api-token>",
}) })
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -110,8 +109,9 @@ Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings
|---------|-------------| |---------|-------------|
| `elydora install` | Install Elydora audit hook for a coding agent | | `elydora install` | Install Elydora audit hook for a coding agent |
| `elydora uninstall` | Remove Elydora audit hook for a coding agent | | `elydora uninstall` | Remove Elydora audit hook for a coding agent |
| `elydora status` | Show installation status for all agents | | `elydora status` | Show installation status for one agent or all agents |
| `elydora agents` | List supported coding agents | | `elydora agents` | List supported coding agents |
| `elydora version` | Print the CLI version |
### Supported Agents ### Supported Agents
@@ -158,24 +158,24 @@ client.SetToken("new-api-token")
### Authentication ### Authentication
Authentication uses Better Auth. Register and sign in via the Elydora console or the Better Auth endpoints, then issue a long-lived API token for SDK use:
```go ```go
// Sign up (Better Auth) — use the console or call directly: // Get the authenticated user and current organization
// POST /api/auth/sign-up/email { email, password, name } me, err := client.GetMe()
// // me.User.Email, me.User.Role, me.CurrentOrganization.OrgID
// Sign in (Better Auth) — get a session token:
// POST /api/auth/sign-in/email { email, password } // Issue an API token; this call needs a client whose Token is a Console session token
//
// Issue a long-lived API token from an active session:
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{ tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
TTLSeconds: &ttlSeconds, TTLSeconds: &ttlSeconds,
}) })
client.SetToken(tokenResp.Token)
// Update the token on an existing client instance at runtime // Rotate the installed API token; the response carries the replacement
client.SetToken("new-api-token") rotated, err := client.RotateApiToken()
client.SetToken(rotated.Token)
``` ```
`elydora.Register` and `elydora.Login` remain for password accounts and are deprecated; use the Console sign-in flow.
### Operations ### Operations
```go ```go
@@ -215,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
// Get agent details // Get agent details
details, err := client.GetAgent(agentID) details, err := client.GetAgent(agentID)
// Change the integration type
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
// Freeze an agent // Freeze an agent
err := client.FreezeAgent(agentID, "security review") frozen, err := client.FreezeAgent(agentID, "security review")
// Revoke a key // Revoke a key
err := client.RevokeKey(agentID, kid, "key rotation") err := client.RevokeKey(agentID, kid, "key rotation")
@@ -225,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
agents, err := client.ListAgents() agents, err := client.ListAgents()
// Unfreeze a previously frozen agent // Unfreeze a previously frozen agent
err := client.UnfreezeAgent(agentID, "review complete") unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
// Delete an agent permanently // Delete an agent permanently
deleted, err := client.DeleteAgent(agentID) deleted, err := client.DeleteAgent(agentID)
@@ -266,6 +269,25 @@ detail, err := client.GetExport(exportID)
data, err := client.DownloadExport(exportID) data, err := client.DownloadExport(exportID)
``` ```
### Webhooks
```go
webhook, err := client.RegisterWebhook(&elydora.RegisterWebhookRequest{
EndpointURL: "https://example.com/elydora",
Events: []string{"operation.accepted"},
Secret: "<signing-secret>",
})
webhooks, err := client.ListWebhooks()
err = client.DeleteWebhook(webhook.Webhook.WebhookID)
```
### Organization
```go
members, err := client.ListMembers()
events, err := client.ListAdminEvents(50)
```
### JWKS ### JWKS
```go ```go
@@ -277,7 +299,11 @@ jwks, err := client.GetJWKS()
```go ```go
// Check API health (no authentication required, does not need a client) // Check API health (no authentication required, does not need a client)
health, err := elydora.Health("https://api.elydora.com") health, err := elydora.Health("https://api.elydora.com")
// health.Status, health.Version, health.ProtocolVersion, health.Timestamp // health.Status, health.Version, health.ProtocolVersion, health.Capabilities, health.Timestamp
// Check dependency health (D1, auth storage, R2, KV)
deep, err := elydora.DeepHealth("https://api.elydora.com")
// deep.Dependencies.D1.Status, deep.Dependencies.KV.LatencyMs
``` ```
### Constants ### Constants
@@ -295,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` | | `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` | | `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` | | `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
| `WebhookStatus` | `WebhookStatusActive`, `WebhookStatusDisabled` |
| `IntegrationType` | One constant per supported agent key plus `IntegrationTypeEnterprise`, `IntegrationTypeGUI`, `IntegrationTypeSDK`, `IntegrationTypeOther` |
| `AdminAction` | One constant per admin event action, for example `AdminActionAgentFreeze` |
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` | | `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeAgentFrozen`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` | | `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeKeyRetired`, `ErrorCodeAgentFrozen`, `ErrorCodeAgentRevoked`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
## Error Handling ## Error Handling
+16
View File
@@ -0,0 +1,16 @@
package elydora
import "fmt"
// ListAdminEvents retrieves recent administrative events; limit 0 uses the server default.
func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) {
path := "/v1/admin/events"
if limit > 0 {
path = fmt.Sprintf("%s?limit=%d", path, limit)
}
var result ListAdminEventsResponse
if err := c.doGet(path, &result); err != nil {
return nil, err
}
return &result, nil
}
+29 -16
View File
@@ -3,17 +3,11 @@ package elydora
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
) )
// RegisterAgent registers a new AI agent within the organization. // RegisterAgent registers a new AI agent within the organization.
func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) { func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) {
if req == nil {
return nil, fmt.Errorf("elydora: register agent request must not be nil")
}
if !req.IntegrationType.IsValid() {
return nil, fmt.Errorf("elydora: invalid integration_type %q", req.IntegrationType)
}
var result RegisterAgentResponse var result RegisterAgentResponse
if err := c.doPost("/v1/agents/register", req, &result); err != nil { if err := c.doPost("/v1/agents/register", req, &result); err != nil {
return nil, err return nil, err
@@ -24,22 +18,26 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
// GetAgent retrieves an agent by ID. // GetAgent retrieves an agent by ID.
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) { func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
var result GetAgentResponse var result GetAgentResponse
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil { if err := c.doGet(fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &result); err != nil {
return nil, err return nil, err
} }
return &result, nil return &result, nil
} }
// FreezeAgent freezes an agent, preventing it from submitting operations. // FreezeAgent freezes an agent, preventing it from submitting operations.
func (c *Client) FreezeAgent(agentID, reason string) error { func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{ var result FreezeAgentResponse
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
Reason: reason, Reason: reason,
}, nil) }, &result); err != nil {
return nil, err
}
return &result, nil
} }
// RevokeKey revokes a specific key for an agent. // RevokeKey revokes a specific key for an agent.
func (c *Client) RevokeKey(agentID, kid, reason string) error { func (c *Client) RevokeKey(agentID, kid, reason string) error {
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{ return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", url.PathEscape(agentID)), &RevokeAgentRequest{
KID: kid, KID: kid,
Reason: reason, Reason: reason,
}, nil) }, nil)
@@ -55,16 +53,31 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
} }
// UnfreezeAgent unfreezes a previously frozen agent. // UnfreezeAgent unfreezes a previously frozen agent.
func (c *Client) UnfreezeAgent(agentID, reason string) error { func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{ var result UnfreezeAgentResponse
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
Reason: reason, Reason: reason,
}, nil) }, &result); err != nil {
return nil, err
}
return &result, nil
}
// UpdateAgent updates an agent's integration type.
func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) {
var result UpdateAgentResponse
if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{
IntegrationType: integrationType,
}, &result); err != nil {
return nil, err
}
return &result, nil
} }
// DeleteAgent permanently deletes an agent and all associated data. // DeleteAgent permanently deletes an agent and all associated data.
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) { func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
var result DeleteAgentResponse var result DeleteAgentResponse
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil { if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), nil, &result); err != nil {
return nil, err return nil, err
} }
return &result, nil return &result, nil
+19 -95
View File
@@ -1,51 +1,30 @@
package elydora package elydora
import ( import "net/http"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// RegisterOption is a functional option for the Register function. // RegisterOption configures a registration request.
type RegisterOption func(*AuthRegisterRequest) type RegisterOption func(*AuthRegisterRequest)
// WithDisplayName sets the display_name on a registration request. // WithDisplayName sets the display_name on a registration request.
func WithDisplayName(name string) RegisterOption { func WithDisplayName(name string) RegisterOption {
return func(req *AuthRegisterRequest) { return func(req *AuthRegisterRequest) { req.DisplayName = name }
req.DisplayName = name
}
} }
// WithOrgName sets the org_name on a registration request. // WithOrgName sets the org_name on a registration request.
func WithOrgName(name string) RegisterOption { func WithOrgName(name string) RegisterOption {
return func(req *AuthRegisterRequest) { return func(req *AuthRegisterRequest) { req.OrgName = name }
req.OrgName = name
}
} }
// Register creates a new user and organization. // Register creates a new user and organization.
// //
// Deprecated: Use Better Auth endpoints directly. See docs. // Deprecated: use the Console sign-up flow; password registration is being phased out.
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) { func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
if baseURL == "" { req := &AuthRegisterRequest{Email: email, Password: password}
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
req := &AuthRegisterRequest{
Email: email,
Password: password,
}
for _, opt := range opts { for _, opt := range opts {
opt(req) opt(req)
} }
var result AuthRegisterResponse var result AuthRegisterResponse
if err := authPost(baseURL, "/v1/auth/register", req, &result); err != nil { if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/register", req, &result); err != nil {
return nil, err return nil, err
} }
return &result, nil return &result, nil
@@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg
// Login authenticates a user and returns a session token. // Login authenticates a user and returns a session token.
// //
// Deprecated: Use Better Auth endpoints directly. See docs. // Deprecated: use the Console sign-in flow; password login is being phased out.
func Login(baseURL, email, password string) (*AuthLoginResponse, error) { func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
if baseURL == "" { req := &AuthLoginRequest{Email: email, Password: password}
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
req := &AuthLoginRequest{
Email: email,
Password: password,
}
var result AuthLoginResponse var result AuthLoginResponse
if err := authPost(baseURL, "/v1/auth/login", req, &result); err != nil { if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/login", req, &result); err != nil {
return nil, err return nil, err
} }
return &result, nil return &result, nil
} }
// authPost is a helper for unauthenticated POST requests used by Register and Login.
func authPost(baseURL, path string, body interface{}, result interface{}) error {
u := baseURL + path
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("elydora: marshal request body: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("elydora: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("elydora: http request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return fmt.Errorf("elydora: read response body: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
}
return nil
}
var errResp errorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
return &ElydoraError{
StatusCode: resp.StatusCode,
Code: errResp.Error.Code,
Message: errResp.Error.Message,
RequestID: errResp.Error.RequestID,
Details: errResp.Error.Details,
}
}
return &ElydoraError{
StatusCode: resp.StatusCode,
Code: ErrorCodeInternalError,
Message: string(respBody),
}
}
// GetMe retrieves the current authenticated user's profile. // GetMe retrieves the current authenticated user's profile.
func (c *Client) GetMe() (*GetMeResponse, error) { func (c *Client) GetMe() (*GetMeResponse, error) {
var result GetMeResponse var result GetMeResponse
@@ -144,3 +59,12 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
} }
return &result, nil return &result, nil
} }
// RotateApiToken rotates the current API token; the old token stays valid for 24h.
func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) {
var result RotateApiTokenResponse
if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil {
return nil, err
}
return &result, nil
}
+172 -110
View File
@@ -14,6 +14,8 @@ const (
defaultBaseURL = "https://api.elydora.com" defaultBaseURL = "https://api.elydora.com"
defaultTTLMs = 30000 defaultTTLMs = 30000
defaultMaxRetries = 3 defaultMaxRetries = 3
maxResponseBytes = 10 << 20
requestTimeout = 30 * time.Second
) )
// Config holds the configuration for creating a new Client. // Config holds the configuration for creating a new Client.
@@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) {
if cfg == nil { if cfg == nil {
return nil, fmt.Errorf("elydora: config must not be nil") return nil, fmt.Errorf("elydora: config must not be nil")
} }
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
ttlMs := cfg.TTLMs ttlMs := cfg.TTLMs
if ttlMs <= 0 { if ttlMs <= 0 {
ttlMs = defaultTTLMs ttlMs = defaultTTLMs
} }
maxRetries := cfg.MaxRetries maxRetries := cfg.MaxRetries
if maxRetries <= 0 { if maxRetries <= 0 {
maxRetries = defaultMaxRetries maxRetries = defaultMaxRetries
} }
return &Client{ return &Client{
orgID: cfg.OrgID, orgID: cfg.OrgID,
agentID: cfg.AgentID, agentID: cfg.AgentID,
privateKey: cfg.PrivateKey, privateKey: cfg.PrivateKey,
baseURL: baseURL, baseURL: normalizeBaseURL(cfg.BaseURL),
ttlMs: ttlMs, ttlMs: ttlMs,
maxRetries: maxRetries, maxRetries: maxRetries,
token: cfg.Token, token: cfg.Token,
httpClient: &http.Client{ httpClient: &http.Client{Timeout: requestTimeout},
Timeout: 30 * time.Second,
},
}, nil }, nil
} }
// doRequest executes an HTTP request with retries and error handling.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
u := c.baseURL + path
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("elydora: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(data)
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// Reset body reader for retry
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
}
req, err := http.NewRequest(method, u, bodyReader)
if err != nil {
return fmt.Errorf("elydora: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("elydora: http request: %w", err)
continue
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("elydora: read response body: %w", err)
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
}
return nil
}
// Parse error response
var errResp errorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
apiErr := &ElydoraError{
StatusCode: resp.StatusCode,
Code: errResp.Error.Code,
Message: errResp.Error.Message,
RequestID: errResp.Error.RequestID,
Details: errResp.Error.Details,
}
// Don't retry 4xx errors (except 429)
if resp.StatusCode < 500 && resp.StatusCode != 429 {
return apiErr
}
lastErr = apiErr
continue
}
lastErr = &ElydoraError{
StatusCode: resp.StatusCode,
Code: ErrorCodeInternalError,
Message: string(respBody),
}
if resp.StatusCode < 500 && resp.StatusCode != 429 {
return lastErr
}
}
return lastErr
}
// doGet performs a GET request.
func (c *Client) doGet(path string, result interface{}) error {
return c.doRequest(http.MethodGet, path, nil, result)
}
// doPost performs a POST request.
func (c *Client) doPost(path string, body interface{}, result interface{}) error {
return c.doRequest(http.MethodPost, path, body, result)
}
// SetToken sets the API token used for authenticated API requests. // SetToken sets the API token used for authenticated API requests.
func (c *Client) SetToken(token string) { func (c *Client) SetToken(token string) {
c.token = token c.token = token
} }
func normalizeBaseURL(baseURL string) string {
if baseURL == "" {
baseURL = defaultBaseURL
}
return strings.TrimRight(baseURL, "/")
}
func newRequest(method, url string, body []byte, token, accept string) (*http.Request, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, fmt.Errorf("elydora: create request: %w", err)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req, nil
}
func marshalBody(body interface{}) ([]byte, error) {
if body == nil {
return nil, nil
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("elydora: marshal request body: %w", err)
}
return data, nil
}
func readBody(resp *http.Response, limit int64) ([]byte, error) {
defer resp.Body.Close()
var reader io.Reader = resp.Body
if limit > 0 {
reader = io.LimitReader(resp.Body, limit)
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("elydora: read response body: %w", err)
}
return body, nil
}
func decodeSuccess(body []byte, result interface{}) error {
if result == nil {
return nil
}
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("elydora: unmarshal response: %w", err)
}
return nil
}
func apiError(status int, body []byte) *ElydoraError {
var errResp errorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Code != "" {
return &ElydoraError{
StatusCode: status,
Code: errResp.Error.Code,
Message: errResp.Error.Message,
RequestID: errResp.Error.RequestID,
Details: errResp.Error.Details,
}
}
return &ElydoraError{StatusCode: status, Code: ErrorCodeInternalError, Message: string(body)}
}
func retryable(status int) bool {
return status >= 500 || status == http.StatusTooManyRequests
}
// A lost response to a non-idempotent request must not be replayed.
func idempotent(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete:
return true
}
return false
}
// doRequest executes an authenticated request; only idempotent methods retry.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
data, err := marshalBody(body)
if err != nil {
return err
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
}
req, err := newRequest(method, c.baseURL+path, data, c.token, "application/json")
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("elydora: http request: %w", err)
if !idempotent(method) {
return lastErr
}
continue
}
respBody, err := readBody(resp, maxResponseBytes)
if err != nil {
lastErr = err
if !idempotent(method) {
return lastErr
}
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return decodeSuccess(respBody, result)
}
lastErr = apiError(resp.StatusCode, respBody)
if !retryable(resp.StatusCode) || !idempotent(method) {
return lastErr
}
}
return lastErr
}
func (c *Client) doGet(path string, result interface{}) error {
return c.doRequest(http.MethodGet, path, nil, result)
}
func (c *Client) doPost(path string, body interface{}, result interface{}) error {
return c.doRequest(http.MethodPost, path, body, result)
}
// publicRequest sends one unauthenticated request and decodes accepted statuses.
func publicRequest(method, url string, body interface{}, result interface{}, accepted ...int) error {
data, err := marshalBody(body)
if err != nil {
return err
}
req, err := newRequest(method, url, data, "", "application/json")
if err != nil {
return err
}
resp, err := (&http.Client{Timeout: requestTimeout}).Do(req)
if err != nil {
return fmt.Errorf("elydora: http request: %w", err)
}
respBody, err := readBody(resp, maxResponseBytes)
if err != nil {
return err
}
for _, status := range accepted {
if resp.StatusCode == status {
return decodeSuccess(respBody, result)
}
}
if len(accepted) == 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 {
return decodeSuccess(respBody, result)
}
return apiError(resp.StatusCode, respBody)
}
-3
View File
@@ -119,9 +119,6 @@ func readSecretFileWithOpen(
if err := validateSecretFile(before, path, label); err != nil { if err := validateSecretFile(before, path, label); err != nil {
return "", err return "", err
} }
if !os.SameFile(before, before) {
return "", fmt.Errorf("establish %s file identity at %s", label, path)
}
file, err := openFile(path) file, err := openFile(path)
if err != nil { if err != nil {
+1 -26
View File
@@ -11,7 +11,7 @@ import (
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins" "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
) )
const version = "2.0.1" const version = "2.1.0"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -57,10 +57,6 @@ Run "elydora <command> -h" for details on each command.
`) `)
} }
// ---------------------------------------------------------------------------
// install
// ---------------------------------------------------------------------------
func cmdInstall(args []string) { func cmdInstall(args []string) {
if err := rejectLegacySecretArguments(args); err != nil { if err := rejectLegacySecretArguments(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -160,10 +156,6 @@ func cmdInstall(args []string) {
fmt.Printf(" Guard script: %s\n", guardScriptPath) fmt.Printf(" Guard script: %s\n", guardScriptPath)
} }
// ---------------------------------------------------------------------------
// uninstall
// ---------------------------------------------------------------------------
func cmdUninstall(args []string) { func cmdUninstall(args []string) {
fs := flag.NewFlagSet("uninstall", flag.ExitOnError) fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (required)") agent := fs.String("agent", "", "Agent name (required)")
@@ -209,10 +201,6 @@ func cmdUninstall(args []string) {
} }
} }
// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------
func cmdStatus(args []string) { func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError) fs := flag.NewFlagSet("status", flag.ExitOnError)
agent := fs.String("agent", "", "Agent name (optional — omit to show all)") agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
@@ -256,10 +244,6 @@ func showAgentStatus(name string) {
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state) fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
} }
// ---------------------------------------------------------------------------
// agents
// ---------------------------------------------------------------------------
func cmdAgents() { func cmdAgents() {
fmt.Println("Supported agents:") fmt.Println("Supported agents:")
fmt.Println() fmt.Println()
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
sort.Strings(names) sort.Strings(names)
return names return names
} }
// guardScriptPathForAgent returns the path to ~/.elydora/<agentId>/guard.js.
func guardScriptPathForAgent(agentId string) (string, error) {
agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId)
if err != nil {
return "", err
}
return filepath.Join(agentDirectory, "guard.js"), nil
}
+4 -2
View File
@@ -5,6 +5,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
) )
func setMainTestHome(t *testing.T) string { func setMainTestHome(t *testing.T) string {
@@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string {
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) { func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
setMainTestHome(t) setMainTestHome(t)
_, err := guardScriptPathForAgent("../escape") _, err := plugins.ResolveAgentRuntimeDirectory("../escape")
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
t.Fatalf("guardScriptPathForAgent() error = %v", err) t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err)
} }
} }
+5 -4
View File
@@ -10,8 +10,7 @@ type AugmentPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Auggie commits its provider guard together // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// with both wrappers, the audit runtime, and user settings.
func (p *AugmentPlugin) ManagesGuardRuntime() bool { func (p *AugmentPlugin) ManagesGuardRuntime() bool {
return true return true
} }
@@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeAugmentChanges( if err := writeManagedChanges(
changes, changes,
"Install Augment Code CLI hooks", "Install Augment Code CLI hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.configPath), filepath.Dir(document.configPath),
"Auggie configuration directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeAugmentChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Augment Code CLI hooks", "Uninstall Augment Code CLI hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.configPath), filepath.Dir(document.configPath),
"Auggie configuration directory",
) )
} }
@@ -16,7 +16,6 @@ const (
augmentGuardScript = "guard.js" augmentGuardScript = "guard.js"
augmentAuditScript = "hook.js" augmentAuditScript = "hook.js"
augmentHookTimeout = float64(10_000) augmentHookTimeout = float64(10_000)
augmentPOSIXApostrophe = `'"'"'`
) )
var ( var (
@@ -68,14 +67,6 @@ type augmentRuntimeContract struct {
auditWrapper string auditWrapper string
} }
func cloneAugmentObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func validateAugmentHandler( func validateAugmentHandler(
value any, value any,
event string, event string,
@@ -114,7 +105,7 @@ func validateAugmentHandler(
return nil, fmt.Errorf("%s timeout must be a positive finite number", label) return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
} }
} }
return cloneAugmentObject(handler), nil return cloneJSONObject(handler), nil
} }
func validateAugmentMetadata(value any, label string) error { func validateAugmentMetadata(value any, label string) error {
@@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup
} }
handlers = append(handlers, handler) handlers = append(handlers, handler)
} }
return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil return augmentGroup{object: cloneJSONObject(object), handlers: handlers}, nil
} }
func readAugmentHooks(root map[string]any) (augmentHooks, error) { func readAugmentHooks(root map[string]any) (augmentHooks, error) {
@@ -248,7 +239,7 @@ func renderAugmentDocument(
if !document.exists && len(hooks) == 0 { if !document.exists && len(hooks) == 0 {
return &augmentRenderedDocument{document: document}, nil return &augmentRenderedDocument{document: document}, nil
} }
root := cloneAugmentObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
@@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any {
for event, groups := range hooks { for event, groups := range hooks {
values := make([]any, 0, len(groups)) values := make([]any, 0, len(groups))
for _, group := range groups { for _, group := range groups {
object := cloneAugmentObject(group.object) object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers)) handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers { for _, handler := range group.handlers {
handlers = append(handlers, handler) handlers = append(handlers, handler)
@@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) {
} }
func readAugmentPOSIXArgument(command string) (string, bool) { func readAugmentPOSIXArgument(command string) (string, bool) {
if len(command) < 2 || command[0] != '\'' { value, end, ok := readPOSIXArgument(command, 0)
return "", false return value, ok && end == len(command) && value != ""
}
var value strings.Builder
for index := 1; index < len(command); {
if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) {
value.WriteByte('\'')
index += len(augmentPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index == len(command)-1 && value.Len() > 0
}
value.WriteByte(command[index])
index++
}
return "", false
} }
func parseAugmentCommand(command string) (string, bool) { func parseAugmentCommand(command string) (string, bool) {
@@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) {
return readAugmentPOSIXArgument(command) return readAugmentPOSIXArgument(command)
} }
func normalizeAugmentPath(value string) string {
absolute, err := filepath.Abs(value)
if err == nil {
value = absolute
}
value = filepath.ToSlash(filepath.Clean(value))
if runtime.GOOS == "windows" {
return strings.ToLower(value)
}
return value
}
func sameAugmentPath(left, right string) bool {
return normalizeAugmentPath(left) == normalizeAugmentPath(right)
}
func sameAugmentAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameAugmentFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func managedAugmentAgentID( func managedAugmentAgentID(
handler map[string]any, handler map[string]any,
wrapperName, runtimeRoot string, wrapperName, runtimeRoot string,
@@ -385,11 +331,11 @@ func managedAugmentAgentID(
return "", false return "", false
} }
wrapperPath, ok := parseAugmentCommand(command) wrapperPath, ok := parseAugmentCommand(command)
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) { if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
return "", false return "", false
} }
agentDirectory := filepath.Dir(wrapperPath) agentDirectory := filepath.Dir(wrapperPath)
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
@@ -407,7 +353,7 @@ func removeManagedAugmentGroups(
groupChanged := false groupChanged := false
for _, handler := range group.handlers { for _, handler := range group.handlers {
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot) managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID)) remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
if remove { if remove {
changed = true changed = true
groupChanged = true groupChanged = true
@@ -1,96 +1,42 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type augmentRuntimePaths struct { type augmentRuntimePaths struct {
runtimeRoot string managedRuntimePaths
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
guardWrapperPath string guardWrapperPath string
auditWrapperPath string auditWrapperPath string
} }
func validateAugmentInstallConfig(config InstallConfig) error { func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
for _, field := range []struct{ name, value string }{ wrappers := resolveAugmentWrapperPaths(agentDirectory)
{"agent name", config.AgentName}, return []managedArtifact{
{"organization ID", config.OrgID}, {wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
{"agent ID", config.AgentID}, {wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
} }
}
if config.AgentName != augmentAgentKey {
return fmt.Errorf(
"Augment Code CLI installation requires agent name %s",
augmentAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
} }
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) { func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
if err := validateAugmentInstallConfig(config); err != nil { if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
return nil, err return nil, err
} }
runtimeRoot, err := AgentRuntimeRoot() paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
if err != nil { if err != nil {
return nil, err return nil, err
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
if err != nil { return &augmentRuntimePaths{
return nil, err managedRuntimePaths: *paths,
}
wrappers := resolveAugmentWrapperPaths(agentDirectory)
paths := &augmentRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, augmentGuardScript),
auditPath: filepath.Join(agentDirectory, augmentAuditScript),
guardWrapperPath: wrappers.guard, guardWrapperPath: wrappers.guard,
auditWrapperPath: wrappers.audit, auditWrapperPath: wrappers.audit,
} }, nil
if !filepath.IsAbs(config.GuardScriptPath) || }
!sameAugmentPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf( func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
"Elydora guard runtime must use the managed agent directory: %s", return validateManagedRuntimeIdentity(
paths.guardPath, agentDirectory, agentID, augmentAgentKey, "Auggie",
augmentWrapperArtifacts(agentDirectory)...,
) )
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameAugmentPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
} }
func preflightAugmentInstallation( func preflightAugmentInstallation(
@@ -98,55 +44,25 @@ func preflightAugmentInstallation(
document *augmentDocument, document *augmentDocument,
) (*augmentRuntimePaths, string, error) { ) (*augmentRuntimePaths, string, error) {
if document == nil || document.configPath == "" { if document == nil || document.configPath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
"Augment Code CLI installation requires a user settings document",
)
} }
paths, err := augmentAgentPaths(config) paths, err := augmentAgentPaths(config)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateAugmentRuntimeIdentity( if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
paths.agentDirectory,
config.AgentID,
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
return nil, "", err
}
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil { if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
return nil, "", err return nil, "", err
} }
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildAugmentRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": augmentAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareAugmentInstallationChanges( func prepareAugmentInstallationChanges(
config InstallConfig, config InstallConfig,
paths *augmentRuntimePaths, paths *augmentRuntimePaths,
@@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges(
if paths == nil || rendered == nil { if paths == nil || rendered == nil {
return nil, fmt.Errorf("prepared Auggie installation is required") return nil, fmt.Errorf("prepared Auggie installation is required")
} }
if err := validateAugmentRuntimeIdentity( if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
paths.agentDirectory,
config.AgentID,
); err != nil {
return nil, err return nil, err
} }
runtimeConfig, err := buildAugmentRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
config,
&paths.managedRuntimePaths,
augmentAgentKey,
generateGuardScript(augmentAgentKey, config.AgentID, "", false, ""),
buildHookScriptWithOutput(augmentAgentKey, config.AgentID, "", false, true),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct { for _, item := range []struct {
path, label string path, label string
content []byte content []byte
mode os.FileMode
}{ }{
{ {paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
paths.guardPath, {paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
"Elydora guard runtime", } {
[]byte(generateGuardScript( change, err := prepareFileChange(item.path, item.label, item.content, 0700)
augmentAgentKey,
config.AgentID,
"",
false,
"",
)),
0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath,
"Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
augmentAgentKey,
config.AgentID,
"",
false,
true,
)),
0700,
},
{
paths.guardWrapperPath,
"Auggie guard wrapper",
buildAugmentWrapper(nodePath, paths.guardPath),
0700,
},
{
paths.auditWrapperPath,
"Auggie audit wrapper",
buildAugmentWrapper(nodePath, paths.auditPath),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
-44
View File
@@ -83,43 +83,6 @@ func prepareRenderedAugmentChange(
) )
} }
func writeAugmentChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if settingsDirectory != "" {
if err := ensureManagedDirectory(
settingsDirectory,
"Auggie configuration directory",
); err != nil {
return err
}
}
return writeChanges(changes, label, rename)
}
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error { func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
events := make([]string, 0, len(hooks)) events := make([]string, 0, len(hooks))
for event := range hooks { for event := range hooks {
@@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()), audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
} }
} }
func requireAugmentAbsoluteNode(nodePath string) error {
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return fmt.Errorf("Auggie hooks require an absolute Node.js executable path")
}
return nil
}
+26 -281
View File
@@ -2,11 +2,8 @@ package plugins
import ( import (
"bytes" "bytes"
"fmt"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings"
) )
func managedAugmentIDs( func managedAugmentIDs(
@@ -17,19 +14,10 @@ func managedAugmentIDs(
result := map[string]string{} result := map[string]string{}
for _, group := range groups { for _, group := range groups {
for _, handler := range group.handlers { for _, handler := range group.handlers {
agentID, managed := managedAugmentAgentID( agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
handler, if managed {
wrapperName, result[managedReferenceKey(agentID)] = agentID
runtimeRoot,
)
if !managed {
continue
} }
key := agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(key)
}
result[key] = agentID
} }
} }
return result return result
@@ -39,16 +27,8 @@ func augmentRuntimeContracts(
hooks augmentHooks, hooks augmentHooks,
runtimeRoot string, runtimeRoot string,
) []augmentRuntimeContract { ) []augmentRuntimeContract {
guards := managedAugmentIDs( guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
hooks["PreToolUse"], audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
augmentGuardWrapperName(),
runtimeRoot,
)
audits := managedAugmentIDs(
hooks["PostToolUse"],
augmentAuditWrapperName(),
runtimeRoot,
)
keys := make([]string, 0, len(guards)) keys := make([]string, 0, len(guards))
for key := range guards { for key := range guards {
if _, exists := audits[key]; exists { if _, exists := audits[key]; exists {
@@ -72,282 +52,47 @@ func augmentRuntimeContracts(
return contracts return contracts
} }
func readAugmentRuntimeConfig(path string) (map[string]any, bool, error) { func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
snapshot, err := readManagedFile( wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
path, if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
"Elydora runtime config", !sameManagedPath(contract.auditWrapper, wrappers.audit) {
maxRuntimeConfigBytes, return false, nil
)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
} }
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireAugmentRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateAugmentRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id",
"agent_id",
"kid",
"base_url",
"token",
"agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireAugmentRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireAugmentRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireAugmentRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameAugmentAgentID(agentID, expectedAgentID) ||
config["agent_name"] != augmentAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Auggie hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireAugmentRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireAugmentRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readAugmentRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct { for _, item := range []struct {
path string path, label string
label string expected []byte
limit int64
}{ }{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, {contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
{filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, {contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
{filepath.Join(agentDirectory, augmentAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, augmentGuardWrapperName()), "Auggie guard wrapper", maxManagedSourceBytes},
{filepath.Join(agentDirectory, augmentAuditWrapperName()), "Auggie audit wrapper", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} { } {
exists, inspectErr := managedPhysicalFileExists( snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
item.path, if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
item.label,
item.limit,
)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != augmentAgentKey ||
!sameAugmentAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Auggie agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func augmentRuntimeContractExists(
contract augmentRuntimeContract,
runtimeRoot string,
nodePath string,
) (bool, error) {
agentDirectory := filepath.Dir(contract.guardPath)
wrappers := resolveAugmentWrapperPaths(agentDirectory)
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameAugmentPath(
contract.auditPath,
filepath.Join(agentDirectory, augmentAuditScript),
) ||
!sameAugmentPath(contract.guardWrapper, wrappers.guard) ||
!sameAugmentPath(contract.auditWrapper, wrappers.audit) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err return false, err
} }
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
} }
configPath := filepath.Join(agentDirectory, "config.json") return true, nil
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readAugmentRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
guardWrapper, err := readManagedFile(
contract.guardWrapper,
"Auggie guard wrapper",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
auditWrapper, err := readManagedFile(
contract.auditWrapper,
"Auggie audit wrapper",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil ||
guardWrapper == nil || auditWrapper == nil {
return false, nil
}
if err := validateAugmentRuntimeConfig(
config,
contract.agentID,
configPath,
); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 &&
len(audit.contents) > 0 &&
bytes.Equal(
guardWrapper.contents,
buildAugmentWrapper(nodePath, contract.guardPath),
) &&
bytes.Equal(
auditWrapper.contents,
buildAugmentWrapper(nodePath, contract.auditPath),
), nil
} }
func augmentRuntimeFilesExist( func augmentRuntimeFilesExist(
contracts []augmentRuntimeContract, contracts []augmentRuntimeContract,
runtimeRoot string, runtimeRoot string,
) (bool, error) { ) (bool, error) {
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
if err != nil { if err != nil {
return false, err return false, err
} }
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
return false, err
}
for _, contract := range contracts { for _, contract := range contracts {
exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath) runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}
exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil)
if err != nil || !exists {
if err != nil { if err != nil {
return false, err return false, err
} }
if exists { continue
return true, nil }
matches, err := augmentWrappersMatch(contract, nodePath)
if err != nil || matches {
return matches, err
} }
} }
return false, nil return false, nil
@@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
) )
failed := false failed := false
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameAugmentPath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") && strings.HasSuffix(source, ".tmp") &&
!failed { !failed {
failed = true failed = true
@@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err) t.Fatalf("write concurrent settings: %v", err)
} }
err := writeAugmentChanges( err := writeManagedChanges(
changes, changes,
"Install Augment Code CLI hooks", "Install Augment Code CLI hooks",
nil, nil,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.configPath), filepath.Dir(document.configPath),
"Auggie configuration directory",
) )
if err == nil || !strings.Contains(err.Error(), "changed during installation") { if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err) t.Fatalf("prepared install error = %v", err)
@@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
t.Fatalf("read installed settings: %v", err) t.Fatalf("read installed settings: %v", err)
} }
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameAugmentPath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Auggie uninstall failure") return errors.New("injected Auggie uninstall failure")
} }
+5 -4
View File
@@ -10,8 +10,7 @@ type ClaudeCodePlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Claude Code commits its provider guard with // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// the audit runtime and user settings document.
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool { func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
return true return true
} }
@@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeClaudeChanges( if err := writeManagedChanges(
changes, changes,
"Install Claude Code hooks", "Install Claude Code hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Claude Code configuration directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeClaudeChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Claude Code hooks", "Uninstall Claude Code hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Claude Code configuration directory",
) )
} }
@@ -77,19 +77,7 @@ type claudeRenderedDocument struct {
remove bool remove bool
} }
type claudeRuntimeContract struct { type claudeRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func cloneClaudeObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func cloneClaudeHooks(source claudeHooks) claudeHooks { func cloneClaudeHooks(source claudeHooks) claudeHooks {
clone := make(claudeHooks, len(source)) clone := make(claudeHooks, len(source))
@@ -299,7 +287,7 @@ func validateClaudeHandler(
} }
} }
} }
return cloneClaudeObject(handler), nil return cloneJSONObject(handler), nil
} }
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) { func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
@@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup,
} }
handlers = append(handlers, handler) handlers = append(handlers, handler)
} }
return claudeGroup{object: cloneClaudeObject(object), handlers: handlers}, nil return claudeGroup{object: cloneJSONObject(object), handlers: handlers}, nil
} }
func readClaudeHooks(root map[string]any) (claudeHooks, error) { func readClaudeHooks(root map[string]any) (claudeHooks, error) {
@@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any {
for event, groups := range hooks { for event, groups := range hooks {
values := make([]any, 0, len(groups)) values := make([]any, 0, len(groups))
for _, group := range groups { for _, group := range groups {
object := cloneClaudeObject(group.object) object := cloneJSONObject(group.object)
handlers := make([]any, 0, len(group.handlers)) handlers := make([]any, 0, len(group.handlers))
for _, handler := range group.handlers { for _, handler := range group.handlers {
handlers = append(handlers, handler) handlers = append(handlers, handler)
@@ -439,7 +427,7 @@ func renderClaudeDocument(
document: document, changed: true, remove: true, document: document, changed: true, remove: true,
}, nil }, nil
} }
root := cloneClaudeObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
@@ -1,182 +1,48 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type claudeRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateClaudeInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != claudeAgentKey {
return fmt.Errorf(
"Claude Code installation requires agent name %s",
claudeAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) {
if err := validateClaudeInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &claudeRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, claudeGuardScript),
auditPath: filepath.Join(agentDirectory, claudeAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameClaudePath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameClaudePath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightClaudeInstallation( func preflightClaudeInstallation(
config InstallConfig, config InstallConfig,
document *claudeDocument, document *claudeDocument,
) (*claudeRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if document == nil || document.filePath == "" { if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf("Claude Code installation requires a user settings document")
"Claude Code installation requires a user settings document",
)
} }
paths, err := claudeAgentPaths(config) if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := requireClaudeAbsoluteNode(nodePath); err != nil {
return nil, "", err
}
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildClaudeRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": claudeAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareClaudeInstallationChanges( func prepareClaudeInstallationChanges(
config InstallConfig, config InstallConfig,
paths *claudeRuntimePaths, paths *managedRuntimePaths,
rendered *claudeRenderedDocument, rendered *claudeRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildClaudeRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
if err != nil { config,
return nil, err paths,
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(claudeAgentKey, config.AgentID, "", false, "")),
0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
claudeAgentKey, claudeAgentKey,
config.AgentID, generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
"", buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
false, )
true,
)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
documentChange, err := prepareRenderedClaudeChange(rendered) documentChange, err := prepareRenderedClaudeChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,38 +71,3 @@ func prepareRenderedClaudeChange(
rendered.remove, rendered.remove,
) )
} }
func writeClaudeChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(
settingsDirectory,
"Claude Code configuration directory",
); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
@@ -1,73 +1,18 @@
package plugins package plugins
import ( import (
"fmt"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
) )
type claudeRuntimeReference struct { type claudeRuntimeReference = managedScriptReference
agentID string
scriptPath string
}
func sameClaudePath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameClaudeAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func isClaudeNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func sameClaudeFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func claudeRuntimeReferenceForScript( func claudeRuntimeReferenceForScript(
scriptPath string, scriptPath string,
scriptName string, scriptName string,
) (*claudeRuntimeReference, error) { ) (*claudeRuntimeReference, error) {
if !filepath.IsAbs(scriptPath) || return resolveManagedScript(scriptPath, scriptName)
!sameClaudeFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
agentDirectory := filepath.Dir(scriptPath)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
func claudeLegacyReference( func claudeLegacyReference(
@@ -95,7 +40,7 @@ func managedClaudeReference(
handler["statusMessage"] == statusMessage { handler["statusMessage"] == statusMessage {
nodePath, nodeOK := handler["command"].(string) nodePath, nodeOK := handler["command"].(string)
args, argsOK := handler["args"].([]any) args, argsOK := handler["args"].([]any)
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) && if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
argsOK && len(args) == 1 { argsOK && len(args) == 1 {
if scriptPath, ok := args[0].(string); ok { if scriptPath, ok := args[0].(string); ok {
return claudeRuntimeReferenceForScript(scriptPath, scriptName) return claudeRuntimeReferenceForScript(scriptPath, scriptName)
@@ -146,7 +91,7 @@ func removeManagedClaudeGroups(
return nil, false, err return nil, false, err
} }
owned := reference != nil && owned := reference != nil &&
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID)) (agentID == "" || sameManagedAgentID(reference.agentID, agentID))
if owned { if owned {
removed = true removed = true
continue continue
@@ -154,7 +99,7 @@ func removeManagedClaudeGroups(
kept = append(kept, handler) kept = append(kept, handler)
} }
if len(kept) > 0 || !exactManagedClaudeGroup(group) { if len(kept) > 0 || !exactManagedClaudeGroup(group) {
object := cloneClaudeObject(group.object) object := cloneJSONObject(group.object)
object["hooks"] = group.object["hooks"] object["hooks"] = group.object["hooks"]
result = append(result, claudeGroup{object: object, handlers: kept}) result = append(result, claudeGroup{object: object, handlers: kept})
} }
@@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool {
return handlerCount > 0 return handlerCount > 0
} }
func claudeReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func claudeReferencesForEvent( func claudeReferencesForEvent(
groups []claudeGroup, groups []claudeGroup,
scriptName string, scriptName string,
@@ -263,7 +201,7 @@ func claudeReferencesForEvent(
return nil, err return nil, err
} }
if reference != nil { if reference != nil {
key := claudeReferenceKey(reference.agentID) key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference) result[key] = append(result[key], *reference)
} }
} }
@@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
success := successes[key] success := successes[key]
failure := failures[key] failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameClaudePath(success[0].scriptPath, failure[0].scriptPath) { !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue continue
} }
contracts = append(contracts, claudeRuntimeContract{ contracts = append(contracts, claudeRuntimeContract{
@@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
} }
return contracts, nil return contracts, nil
} }
func requireClaudeAbsoluteNode(nodePath string) error {
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path")
}
return nil
}
@@ -150,10 +150,10 @@ func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
}, "query parameters"}, }, "query parameters"},
{"guard path", func(f *claudeFixture) { {"guard path", func(f *claudeFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
{"audit path", func(f *claudeFixture) { {"audit path", func(f *claudeFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed Elydora agent directory"}, }, "managed agent directory"},
} }
for _, testCase := range tests { for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
@@ -1,231 +1,5 @@
package plugins package plugins
import ( func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"fmt" return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
"path/filepath"
"sort"
"strings"
)
func readClaudeRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireClaudeRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateClaudeRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireClaudeRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameClaudeAgentID(agentID, expectedAgentID) ||
config["agent_name"] != claudeAgentKey {
return fmt.Errorf(
"elydora runtime identity does not match Claude Code hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireClaudeRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateClaudeRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readClaudeRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, claudeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != claudeAgentKey ||
!sameClaudeAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Claude Code agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) ||
!sameClaudePath(
contract.auditPath,
filepath.Join(agentDirectory, claudeAuditScript),
) {
return false, nil
}
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readClaudeRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateClaudeRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
}
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
}
func claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := claudeRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
@@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
} }
before := snapshotClaudeFiles(t, paths...) before := snapshotClaudeFiles(t, paths...)
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameClaudePath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Claude settings failure") return errors.New("injected Claude settings failure")
} }
@@ -114,13 +114,14 @@ func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) {
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
t.Fatalf("write concurrent settings: %v", err) t.Fatalf("write concurrent settings: %v", err)
} }
err = writeClaudeChanges( err = writeManagedChanges(
changes, changes,
"Install Claude Code hooks", "Install Claude Code hooks",
nil, nil,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Claude Code configuration directory",
) )
if err == nil || !strings.Contains(err.Error(), "changed during installation") { if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("prepared install error = %v", err) t.Fatalf("prepared install error = %v", err)
@@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
t.Fatalf("read installed settings: %v", err) t.Fatalf("read installed settings: %v", err)
} }
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameClaudePath(destination, fixture.configPath) && if sameManagedPath(destination, fixture.configPath) &&
strings.HasSuffix(source, ".tmp") { strings.HasSuffix(source, ".tmp") {
return errors.New("injected Claude uninstall failure") return errors.New("injected Claude uninstall failure")
} }
+1 -2
View File
@@ -7,8 +7,7 @@ type ClinePlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Cline commits both generated runtimes and // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// both native hook files in one transaction.
func (p *ClinePlugin) ManagesGuardRuntime() bool { func (p *ClinePlugin) ManagesGuardRuntime() bool {
return true return true
} }
+3 -42
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
@@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) {
}, nil }, nil
} }
func sameClineAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func normalizeClinePath(value string) (string, error) {
absolute, err := filepath.Abs(value)
if err != nil {
return "", err
}
normalized := filepath.Clean(absolute)
if runtime.GOOS == "windows" {
normalized = strings.ToLower(normalized)
}
return normalized, nil
}
func sameClinePath(left, right string) (bool, error) {
normalizedLeft, err := normalizeClinePath(left)
if err != nil {
return false, err
}
normalizedRight, err := normalizeClinePath(right)
if err != nil {
return false, err
}
return normalizedLeft == normalizedRight, nil
}
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) { func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
metadata := clineHookMetadata{ metadata := clineHookMetadata{
Version: clineMetadataVersion, Version: clineMetadataVersion,
@@ -314,7 +282,7 @@ func clineContractForFiles(
if guard.Kind != "guard" || audit.Kind != "audit" { if guard.Kind != "guard" || audit.Kind != "audit" {
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata") return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
} }
if !sameClineAgentID(guard.AgentID, audit.AgentID) { if !sameManagedAgentID(guard.AgentID, audit.AgentID) {
return nil, fmt.Errorf("Elydora Cline hook files reference different agents") return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID) agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
@@ -323,15 +291,8 @@ func clineContractForFiles(
} }
expectedGuard := filepath.Join(agentDirectory, clineGuardScript) expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
expectedAudit := filepath.Join(agentDirectory, clineAuditScript) expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard) if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
if err != nil { !sameManagedPath(audit.RuntimePath, expectedAudit) {
return nil, fmt.Errorf("compare Cline guard runtime path: %w", err)
}
auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit)
if err != nil {
return nil, fmt.Errorf("compare Cline audit runtime path: %w", err)
}
if !guardMatches || !auditMatches {
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path") return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
} }
return &clineRuntimeContract{ return &clineRuntimeContract{
+20 -128
View File
@@ -1,97 +1,21 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type clineRuntimePaths struct { type clineRuntimePaths struct {
runtimeRoot string managedRuntimePaths
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
hooks clineHookPaths hooks clineHookPaths
} }
func validateClineInstallConfig(config InstallConfig) error { func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
for _, field := range []struct{ name, value string }{ if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != clineAgentKey {
return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func clineAgentPaths(
config InstallConfig,
hooks clineHookPaths,
) (*clineRuntimePaths, error) {
if err := validateClineInstallConfig(config); err != nil {
return nil, err return nil, err
} }
runtimeRoot, err := AgentRuntimeRoot() paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
if err != nil { if err != nil {
return nil, err return nil, err
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
if err != nil {
return nil, err
}
paths := &clineRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, clineGuardScript),
auditPath: filepath.Join(agentDirectory, clineAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameClinePathValue(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameClinePathValue(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func sameClinePathValue(left, right string) bool {
matches, err := sameClinePath(left, right)
return err == nil && matches
} }
func preflightClineInstallation( func preflightClineInstallation(
@@ -102,39 +26,20 @@ func preflightClineInstallation(
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
); err != nil {
return nil, err return nil, err
} }
nodePath, err := resolveNodeRuntime() if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
if err != nil {
return nil, err return nil, err
} }
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path")
}
return paths, nil return paths, nil
} }
func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) { func clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
value := map[string]any{ return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, []byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
"base_url": config.BaseURL, "agent_name": clineAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
} }
func prepareClineInstallationChanges( func prepareClineInstallationChanges(
@@ -146,11 +51,9 @@ func prepareClineInstallationChanges(
if paths == nil { if paths == nil {
return nil, fmt.Errorf("prepared Cline installation is required") return nil, fmt.Errorf("prepared Cline installation is required")
} }
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
return nil, err paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
} ); err != nil {
runtimeConfig, err := buildClineRuntimeConfig(config)
if err != nil {
return nil, err return nil, err
} }
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath) guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
@@ -175,24 +78,13 @@ func prepareClineInstallationChanges(
); err != nil { ); err != nil {
return nil, err return nil, err
} }
runtimeItems := []struct { guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
path, label string changes, err := managedRuntimeFileChanges(
content []byte config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
mode os.FileMode )
}{
{paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700},
}
changes := make([]*fileChange, 0, len(runtimeItems)+2)
for _, item := range runtimeItems {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
for _, item := range []struct { for _, item := range []struct {
file clineHookFile file clineHookFile
label string label string
+3 -12
View File
@@ -74,7 +74,7 @@ func writeClineHookChanges(
rename renameFunc, rename renameFunc,
paths clineHookPaths, paths clineHookPaths,
) error { ) error {
if !hasClineChanges(changes) { if !hasFileChanges(changes) {
return nil return nil
} }
if err := ensureManagedDirectory( if err := ensureManagedDirectory(
@@ -95,7 +95,7 @@ func writeClineChanges(
rename renameFunc, rename renameFunc,
paths *clineRuntimePaths, paths *clineRuntimePaths,
) error { ) error {
if !hasClineChanges(changes) { if !hasFileChanges(changes) {
return nil return nil
} }
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil { if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
@@ -107,15 +107,6 @@ func writeClineChanges(
return writeClineHookChanges(changes, label, rename, paths.hooks) return writeClineHookChanges(changes, label, rename, paths.hooks)
} }
func hasClineChanges(changes []*fileChange) bool {
for _, change := range changes {
if change != nil {
return true
}
}
return false
}
func prepareClineUninstallChanges( func prepareClineUninstallChanges(
files []clineHookFile, files []clineHookFile,
agentID string, agentID string,
@@ -123,7 +114,7 @@ func prepareClineUninstallChanges(
changes := make([]*fileChange, 0, len(files)) changes := make([]*fileChange, 0, len(files))
for _, file := range files { for _, file := range files {
if file.metadata == nil || if file.metadata == nil ||
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) { (agentID != "" && !sameManagedAgentID(file.metadata.AgentID, agentID)) {
continue continue
} }
if err := assertClineWrapperIntegrity(file); err != nil { if err := assertClineWrapperIntegrity(file); err != nil {
+7 -198
View File
@@ -1,206 +1,15 @@
package plugins package plugins
import ( import "path/filepath"
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
)
func readClineRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireClineRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateClineRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireClineRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireClineRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireClineRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameClineAgentID(agentID, expectedAgentID) || config["agent_name"] != clineAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Cline hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireClineRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireClineRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateClineRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readClineRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, clineGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, clineAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != clineAgentKey ||
!sameClineAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Cline agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) { func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot() if !sameManagedPath(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) {
if err != nil {
return false, err
}
if !sameClinePathValue(filepath.Dir(contract.agentDirectory), runtimeRoot) ||
!sameClinePathValue(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) ||
!sameClinePathValue(contract.auditPath, filepath.Join(contract.agentDirectory, clineAuditScript)) {
return false, nil return false, nil
} }
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") return managedRuntimeContractExists(
if err != nil || !rootExists { managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath},
return false, err clineAgentKey,
} "Cline",
directoryExists, err := managedPhysicalDirectoryExists( clineExpectedScripts,
contract.agentDirectory,
"Elydora agent runtime directory",
) )
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(contract.agentDirectory, "config.json")
keyPath := filepath.Join(contract.agentDirectory, "private.key")
config, configExists, err := readClineRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateClineRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(clineAgentKey, contract.agentID, "", false, ""))
expectedAudit := []byte(buildHookScriptWithOutput(clineAgentKey, contract.agentID, "", false, true))
return bytes.Equal(guard.contents, expectedGuard) && bytes.Equal(audit.contents, expectedAudit), nil
} }
+6 -6
View File
@@ -10,14 +10,12 @@ type CodexPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Codex commits its provider guard with the // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// audit runtime and user hook document.
func (p *CodexPlugin) ManagesGuardRuntime() bool { func (p *CodexPlugin) ManagesGuardRuntime() bool {
return true return true
} }
// PreflightInstall validates every existing source before the CLI creates // PreflightInstall validates every source before the CLI creates runtime state.
// runtime state.
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error { func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
document, err := readCodexDocument() document, err := readCodexDocument()
if err != nil { if err != nil {
@@ -61,13 +59,14 @@ func (p *CodexPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeCodexChanges( if err := writeManagedChanges(
changes, changes,
"Install Codex hooks", "Install Codex hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Codex hooks directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -90,13 +89,14 @@ func (p *CodexPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeCodexChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Codex hooks", "Uninstall Codex hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Codex hooks directory",
) )
} }
+16 -176
View File
@@ -1,53 +1,12 @@
package plugins package plugins
import ( import "path/filepath"
"encoding/base64"
"encoding/binary"
"os"
"path/filepath"
"runtime"
"strings"
"unicode/utf16"
)
func quoteCodexPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func codexPowerShellPath() string {
root := "C:\\Windows"
if runtime.GOOS == "windows" {
configured := os.Getenv("SystemRoot")
if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") {
root = configured
}
}
return strings.TrimRight(root, `\/`) +
`\System32\WindowsPowerShell\v1.0\powershell.exe`
}
func encodeCodexPowerShell(source string) string {
runes := utf16.Encode([]rune(source))
raw := make([]byte, len(runes)*2)
for index, value := range runes {
binary.LittleEndian.PutUint16(raw[index*2:], value)
}
return base64.StdEncoding.EncodeToString(raw)
}
func codexWindowsCommand(runtimePath, scriptPath string) string {
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
return `"` + codexPowerShellPath() +
`" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` +
encodeCodexPowerShell(source)
}
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any { func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
return map[string]any{ return map[string]any{
"type": "command", "type": "command",
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), "command": posixSource(runtimePath, scriptPath),
"commandWindows": codexWindowsCommand(runtimePath, scriptPath), "commandWindows": encodedWindowsCommand(runtimePath, scriptPath),
"timeout": codexHookTimeout, "timeout": codexHookTimeout,
"statusMessage": statusMessage, "statusMessage": statusMessage,
} }
@@ -57,124 +16,6 @@ func codexMatcherGroup(handler map[string]any) map[string]any {
return map[string]any{"matcher": "*", "hooks": []any{handler}} return map[string]any{"matcher": "*", "hooks": []any{handler}}
} }
func readCodexPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], codexPOSIXApostrophe) {
value.WriteByte('\'')
index += len(codexPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func parseCodexPOSIXCommand(value any) (string, string, bool) {
command, ok := value.(string)
if !ok {
return "", "", false
}
runtimePath, next, ok := readCodexPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readCodexPOSIXArgument(command, next+1)
return runtimePath, scriptPath, ok && end == len(command)
}
func readCodexPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCodexPowerShellSource(source string) (string, string, bool) {
if !strings.HasPrefix(source, "& ") {
return "", "", false
}
runtimePath, next, ok := readCodexPowerShellArgument(source, 2)
if !ok || next >= len(source) || source[next] != ' ' {
return "", "", false
}
scriptPath, end, ok := readCodexPowerShellArgument(source, next+1)
return runtimePath, scriptPath, ok && source[end:] == "; exit $LASTEXITCODE"
}
func windowsPathBase(value string) string {
index := strings.LastIndexAny(value, `\/`)
return value[index+1:]
}
func isAbsoluteWindowsPath(value string) bool {
return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') ||
(value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' &&
(value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`)
}
func decodeCodexPowerShell(encoded string) (string, bool) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 {
return "", false
}
units := make([]uint16, len(raw)/2)
for index := range units {
units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8
}
return string(utf16.Decode(units)), true
}
func parseCodexWindowsCommand(value any) (string, string, bool) {
command, ok := value.(string)
if !ok || !strings.HasPrefix(command, `"`) {
return "", "", false
}
closing := strings.IndexByte(command[1:], '"')
if closing < 0 {
return "", "", false
}
closing++
powerShell := command[1:closing]
prefix := `" -NoLogo -NoProfile -NonInteractive -EncodedCommand `
if !isAbsoluteWindowsPath(powerShell) ||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") ||
!strings.HasPrefix(command[closing:], prefix) {
return "", "", false
}
encoded := command[closing+len(prefix):]
source, ok := decodeCodexPowerShell(encoded)
if !ok {
return "", "", false
}
return parseCodexPowerShellSource(source)
}
func isCodexNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func exactCodexHandlerKeys(handler map[string]any) bool { func exactCodexHandlerKeys(handler map[string]any) bool {
if len(handler) != 5 { if len(handler) != 5 {
return false return false
@@ -192,20 +33,21 @@ func codexManagedScriptPath(handler map[string]any, status string) (string, bool
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status { handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
return "", false return "", false
} }
posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"]) command, _ := handler["command"].(string)
windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"]) commandWindows, _ := handler["commandWindows"].(string)
posixRuntime, posixScript, posixOK := parsePOSIXCommand(command)
windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows)
if !windowsOK && posixOK { if !windowsOK && posixOK {
legacy, ok := handler["commandWindows"].(string)
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript) expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
if ok && legacy == expected { if commandWindows == expected {
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
} }
} }
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) || if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
!filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) || !filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) ||
!isCodexNodeExecutable(windowsRuntime) || !isNodeExecutable(windowsRuntime) ||
!sameCodexPath(posixRuntime, windowsRuntime) || !sameManagedPath(posixRuntime, windowsRuntime) ||
!sameCodexPath(posixScript, windowsScript) { !sameManagedPath(posixScript, windowsScript) {
return "", false return "", false
} }
return posixScript, true return posixScript, true
@@ -217,14 +59,12 @@ func codexManagedAgentID(
status string, status string,
) (string, bool) { ) (string, bool) {
scriptPath, managed := codexManagedScriptPath(handler, status) scriptPath, managed := codexManagedScriptPath(handler, status)
if !managed || filepath.Base(scriptPath) != scriptName { if !managed {
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) reference, err := resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot() if err != nil || reference == nil {
if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) return reference.agentID, true
return agentID, agentID != "" && agentID != "." && agentID != ".."
} }
+5 -56
View File
@@ -6,9 +6,7 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"sort" "sort"
"strings"
) )
const ( const (
@@ -20,7 +18,6 @@ const (
codexOwnedDescription = "Elydora audit and freeze enforcement" codexOwnedDescription = "Elydora audit and freeze enforcement"
codexGuardStatusMessage = "Checking Elydora agent state" codexGuardStatusMessage = "Checking Elydora agent state"
codexAuditStatusMessage = "Recording Elydora tool use" codexAuditStatusMessage = "Recording Elydora tool use"
codexPOSIXApostrophe = `'"'"'`
) )
type codexHooks map[string][]map[string]any type codexHooks map[string][]map[string]any
@@ -40,19 +37,7 @@ type codexRenderedDocument struct {
remove bool remove bool
} }
type codexRuntimeContract struct { type codexRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func cloneCodexObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func cloneCodexHooks(source codexHooks) codexHooks { func cloneCodexHooks(source codexHooks) codexHooks {
clone := make(codexHooks, len(source)) clone := make(codexHooks, len(source))
@@ -62,28 +47,6 @@ func cloneCodexHooks(source codexHooks) codexHooks {
return clone return clone
} }
func sameCodexPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCodexAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func readCodexHooks(value any, label string) (codexHooks, error) { func readCodexHooks(value any, label string) (codexHooks, error) {
object, ok := value.(map[string]any) object, ok := value.(map[string]any)
if !ok { if !ok {
@@ -126,16 +89,6 @@ func readCodexHooks(value any, label string) (codexHooks, error) {
return hooks, nil return hooks, nil
} }
func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) {
if !json.Valid(source) {
var value any
if err := json.Unmarshal(source, &value); err != nil {
return nil, fmt.Errorf("parse %s: %w", label, err)
}
}
return decodeJSONCObject(source, label, false)
}
func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) { func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) {
label := fmt.Sprintf("Codex user hooks at %s", filePath) label := fmt.Sprintf("Codex user hooks at %s", filePath)
root, err := decodeStrictJSONObject(raw, label) root, err := decodeStrictJSONObject(raw, label)
@@ -180,13 +133,13 @@ func removeCodexGroups(
for _, value := range handlers { for _, value := range handlers {
handler := value.(map[string]any) handler := value.(map[string]any)
managedID, managed := codexManagedAgentID(handler, scriptName, status) managedID, managed := codexManagedAgentID(handler, scriptName, status)
if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) { if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
continue continue
} }
kept = append(kept, handler) kept = append(kept, handler)
} }
if len(kept) > 0 || !exactCodexMatcherGroup(group) { if len(kept) > 0 || !exactCodexMatcherGroup(group) {
next := cloneCodexObject(group) next := cloneJSONObject(group)
next["hooks"] = kept next["hooks"] = kept
result = append(result, next) result = append(result, next)
} }
@@ -262,7 +215,7 @@ func renderCodexDocument(
if len(hooks) == 0 && entirelyManagedCodexDocument(document) { if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
} }
root := cloneCodexObject(document.root) root := cloneJSONObject(document.root)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
} else { } else {
@@ -295,11 +248,7 @@ func managedCodexIDs(
if !managed { if !managed {
continue continue
} }
key := agentID result[managedReferenceKey(agentID)] = agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(agentID)
}
result[key] = agentID
} }
} }
return result return result
+17 -128
View File
@@ -1,94 +1,26 @@
package plugins package plugins
import ( import "path/filepath"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type codexRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateCodexInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != codexAgentKey {
return fmt.Errorf("codex installation requires agent name %s", codexAgentKey)
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) {
if err := validateCodexInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &codexRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, codexGuardScript),
auditPath: filepath.Join(agentDirectory, codexAuditScript),
}
if !sameCodexPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && !sameCodexPath(config.HookScript, paths.auditPath) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightCodexInstallation( func preflightCodexInstallation(
config InstallConfig, config InstallConfig,
hooksPath string, hooksPath string,
) (*codexRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
paths, err := codexAgentPaths(config) if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if _, err := managedPhysicalDirectoryExists( if _, err := managedPhysicalDirectoryExists(
filepath.Dir(hooksPath), filepath.Dir(hooksPath), "Codex hooks directory",
"Codex hooks directory",
); err != nil { ); err != nil {
return nil, "", err return nil, "", err
} }
if err := validateCodexRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, codexAgentKey, "Codex",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
@@ -98,64 +30,21 @@ func preflightCodexInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildCodexRuntimeConfig(config InstallConfig) ([]byte, error) {
encoded, err := json.MarshalIndent(agentRuntimeConfig{
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
BaseURL: config.BaseURL, Token: config.Token, AgentName: codexAgentKey,
}, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareCodexInstallationChanges( func prepareCodexInstallationChanges(
config InstallConfig, config InstallConfig,
paths *codexRuntimePaths, paths *managedRuntimePaths,
rendered *codexRenderedDocument, rendered *codexRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildCodexRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
if err != nil { config,
return nil, err paths,
} codexAgentKey,
items := []struct { generateGuardScript(codexAgentKey, config.AgentID, "", false, ""),
path, label string buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true),
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(codexAgentKey, config.AgentID, "", false, "")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
codexAgentKey, config.AgentID, "", false, true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(
item.path,
item.label,
item.content,
item.mode,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
documentChange, err := prepareRenderedCodexChange(rendered) documentChange, err := prepareRenderedCodexChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
+2 -148
View File
@@ -82,152 +82,6 @@ func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, e
) )
} }
func writeCodexChanges( func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
changes []*fileChange, return managedRuntimeFilesPresent(contracts, codexAgentKey)
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
hooksDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(hooksDirectory, "Codex hooks directory"); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
func readCodexRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func validateCodexRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readCodexRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, codexGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, codexAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != codexAgentKey ||
!sameCodexAgentID(configuredID, agentID) {
return fmt.Errorf(
"elydora runtime config identity does not match Codex agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func codexRuntimeFilesExist(contracts []codexRuntimeContract) (bool, error) {
for _, contract := range contracts {
agentDirectory := filepath.Dir(contract.guardPath)
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil {
return false, err
}
if !directoryExists {
continue
}
config, exists, err := readCodexRuntimeConfig(
filepath.Join(agentDirectory, "config.json"),
)
if err != nil {
return false, err
}
configuredID, idOK := config["agent_id"].(string)
if !exists || !idOK || config["agent_name"] != codexAgentKey ||
!sameCodexAgentID(configuredID, contract.agentID) {
continue
}
complete := true
for _, item := range []struct {
path string
label string
limit int64
}{
{contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes},
{contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return false, inspectErr
}
complete = complete && exists
}
if complete {
return true, nil
}
}
return false, nil
} }
@@ -249,7 +249,7 @@ func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) {
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`) source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
fixture := prepareCodexFixture(t, string(source)) fixture := prepareCodexFixture(t, string(source))
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if sameCodexPath(destination, fixture.configPath) { if sameManagedPath(destination, fixture.configPath) {
return errors.New("injected Codex hooks commit failure") return errors.New("injected Codex hooks commit failure")
} }
return os.Rename(source, destination) return os.Rename(source, destination)
+3 -3
View File
@@ -355,10 +355,10 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 { if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
t.Fatalf("unexpected remaining hooks: %#v", hooks) t.Fatalf("unexpected remaining hooks: %#v", hooks)
} }
_, remainingScript, parsed := parseCodexPOSIXCommand( _, remainingScript, parsed := parsePOSIXCommand(
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"], requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string),
) )
if !parsed || !sameCodexPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) { if !parsed || !sameManagedPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) {
t.Fatalf("other agent handler was removed: %#v", hooks) t.Fatalf("other agent handler was removed: %#v", hooks)
} }
} }
+3 -5
View File
@@ -10,14 +10,12 @@ type CopilotPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Copilot commits its provider guard with the // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// audit runtime and hook documents.
func (p *CopilotPlugin) ManagesGuardRuntime() bool { func (p *CopilotPlugin) ManagesGuardRuntime() bool {
return true return true
} }
// PreflightInstall validates hook sources, settings, matchers, credentials, // PreflightInstall validates every source before the CLI creates runtime state.
// and runtime identity before any write.
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error { func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
sources, _, err := readCopilotSources() sources, _, err := readCopilotSources()
if err != nil { if err != nil {
@@ -165,7 +163,7 @@ func mergedCopilotContracts(
} }
unique := map[string]copilotRuntimeContract{} unique := map[string]copilotRuntimeContract{}
for _, contract := range contracts { for _, contract := range contracts {
unique[copilotEntryKey(contract.agentID)] = contract unique[managedReferenceKey(contract.agentID)] = contract
} }
result := make([]copilotRuntimeContract, 0, len(unique)) result := make([]copilotRuntimeContract, 0, len(unique))
for _, contract := range unique { for _, contract := range unique {
+20 -156
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
) )
@@ -17,7 +16,6 @@ const (
copilotConfigFile = "elydora-audit.json" copilotConfigFile = "elydora-audit.json"
copilotHookTimeout = float64(10) copilotHookTimeout = float64(10)
copilotLegacyTimeout = float64(5) copilotLegacyTimeout = float64(5)
copilotPOSIXApostrophe = `'"'"'`
) )
var copilotManagedEvents = []struct { var copilotManagedEvents = []struct {
@@ -61,23 +59,19 @@ type copilotRenderedDocument struct {
remove bool remove bool
} }
type copilotRuntimeContract struct { type copilotRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
type copilotManagedEntry struct { type copilotManagedEntry = managedScriptReference
agentID string
scriptPath string
}
func cloneCopilotObject(value map[string]any) map[string]any { func copilotManagedEntryForHandler(
clone := make(map[string]any, len(value)) handler map[string]any,
for key, item := range value { scriptName string,
clone[key] = item ) (*copilotManagedEntry, error) {
scriptPath, managed := copilotManagedScriptPath(handler)
if !managed {
return nil, nil
} }
return clone return resolveManagedScript(scriptPath, scriptName)
} }
func cloneCopilotHooks(source copilotHooks) copilotHooks { func cloneCopilotHooks(source copilotHooks) copilotHooks {
@@ -90,109 +84,15 @@ func cloneCopilotHooks(source copilotHooks) copilotHooks {
return clone return clone
} }
func sameCopilotPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCopilotAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCopilotFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func quoteCopilotPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildCopilotHandler(nodePath, scriptPath string) map[string]any { func buildCopilotHandler(nodePath, scriptPath string) map[string]any {
return map[string]any{ return map[string]any{
"type": "command", "type": "command",
"bash": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath), "bash": posixSource(nodePath, scriptPath),
"powershell": "& " + quoteCopilotPowerShell(nodePath) + " " + quoteCopilotPowerShell(scriptPath) + "; exit $LASTEXITCODE", "powershell": powerShellSource(nodePath, scriptPath),
"timeoutSec": copilotHookTimeout, "timeoutSec": copilotHookTimeout,
} }
} }
func readCopilotPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], copilotPOSIXApostrophe) {
value.WriteByte('\'')
index += len(copilotPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func readCopilotPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCopilotBash(command string) (string, string, bool) {
executable, next, ok := readCopilotPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCopilotPOSIXArgument(command, next+1)
return executable, script, ok && end == len(command)
}
func parseCopilotPowerShell(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readCopilotPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCopilotPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == "; exit $LASTEXITCODE"
}
func parseCopilotLegacyCommand(command string) (string, bool) { func parseCopilotLegacyCommand(command string) (string, bool) {
command = strings.TrimSpace(command) command = strings.TrimSpace(command)
separator := strings.IndexAny(command, " \t") separator := strings.IndexAny(command, " \t")
@@ -211,11 +111,6 @@ func parseCopilotLegacyCommand(command string) (string, bool) {
return script, script != "" return script, script != ""
} }
func isCopilotNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func copilotManagedScriptPath(handler map[string]any) (string, bool) { func copilotManagedScriptPath(handler map[string]any) (string, bool) {
if len(handler) != 4 || handler["type"] != "command" { if len(handler) != 4 || handler["type"] != "command" {
return "", false return "", false
@@ -226,11 +121,11 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
return "", false return "", false
} }
if handler["timeoutSec"] == copilotHookTimeout { if handler["timeoutSec"] == copilotHookTimeout {
bashNode, bashScript, bashParsed := parseCopilotBash(bash) bashNode, bashScript, bashParsed := parsePOSIXCommand(bash)
psNode, psScript, psParsed := parseCopilotPowerShell(powershell) psNode, psScript, psParsed := parsePowerShellSource(powershell)
if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) && if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) &&
isCopilotNodeExecutable(bashNode) && sameCopilotPath(bashNode, psNode) && isNodeExecutable(bashNode) && sameManagedPath(bashNode, psNode) &&
sameCopilotPath(bashScript, psScript) { sameManagedPath(bashScript, psScript) {
return bashScript, true return bashScript, true
} }
} }
@@ -240,30 +135,6 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
return parseCopilotLegacyCommand(bash) return parseCopilotLegacyCommand(bash)
} }
func copilotManagedEntryForHandler(
handler map[string]any,
scriptName string,
) (*copilotManagedEntry, error) {
scriptPath, managed := copilotManagedScriptPath(handler)
if !managed || !filepath.IsAbs(scriptPath) ||
!sameCopilotFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory := filepath.Dir(scriptPath)
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &copilotManagedEntry{agentID: agentID, scriptPath: scriptPath}, nil
}
func removeManagedCopilotHooks( func removeManagedCopilotHooks(
hooks copilotHooks, hooks copilotHooks,
agentID string, agentID string,
@@ -277,7 +148,7 @@ func removeManagedCopilotHooks(
return nil, err return nil, err
} }
remove := entry != nil && remove := entry != nil &&
(agentID == "" || sameCopilotAgentID(entry.agentID, agentID)) (agentID == "" || sameManagedAgentID(entry.agentID, agentID))
if !remove { if !remove {
kept = append(kept, handler) kept = append(kept, handler)
} }
@@ -361,7 +232,7 @@ func renderCopilotDocument(
document: document, changed: true, remove: true, document: document, changed: true, remove: true,
}, nil }, nil
} }
root := cloneCopilotObject(document.root) root := cloneJSONObject(document.root)
root["version"] = float64(1) root["version"] = float64(1)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
@@ -387,13 +258,6 @@ func renderCopilotDocument(
}, nil }, nil
} }
func copilotEntryKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func copilotManagedEntries( func copilotManagedEntries(
handlers []map[string]any, handlers []map[string]any,
scriptName string, scriptName string,
@@ -405,7 +269,7 @@ func copilotManagedEntries(
return nil, err return nil, err
} }
if entry != nil { if entry != nil {
key := copilotEntryKey(entry.agentID) key := managedReferenceKey(entry.agentID)
result[key] = append(result[key], *entry) result[key] = append(result[key], *entry)
} }
} }
@@ -438,7 +302,7 @@ func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, erro
success := successes[key] success := successes[key]
failure := failures[key] failure := failures[key]
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
!sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) { !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
continue continue
} }
contracts = append(contracts, copilotRuntimeContract{ contracts = append(contracts, copilotRuntimeContract{
@@ -1,103 +1,19 @@
package plugins package plugins
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
) )
type copilotRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
type preparedCopilotInstallation struct { type preparedCopilotInstallation struct {
changes []*fileChange changes []*fileChange
preconditions []filePrecondition preconditions []filePrecondition
paths *copilotRuntimePaths paths *managedRuntimePaths
}
func validateCopilotInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != copilotAgentKey {
return fmt.Errorf(
"GitHub Copilot CLI installation requires agent name %s",
copilotAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func copilotAgentPaths(config InstallConfig) (*copilotRuntimePaths, error) {
if err := validateCopilotInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &copilotRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
auditPath: filepath.Join(agentDirectory, copilotAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameCopilotPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameCopilotPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
} }
func preflightCopilotInstallation( func preflightCopilotInstallation(
config InstallConfig, config InstallConfig,
sources *copilotSources, sources *copilotSources,
) (*copilotRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if sources == nil || sources.user == nil || sources.user.filePath == "" { if sources == nil || sources.user == nil || sources.user.filePath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf(
"GitHub Copilot CLI installation requires a user hook path", "GitHub Copilot CLI installation requires a user hook path",
@@ -106,22 +22,22 @@ func preflightCopilotInstallation(
if err := requireCopilotHooksEnabled(sources); err != nil { if err := requireCopilotHooksEnabled(sources); err != nil {
return nil, "", err return nil, "", err
} }
paths, err := copilotAgentPaths(config) if err := validateManagedInstallConfig(config, copilotAgentKey, "GitHub Copilot CLI"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, copilotGuardScript, copilotAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateCopilotRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, copilotAgentKey, "Copilot",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("GitHub Copilot CLI")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if !filepath.IsAbs(nodePath) || !isCopilotNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"GitHub Copilot CLI hooks require an absolute Node.js executable path",
)
}
hookSources := []copilotHooks{sources.user.hooks} hookSources := []copilotHooks{sources.user.hooks}
if sources.legacy != nil { if sources.legacy != nil {
hookSources = append(hookSources, sources.legacy.hooks) hookSources = append(hookSources, sources.legacy.hooks)
@@ -132,28 +48,6 @@ func preflightCopilotInstallation(
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildCopilotRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": copilotAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func validateCopilotRenderedSet( func validateCopilotRenderedSet(
sources *copilotSources, sources *copilotSources,
rendered []*copilotRenderedDocument, rendered []*copilotRenderedDocument,
@@ -169,7 +63,7 @@ func validateCopilotRenderedSet(
matches := 0 matches := 0
for _, item := range rendered { for _, item := range rendered {
if item != nil && item.document != nil && if item != nil && item.document != nil &&
sameCopilotPath(item.document.filePath, document.filePath) { sameManagedPath(item.document.filePath, document.filePath) {
matches++ matches++
} }
} }
@@ -218,34 +112,13 @@ func prepareCopilotInstallation(
if err := validateCopilotRenderedSet(sources, rendered); err != nil { if err := validateCopilotRenderedSet(sources, rendered); err != nil {
return nil, err return nil, err
} }
runtimeConfig, err := buildCopilotRuntimeConfig(config) guardScript, auditScript := copilotExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, copilotAgentKey, string(guardScript), string(auditScript),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(copilotAgentKey, config.AgentID, "", false, "")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(copilotAgentKey, config.AgentID, "", false, true)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
for _, document := range rendered { for _, document := range rendered {
change, changeErr := prepareRenderedCopilotChange(document) change, changeErr := prepareRenderedCopilotChange(document)
if changeErr != nil { if changeErr != nil {
@@ -275,3 +148,8 @@ func commitCopilotInstallation(
prepared.preconditions, prepared.preconditions,
) )
} }
func copilotExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(copilotAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(copilotAgentKey, agentID, "", false, true))
}
+1 -8
View File
@@ -240,14 +240,7 @@ func writeCopilotChanges(
agentDirectory string, agentDirectory string,
preconditions []filePrecondition, preconditions []filePrecondition,
) error { ) error {
hasChanges := false if !hasFileChanges(changes) {
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return writeChanges(changes, label, rename, preconditions...) return writeChanges(changes, label, rename, preconditions...)
} }
if agentDirectory != "" { if agentDirectory != "" {
@@ -274,7 +274,7 @@ func validateCopilotHooks(value any, label string) (copilotHooks, error) {
if err := validateCopilotHandler(handler, event, itemLabel); err != nil { if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
return nil, err return nil, err
} }
handlers = append(handlers, cloneCopilotObject(handler)) handlers = append(handlers, cloneJSONObject(handler))
} }
hooks[event] = handlers hooks[event] = handlers
} }
+2 -258
View File
@@ -1,261 +1,5 @@
package plugins package plugins
import ( func copilotRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"bytes" return managedRuntimeFilesExist(contracts, copilotAgentKey, "Copilot", copilotExpectedScripts)
"fmt"
"path/filepath"
"sort"
"strings"
)
func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireCopilotRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateCopilotRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameCopilotAgentID(agentID, expectedAgentID) ||
config["agent_name"] != copilotAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Copilot hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != copilotAgentKey ||
!sameCopilotAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Copilot agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameCopilotPath(
contract.guardPath,
filepath.Join(agentDirectory, copilotGuardScript),
) &&
sameCopilotPath(
contract.auditPath,
filepath.Join(agentDirectory, copilotAuditScript),
), nil
}
func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) {
validPaths, err := validCopilotContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(
copilotAgentKey,
contract.agentID,
"",
false,
"",
))
expectedAudit := []byte(buildHookScriptWithOutput(
copilotAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := copilotRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
+26 -54
View File
@@ -10,55 +10,27 @@ type CursorPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Cursor commits its provider-specific guard // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// in the same transaction as the rest of the installation.
func (p *CursorPlugin) ManagesGuardRuntime() bool { func (p *CursorPlugin) ManagesGuardRuntime() bool {
return true return true
} }
func cursorAgentPaths(config InstallConfig) ( func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) {
runtimeRoot, agentDirectory, guardPath, auditPath string, return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript)
err error,
) {
if config.AgentID == "" {
return "", "", "", "", fmt.Errorf("agent ID is required")
}
runtimeRoot, err = AgentRuntimeRoot()
if err != nil {
return "", "", "", "", err
}
agentDirectory, err = ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return "", "", "", "", err
}
guardPath = filepath.Join(agentDirectory, cursorGuardScript)
if !sameCursorPath(config.GuardScriptPath, guardPath) {
return "", "", "", "", fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
guardPath,
)
}
auditPath = filepath.Join(agentDirectory, cursorAuditScript)
if config.HookScript != "" && !sameCursorPath(config.HookScript, auditPath) {
return "", "", "", "", fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
auditPath,
)
}
return runtimeRoot, agentDirectory, guardPath, auditPath, nil
} }
// PreflightInstall validates every existing source before the CLI creates a // PreflightInstall validates every source before the CLI creates runtime state.
// runtime directory or guard file.
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error { func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
if _, err := readCursorDocument(); err != nil { if _, err := readCursorDocument(); err != nil {
return err return err
} }
_, agentDirectory, _, _, err := cursorAgentPaths(config) paths, err := cursorAgentPaths(config)
if err != nil { if err != nil {
return err return err
} }
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
); err != nil {
return err return err
} }
_, err = resolveNodeRuntime() _, err = resolveNodeRuntime()
@@ -70,44 +42,42 @@ func (p *CursorPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config) paths, err := cursorAgentPaths(config)
if err != nil { if err != nil {
return err return err
} }
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
); err != nil {
return err return err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveNodeRuntime()
if err != nil { if err != nil {
return err return err
} }
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "") hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "")
hooks["preToolUse"] = append( hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath))
hooks["preToolUse"], hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath))
buildCursorHandler(nodePath, guardPath),
)
hooks["postToolUse"] = append(
hooks["postToolUse"],
buildCursorHandler(nodePath, auditPath),
)
hooks["postToolUseFailure"] = append( hooks["postToolUseFailure"] = append(
hooks["postToolUseFailure"], hooks["postToolUseFailure"],
buildCursorHandler(nodePath, auditPath), buildCursorHandler(nodePath, paths.auditPath),
) )
rendered, err := renderCursorDocument(document, hooks, runtimeRoot) rendered, err := renderCursorDocument(document, hooks, paths.runtimeRoot)
if err != nil { if err != nil {
return fmt.Errorf("render Cursor user hooks: %w", err) return fmt.Errorf("render Cursor user hooks: %w", err)
} }
changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered) changes, err := prepareCursorInstallationChanges(config, paths, rendered)
if err != nil { if err != nil {
return err return err
} }
if err := writeCursorChanges( if err := writeManagedChanges(
changes, changes,
"Install Cursor hooks", "Install Cursor hooks",
p.rename, p.rename,
runtimeRoot, paths.runtimeRoot,
agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath),
"Cursor hooks directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -133,12 +103,14 @@ func (p *CursorPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeCursorChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Cursor hooks", "Uninstall Cursor hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath),
"Cursor hooks directory",
) )
} }
+9 -111
View File
@@ -16,7 +16,6 @@ const (
cursorGuardScript = "guard.js" cursorGuardScript = "guard.js"
cursorAuditScript = "hook.js" cursorAuditScript = "hook.js"
cursorHookTimeout = float64(10) cursorHookTimeout = float64(10)
cursorPOSIXApostrophe = `'"'"'`
) )
type cursorHooks map[string][]map[string]any type cursorHooks map[string][]map[string]any
@@ -36,11 +35,7 @@ type cursorRenderedDocument struct {
remove bool remove bool
} }
type cursorRuntimeContract struct { type cursorRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func cloneCursorHooks(source cursorHooks) cursorHooks { func cloneCursorHooks(source cursorHooks) cursorHooks {
clone := make(cursorHooks, len(source)) clone := make(cursorHooks, len(source))
@@ -50,104 +45,16 @@ func cloneCursorHooks(source cursorHooks) cursorHooks {
return clone return clone
} }
func sameCursorPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameCursorAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func quoteCursorPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildCursorHandler(nodePath, scriptPath string) map[string]any { func buildCursorHandler(nodePath, scriptPath string) map[string]any {
command := quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) command := posixSource(nodePath, scriptPath)
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = "& " + quoteCursorPowerShell(nodePath) + " " + command = powerShellSource(nodePath, scriptPath)
quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE"
} }
return map[string]any{ return map[string]any{
"command": command, "timeout": cursorHookTimeout, "failClosed": true, "command": command, "timeout": cursorHookTimeout, "failClosed": true,
} }
} }
func readCursorPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], cursorPOSIXApostrophe) {
value.WriteByte('\'')
index += len(cursorPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func readCursorPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func parseCursorPOSIXCommand(command string) (string, string, bool) {
executable, next, ok := readCursorPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCursorPOSIXArgument(command, next+1)
return executable, script, ok && end == len(command)
}
func parseCursorPowerShellCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readCursorPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readCursorPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == "; exit $LASTEXITCODE"
}
func parseCursorLegacyCommand(command string) (string, bool) { func parseCursorLegacyCommand(command string) (string, bool) {
separator := strings.IndexByte(command, ' ') separator := strings.IndexByte(command, ' ')
if separator < 0 { if separator < 0 {
@@ -164,11 +71,6 @@ func parseCursorLegacyCommand(command string) (string, bool) {
return script, script != "" && !strings.ContainsAny(script, "\r\n") return script, script != "" && !strings.ContainsAny(script, "\r\n")
} }
func isCursorNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func cursorManagedScriptPath(handler map[string]any) (string, bool) { func cursorManagedScriptPath(handler map[string]any) (string, bool) {
if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true { if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true {
command, ok := handler["command"].(string) command, ok := handler["command"].(string)
@@ -178,12 +80,12 @@ func cursorManagedScriptPath(handler map[string]any) (string, bool) {
var nodePath, scriptPath string var nodePath, scriptPath string
var parsed bool var parsed bool
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command) nodePath, scriptPath, parsed = parsePowerShellSource(command)
} else { } else {
nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command) nodePath, scriptPath, parsed = parsePOSIXCommand(command)
} }
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) && if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
isCursorNodeExecutable(nodePath) { isNodeExecutable(nodePath) {
return scriptPath, true return scriptPath, true
} }
} }
@@ -207,7 +109,7 @@ func cursorManagedAgentID(
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) agentDirectory := filepath.Dir(scriptPath)
if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
@@ -297,7 +199,7 @@ func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cu
kept := make([]map[string]any, 0, len(result[contract.event])) kept := make([]map[string]any, 0, len(result[contract.event]))
for _, handler := range result[contract.event] { for _, handler := range result[contract.event] {
managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot) managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot)
if managed && (agentID == "" || sameCursorAgentID(managedID, agentID)) { if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
continue continue
} }
kept = append(kept, handler) kept = append(kept, handler)
@@ -384,11 +286,7 @@ func managedCursorIDs(
if !managed { if !managed {
continue continue
} }
key := agentID result[managedReferenceKey(agentID)] = agentID
if runtime.GOOS == "windows" {
key = strings.ToLower(agentID)
}
result[key] = agentID
} }
return result return result
} }
+19 -264
View File
@@ -1,13 +1,9 @@
package plugins package plugins
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
) )
func cursorConfigPath() (string, error) { func cursorConfigPath() (string, error) {
@@ -27,21 +23,14 @@ func readCursorDocument() (*cursorDocument, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
info, err := os.Lstat(filePath) snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes)
if errors.Is(err, os.ErrNotExist) { if err != nil {
return nil, err
}
if snapshot == nil {
return createCursorDocument(filePath), nil return createCursorDocument(filePath), nil
} }
if err != nil { return parseCursorDocument(filePath, snapshot.contents, runtimeRoot)
return nil, fmt.Errorf("inspect Cursor user hooks at %s: %w", filePath, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, fmt.Errorf("Cursor user hooks path is not a physical file: %s", filePath)
}
raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is the fixed Cursor user configuration path.
if err != nil {
return nil, fmt.Errorf("read Cursor user hooks at %s: %w", filePath, err)
}
return parseCursorDocument(filePath, raw, runtimeRoot)
} }
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) { func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
@@ -59,168 +48,26 @@ func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange,
) )
} }
func cursorPhysicalFileExists(path, label string) (bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect %s at %s: %w", label, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return false, fmt.Errorf("%s path is not a physical file: %s", label, path)
}
return true, nil
}
func cursorPhysicalDirectoryExists(path string) (bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("inspect Elydora runtime directory at %s: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return false, fmt.Errorf("Elydora runtime path is not a physical directory: %s", path)
}
return true, nil
}
func readCursorRuntimeConfig(path string) (map[string]any, bool, error) {
exists, err := cursorPhysicalFileExists(path, "Elydora runtime config")
if err != nil || !exists {
return nil, exists, err
}
raw, err := os.ReadFile(path) // #nosec G304 -- path is confined to one validated Elydora agent directory.
if err != nil {
return nil, true, fmt.Errorf("read Elydora runtime config at %s: %w", path, err)
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
if !json.Valid(raw) {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil, true, fmt.Errorf("parse %s: %w", label, err)
}
}
config, err := decodeJSONCObject(raw, label, false)
if err != nil {
return nil, true, err
}
return config, true, nil
}
func validateCursorRuntimeIdentity(path, agentID string) (bool, error) {
config, exists, err := readCursorRuntimeConfig(path)
if err != nil || !exists {
return exists, err
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != cursorAgentKey ||
!sameCursorAgentID(configuredID, agentID) {
return true, fmt.Errorf(
"Elydora runtime config identity does not match Cursor agent %s: %s",
agentID,
path,
)
}
return true, nil
}
func preflightCursorRuntime(agentDirectory, agentID string) error {
exists, err := cursorPhysicalDirectoryExists(agentDirectory)
if err != nil || !exists {
return err
}
identityExists, err := validateCursorRuntimeIdentity(
filepath.Join(agentDirectory, "config.json"), agentID,
)
if err != nil {
return err
}
runtimeExists := false
for _, item := range []struct{ path, label string }{
{filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime"},
{filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime"},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key"},
} {
exists, err := cursorPhysicalFileExists(item.path, item.label)
if err != nil {
return err
}
runtimeExists = runtimeExists || exists
}
if runtimeExists && !identityExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
func prepareCursorInstallationChanges( func prepareCursorInstallationChanges(
config InstallConfig, config InstallConfig,
agentDirectory string, paths *managedRuntimePaths,
rendered *cursorRenderedDocument, rendered *cursorRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
baseURL := config.BaseURL if config.BaseURL == "" {
if baseURL == "" { config.BaseURL = "https://api.elydora.com"
baseURL = "https://api.elydora.com"
} }
runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{ changes, err := managedRuntimeFileChanges(
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, config,
BaseURL: baseURL, Token: config.Token, AgentName: cursorAgentKey, paths,
}, "", " ") cursorAgentKey,
if err != nil { generateGuardScript(
return nil, fmt.Errorf("encode Elydora runtime config: %w", err) cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor",
} ),
runtimeConfig = append(runtimeConfig, '\n') buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true),
if len(runtimeConfig) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
) )
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime",
[]byte(generateGuardScript(
cursorAgentKey,
config.AgentID,
`{"permission":"allow"}`+"\n",
true,
"cursor",
)), 0700,
},
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
{
filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
cursorAgentKey,
config.AgentID,
"{}\n",
true,
true,
)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
if err := validateRuntimeFileTarget(item.path, item.label); err != nil {
return nil, err
}
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
changes = append(changes, change)
}
documentChange, err := prepareRenderedCursorChange(rendered) documentChange, err := prepareRenderedCursorChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -228,98 +75,6 @@ func prepareCursorInstallationChanges(
return append(changes, documentChange), nil return append(changes, documentChange), nil
} }
func ensureCursorDirectory(path, label string) error { func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
if err := os.MkdirAll(path, 0700); err != nil { return managedRuntimeFilesPresent(contracts, cursorAgentKey)
return fmt.Errorf("create %s directory at %s: %w", label, path, err)
}
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect %s directory at %s: %w", label, path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("%s directory is not a physical directory: %s", label, path)
}
return nil
}
func writeCursorChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot, agentDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
directories := map[string]string{}
for _, change := range changes {
if change == nil {
continue
}
directory := filepath.Dir(change.filePath)
key := filepath.Clean(directory)
if runtime.GOOS == "windows" {
key = strings.ToLower(key)
}
directories[key] = directory
}
for _, directory := range directories {
if err := ensureCursorDirectory(directory, label); err != nil {
return err
}
}
return writeChanges(changes, label, rename)
}
func cursorRuntimeFilesExist(contracts []cursorRuntimeContract) (bool, error) {
for _, contract := range contracts {
agentDirectory := filepath.Dir(contract.guardPath)
directoryExists, err := cursorPhysicalDirectoryExists(agentDirectory)
if err != nil {
return false, err
}
if !directoryExists {
continue
}
config, exists, err := readCursorRuntimeConfig(filepath.Join(agentDirectory, "config.json"))
if err != nil {
return false, err
}
configuredID, idOK := config["agent_id"].(string)
if !exists || !idOK || config["agent_name"] != cursorAgentKey ||
!sameCursorAgentID(configuredID, contract.agentID) {
continue
}
complete := true
for _, item := range []struct{ path, label string }{
{contract.guardPath, "Elydora guard runtime"},
{contract.auditPath, "Elydora audit runtime"},
{filepath.Join(agentDirectory, "private.key"), "Elydora private key"},
} {
exists, err := cursorPhysicalFileExists(item.path, item.label)
if err != nil {
return false, err
}
complete = complete && exists
}
if complete {
return true, nil
}
}
return false, nil
} }
@@ -183,7 +183,7 @@ func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) {
fixture.guardPath, fixture.guardPath,
fixture.hookPath, fixture.hookPath,
} { } {
if exists, err := cursorPhysicalFileExists(path, "Cursor runtime"); err != nil || !exists { if exists, err := managedPhysicalFileExists(path, "Cursor runtime", maxManagedSourceBytes); err != nil || !exists {
t.Fatalf("runtime file %s exists = %v, %v", path, exists, err) t.Fatalf("runtime file %s exists = %v, %v", path, exists, err)
} }
} }
@@ -200,7 +200,7 @@ func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) {
} }
failed := false failed := false
fixture.plugin.rename = func(source, destination string) error { fixture.plugin.rename = func(source, destination string) error {
if !failed && sameCursorPath(destination, fixture.configPath) { if !failed && sameManagedPath(destination, fixture.configPath) {
failed = true failed = true
return errors.New("injected Cursor config commit failure") return errors.New("injected Cursor config commit failure")
} }
+4 -6
View File
@@ -7,14 +7,12 @@ type DroidPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Droid commits its guard, audit runtime, // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// credentials, and hook documents in one transaction.
func (p *DroidPlugin) ManagesGuardRuntime() bool { func (p *DroidPlugin) ManagesGuardRuntime() bool {
return true return true
} }
// PreflightInstall validates every hook source, policy layer, credential, // PreflightInstall validates every source before the CLI creates runtime state.
// matcher, and runtime identity before any write.
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error { func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
sources, err := readDroidSources() sources, err := readDroidSources()
if err != nil { if err != nil {
@@ -107,7 +105,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := AgentRuntimeRoot()
if err != nil { if err != nil {
return err return err
} }
@@ -133,7 +131,7 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
if droidHookBlocked(sources) != nil { if droidHookBlocked(sources) != nil {
return status, nil return status, nil
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := AgentRuntimeRoot()
if err != nil { if err != nil {
return status, err return status, err
} }
+1 -1
View File
@@ -239,7 +239,7 @@ func droidAdditionsFor(
document, target *droidDocument, document, target *droidDocument,
groups map[string]map[string]any, groups map[string]map[string]any,
) map[string]map[string]any { ) map[string]map[string]any {
if sameDroidPath(document.filePath, target.filePath) { if sameManagedPath(document.filePath, target.filePath) {
return groups return groups
} }
return map[string]map[string]any{} return map[string]map[string]any{}
+9 -151
View File
@@ -20,8 +20,6 @@ const (
droidAuditScript = "hook.js" droidAuditScript = "hook.js"
droidHookTimeout = float64(10) droidHookTimeout = float64(10)
droidOwnedFileMarker = "// Managed by Elydora" droidOwnedFileMarker = "// Managed by Elydora"
droidPOSIXApostrophe = `'"'"'`
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
) )
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
@@ -35,11 +33,7 @@ type droidManagedRemoval struct {
removeGroup bool removeGroup bool
} }
type droidRuntimeContract struct { type droidRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
type droidRegexEntry struct { type droidRegexEntry struct {
Label string `json:"label"` Label string `json:"label"`
@@ -178,145 +172,21 @@ for (const entry of entries) {
return nil return nil
} }
func buildDroidCommand(nodePath, scriptPath string) string {
if runtime.GOOS == "windows" {
return "& " + quoteDroidPowerShellArgument(nodePath) + " " +
quoteDroidPowerShellArgument(scriptPath) + droidWindowsExitSuffix
}
return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
}
func quoteDroidPowerShellArgument(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func buildDroidGroup(nodePath, scriptPath string) map[string]any { func buildDroidGroup(nodePath, scriptPath string) map[string]any {
return map[string]any{ return map[string]any{
"matcher": "*", "matcher": "*",
"hooks": []any{map[string]any{ "hooks": []any{map[string]any{
"type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout, "type": "command", "command": buildShellCommand(nodePath, scriptPath), "timeout": droidHookTimeout,
}}, }},
} }
} }
func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) { func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) {
if runtime.GOOS != "windows" { executable, script, ok := parseShellCommand(command)
return parseDroidPOSIXCommand(command) if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] {
}
executable, script, ok := parseDroidPowerShellCommand(command)
if ok || len(includeLegacy) == 0 || !includeLegacy[0] {
return executable, script, ok return executable, script, ok
} }
return parseDroidLegacyWindowsCommand(command) return parseQuotedWindowsCommand(command)
}
func parseDroidPOSIXCommand(command string) (string, string, bool) {
executable, next, ok := readDroidPOSIXArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidPOSIXArgument(command, next+1)
return executable, script, ok && end == len(command) && executable != "" && script != ""
}
func parseDroidPowerShellCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") {
return "", "", false
}
executable, next, ok := readDroidPowerShellArgument(command, 2)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidPowerShellArgument(command, next+1)
return executable, script, ok && command[end:] == droidWindowsExitSuffix && executable != "" && script != ""
}
func parseDroidLegacyWindowsCommand(command string) (string, string, bool) {
executable, next, ok := readDroidLegacyWindowsArgument(command, 0)
if !ok || next >= len(command) || command[next] != ' ' {
return "", "", false
}
script, end, ok := readDroidLegacyWindowsArgument(command, next+1)
return executable, script, ok && end == len(command) && executable != "" && script != ""
}
func readDroidPowerShellArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); index++ {
if command[index] != '\'' {
value.WriteByte(command[index])
continue
}
if index+1 < len(command) && command[index+1] == '\'' {
value.WriteByte('\'')
index++
continue
}
return value.String(), index + 1, true
}
return "", start, false
}
func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '"' {
return "", start, false
}
end := strings.IndexByte(command[start+1:], '"')
if end < 0 {
return "", start, false
}
end += start + 1
value := command[start+1 : end]
return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n")
}
func readDroidPOSIXArgument(command string, start int) (string, int, bool) {
if start >= len(command) || command[start] != '\'' {
return "", start, false
}
var value strings.Builder
for index := start + 1; index < len(command); {
if strings.HasPrefix(command[index:], droidPOSIXApostrophe) {
value.WriteByte('\'')
index += len(droidPOSIXApostrophe)
continue
}
if command[index] == '\'' {
return value.String(), index + 1, true
}
value.WriteByte(command[index])
index++
}
return "", start, false
}
func sameDroidPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameDroidAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func droidRuntimeRoot() (string, error) {
return AgentRuntimeRoot()
} }
func managedDroidAgentID( func managedDroidAgentID(
@@ -333,29 +203,17 @@ func managedDroidAgentID(
} }
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...) executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
!isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) { !isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
return "", false return "", false
} }
agentDirectory := filepath.Dir(scriptPath) agentDirectory := filepath.Dir(scriptPath)
if !sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return "", false
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".." return agentID, agentID != "" && agentID != "." && agentID != ".."
} }
func isDroidNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func sameDroidFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval { func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval {
removals := make([]droidManagedRemoval, 0) removals := make([]droidManagedRemoval, 0)
for _, contract := range []struct{ event, script string }{ for _, contract := range []struct{ event, script string }{
@@ -370,7 +228,7 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
managedID, managed := managedDroidAgentID( managedID, managed := managedDroidAgentID(
handlerValue.(map[string]any), contract.script, runtimeRoot, true, handlerValue.(map[string]any), contract.script, runtimeRoot, true,
) )
if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) { if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
indexes = append(indexes, handlerIndex) indexes = append(indexes, handlerIndex)
} }
} }
@@ -400,7 +258,7 @@ func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []dro
contracts := make([]droidRuntimeContract, 0, len(guardIDs)) contracts := make([]droidRuntimeContract, 0, len(guardIDs))
for _, guardID := range guardIDs { for _, guardID := range guardIDs {
for auditID := range audits { for auditID := range audits {
if !sameDroidAgentID(guardID, auditID) { if !sameManagedAgentID(guardID, auditID) {
continue continue
} }
root := filepath.Join(runtimeRoot, guardID) root := filepath.Join(runtimeRoot, guardID)
+23 -156
View File
@@ -1,24 +1,13 @@
package plugins package plugins
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
) )
type droidRuntimePaths struct {
runtimeRoot string
agentDirectory string
guardPath string
auditPath string
}
type preparedDroidInstallation struct { type preparedDroidInstallation struct {
changes []*fileChange changes []*fileChange
preconditions []filePrecondition preconditions []filePrecondition
paths *droidRuntimePaths paths *managedRuntimePaths
} }
type preparedDroidUninstall struct { type preparedDroidUninstall struct {
@@ -39,80 +28,10 @@ func droidSourceLabel(document *droidDocument) string {
} }
} }
func validateDroidInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != droidAgentKey {
return fmt.Errorf(
"Factory Droid installation requires agent name %s",
droidAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func droidAgentPaths(config InstallConfig) (*droidRuntimePaths, error) {
if err := validateDroidInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &droidRuntimePaths{
runtimeRoot: runtimeRoot,
agentDirectory: agentDirectory,
guardPath: filepath.Join(agentDirectory, droidGuardScript),
auditPath: filepath.Join(agentDirectory, droidAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameDroidPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameDroidPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightDroidInstallation( func preflightDroidInstallation(
config InstallConfig, config InstallConfig,
sources *droidSources, sources *droidSources,
) (*droidRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if sources == nil || sources.root == nil || sources.policy == nil { if sources == nil || sources.root == nil || sources.policy == nil {
return nil, "", fmt.Errorf("Factory Droid installation sources are required") return nil, "", fmt.Errorf("Factory Droid installation sources are required")
} }
@@ -126,50 +45,25 @@ func preflightDroidInstallation(
if err := validateDroidRegexes(hooks...); err != nil { if err := validateDroidRegexes(hooks...); err != nil {
return nil, "", err return nil, "", err
} }
paths, err := droidAgentPaths(config) if err := validateManagedInstallConfig(config, droidAgentKey, "Factory Droid"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, droidGuardScript, droidAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateDroidRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, droidAgentKey, "Factory Droid",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Factory Droid")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if !filepath.IsAbs(nodePath) || !isDroidNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Factory Droid hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildDroidRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID,
"agent_id": config.AgentID,
"kid": config.KID,
"base_url": config.BaseURL,
"agent_name": droidAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareRenderedDroidChange( func prepareRenderedDroidChange(
rendered *droidRenderedDocument, rendered *droidRenderedDocument,
) (*fileChange, error) { ) (*fileChange, error) {
@@ -198,7 +92,7 @@ func validateDroidRenderedSet(
matches := 0 matches := 0
for _, item := range rendered { for _, item := range rendered {
if item != nil && item.document != nil && if item != nil && item.document != nil &&
sameDroidPath(item.document.filePath, document.filePath) { sameManagedPath(item.document.filePath, document.filePath) {
matches++ matches++
} }
} }
@@ -217,7 +111,7 @@ func droidSourcePreconditions(
for _, document := range droidSourceDocuments(sources) { for _, document := range droidSourceDocuments(sources) {
changed := false changed := false
for _, path := range changedPaths { for _, path := range changedPaths {
if sameDroidPath(document.filePath, path) { if sameManagedPath(document.filePath, path) {
changed = true changed = true
break break
} }
@@ -246,38 +140,13 @@ func prepareDroidInstallation(
if err := validateDroidRenderedSet(sources, rendered); err != nil { if err := validateDroidRenderedSet(sources, rendered); err != nil {
return nil, err return nil, err
} }
runtimeConfig, err := buildDroidRuntimeConfig(config) guardScript, auditScript := droidExpectedScripts(config.AgentID, nil)
changes, err := managedRuntimeFileChanges(
config, paths, droidAgentKey, string(guardScript), string(auditScript),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath,
"Elydora guard runtime",
[]byte(generateGuardScript(droidAgentKey, config.AgentID, "", false, "")),
0700,
},
{filepath.Join(paths.agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
{filepath.Join(paths.agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath,
"Elydora audit runtime",
[]byte(buildHookScriptWithOutput(droidAgentKey, config.AgentID, "", false, true)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
changedPaths := make([]string, 0, len(rendered)) changedPaths := make([]string, 0, len(rendered))
for _, document := range rendered { for _, document := range rendered {
change, changeErr := prepareRenderedDroidChange(document) change, changeErr := prepareRenderedDroidChange(document)
@@ -305,14 +174,7 @@ func writeDroidChanges(
runtimeRoot, agentDirectory string, runtimeRoot, agentDirectory string,
preconditions []filePrecondition, preconditions []filePrecondition,
) error { ) error {
hasChanges := false if !hasFileChanges(changes) {
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return writeChanges(changes, label, rename, preconditions...) return writeChanges(changes, label, rename, preconditions...)
} }
if err := assertFilePreconditions(preconditions, label); err != nil { if err := assertFilePreconditions(preconditions, label); err != nil {
@@ -365,7 +227,7 @@ func prepareDroidUninstall(
for _, item := range rendered { for _, item := range rendered {
changed := false changed := false
for _, path := range changedPaths { for _, path := range changedPaths {
if sameDroidPath(item.document.filePath, path) { if sameManagedPath(item.document.filePath, path) {
changed = true changed = true
break break
} }
@@ -398,3 +260,8 @@ func commitDroidUninstall(
prepared.preconditions, prepared.preconditions,
) )
} }
func droidExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
return []byte(generateGuardScript(droidAgentKey, agentID, "", false, "")),
[]byte(buildHookScriptWithOutput(droidAgentKey, agentID, "", false, true))
}
+2 -248
View File
@@ -1,251 +1,5 @@
package plugins package plugins
import ( func droidRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
"bytes" return managedRuntimeFilesExist(contracts, droidAgentKey, "Factory Droid", droidExpectedScripts)
"fmt"
"path/filepath"
"sort"
"strings"
)
func readDroidRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
config, err := decodeStrictJSONObject(
snapshot.contents,
fmt.Sprintf("Elydora runtime config at %s", path),
)
return config, true, err
}
func requireDroidRuntimeString(
config map[string]any,
field, configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateDroidRuntimeConfig(
config map[string]any,
expectedAgentID, configPath string,
) error {
supported := stringSet(
"org_id",
"agent_id",
"kid",
"base_url",
"token",
"agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireDroidRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireDroidRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireDroidRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameDroidAgentID(agentID, expectedAgentID) || config["agent_name"] != droidAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Factory Droid hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireDroidRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireDroidRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateDroidRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readDroidRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, droidGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, droidAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != droidAgentKey ||
!sameDroidAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Factory Droid agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validDroidContractPaths(contract droidRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameDroidPath(
contract.guardPath,
filepath.Join(agentDirectory, droidGuardScript),
) &&
sameDroidPath(
contract.auditPath,
filepath.Join(agentDirectory, droidAuditScript),
), nil
}
func droidRuntimeContractExists(contract droidRuntimeContract) (bool, error) {
validPaths, err := validDroidContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readDroidRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateDroidRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(droidAgentKey, contract.agentID, "", false, ""))
expectedAudit := []byte(buildHookScriptWithOutput(
droidAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func droidRuntimeFilesExist(contracts []droidRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := droidRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
} }
+1 -1
View File
@@ -57,7 +57,7 @@ func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
requireDroidNativeGroup(t, auditGroup) requireDroidNativeGroup(t, auditGroup)
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string) guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
if runtime.GOOS == "windows" && if runtime.GOOS == "windows" &&
(!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, droidWindowsExitSuffix)) { (!strings.HasPrefix(guardCommand, "& '") || !strings.HasSuffix(guardCommand, powerShellExitSuffix)) {
t.Fatalf("Windows Factory command = %q", guardCommand) t.Fatalf("Windows Factory command = %q", guardCommand)
} }
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig) runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
@@ -237,7 +237,7 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
handler := requireDroidObject(t, handlerValue) handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string) command, _ := handler["command"].(string)
_, configuredPath, managed := parseDroidCommand(command, true) _, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) { if managed && sameManagedPath(configuredPath, scriptPath) {
return group return group
} }
} }
@@ -253,7 +253,7 @@ func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[s
handler := requireDroidObject(t, handlerValue) handler := requireDroidObject(t, handlerValue)
command, _ := handler["command"].(string) command, _ := handler["command"].(string)
_, configuredPath, managed := parseDroidCommand(command, true) _, configuredPath, managed := parseDroidCommand(command, true)
if managed && sameDroidPath(configuredPath, scriptPath) { if managed && sameManagedPath(configuredPath, scriptPath) {
return handler return handler
} }
} }
@@ -344,3 +344,12 @@ func requireNoDroidStagingFiles(t *testing.T, root string) {
t.Fatalf("walk staging files: %v", err) t.Fatalf("walk staging files: %v", err)
} }
} }
func standardizeJSONC(source []byte, label string, allowTrailingCommas bool) ([]byte, error) {
value, err := parseJSONC(source, label, allowTrailingCommas)
if err != nil {
return nil, err
}
value.Standardize()
return value.Pack(), nil
}
@@ -53,7 +53,7 @@ func TestDroidInstallRollsBackRuntimeAfterLateHookCommitFailure(t *testing.T) {
prepared := prepareDroidTestInstallation(t, fixture, fixture.config) prepared := prepareDroidTestInstallation(t, fixture, fixture.config)
failed := false failed := false
rename := func(source, destination string) error { rename := func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.configPath) { if !failed && sameManagedPath(destination, fixture.configPath) {
failed = true failed = true
return errors.New("injected Droid hook commit failure") return errors.New("injected Droid hook commit failure")
} }
@@ -85,7 +85,7 @@ func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T)
t.Fatalf("concurrent source error = %v", err) t.Fatalf("concurrent source error = %v", err)
} }
for path, source := range before { for path, source := range before {
if sameDroidPath(path, fixture.configPath) { if sameManagedPath(path, fixture.configPath) {
continue continue
} }
if readDroidTestFile(t, path) != source { if readDroidTestFile(t, path) != source {
@@ -205,7 +205,7 @@ func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) {
t.Fatal("same-content replacement changed") t.Fatal("same-content replacement changed")
} }
for path, source := range before { for path, source := range before {
if !sameDroidPath(path, fixture.configPath) && readDroidTestFile(t, path) != source { if !sameManagedPath(path, fixture.configPath) && readDroidTestFile(t, path) != source {
t.Fatalf("runtime changed after inode replacement: %s", path) t.Fatalf("runtime changed after inode replacement: %s", path)
} }
} }
@@ -226,7 +226,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read uninstall sources: %v", err) t.Fatalf("read uninstall sources: %v", err)
} }
runtimeRoot, err := droidRuntimeRoot() runtimeRoot, err := AgentRuntimeRoot()
if err != nil { if err != nil {
t.Fatalf("resolve runtime root: %v", err) t.Fatalf("resolve runtime root: %v", err)
} }
@@ -240,7 +240,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
} }
failed := false failed := false
rename := func(source, destination string) error { rename := func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.settingsPath) { if !failed && sameManagedPath(destination, fixture.settingsPath) {
failed = true failed = true
return errors.New("injected Droid uninstall failure") return errors.New("injected Droid uninstall failure")
} }
+5 -4
View File
@@ -10,8 +10,7 @@ type GeminiPlugin struct {
rename renameFunc rename renameFunc
} }
// ManagesGuardRuntime reports that Gemini commits both generated runtimes and // ManagesGuardRuntime reports that the guard is committed with the audit runtime.
// the user settings document through one transaction.
func (p *GeminiPlugin) ManagesGuardRuntime() bool { func (p *GeminiPlugin) ManagesGuardRuntime() bool {
return true return true
} }
@@ -92,13 +91,14 @@ func (p *GeminiPlugin) Install(config InstallConfig) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeGeminiChanges( if err := writeManagedChanges(
changes, changes,
"Install Gemini CLI hooks", "Install Gemini CLI hooks",
p.rename, p.rename,
paths.runtimeRoot, paths.runtimeRoot,
paths.agentDirectory, paths.agentDirectory,
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
); err != nil { ); err != nil {
return err return err
} }
@@ -120,13 +120,14 @@ func (p *GeminiPlugin) Uninstall(agentID string) error {
if err != nil { if err != nil {
return err return err
} }
return writeGeminiChanges( return writeManagedChanges(
[]*fileChange{change}, []*fileChange{change},
"Uninstall Gemini CLI hooks", "Uninstall Gemini CLI hooks",
p.rename, p.rename,
"", "",
"", "",
filepath.Dir(document.filePath), filepath.Dir(document.filePath),
"Gemini CLI configuration directory",
) )
} }
+15 -74
View File
@@ -7,83 +7,37 @@ import (
"strings" "strings"
) )
type geminiRuntimeReference struct { type geminiRuntimeReference = managedScriptReference
agentID string
scriptPath string
}
func sameGeminiPath(left, right string) bool {
left = filepath.Clean(left)
right = filepath.Clean(right)
if absolute, err := filepath.Abs(left); err == nil {
left = absolute
}
if absolute, err := filepath.Abs(right); err == nil {
right = absolute
}
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGeminiAgentID(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func sameGeminiFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func isGeminiNodeExecutable(path string) bool {
name := filepath.Base(path)
return name == "node" || strings.EqualFold(name, "node.exe")
}
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) { func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
return "", fmt.Errorf( return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths")
"Gemini CLI hook commands require absolute runtime and script paths",
)
} }
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil return posixSource(runtimePath, scriptPath), nil
} }
source := "& " + quoteCodexPowerShell(runtimePath) + " " + return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags +
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil
return "& " + quoteCodexPowerShell(codexPowerShellPath()) +
" -NoLogo -NoProfile -NonInteractive -EncodedCommand " +
encodeCodexPowerShell(source), nil
} }
func parseGeminiWindowsCommand(command string) (string, string, bool) { func parseGeminiWindowsCommand(command string) (string, string, bool) {
if !strings.HasPrefix(command, "& ") { if !strings.HasPrefix(command, "& ") {
return "", "", false return "", "", false
} }
powerShell, next, ok := readCodexPowerShellArgument(command, 2) powerShell, next, ok := readPowerShellArgument(command, 2)
if !ok || !isAbsoluteWindowsPath(powerShell) || if !ok || !isPowerShellExecutable(powerShell) ||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") { !strings.HasPrefix(command[next:], powerShellEncodedFlags) {
return "", "", false return "", "", false
} }
prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand " source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):])
if !strings.HasPrefix(command[next:], prefix) {
return "", "", false
}
source, ok := decodeCodexPowerShell(command[next+len(prefix):])
if !ok { if !ok {
return "", "", false return "", "", false
} }
return parseCodexPowerShellSource(source) return parsePowerShellSource(source)
} }
func parseGeminiCommand(command string) (string, string, bool) { func parseGeminiCommand(command string) (string, string, bool) {
if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok {
return runtimePath, scriptPath, true return runtimePath, scriptPath, true
} }
return parseGeminiWindowsCommand(command) return parseGeminiWindowsCommand(command)
@@ -110,23 +64,10 @@ func geminiRuntimeReferenceForCommand(
if !current && includeLegacy { if !current && includeLegacy {
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command) runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
} }
validRuntime := (current && filepath.IsAbs(runtimePath) && validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) ||
isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node") (legacy && runtimePath == "node")
if !validRuntime || !filepath.IsAbs(scriptPath) || if !validRuntime {
!sameGeminiFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil return nil, nil
} }
agentDirectory := filepath.Dir(scriptPath) return resolveManagedScript(scriptPath, scriptName)
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) {
return nil, nil
}
agentID := filepath.Base(agentDirectory)
if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
} }
+3 -25
View File
@@ -1,10 +1,7 @@
package plugins package plugins
import ( import (
"path/filepath"
"runtime"
"sort" "sort"
"strings"
) )
const ( const (
@@ -27,11 +24,7 @@ type geminiManagedRemoval struct {
removeGroup bool removeGroup bool
} }
type geminiRuntimeContract struct { type geminiRuntimeContract = managedRuntimeContract
agentID string
guardPath string
auditPath string
}
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) { func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
command, err := buildGeminiCommand(runtimePath, scriptPath) command, err := buildGeminiCommand(runtimePath, scriptPath)
@@ -151,7 +144,7 @@ func managedGeminiRemovals(
return nil, err return nil, err
} }
if reference != nil && if reference != nil &&
(agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) { (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
indexes = append(indexes, handlerIndex) indexes = append(indexes, handlerIndex)
} }
} }
@@ -168,13 +161,6 @@ func managedGeminiRemovals(
return removals, nil return removals, nil
} }
func geminiReferenceKey(agentID string) string {
if runtime.GOOS == "windows" {
return strings.ToLower(agentID)
}
return agentID
}
func geminiReferencesForEvent( func geminiReferencesForEvent(
groups []any, groups []any,
scriptName string, scriptName string,
@@ -196,7 +182,7 @@ func geminiReferencesForEvent(
return nil, err return nil, err
} }
if reference != nil { if reference != nil {
key := geminiReferenceKey(reference.agentID) key := managedReferenceKey(reference.agentID)
result[key] = append(result[key], *reference) result[key] = append(result[key], *reference)
} }
} }
@@ -243,11 +229,3 @@ func geminiRuntimeContracts(
} }
return contracts, nil return contracts, nil
} }
func geminiRuntimeRoot() (string, error) {
root, err := AgentRuntimeRoot()
if err != nil {
return "", err
}
return filepath.Clean(root), nil
}
@@ -1,190 +1,48 @@
package plugins package plugins
import ( import "fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type geminiRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
func validateGeminiInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != geminiAgentKey {
return fmt.Errorf(
"Gemini CLI installation requires agent name %s",
geminiAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) {
if err := validateGeminiInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := geminiRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &geminiRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, geminiGuardScript),
auditPath: filepath.Join(agentDirectory, geminiAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameGeminiPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"guard runtime must use the managed Elydora agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameGeminiPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"audit runtime must use the managed Elydora agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightGeminiInstallation( func preflightGeminiInstallation(
config InstallConfig, config InstallConfig,
document *geminiDocument, document *geminiDocument,
) (*geminiRuntimePaths, string, error) { ) (*managedRuntimePaths, string, error) {
if document == nil || document.filePath == "" { if document == nil || document.filePath == "" {
return nil, "", fmt.Errorf( return nil, "", fmt.Errorf("Gemini CLI installation requires a user settings document")
"Gemini CLI installation requires a user settings document",
)
} }
paths, err := geminiAgentPaths(config) if err := validateManagedInstallConfig(config, geminiAgentKey, "Gemini CLI"); err != nil {
return nil, "", err
}
paths, err := resolveManagedRuntimePaths(config, geminiGuardScript, geminiAuditScript)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { if err := validateManagedRuntimeIdentity(
paths.agentDirectory, config.AgentID, geminiAgentKey, "Gemini CLI",
); err != nil {
return nil, "", err return nil, "", err
} }
nodePath, err := resolveNodeRuntime() nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"Gemini CLI hooks require an absolute Node.js executable path",
)
}
return paths, nodePath, nil return paths, nodePath, nil
} }
func buildGeminiRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": geminiAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func prepareGeminiInstallationChanges( func prepareGeminiInstallationChanges(
config InstallConfig, config InstallConfig,
paths *geminiRuntimePaths, paths *managedRuntimePaths,
rendered *geminiRenderedDocument, rendered *geminiRenderedDocument,
) ([]*fileChange, error) { ) ([]*fileChange, error) {
runtimeConfig, err := buildGeminiRuntimeConfig(config) changes, err := managedRuntimeFileChanges(
config,
paths,
geminiAgentKey,
generateGuardScript(geminiAgentKey, config.AgentID, "{}\n", false, ""),
buildHookScriptWithOutput(geminiAgentKey, config.AgentID, "{}\n", false, true),
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(
geminiAgentKey,
config.AgentID,
"{}\n",
false,
"",
)),
0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(
geminiAgentKey,
config.AgentID,
"{}\n",
false,
true,
)),
0700,
},
}
changes := make([]*fileChange, 0, len(items)+1)
for _, item := range items {
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
if err != nil {
return nil, err
}
changes = append(changes, change)
}
documentChange, err := prepareRenderedGeminiChange(rendered) documentChange, err := prepareRenderedGeminiChange(rendered)
if err != nil { if err != nil {
return nil, err return nil, err
-35
View File
@@ -67,38 +67,3 @@ func prepareRenderedGeminiChange(
rendered.remove, rendered.remove,
) )
} }
func writeGeminiChanges(
changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string,
settingsDirectory string,
) error {
hasChanges := false
for _, change := range changes {
if change != nil {
hasChanges = true
break
}
}
if !hasChanges {
return nil
}
if agentDirectory != "" {
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
return err
}
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
return err
}
}
if err := ensureManagedDirectory(
settingsDirectory,
"Gemini CLI configuration directory",
); err != nil {
return err
}
return writeChanges(changes, label, rename)
}
Loaded 100 of 364 files, more files were not shown because too many files have changed in this diff. Show more