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:
1 parent
2010175a82
commit
bc6983a62e
364 files changed
+10180
-23349
No files matched your search
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -287,7 +289,7 @@ Register a new agent. Required role: **integration_engineer**.
|
||||
"responsible_entity": "engineering-team@example.com",
|
||||
"keys": [
|
||||
{
|
||||
"kid": "my-agent-v1-key-v1",
|
||||
"kid": "my-agent-v1-key-1",
|
||||
"public_key": "<base64url Ed25519 public key>",
|
||||
"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**.
|
||||
|
||||
**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',
|
||||
integration_type: 'sdk',
|
||||
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
|
||||
@@ -633,7 +635,7 @@ client.register_agent({
|
||||
"agent_id": "order-processor-v1",
|
||||
"integration_type": "sdk",
|
||||
"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"]),
|
||||
"algorithm": "ed25519"}],
|
||||
})
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** BullMQ queue adapter implementing the MessageQueue interface. */
|
||||
|
||||
import { Queue as BullQueue } from 'bullmq';
|
||||
import type { ConnectionOptions } from 'bullmq';
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Adapter interfaces for ElydoraOpenSource infrastructure services. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database (replaces D1Database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A prepared (and optionally bound) SQL statement.
|
||||
* Mirrors D1PreparedStatement's interface exactly.
|
||||
*/
|
||||
/** A prepared (and optionally bound) SQL statement. */
|
||||
export interface PreparedStatement {
|
||||
/**
|
||||
* Bind positional parameters (? placeholders) to the statement.
|
||||
* Returns a new statement with the values set.
|
||||
*/
|
||||
/** Bind positional parameters (? placeholders) to the statement. */
|
||||
bind(...values: unknown[]): PreparedStatement;
|
||||
|
||||
/** Execute and return the first row, or null if no rows match. */
|
||||
@@ -31,18 +19,12 @@ export interface PreparedStatement {
|
||||
run(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relational database adapter.
|
||||
* Mirrors the subset of D1Database methods used by Elydora services.
|
||||
*/
|
||||
/** Relational database adapter. */
|
||||
export interface Database {
|
||||
/** Prepare a SQL statement for execution. */
|
||||
prepare(sql: string): PreparedStatement;
|
||||
|
||||
/**
|
||||
* Execute multiple prepared statements atomically (within a transaction).
|
||||
* Mirrors D1Database.batch().
|
||||
*/
|
||||
/** Execute multiple prepared statements atomically (within a transaction). */
|
||||
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
|
||||
}
|
||||
|
||||
@@ -73,10 +55,7 @@ export interface ObjectStoreHead {
|
||||
readonly httpMetadata?: { readonly contentType?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Object/blob storage adapter.
|
||||
* Mirrors the subset of R2Bucket methods used by Elydora services.
|
||||
*/
|
||||
/** Object/blob storage adapter. */
|
||||
export interface ObjectStore {
|
||||
/** Upload an object to the store. */
|
||||
put(
|
||||
@@ -96,10 +75,7 @@ export interface ObjectStore {
|
||||
// Cache (replaces KVNamespace)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Key/value cache adapter.
|
||||
* Mirrors the subset of KVNamespace methods used by Elydora services.
|
||||
*/
|
||||
/** Key/value cache adapter. */
|
||||
export interface Cache {
|
||||
/** Retrieve a cached value, or null if not found / expired. */
|
||||
get(key: string): Promise<string | null>;
|
||||
@@ -112,10 +88,7 @@ export interface Cache {
|
||||
// Message queue (replaces Queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Message queue adapter.
|
||||
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
|
||||
*/
|
||||
/** Message queue adapter. */
|
||||
export interface MessageQueue {
|
||||
/** Enqueue a message under the caller's durable message ID. */
|
||||
send(messageId: string, body: unknown): Promise<void>;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. */
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
@@ -64,7 +58,7 @@ export class MinioAdapter implements ObjectStore {
|
||||
} else if (body instanceof Uint8Array) {
|
||||
bodyData = body;
|
||||
} else {
|
||||
// Web ReadableStream — collect into buffer
|
||||
// Web ReadableStream: collect into buffer
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
/**
|
||||
* 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().
|
||||
*/
|
||||
/** PostgreSQL adapter implementing the Database interface. */
|
||||
|
||||
import pg from 'pg';
|
||||
import type { Database, PreparedStatement } from './interfaces.js';
|
||||
@@ -25,10 +16,7 @@ types.setTypeParser(20, (val: string) => Number(val));
|
||||
// Placeholder conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert SQLite `?` positional placeholders to PostgreSQL `$N` style.
|
||||
* Only replaces `?` outside of SQL string literals (safe for all Elydora queries).
|
||||
*/
|
||||
/** Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. */
|
||||
function convertPlaceholders(sql: string): string {
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
@@ -38,10 +26,7 @@ function convertPlaceholders(sql: string): string {
|
||||
// Statement implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Internal PostgreSQL statement that implements PreparedStatement.
|
||||
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
|
||||
*/
|
||||
/** Internal PostgreSQL statement that implements PreparedStatement. */
|
||||
class PostgresStatement implements PreparedStatement {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Redis cache adapter implementing the Cache interface. */
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import type { Cache } from './interfaces.js';
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Internationalization (i18n) translation map. */
|
||||
|
||||
export type Lang = 'en' | 'zh';
|
||||
|
||||
@@ -29,6 +18,14 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
en: 'The signing key has been revoked.',
|
||||
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': {
|
||||
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',
|
||||
@@ -363,15 +360,7 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Retrieve a translated message by key. */
|
||||
export function getMessage(
|
||||
key: string,
|
||||
lang: Lang = 'en',
|
||||
@@ -386,12 +375,7 @@ export function getMessage(
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the preferred language from an Accept-Language header value.
|
||||
*
|
||||
* Returns 'zh' if the header contains any Chinese locale indicator,
|
||||
* otherwise defaults to 'en'.
|
||||
*/
|
||||
/** Detect the preferred language from an Accept-Language header value. */
|
||||
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
|
||||
if (!acceptLanguage) return 'en';
|
||||
if (acceptLanguage.includes('zh')) return 'zh';
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* Elydora API — Node.js entry point.
|
||||
*/
|
||||
/** Elydora API: Node.js entry point. */
|
||||
|
||||
import 'dotenv/config';
|
||||
import { serve } from '@hono/node-server';
|
||||
@@ -90,7 +88,7 @@ app.use('/*', async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// CORS — always restrict to the configured allowlist
|
||||
// CORS: always restrict to the configured allowlist
|
||||
app.use(
|
||||
'/*',
|
||||
cors({
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Better Auth configuration for ElydoraOpenSource. */
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
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({
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Authentication middleware for opaque API tokens and Better Auth sessions. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Global error handler middleware. */
|
||||
|
||||
import type { ErrorHandler } from 'hono';
|
||||
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 type { Lang } from '../i18n/messages.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Maps each ErrorCode to its corresponding i18n message key. */
|
||||
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||
INVALID_SIGNATURE: 'error.invalidSignature',
|
||||
UNKNOWN_AGENT: 'error.unknownAgent',
|
||||
KEY_REVOKED: 'error.keyRevoked',
|
||||
KEY_RETIRED: 'error.keyRetired',
|
||||
AGENT_FROZEN: 'error.agentFrozen',
|
||||
AGENT_REVOKED: 'error.agentRevoked',
|
||||
TTL_EXPIRED: 'error.ttlExpired',
|
||||
REPLAY_DETECTED: 'error.replayDetected',
|
||||
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
|
||||
@@ -42,16 +27,7 @@ const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||
VALIDATION_ERROR: 'error.validationError',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
/** Custom error class that carries an HTTP status and Elydora error code. */
|
||||
export class AppError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly errorCode: ErrorCode;
|
||||
@@ -68,13 +44,13 @@ export class AppError extends Error {
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
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));
|
||||
this.messageKey = messageOrOpts.key;
|
||||
this.messageParams = messageOrOpts.params;
|
||||
this.usesDefaultMessage = false;
|
||||
} 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]);
|
||||
this.usesDefaultMessage = true;
|
||||
} else {
|
||||
@@ -88,15 +64,7 @@ export class AppError extends Error {
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Resolve the user-facing message for a given language. */
|
||||
resolveMessage(lang: Lang): string {
|
||||
if (this.messageKey) {
|
||||
return getMessage(this.messageKey, lang, this.messageParams);
|
||||
@@ -111,12 +79,7 @@ export class AppError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Build a standardised error response body. */
|
||||
export function buildErrorResponse(
|
||||
code: ErrorCode,
|
||||
requestId: string,
|
||||
@@ -139,12 +102,7 @@ export function buildErrorResponse(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Hono error handler that converts all errors into the ErrorResponse format. */
|
||||
export const globalErrorHandler: ErrorHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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')`.
|
||||
*/
|
||||
/** i18n middleware: language detection from Accept-Language header. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Role-based access control (RBAC) middleware. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
|
||||
/**
|
||||
* Privilege level map. Higher number = more privilege.
|
||||
* integration_engineer is a special role with limited admin capabilities
|
||||
* for agent management only.
|
||||
*/
|
||||
/** Privilege level map. */
|
||||
const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||
readonly_investigator: 10,
|
||||
integration_engineer: 20,
|
||||
@@ -28,21 +14,12 @@ const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||
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 {
|
||||
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Create an RBAC middleware that requires a minimum role. */
|
||||
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Request ID middleware. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
|
||||
@@ -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 type { Env, AppVariables } from '../types.js';
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Audit routes: query the tamper-evident audit log. */
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
@@ -19,7 +13,7 @@ const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
audit.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/audit/query — Query the audit log
|
||||
// POST /v1/audit/query: Query the audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
audit.post(
|
||||
'/query',
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** Auth routes: registration, login, profile retrieval, and token refresh. */
|
||||
|
||||
import { Hono } from 'hono';
|
||||
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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
const betterAuthInstance = createAuth(
|
||||
@@ -154,7 +146,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
|
||||
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
|
||||
const session = await betterAuthInstance.api.getSession({
|
||||
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) => {
|
||||
if (c.get('auth_token_type') !== 'session') {
|
||||
|
||||
@@ -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 type { Env, AppVariables } from '../types.js';
|
||||
@@ -14,7 +12,7 @@ const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
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) => {
|
||||
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(
|
||||
'/:epoch_id',
|
||||
|
||||
@@ -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 type { Env, AppVariables } from '../types.js';
|
||||
@@ -16,7 +14,7 @@ const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
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) => {
|
||||
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(
|
||||
'/',
|
||||
@@ -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(
|
||||
'/: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(
|
||||
'/:export_id/download',
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** JWKS route: public key discovery endpoint. */
|
||||
|
||||
import { Hono } from 'hono';
|
||||
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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
|
||||
// GET /.well-known/elydora/jwks.json: Retrieve the platform JWKS
|
||||
// ---------------------------------------------------------------------------
|
||||
jwks.get('/', async (c) => {
|
||||
// Derive the public key from the server signing key
|
||||
|
||||
@@ -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 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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/operations — Submit a signed EOR
|
||||
// POST /v1/operations: Submit a signed EOR
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation submission uses the EOR's embedded signature for authentication
|
||||
// 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(
|
||||
'/: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(
|
||||
'/:operation_id/verify',
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
|
||||
*
|
||||
* Run as a separate Node.js process: node dist/scheduler.js
|
||||
*/
|
||||
/** Epoch scheduler: runs every 5 minutes to create Merkle tree rollups. */
|
||||
|
||||
import 'dotenv/config';
|
||||
import cron from 'node-cron';
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Agent service: business logic for agent lifecycle management. */
|
||||
|
||||
import type {
|
||||
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 type { Database } from '../adapters/interfaces.js';
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Audit service: business logic for querying the tamper-evident audit log. */
|
||||
|
||||
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
|
||||
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Auth service: password hashing and user profile retrieval. */
|
||||
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Epoch service: business logic for retrieving epoch root records. */
|
||||
|
||||
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
@@ -82,13 +75,7 @@ export async function getEpoch(
|
||||
// Create epoch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Create a new epoch by building a Merkle tree over operations in a time window, signing the Epoch Record, and p */
|
||||
export async function createEpoch(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
@@ -97,7 +84,7 @@ export async function createEpoch(
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): 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
|
||||
.prepare(
|
||||
'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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/** Export service: business logic for compliance export job management. */
|
||||
|
||||
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.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 @@
|
||||
/**
|
||||
* 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 type { Operation, Receipt, GetOperationResponse } from '../shared/index.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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 { submitOperation } from './operation-submit.js';
|
||||
export { verifyOperation } from './operation-verify.js';
|
||||
|
||||
export async function getOperation(
|
||||
db: Database,
|
||||
@@ -354,7 +35,7 @@ export async function getOperation(
|
||||
payload = (eor.payload as Record<string, unknown>) ?? undefined;
|
||||
}
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.',
|
||||
UNKNOWN_AGENT: 'The specified agent does not exist.',
|
||||
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_REVOKED: 'The agent has been revoked.',
|
||||
TTL_EXPIRED: 'The operation TTL has expired.',
|
||||
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
|
||||
PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
|
||||
|
||||
@@ -56,7 +56,9 @@ export type ErrorCode =
|
||||
| 'INVALID_SIGNATURE'
|
||||
| 'UNKNOWN_AGENT'
|
||||
| 'KEY_REVOKED'
|
||||
| 'KEY_RETIRED'
|
||||
| 'AGENT_FROZEN'
|
||||
| 'AGENT_REVOKED'
|
||||
| 'TTL_EXPIRED'
|
||||
| 'REPLAY_DETECTED'
|
||||
| 'PREV_HASH_MISMATCH'
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Elydora Operation Record (EOR) The fundamental unit of auditable activity in the Elydora protocol. */
|
||||
export interface EOR {
|
||||
/** Protocol version, always "1.0" */
|
||||
readonly op_version: '1.0';
|
||||
@@ -37,11 +32,7 @@ export interface EOR {
|
||||
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 {
|
||||
/** Previous chain hash (base64url) */
|
||||
readonly prev_ech: string;
|
||||
@@ -55,11 +46,7 @@ export interface ECH {
|
||||
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 {
|
||||
/** Receipt version */
|
||||
readonly receipt_version: string;
|
||||
@@ -87,11 +74,7 @@ export interface EAR {
|
||||
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 {
|
||||
/** Unique epoch identifier */
|
||||
readonly epoch_id: string;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Environment bindings for the ElydoraOpenSource Node.js server. */
|
||||
|
||||
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
|
||||
|
||||
@@ -46,9 +40,7 @@ export interface Env {
|
||||
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 {
|
||||
/** UUIDv7 request identifier injected by the request-id middleware */
|
||||
request_id: string;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cryptographic utilities for the Elydora API. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url helpers (RFC 4648 section 5)
|
||||
@@ -52,11 +47,7 @@ export async function sha256Base64url(data: BufferSource | string): Promise<stri
|
||||
// Ed25519 signature verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
|
||||
*
|
||||
* @param publicKeyBytes - 32-byte raw Ed25519 public key
|
||||
*/
|
||||
/** Import a raw Ed25519 public key (32 bytes) as a CryptoKey. */
|
||||
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -67,14 +58,7 @@ export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promis
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Verify an Ed25519 signature over data. */
|
||||
export async function verifyEd25519Signature(
|
||||
publicKeyBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array,
|
||||
@@ -97,15 +81,7 @@ export async function verifyEd25519Signature(
|
||||
// Ed25519 signing (server-side receipt signing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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> } }
|
||||
* }
|
||||
*/
|
||||
/** PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. */
|
||||
const ED25519_PKCS8_PREFIX = new Uint8Array([
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0
|
||||
@@ -123,14 +99,7 @@ function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
|
||||
return pkcs8;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Import a raw Ed25519 private key for signing. */
|
||||
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
@@ -144,13 +113,7 @@ export async function importEd25519PrivateKey(privateKeyBase64url: string): Prom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Sign data with an Ed25519 private key. */
|
||||
export async function signEd25519(
|
||||
privateKeyBase64url: string,
|
||||
data: Uint8Array,
|
||||
@@ -164,12 +127,7 @@ export async function signEd25519(
|
||||
return base64urlEncode(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the public key from an Ed25519 private key (for JWKS). */
|
||||
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
@@ -178,7 +136,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
|
||||
'pkcs8',
|
||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
true, // extractable — needed to export the public key
|
||||
true, // extractable: needed to export the public key
|
||||
['sign'],
|
||||
);
|
||||
// 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Canonicalize a JSON value according to JCS (RFC 8785). */
|
||||
export function jcsCanonicalise(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
@@ -237,13 +187,7 @@ export function jcsCanonicalise(value: unknown): string {
|
||||
// Chain hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 '|'.
|
||||
*/
|
||||
/** Compute the chain hash for an operation. */
|
||||
export async function computeChainHash(
|
||||
prevChainHash: string,
|
||||
payloadHash: string,
|
||||
@@ -258,11 +202,7 @@ export async function computeChainHash(
|
||||
// Receipt hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the hash of an EAR receipt for signing.
|
||||
*
|
||||
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
|
||||
*/
|
||||
/** Compute the hash of an EAR receipt for signing. */
|
||||
export async function computeReceiptHash(receiptFields: {
|
||||
receipt_version: string;
|
||||
receipt_id: string;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** SHA-256 Merkle tree implementation for epoch rollups. */
|
||||
|
||||
import { sha256Base64url, base64urlDecode } from './crypto.js';
|
||||
|
||||
@@ -38,10 +32,7 @@ export interface MerkleProof {
|
||||
// Internal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the parent hash from two child hashes.
|
||||
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
|
||||
*/
|
||||
/** Compute the parent hash from two child hashes. */
|
||||
async function hashPair(left: string, right: string): Promise<string> {
|
||||
const leftBytes = base64urlDecode(left);
|
||||
const rightBytes = base64urlDecode(right);
|
||||
@@ -55,12 +46,7 @@ async function hashPair(left: string, right: string): Promise<string> {
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Build a Merkle tree from leaf hashes and their corresponding operation IDs. */
|
||||
export async function buildMerkleTree(
|
||||
leafHashes: string[],
|
||||
operationIds: string[],
|
||||
@@ -103,11 +89,7 @@ export async function buildMerkleTree(
|
||||
// Proof generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a Merkle inclusion proof for a given leaf hash.
|
||||
*
|
||||
* Returns null if the leaf is not found in the tree.
|
||||
*/
|
||||
/** Generate a Merkle inclusion proof for a given leaf hash. */
|
||||
export function getMerkleProof(
|
||||
tree: MerkleTree,
|
||||
leafHash: string,
|
||||
@@ -142,9 +124,7 @@ export function getMerkleProof(
|
||||
// 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> {
|
||||
let current = proof.leaf;
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cursor-based pagination helpers. */
|
||||
|
||||
import { base64urlDecode, base64urlEncode } from './crypto.js';
|
||||
|
||||
@@ -17,24 +10,14 @@ export interface CursorPayload {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a cursor payload into an opaque cursor string.
|
||||
*
|
||||
* @param payload - The cursor data to encode
|
||||
* @returns An opaque base64url-encoded cursor string
|
||||
*/
|
||||
/** Encode a cursor payload into an opaque cursor string. */
|
||||
export function encodeCursor(payload: CursorPayload): string {
|
||||
const json = JSON.stringify(payload);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
return base64urlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque cursor string back into its payload.
|
||||
*
|
||||
* @param cursor - The opaque cursor string
|
||||
* @returns The decoded cursor payload, or null if invalid
|
||||
*/
|
||||
/** Decode an opaque cursor string back into its payload. */
|
||||
export function decodeCursor(cursor: string): CursorPayload | null {
|
||||
try {
|
||||
const bytes = base64urlDecode(cursor);
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Branded PDF generator for Elydora compliance exports. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -46,15 +35,15 @@ const CW = PW - ML - MR; // 500pt content width
|
||||
|
||||
// Header bar
|
||||
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
|
||||
const FTR_LINE = 44; // y of separator line
|
||||
const FTR_TEXT = 28; // y of footer text baseline
|
||||
|
||||
// Content area
|
||||
const CT = HDR_Y - 20; // 716 — content top
|
||||
const CB = FTR_LINE + 12; // 56 — content bottom
|
||||
const CT = HDR_Y - 20; // 716: content top
|
||||
const CB = FTR_LINE + 12; // 56 : content bottom
|
||||
|
||||
// Operations table
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Elydora "E" logo mark.
|
||||
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
|
||||
* Transform flips Y for PDF coordinate system.
|
||||
*/
|
||||
/** Draw the Elydora "E" logo mark. */
|
||||
function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
||||
const sc = size / 24;
|
||||
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). */
|
||||
@@ -321,7 +306,7 @@ function assemblePDF(pageStreams: string[]): Uint8Array {
|
||||
// Obj 1: Catalog
|
||||
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('');
|
||||
|
||||
// Obj 3–5: Built-in fonts
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** RFC 3161 Timestamping Authority (TSA) client utilities. */
|
||||
|
||||
/** Default public TSA endpoint (Sectigo / Comodo). */
|
||||
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* }
|
||||
*/
|
||||
/** Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). */
|
||||
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||
if (sha256Hash.length !== 32) {
|
||||
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([
|
||||
0x30, 0x39, // SEQUENCE (57 bytes)
|
||||
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
|
||||
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint
|
||||
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier
|
||||
0x30, 0x31, // SEQUENCE (49 bytes): MessageImprint
|
||||
0x30, 0x0d, // SEQUENCE (13 bytes): AlgorithmIdentifier
|
||||
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)
|
||||
0x05, 0x00, // NULL (parameters)
|
||||
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]);
|
||||
|
||||
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
|
||||
@@ -55,13 +33,7 @@ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. */
|
||||
export async function requestTimestamp(
|
||||
sha256Hash: Uint8Array,
|
||||
tsaUrl: string = DEFAULT_TSA_URL,
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
/**
|
||||
* 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+.
|
||||
*/
|
||||
/** UUIDv7 generator conforming to RFC 9562. */
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
/** Generate a UUIDv7 string. */
|
||||
export function generateUUIDv7(): string {
|
||||
const now = Date.now();
|
||||
const bytes = new Uint8Array(16);
|
||||
@@ -46,9 +28,7 @@ export function generateUUIDv7(): string {
|
||||
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 {
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* BullMQ queue worker — processes operation, export, and TSA jobs.
|
||||
*
|
||||
* Run as a separate Node.js process: node dist/worker.js
|
||||
*/
|
||||
/** BullMQ queue worker: processes operation, export, and TSA jobs. */
|
||||
|
||||
import 'dotenv/config';
|
||||
import { Worker, type Job } from 'bullmq';
|
||||
|
||||
+50
-21
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
```go
|
||||
@@ -23,15 +25,12 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize the client with your API token.
|
||||
// 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
|
||||
// Create client with an API token issued in the Console
|
||||
client, err := elydora.NewClient(&elydora.Config{
|
||||
OrgID: "org-123",
|
||||
AgentID: "my-agent-id",
|
||||
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
||||
Token: "your-api-token",
|
||||
Token: "<api-token>",
|
||||
})
|
||||
if err != nil {
|
||||
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 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 version` | Print the CLI version |
|
||||
|
||||
### Supported Agents
|
||||
|
||||
@@ -158,24 +158,24 @@ client.SetToken("new-api-token")
|
||||
|
||||
### 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
|
||||
// Sign up (Better Auth) — use the console or call directly:
|
||||
// POST /api/auth/sign-up/email { email, password, name }
|
||||
//
|
||||
// Sign in (Better Auth) — get a session token:
|
||||
// POST /api/auth/sign-in/email { email, password }
|
||||
//
|
||||
// Issue a long-lived API token from an active session:
|
||||
// Get the authenticated user and current organization
|
||||
me, err := client.GetMe()
|
||||
// me.User.Email, me.User.Role, me.CurrentOrganization.OrgID
|
||||
|
||||
// Issue an API token; this call needs a client whose Token is a Console session token
|
||||
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
||||
TTLSeconds: &ttlSeconds,
|
||||
})
|
||||
client.SetToken(tokenResp.Token)
|
||||
|
||||
// Update the token on an existing client instance at runtime
|
||||
client.SetToken("new-api-token")
|
||||
// Rotate the installed API token; the response carries the replacement
|
||||
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
|
||||
|
||||
```go
|
||||
@@ -215,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
|
||||
// Get agent details
|
||||
details, err := client.GetAgent(agentID)
|
||||
|
||||
// Change the integration type
|
||||
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
|
||||
|
||||
// Freeze an agent
|
||||
err := client.FreezeAgent(agentID, "security review")
|
||||
frozen, err := client.FreezeAgent(agentID, "security review")
|
||||
|
||||
// Revoke a key
|
||||
err := client.RevokeKey(agentID, kid, "key rotation")
|
||||
@@ -225,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
|
||||
agents, err := client.ListAgents()
|
||||
|
||||
// Unfreeze a previously frozen agent
|
||||
err := client.UnfreezeAgent(agentID, "review complete")
|
||||
unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
|
||||
|
||||
// Delete an agent permanently
|
||||
deleted, err := client.DeleteAgent(agentID)
|
||||
@@ -266,6 +269,25 @@ detail, err := client.GetExport(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
|
||||
|
||||
```go
|
||||
@@ -277,7 +299,11 @@ jwks, err := client.GetJWKS()
|
||||
```go
|
||||
// Check API health (no authentication required, does not need a client)
|
||||
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
|
||||
@@ -295,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
||||
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
||||
| `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` |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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
@@ -3,17 +3,11 @@ package elydora
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// RegisterAgent registers a new AI agent within the organization.
|
||||
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
|
||||
if err := c.doPost("/v1/agents/register", req, &result); err != nil {
|
||||
return nil, err
|
||||
@@ -24,22 +18,26 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
|
||||
// GetAgent retrieves an agent by ID.
|
||||
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
||||
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 &result, nil
|
||||
}
|
||||
|
||||
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
||||
func (c *Client) FreezeAgent(agentID, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{
|
||||
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
|
||||
var result FreezeAgentResponse
|
||||
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RevokeKey revokes a specific key for an agent.
|
||||
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,
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
@@ -55,16 +53,31 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
|
||||
}
|
||||
|
||||
// UnfreezeAgent unfreezes a previously frozen agent.
|
||||
func (c *Client) UnfreezeAgent(agentID, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{
|
||||
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
|
||||
var result UnfreezeAgentResponse
|
||||
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
|
||||
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.
|
||||
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
||||
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 &result, nil
|
||||
|
||||
+19
-95
@@ -1,51 +1,30 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
import "net/http"
|
||||
|
||||
// RegisterOption is a functional option for the Register function.
|
||||
// RegisterOption configures a registration request.
|
||||
type RegisterOption func(*AuthRegisterRequest)
|
||||
|
||||
// WithDisplayName sets the display_name on a registration request.
|
||||
func WithDisplayName(name string) RegisterOption {
|
||||
return func(req *AuthRegisterRequest) {
|
||||
req.DisplayName = name
|
||||
}
|
||||
return func(req *AuthRegisterRequest) { req.DisplayName = name }
|
||||
}
|
||||
|
||||
// WithOrgName sets the org_name on a registration request.
|
||||
func WithOrgName(name string) RegisterOption {
|
||||
return func(req *AuthRegisterRequest) {
|
||||
req.OrgName = name
|
||||
}
|
||||
return func(req *AuthRegisterRequest) { req.OrgName = name }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
req := &AuthRegisterRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
req := &AuthRegisterRequest{Email: email, Password: password}
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
|
||||
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 &result, nil
|
||||
@@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg
|
||||
|
||||
// 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) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
req := &AuthLoginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
req := &AuthLoginRequest{Email: email, Password: password}
|
||||
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 &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.
|
||||
func (c *Client) GetMe() (*GetMeResponse, error) {
|
||||
var result GetMeResponse
|
||||
@@ -144,3 +59,12 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
|
||||
}
|
||||
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
@@ -14,6 +14,8 @@ const (
|
||||
defaultBaseURL = "https://api.elydora.com"
|
||||
defaultTTLMs = 30000
|
||||
defaultMaxRetries = 3
|
||||
maxResponseBytes = 10 << 20
|
||||
requestTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Config holds the configuration for creating a new Client.
|
||||
@@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) {
|
||||
if cfg == 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
|
||||
if ttlMs <= 0 {
|
||||
ttlMs = defaultTTLMs
|
||||
}
|
||||
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = defaultMaxRetries
|
||||
}
|
||||
|
||||
return &Client{
|
||||
orgID: cfg.OrgID,
|
||||
agentID: cfg.AgentID,
|
||||
privateKey: cfg.PrivateKey,
|
||||
baseURL: baseURL,
|
||||
baseURL: normalizeBaseURL(cfg.BaseURL),
|
||||
ttlMs: ttlMs,
|
||||
maxRetries: maxRetries,
|
||||
token: cfg.Token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
httpClient: &http.Client{Timeout: requestTimeout},
|
||||
}, 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.
|
||||
func (c *Client) SetToken(token string) {
|
||||
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)
|
||||
}
|
||||
@@ -119,9 +119,6 @@ func readSecretFileWithOpen(
|
||||
if err := validateSecretFile(before, path, label); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !os.SameFile(before, before) {
|
||||
return "", fmt.Errorf("establish %s file identity at %s", label, path)
|
||||
}
|
||||
|
||||
file, err := openFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||
)
|
||||
|
||||
const version = "2.0.1"
|
||||
const version = "2.1.0"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
@@ -57,10 +57,6 @@ Run "elydora <command> -h" for details on each command.
|
||||
`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdInstall(args []string) {
|
||||
if err := rejectLegacySecretArguments(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
@@ -160,10 +156,6 @@ func cmdInstall(args []string) {
|
||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdUninstall(args []string) {
|
||||
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||
agent := fs.String("agent", "", "Agent name (required)")
|
||||
@@ -209,10 +201,6 @@ func cmdUninstall(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdStatus(args []string) {
|
||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdAgents() {
|
||||
fmt.Println("Supported agents:")
|
||||
fmt.Println()
|
||||
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
|
||||
sort.Strings(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
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||
)
|
||||
|
||||
func setMainTestHome(t *testing.T) string {
|
||||
@@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string {
|
||||
|
||||
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
|
||||
setMainTestHome(t)
|
||||
_, err := guardScriptPathForAgent("../escape")
|
||||
_, err := plugins.ResolveAgentRuntimeDirectory("../escape")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
|
||||
t.Fatalf("guardScriptPathForAgent() error = %v", err)
|
||||
t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ type AugmentPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Auggie commits its provider guard together
|
||||
// with both wrappers, the audit runtime, and user settings.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
@@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAugmentChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Augment Code CLI hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.configPath),
|
||||
"Auggie configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAugmentChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Augment Code CLI hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.configPath),
|
||||
"Auggie configuration directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
augmentAgentKey = "augment"
|
||||
augmentGuardScript = "guard.js"
|
||||
augmentAuditScript = "hook.js"
|
||||
augmentHookTimeout = float64(10_000)
|
||||
augmentPOSIXApostrophe = `'"'"'`
|
||||
augmentAgentKey = "augment"
|
||||
augmentGuardScript = "guard.js"
|
||||
augmentAuditScript = "hook.js"
|
||||
augmentHookTimeout = float64(10_000)
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -68,14 +67,6 @@ type augmentRuntimeContract struct {
|
||||
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(
|
||||
value any,
|
||||
event string,
|
||||
@@ -114,7 +105,7 @@ func validateAugmentHandler(
|
||||
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 {
|
||||
@@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup
|
||||
}
|
||||
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) {
|
||||
@@ -248,7 +239,7 @@ func renderAugmentDocument(
|
||||
if !document.exists && len(hooks) == 0 {
|
||||
return &augmentRenderedDocument{document: document}, nil
|
||||
}
|
||||
root := cloneAugmentObject(document.root)
|
||||
root := cloneJSONObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
@@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any {
|
||||
for event, groups := range hooks {
|
||||
values := make([]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object := cloneAugmentObject(group.object)
|
||||
object := cloneJSONObject(group.object)
|
||||
handlers := make([]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
handlers = append(handlers, handler)
|
||||
@@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) {
|
||||
}
|
||||
|
||||
func readAugmentPOSIXArgument(command string) (string, bool) {
|
||||
if len(command) < 2 || command[0] != '\'' {
|
||||
return "", false
|
||||
}
|
||||
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
|
||||
value, end, ok := readPOSIXArgument(command, 0)
|
||||
return value, ok && end == len(command) && value != ""
|
||||
}
|
||||
|
||||
func parseAugmentCommand(command string) (string, bool) {
|
||||
@@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) {
|
||||
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(
|
||||
handler map[string]any,
|
||||
wrapperName, runtimeRoot string,
|
||||
@@ -385,11 +331,11 @@ func managedAugmentAgentID(
|
||||
return "", false
|
||||
}
|
||||
wrapperPath, ok := parseAugmentCommand(command)
|
||||
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) {
|
||||
if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(wrapperPath)
|
||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
@@ -407,7 +353,7 @@ func removeManagedAugmentGroups(
|
||||
groupChanged := false
|
||||
for _, handler := range group.handlers {
|
||||
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID))
|
||||
remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
|
||||
if remove {
|
||||
changed = true
|
||||
groupChanged = true
|
||||
|
||||
@@ -1,96 +1,42 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type augmentRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
managedRuntimePaths
|
||||
guardWrapperPath string
|
||||
auditWrapperPath string
|
||||
}
|
||||
|
||||
func validateAugmentInstallConfig(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)
|
||||
}
|
||||
func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
|
||||
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
||||
return []managedArtifact{
|
||||
{wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
|
||||
{wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
|
||||
}
|
||||
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) {
|
||||
if err := validateAugmentInstallConfig(config); err != nil {
|
||||
if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
auditWrapperPath: wrappers.audit,
|
||||
}
|
||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||
!sameAugmentPath(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) ||
|
||||
!sameAugmentPath(config.HookScript, paths.auditPath)) {
|
||||
return nil, fmt.Errorf(
|
||||
"Elydora audit runtime must use the managed agent directory: %s",
|
||||
paths.auditPath,
|
||||
)
|
||||
}
|
||||
return paths, nil
|
||||
wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
|
||||
return &augmentRuntimePaths{
|
||||
managedRuntimePaths: *paths,
|
||||
guardWrapperPath: wrappers.guard,
|
||||
auditWrapperPath: wrappers.audit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
|
||||
return validateManagedRuntimeIdentity(
|
||||
agentDirectory, agentID, augmentAgentKey, "Auggie",
|
||||
augmentWrapperArtifacts(agentDirectory)...,
|
||||
)
|
||||
}
|
||||
|
||||
func preflightAugmentInstallation(
|
||||
@@ -98,55 +44,25 @@ func preflightAugmentInstallation(
|
||||
document *augmentDocument,
|
||||
) (*augmentRuntimePaths, string, error) {
|
||||
if document == nil || document.configPath == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"Augment Code CLI installation requires a user settings document",
|
||||
)
|
||||
return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
|
||||
}
|
||||
paths, err := augmentAgentPaths(config)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateAugmentRuntimeIdentity(
|
||||
paths.agentDirectory,
|
||||
config.AgentID,
|
||||
); err != nil {
|
||||
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
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(
|
||||
config InstallConfig,
|
||||
paths *augmentRuntimePaths,
|
||||
@@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges(
|
||||
if paths == nil || rendered == nil {
|
||||
return nil, fmt.Errorf("prepared Auggie installation is required")
|
||||
}
|
||||
if err := validateAugmentRuntimeIdentity(
|
||||
paths.agentDirectory,
|
||||
config.AgentID,
|
||||
); err != nil {
|
||||
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
items := []struct {
|
||||
for _, item := range []struct {
|
||||
path, label string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{
|
||||
paths.guardPath,
|
||||
"Elydora guard runtime",
|
||||
[]byte(generateGuardScript(
|
||||
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)
|
||||
{paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
|
||||
{paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
|
||||
} {
|
||||
change, err := prepareFileChange(item.path, item.label, item.content, 0700)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
events := make([]string, 0, len(hooks))
|
||||
for event := range hooks {
|
||||
@@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
|
||||
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
|
||||
}
|
||||
@@ -2,11 +2,8 @@ package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func managedAugmentIDs(
|
||||
@@ -17,19 +14,10 @@ func managedAugmentIDs(
|
||||
result := map[string]string{}
|
||||
for _, group := range groups {
|
||||
for _, handler := range group.handlers {
|
||||
agentID, managed := managedAugmentAgentID(
|
||||
handler,
|
||||
wrapperName,
|
||||
runtimeRoot,
|
||||
)
|
||||
if !managed {
|
||||
continue
|
||||
agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
if managed {
|
||||
result[managedReferenceKey(agentID)] = agentID
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
result[key] = agentID
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -39,16 +27,8 @@ func augmentRuntimeContracts(
|
||||
hooks augmentHooks,
|
||||
runtimeRoot string,
|
||||
) []augmentRuntimeContract {
|
||||
guards := managedAugmentIDs(
|
||||
hooks["PreToolUse"],
|
||||
augmentGuardWrapperName(),
|
||||
runtimeRoot,
|
||||
)
|
||||
audits := managedAugmentIDs(
|
||||
hooks["PostToolUse"],
|
||||
augmentAuditWrapperName(),
|
||||
runtimeRoot,
|
||||
)
|
||||
guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
|
||||
audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
if _, exists := audits[key]; exists {
|
||||
@@ -72,282 +52,47 @@ func augmentRuntimeContracts(
|
||||
return contracts
|
||||
}
|
||||
|
||||
func readAugmentRuntimeConfig(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
|
||||
func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
|
||||
wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
|
||||
if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
|
||||
!sameManagedPath(contract.auditWrapper, wrappers.audit) {
|
||||
return false, nil
|
||||
}
|
||||
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 {
|
||||
path string
|
||||
label string
|
||||
limit int64
|
||||
path, label string
|
||||
expected []byte
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||
{filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||
{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},
|
||||
{contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
|
||||
{contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
|
||||
} {
|
||||
exists, inspectErr := managedPhysicalFileExists(
|
||||
item.path,
|
||||
item.label,
|
||||
item.limit,
|
||||
)
|
||||
if inspectErr != nil {
|
||||
return inspectErr
|
||||
snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
|
||||
if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
|
||||
return false, err
|
||||
}
|
||||
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
|
||||
}
|
||||
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 := 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
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func augmentRuntimeFilesExist(
|
||||
contracts []augmentRuntimeContract,
|
||||
runtimeRoot string,
|
||||
) (bool, error) {
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, contract := range contracts {
|
||||
exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}
|
||||
exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil)
|
||||
if err != nil || !exists {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
matches, err := augmentWrappersMatch(contract, nodePath)
|
||||
if err != nil || matches {
|
||||
return matches, err
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
|
||||
)
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameAugmentPath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") &&
|
||||
!failed {
|
||||
failed = true
|
||||
@@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||
t.Fatalf("write concurrent settings: %v", err)
|
||||
}
|
||||
err := writeAugmentChanges(
|
||||
err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Augment Code CLI hooks",
|
||||
nil,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.configPath),
|
||||
"Auggie configuration directory",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("prepared install error = %v", err)
|
||||
@@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
||||
t.Fatalf("read installed settings: %v", err)
|
||||
}
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameAugmentPath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
return errors.New("injected Auggie uninstall failure")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ type ClaudeCodePlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Claude Code commits its provider guard with
|
||||
// the audit runtime and user settings document.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
@@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeClaudeChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Claude Code hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Claude Code configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaudeChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Claude Code hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.filePath),
|
||||
"Claude Code configuration directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,19 +77,7 @@ type claudeRenderedDocument struct {
|
||||
remove bool
|
||||
}
|
||||
|
||||
type claudeRuntimeContract struct {
|
||||
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
|
||||
}
|
||||
type claudeRuntimeContract = managedRuntimeContract
|
||||
|
||||
func cloneClaudeHooks(source claudeHooks) claudeHooks {
|
||||
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) {
|
||||
@@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup,
|
||||
}
|
||||
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) {
|
||||
@@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any {
|
||||
for event, groups := range hooks {
|
||||
values := make([]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object := cloneClaudeObject(group.object)
|
||||
object := cloneJSONObject(group.object)
|
||||
handlers := make([]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
handlers = append(handlers, handler)
|
||||
@@ -439,7 +427,7 @@ func renderClaudeDocument(
|
||||
document: document, changed: true, remove: true,
|
||||
}, nil
|
||||
}
|
||||
root := cloneClaudeObject(document.root)
|
||||
root := cloneJSONObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
|
||||
@@ -1,182 +1,48 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
import "fmt"
|
||||
|
||||
func preflightClaudeInstallation(
|
||||
config InstallConfig,
|
||||
document *claudeDocument,
|
||||
) (*claudeRuntimePaths, string, error) {
|
||||
) (*managedRuntimePaths, string, error) {
|
||||
if document == nil || document.filePath == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"Claude Code installation requires a user settings document",
|
||||
)
|
||||
return nil, "", fmt.Errorf("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 {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := requireClaudeAbsoluteNode(nodePath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
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(
|
||||
config InstallConfig,
|
||||
paths *claudeRuntimePaths,
|
||||
paths *managedRuntimePaths,
|
||||
rendered *claudeRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
runtimeConfig, err := buildClaudeRuntimeConfig(config)
|
||||
changes, err := managedRuntimeFileChanges(
|
||||
config,
|
||||
paths,
|
||||
claudeAgentKey,
|
||||
generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
|
||||
buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
documentChange, err := prepareRenderedClaudeChange(rendered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -71,38 +71,3 @@ func prepareRenderedClaudeChange(
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type claudeRuntimeReference struct {
|
||||
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
|
||||
}
|
||||
type claudeRuntimeReference = managedScriptReference
|
||||
|
||||
func claudeRuntimeReferenceForScript(
|
||||
scriptPath string,
|
||||
scriptName string,
|
||||
) (*claudeRuntimeReference, error) {
|
||||
if !filepath.IsAbs(scriptPath) ||
|
||||
!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
|
||||
return resolveManagedScript(scriptPath, scriptName)
|
||||
}
|
||||
|
||||
func claudeLegacyReference(
|
||||
@@ -95,7 +40,7 @@ func managedClaudeReference(
|
||||
handler["statusMessage"] == statusMessage {
|
||||
nodePath, nodeOK := handler["command"].(string)
|
||||
args, argsOK := handler["args"].([]any)
|
||||
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) &&
|
||||
if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
|
||||
argsOK && len(args) == 1 {
|
||||
if scriptPath, ok := args[0].(string); ok {
|
||||
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
||||
@@ -146,7 +91,7 @@ func removeManagedClaudeGroups(
|
||||
return nil, false, err
|
||||
}
|
||||
owned := reference != nil &&
|
||||
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID))
|
||||
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||
if owned {
|
||||
removed = true
|
||||
continue
|
||||
@@ -154,7 +99,7 @@ func removeManagedClaudeGroups(
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
||||
object := cloneClaudeObject(group.object)
|
||||
object := cloneJSONObject(group.object)
|
||||
object["hooks"] = group.object["hooks"]
|
||||
result = append(result, claudeGroup{object: object, handlers: kept})
|
||||
}
|
||||
@@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool {
|
||||
return handlerCount > 0
|
||||
}
|
||||
|
||||
func claudeReferenceKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
return agentID
|
||||
}
|
||||
|
||||
func claudeReferencesForEvent(
|
||||
groups []claudeGroup,
|
||||
scriptName string,
|
||||
@@ -263,7 +201,7 @@ func claudeReferencesForEvent(
|
||||
return nil, err
|
||||
}
|
||||
if reference != nil {
|
||||
key := claudeReferenceKey(reference.agentID)
|
||||
key := managedReferenceKey(reference.agentID)
|
||||
result[key] = append(result[key], *reference)
|
||||
}
|
||||
}
|
||||
@@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
||||
success := successes[key]
|
||||
failure := failures[key]
|
||||
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
|
||||
}
|
||||
contracts = append(contracts, claudeRuntimeContract{
|
||||
@@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
||||
}
|
||||
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"},
|
||||
{"guard path", func(f *claudeFixture) {
|
||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||
}, "managed Elydora agent directory"},
|
||||
}, "managed agent directory"},
|
||||
{"audit path", func(f *claudeFixture) {
|
||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||
}, "managed Elydora agent directory"},
|
||||
}, "managed agent directory"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,231 +1,5 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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
|
||||
func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||
return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
|
||||
}
|
||||
before := snapshotClaudeFiles(t, paths...)
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameClaudePath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
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 {
|
||||
t.Fatalf("write concurrent settings: %v", err)
|
||||
}
|
||||
err = writeClaudeChanges(
|
||||
err = writeManagedChanges(
|
||||
changes,
|
||||
"Install Claude Code hooks",
|
||||
nil,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Claude Code configuration directory",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("prepared install error = %v", err)
|
||||
@@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
||||
t.Fatalf("read installed settings: %v", err)
|
||||
}
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameClaudePath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
return errors.New("injected Claude uninstall failure")
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ type ClinePlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Cline commits both generated runtimes and
|
||||
// both native hook files in one transaction.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) {
|
||||
}, 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) {
|
||||
metadata := clineHookMetadata{
|
||||
Version: clineMetadataVersion,
|
||||
@@ -314,7 +282,7 @@ func clineContractForFiles(
|
||||
if guard.Kind != "guard" || audit.Kind != "audit" {
|
||||
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")
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
||||
@@ -323,15 +291,8 @@ func clineContractForFiles(
|
||||
}
|
||||
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
||||
if err != nil {
|
||||
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 {
|
||||
if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
|
||||
!sameManagedPath(audit.RuntimePath, expectedAudit) {
|
||||
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
||||
}
|
||||
return &clineRuntimeContract{
|
||||
|
||||
@@ -1,97 +1,21 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type clineRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
hooks clineHookPaths
|
||||
managedRuntimePaths
|
||||
hooks clineHookPaths
|
||||
}
|
||||
|
||||
func validateClineInstallConfig(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 != 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 {
|
||||
func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
|
||||
if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
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
|
||||
return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
|
||||
}
|
||||
|
||||
func preflightClineInstallation(
|
||||
@@ -102,39 +26,20 @@ func preflightClineInstallation(
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func buildClineRuntimeConfig(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": 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 clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||
return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
|
||||
[]byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
|
||||
}
|
||||
|
||||
func prepareClineInstallationChanges(
|
||||
@@ -146,11 +51,9 @@ func prepareClineInstallationChanges(
|
||||
if paths == nil {
|
||||
return nil, fmt.Errorf("prepared Cline installation is required")
|
||||
}
|
||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeConfig, err := buildClineRuntimeConfig(config)
|
||||
if err != nil {
|
||||
if err := validateManagedRuntimeIdentity(
|
||||
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
||||
@@ -175,23 +78,12 @@ func prepareClineInstallationChanges(
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeItems := []struct {
|
||||
path, label string
|
||||
content []byte
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
|
||||
changes, err := managedRuntimeFileChanges(
|
||||
config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range []struct {
|
||||
file clineHookFile
|
||||
|
||||
@@ -74,7 +74,7 @@ func writeClineHookChanges(
|
||||
rename renameFunc,
|
||||
paths clineHookPaths,
|
||||
) error {
|
||||
if !hasClineChanges(changes) {
|
||||
if !hasFileChanges(changes) {
|
||||
return nil
|
||||
}
|
||||
if err := ensureManagedDirectory(
|
||||
@@ -95,7 +95,7 @@ func writeClineChanges(
|
||||
rename renameFunc,
|
||||
paths *clineRuntimePaths,
|
||||
) error {
|
||||
if !hasClineChanges(changes) {
|
||||
if !hasFileChanges(changes) {
|
||||
return nil
|
||||
}
|
||||
if err := EnsurePrivateDirectory(paths.runtimeRoot); err != nil {
|
||||
@@ -107,15 +107,6 @@ func writeClineChanges(
|
||||
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(
|
||||
files []clineHookFile,
|
||||
agentID string,
|
||||
@@ -123,7 +114,7 @@ func prepareClineUninstallChanges(
|
||||
changes := make([]*fileChange, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file.metadata == nil ||
|
||||
(agentID != "" && !sameClineAgentID(file.metadata.AgentID, agentID)) {
|
||||
(agentID != "" && !sameManagedAgentID(file.metadata.AgentID, agentID)) {
|
||||
continue
|
||||
}
|
||||
if err := assertClineWrapperIntegrity(file); err != nil {
|
||||
|
||||
@@ -1,206 +1,15 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
import "path/filepath"
|
||||
|
||||
func clineRuntimeFilesExist(contract *clineRuntimeContract) (bool, error) {
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
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)) {
|
||||
if !sameManagedPath(contract.guardPath, filepath.Join(contract.agentDirectory, clineGuardScript)) {
|
||||
return false, nil
|
||||
}
|
||||
rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory")
|
||||
if err != nil || !rootExists {
|
||||
return false, err
|
||||
}
|
||||
directoryExists, err := managedPhysicalDirectoryExists(
|
||||
contract.agentDirectory,
|
||||
"Elydora agent runtime directory",
|
||||
return managedRuntimeContractExists(
|
||||
managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath},
|
||||
clineAgentKey,
|
||||
"Cline",
|
||||
clineExpectedScripts,
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -10,14 +10,12 @@ type CodexPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Codex commits its provider guard with the
|
||||
// audit runtime and user hook document.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *CodexPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// PreflightInstall validates every existing source before the CLI creates
|
||||
// runtime state.
|
||||
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||
func (p *CodexPlugin) PreflightInstall(config InstallConfig) error {
|
||||
document, err := readCodexDocument()
|
||||
if err != nil {
|
||||
@@ -61,13 +59,14 @@ func (p *CodexPlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCodexChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Codex hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Codex hooks directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,13 +89,14 @@ func (p *CodexPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCodexChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Codex hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.filePath),
|
||||
"Codex hooks directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +1,12 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
import "path/filepath"
|
||||
|
||||
func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "command",
|
||||
"command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath),
|
||||
"commandWindows": codexWindowsCommand(runtimePath, scriptPath),
|
||||
"command": posixSource(runtimePath, scriptPath),
|
||||
"commandWindows": encodedWindowsCommand(runtimePath, scriptPath),
|
||||
"timeout": codexHookTimeout,
|
||||
"statusMessage": statusMessage,
|
||||
}
|
||||
@@ -57,124 +16,6 @@ func codexMatcherGroup(handler map[string]any) map[string]any {
|
||||
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 {
|
||||
if len(handler) != 5 {
|
||||
return false
|
||||
@@ -192,20 +33,21 @@ func codexManagedScriptPath(handler map[string]any, status string) (string, bool
|
||||
handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status {
|
||||
return "", false
|
||||
}
|
||||
posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"])
|
||||
windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"])
|
||||
command, _ := handler["command"].(string)
|
||||
commandWindows, _ := handler["commandWindows"].(string)
|
||||
posixRuntime, posixScript, posixOK := parsePOSIXCommand(command)
|
||||
windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows)
|
||||
if !windowsOK && posixOK {
|
||||
legacy, ok := handler["commandWindows"].(string)
|
||||
expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript)
|
||||
if ok && legacy == expected {
|
||||
if commandWindows == expected {
|
||||
windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true
|
||||
}
|
||||
}
|
||||
if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) ||
|
||||
!filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) ||
|
||||
!isCodexNodeExecutable(windowsRuntime) ||
|
||||
!sameCodexPath(posixRuntime, windowsRuntime) ||
|
||||
!sameCodexPath(posixScript, windowsScript) {
|
||||
!filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) ||
|
||||
!isNodeExecutable(windowsRuntime) ||
|
||||
!sameManagedPath(posixRuntime, windowsRuntime) ||
|
||||
!sameManagedPath(posixScript, windowsScript) {
|
||||
return "", false
|
||||
}
|
||||
return posixScript, true
|
||||
@@ -217,14 +59,12 @@ func codexManagedAgentID(
|
||||
status string,
|
||||
) (string, bool) {
|
||||
scriptPath, managed := codexManagedScriptPath(handler, status)
|
||||
if !managed || filepath.Base(scriptPath) != scriptName {
|
||||
if !managed {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
reference, err := resolveManagedScript(scriptPath, scriptName)
|
||||
if err != nil || reference == nil {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
||||
return reference.agentID, true
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,7 +18,6 @@ const (
|
||||
codexOwnedDescription = "Elydora audit and freeze enforcement"
|
||||
codexGuardStatusMessage = "Checking Elydora agent state"
|
||||
codexAuditStatusMessage = "Recording Elydora tool use"
|
||||
codexPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
type codexHooks map[string][]map[string]any
|
||||
@@ -40,19 +37,7 @@ type codexRenderedDocument struct {
|
||||
remove bool
|
||||
}
|
||||
|
||||
type codexRuntimeContract struct {
|
||||
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
|
||||
}
|
||||
type codexRuntimeContract = managedRuntimeContract
|
||||
|
||||
func cloneCodexHooks(source codexHooks) codexHooks {
|
||||
clone := make(codexHooks, len(source))
|
||||
@@ -62,28 +47,6 @@ func cloneCodexHooks(source codexHooks) codexHooks {
|
||||
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) {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
@@ -126,16 +89,6 @@ func readCodexHooks(value any, label string) (codexHooks, error) {
|
||||
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) {
|
||||
label := fmt.Sprintf("Codex user hooks at %s", filePath)
|
||||
root, err := decodeStrictJSONObject(raw, label)
|
||||
@@ -180,13 +133,13 @@ func removeCodexGroups(
|
||||
for _, value := range handlers {
|
||||
handler := value.(map[string]any)
|
||||
managedID, managed := codexManagedAgentID(handler, scriptName, status)
|
||||
if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) {
|
||||
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
if len(kept) > 0 || !exactCodexMatcherGroup(group) {
|
||||
next := cloneCodexObject(group)
|
||||
next := cloneJSONObject(group)
|
||||
next["hooks"] = kept
|
||||
result = append(result, next)
|
||||
}
|
||||
@@ -262,7 +215,7 @@ func renderCodexDocument(
|
||||
if len(hooks) == 0 && entirelyManagedCodexDocument(document) {
|
||||
return &codexRenderedDocument{document: document, changed: true, remove: true}, nil
|
||||
}
|
||||
root := cloneCodexObject(document.root)
|
||||
root := cloneJSONObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
@@ -295,11 +248,7 @@ func managedCodexIDs(
|
||||
if !managed {
|
||||
continue
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(agentID)
|
||||
}
|
||||
result[key] = agentID
|
||||
result[managedReferenceKey(agentID)] = agentID
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -1,94 +1,26 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
import "path/filepath"
|
||||
|
||||
func preflightCodexInstallation(
|
||||
config InstallConfig,
|
||||
hooksPath string,
|
||||
) (*codexRuntimePaths, string, error) {
|
||||
paths, err := codexAgentPaths(config)
|
||||
) (*managedRuntimePaths, string, error) {
|
||||
if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := managedPhysicalDirectoryExists(
|
||||
filepath.Dir(hooksPath),
|
||||
"Codex hooks directory",
|
||||
filepath.Dir(hooksPath), "Codex hooks directory",
|
||||
); err != nil {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
@@ -98,64 +30,21 @@ func preflightCodexInstallation(
|
||||
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(
|
||||
config InstallConfig,
|
||||
paths *codexRuntimePaths,
|
||||
paths *managedRuntimePaths,
|
||||
rendered *codexRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
runtimeConfig, err := buildCodexRuntimeConfig(config)
|
||||
changes, err := managedRuntimeFileChanges(
|
||||
config,
|
||||
paths,
|
||||
codexAgentKey,
|
||||
generateGuardScript(codexAgentKey, config.AgentID, "", false, ""),
|
||||
buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []struct {
|
||||
path, label string
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
documentChange, err := prepareRenderedCodexChange(rendered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -82,152 +82,6 @@ func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, e
|
||||
)
|
||||
}
|
||||
|
||||
func writeCodexChanges(
|
||||
changes []*fileChange,
|
||||
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
|
||||
func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||
return managedRuntimeFilesPresent(contracts, codexAgentKey)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) {
|
||||
source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`)
|
||||
fixture := prepareCodexFixture(t, string(source))
|
||||
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 os.Rename(source, destination)
|
||||
|
||||
@@ -355,10 +355,10 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) {
|
||||
if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
||||
t.Fatalf("unexpected remaining hooks: %#v", hooks)
|
||||
}
|
||||
_, remainingScript, parsed := parseCodexPOSIXCommand(
|
||||
requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"],
|
||||
_, remainingScript, parsed := parsePOSIXCommand(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,12 @@ type CopilotPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Copilot commits its provider guard with the
|
||||
// audit runtime and hook documents.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *CopilotPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// PreflightInstall validates hook sources, settings, matchers, credentials,
|
||||
// and runtime identity before any write.
|
||||
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||
func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
|
||||
sources, _, err := readCopilotSources()
|
||||
if err != nil {
|
||||
@@ -165,7 +163,7 @@ func mergedCopilotContracts(
|
||||
}
|
||||
unique := map[string]copilotRuntimeContract{}
|
||||
for _, contract := range contracts {
|
||||
unique[copilotEntryKey(contract.agentID)] = contract
|
||||
unique[managedReferenceKey(contract.agentID)] = contract
|
||||
}
|
||||
result := make([]copilotRuntimeContract, 0, len(unique))
|
||||
for _, contract := range unique {
|
||||
|
||||
@@ -5,19 +5,17 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
copilotAgentKey = "copilot"
|
||||
copilotGuardScript = "guard.js"
|
||||
copilotAuditScript = "hook.js"
|
||||
copilotConfigFile = "elydora-audit.json"
|
||||
copilotHookTimeout = float64(10)
|
||||
copilotLegacyTimeout = float64(5)
|
||||
copilotPOSIXApostrophe = `'"'"'`
|
||||
copilotAgentKey = "copilot"
|
||||
copilotGuardScript = "guard.js"
|
||||
copilotAuditScript = "hook.js"
|
||||
copilotConfigFile = "elydora-audit.json"
|
||||
copilotHookTimeout = float64(10)
|
||||
copilotLegacyTimeout = float64(5)
|
||||
)
|
||||
|
||||
var copilotManagedEvents = []struct {
|
||||
@@ -61,23 +59,19 @@ type copilotRenderedDocument struct {
|
||||
remove bool
|
||||
}
|
||||
|
||||
type copilotRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
type copilotRuntimeContract = managedRuntimeContract
|
||||
|
||||
type copilotManagedEntry struct {
|
||||
agentID string
|
||||
scriptPath string
|
||||
}
|
||||
type copilotManagedEntry = managedScriptReference
|
||||
|
||||
func cloneCopilotObject(value map[string]any) map[string]any {
|
||||
clone := make(map[string]any, len(value))
|
||||
for key, item := range value {
|
||||
clone[key] = item
|
||||
func copilotManagedEntryForHandler(
|
||||
handler map[string]any,
|
||||
scriptName string,
|
||||
) (*copilotManagedEntry, error) {
|
||||
scriptPath, managed := copilotManagedScriptPath(handler)
|
||||
if !managed {
|
||||
return nil, nil
|
||||
}
|
||||
return clone
|
||||
return resolveManagedScript(scriptPath, scriptName)
|
||||
}
|
||||
|
||||
func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
||||
@@ -90,109 +84,15 @@ func cloneCopilotHooks(source copilotHooks) copilotHooks {
|
||||
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 {
|
||||
return map[string]any{
|
||||
"type": "command",
|
||||
"bash": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath),
|
||||
"powershell": "& " + quoteCopilotPowerShell(nodePath) + " " + quoteCopilotPowerShell(scriptPath) + "; exit $LASTEXITCODE",
|
||||
"bash": posixSource(nodePath, scriptPath),
|
||||
"powershell": powerShellSource(nodePath, scriptPath),
|
||||
"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) {
|
||||
command = strings.TrimSpace(command)
|
||||
separator := strings.IndexAny(command, " \t")
|
||||
@@ -211,11 +111,6 @@ func parseCopilotLegacyCommand(command string) (string, bool) {
|
||||
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) {
|
||||
if len(handler) != 4 || handler["type"] != "command" {
|
||||
return "", false
|
||||
@@ -226,11 +121,11 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
if handler["timeoutSec"] == copilotHookTimeout {
|
||||
bashNode, bashScript, bashParsed := parseCopilotBash(bash)
|
||||
psNode, psScript, psParsed := parseCopilotPowerShell(powershell)
|
||||
bashNode, bashScript, bashParsed := parsePOSIXCommand(bash)
|
||||
psNode, psScript, psParsed := parsePowerShellSource(powershell)
|
||||
if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) &&
|
||||
isCopilotNodeExecutable(bashNode) && sameCopilotPath(bashNode, psNode) &&
|
||||
sameCopilotPath(bashScript, psScript) {
|
||||
isNodeExecutable(bashNode) && sameManagedPath(bashNode, psNode) &&
|
||||
sameManagedPath(bashScript, psScript) {
|
||||
return bashScript, true
|
||||
}
|
||||
}
|
||||
@@ -240,30 +135,6 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
|
||||
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(
|
||||
hooks copilotHooks,
|
||||
agentID string,
|
||||
@@ -277,7 +148,7 @@ func removeManagedCopilotHooks(
|
||||
return nil, err
|
||||
}
|
||||
remove := entry != nil &&
|
||||
(agentID == "" || sameCopilotAgentID(entry.agentID, agentID))
|
||||
(agentID == "" || sameManagedAgentID(entry.agentID, agentID))
|
||||
if !remove {
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
@@ -361,7 +232,7 @@ func renderCopilotDocument(
|
||||
document: document, changed: true, remove: true,
|
||||
}, nil
|
||||
}
|
||||
root := cloneCopilotObject(document.root)
|
||||
root := cloneJSONObject(document.root)
|
||||
root["version"] = float64(1)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
@@ -387,13 +258,6 @@ func renderCopilotDocument(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func copilotEntryKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
return agentID
|
||||
}
|
||||
|
||||
func copilotManagedEntries(
|
||||
handlers []map[string]any,
|
||||
scriptName string,
|
||||
@@ -405,7 +269,7 @@ func copilotManagedEntries(
|
||||
return nil, err
|
||||
}
|
||||
if entry != nil {
|
||||
key := copilotEntryKey(entry.agentID)
|
||||
key := managedReferenceKey(entry.agentID)
|
||||
result[key] = append(result[key], *entry)
|
||||
}
|
||||
}
|
||||
@@ -438,7 +302,7 @@ func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, erro
|
||||
success := successes[key]
|
||||
failure := failures[key]
|
||||
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
|
||||
}
|
||||
contracts = append(contracts, copilotRuntimeContract{
|
||||
|
||||
@@ -1,103 +1,19 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type copilotRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
type preparedCopilotInstallation struct {
|
||||
changes []*fileChange
|
||||
preconditions []filePrecondition
|
||||
paths *copilotRuntimePaths
|
||||
}
|
||||
|
||||
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
|
||||
paths *managedRuntimePaths
|
||||
}
|
||||
|
||||
func preflightCopilotInstallation(
|
||||
config InstallConfig,
|
||||
sources *copilotSources,
|
||||
) (*copilotRuntimePaths, string, error) {
|
||||
) (*managedRuntimePaths, string, error) {
|
||||
if sources == nil || sources.user == nil || sources.user.filePath == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"GitHub Copilot CLI installation requires a user hook path",
|
||||
@@ -106,22 +22,22 @@ func preflightCopilotInstallation(
|
||||
if err := requireCopilotHooksEnabled(sources); err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("GitHub Copilot CLI")
|
||||
if err != nil {
|
||||
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}
|
||||
if sources.legacy != nil {
|
||||
hookSources = append(hookSources, sources.legacy.hooks)
|
||||
@@ -132,28 +48,6 @@ func preflightCopilotInstallation(
|
||||
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(
|
||||
sources *copilotSources,
|
||||
rendered []*copilotRenderedDocument,
|
||||
@@ -169,7 +63,7 @@ func validateCopilotRenderedSet(
|
||||
matches := 0
|
||||
for _, item := range rendered {
|
||||
if item != nil && item.document != nil &&
|
||||
sameCopilotPath(item.document.filePath, document.filePath) {
|
||||
sameManagedPath(item.document.filePath, document.filePath) {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
@@ -218,34 +112,13 @@ func prepareCopilotInstallation(
|
||||
if err := validateCopilotRenderedSet(sources, rendered); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
change, changeErr := prepareRenderedCopilotChange(document)
|
||||
if changeErr != nil {
|
||||
@@ -275,3 +148,8 @@ func commitCopilotInstallation(
|
||||
prepared.preconditions,
|
||||
)
|
||||
}
|
||||
|
||||
func copilotExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||
return []byte(generateGuardScript(copilotAgentKey, agentID, "", false, "")),
|
||||
[]byte(buildHookScriptWithOutput(copilotAgentKey, agentID, "", false, true))
|
||||
}
|
||||
@@ -240,14 +240,7 @@ func writeCopilotChanges(
|
||||
agentDirectory string,
|
||||
preconditions []filePrecondition,
|
||||
) error {
|
||||
hasChanges := false
|
||||
for _, change := range changes {
|
||||
if change != nil {
|
||||
hasChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasChanges {
|
||||
if !hasFileChanges(changes) {
|
||||
return writeChanges(changes, label, rename, preconditions...)
|
||||
}
|
||||
if agentDirectory != "" {
|
||||
|
||||
@@ -274,7 +274,7 @@ func validateCopilotHooks(value any, label string) (copilotHooks, error) {
|
||||
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handlers = append(handlers, cloneCopilotObject(handler))
|
||||
handlers = append(handlers, cloneJSONObject(handler))
|
||||
}
|
||||
hooks[event] = handlers
|
||||
}
|
||||
|
||||
@@ -1,261 +1,5 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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
|
||||
func copilotRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||
return managedRuntimeFilesExist(contracts, copilotAgentKey, "Copilot", copilotExpectedScripts)
|
||||
}
|
||||
@@ -10,55 +10,27 @@ type CursorPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Cursor commits its provider-specific guard
|
||||
// in the same transaction as the rest of the installation.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *CursorPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func cursorAgentPaths(config InstallConfig) (
|
||||
runtimeRoot, agentDirectory, guardPath, auditPath string,
|
||||
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
|
||||
func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) {
|
||||
return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript)
|
||||
}
|
||||
|
||||
// PreflightInstall validates every existing source before the CLI creates a
|
||||
// runtime directory or guard file.
|
||||
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
|
||||
if _, err := readCursorDocument(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, agentDirectory, _, _, err := cursorAgentPaths(config)
|
||||
paths, err := cursorAgentPaths(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
|
||||
if err := validateManagedRuntimeIdentity(
|
||||
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = resolveNodeRuntime()
|
||||
@@ -70,44 +42,42 @@ func (p *CursorPlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config)
|
||||
paths, err := cursorAgentPaths(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
|
||||
if err := validateManagedRuntimeIdentity(
|
||||
paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "")
|
||||
hooks["preToolUse"] = append(
|
||||
hooks["preToolUse"],
|
||||
buildCursorHandler(nodePath, guardPath),
|
||||
)
|
||||
hooks["postToolUse"] = append(
|
||||
hooks["postToolUse"],
|
||||
buildCursorHandler(nodePath, auditPath),
|
||||
)
|
||||
hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "")
|
||||
hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath))
|
||||
hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath))
|
||||
hooks["postToolUseFailure"] = append(
|
||||
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 {
|
||||
return fmt.Errorf("render Cursor user hooks: %w", err)
|
||||
}
|
||||
changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered)
|
||||
changes, err := prepareCursorInstallationChanges(config, paths, rendered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCursorChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Cursor hooks",
|
||||
p.rename,
|
||||
runtimeRoot,
|
||||
agentDirectory,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Cursor hooks directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,12 +103,14 @@ func (p *CursorPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCursorChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Cursor hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.filePath),
|
||||
"Cursor hooks directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cursorAgentKey = "cursor"
|
||||
cursorConfigFile = "hooks.json"
|
||||
cursorGuardScript = "guard.js"
|
||||
cursorAuditScript = "hook.js"
|
||||
cursorHookTimeout = float64(10)
|
||||
cursorPOSIXApostrophe = `'"'"'`
|
||||
cursorAgentKey = "cursor"
|
||||
cursorConfigFile = "hooks.json"
|
||||
cursorGuardScript = "guard.js"
|
||||
cursorAuditScript = "hook.js"
|
||||
cursorHookTimeout = float64(10)
|
||||
)
|
||||
|
||||
type cursorHooks map[string][]map[string]any
|
||||
@@ -36,11 +35,7 @@ type cursorRenderedDocument struct {
|
||||
remove bool
|
||||
}
|
||||
|
||||
type cursorRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
type cursorRuntimeContract = managedRuntimeContract
|
||||
|
||||
func cloneCursorHooks(source cursorHooks) cursorHooks {
|
||||
clone := make(cursorHooks, len(source))
|
||||
@@ -50,104 +45,16 @@ func cloneCursorHooks(source cursorHooks) cursorHooks {
|
||||
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 {
|
||||
command := quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath)
|
||||
command := posixSource(nodePath, scriptPath)
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "& " + quoteCursorPowerShell(nodePath) + " " +
|
||||
quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
||||
command = powerShellSource(nodePath, scriptPath)
|
||||
}
|
||||
return map[string]any{
|
||||
"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) {
|
||||
separator := strings.IndexByte(command, ' ')
|
||||
if separator < 0 {
|
||||
@@ -164,11 +71,6 @@ func parseCursorLegacyCommand(command string) (string, bool) {
|
||||
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) {
|
||||
if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true {
|
||||
command, ok := handler["command"].(string)
|
||||
@@ -178,12 +80,12 @@ func cursorManagedScriptPath(handler map[string]any) (string, bool) {
|
||||
var nodePath, scriptPath string
|
||||
var parsed bool
|
||||
if runtime.GOOS == "windows" {
|
||||
nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command)
|
||||
nodePath, scriptPath, parsed = parsePowerShellSource(command)
|
||||
} else {
|
||||
nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command)
|
||||
nodePath, scriptPath, parsed = parsePOSIXCommand(command)
|
||||
}
|
||||
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
|
||||
isCursorNodeExecutable(nodePath) {
|
||||
isNodeExecutable(nodePath) {
|
||||
return scriptPath, true
|
||||
}
|
||||
}
|
||||
@@ -207,7 +109,7 @@ func cursorManagedAgentID(
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
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]))
|
||||
for _, handler := range result[contract.event] {
|
||||
managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot)
|
||||
if managed && (agentID == "" || sameCursorAgentID(managedID, agentID)) {
|
||||
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, handler)
|
||||
@@ -384,11 +286,7 @@ func managedCursorIDs(
|
||||
if !managed {
|
||||
continue
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(agentID)
|
||||
}
|
||||
result[key] = agentID
|
||||
result[managedReferenceKey(agentID)] = agentID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cursorConfigPath() (string, error) {
|
||||
@@ -27,21 +23,14 @@ func readCursorDocument() (*cursorDocument, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Lstat(filePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snapshot == nil {
|
||||
return createCursorDocument(filePath), nil
|
||||
}
|
||||
if err != nil {
|
||||
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)
|
||||
return parseCursorDocument(filePath, snapshot.contents, runtimeRoot)
|
||||
}
|
||||
|
||||
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
|
||||
@@ -59,167 +48,25 @@ 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(
|
||||
config InstallConfig,
|
||||
agentDirectory string,
|
||||
paths *managedRuntimePaths,
|
||||
rendered *cursorRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.elydora.com"
|
||||
if config.BaseURL == "" {
|
||||
config.BaseURL = "https://api.elydora.com"
|
||||
}
|
||||
runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{
|
||||
OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID,
|
||||
BaseURL: baseURL, Token: config.Token, AgentName: cursorAgentKey,
|
||||
}, "", " ")
|
||||
changes, err := managedRuntimeFileChanges(
|
||||
config,
|
||||
paths,
|
||||
cursorAgentKey,
|
||||
generateGuardScript(
|
||||
cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor",
|
||||
),
|
||||
buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
||||
}
|
||||
runtimeConfig = append(runtimeConfig, '\n')
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
return nil, err
|
||||
}
|
||||
documentChange, err := prepareRenderedCursorChange(rendered)
|
||||
if err != nil {
|
||||
@@ -228,98 +75,6 @@ func prepareCursorInstallationChanges(
|
||||
return append(changes, documentChange), nil
|
||||
}
|
||||
|
||||
func ensureCursorDirectory(path, label string) error {
|
||||
if err := os.MkdirAll(path, 0700); err != nil {
|
||||
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
|
||||
func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||
return managedRuntimeFilesPresent(contracts, cursorAgentKey)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) {
|
||||
fixture.guardPath,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) {
|
||||
}
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !failed && sameCursorPath(destination, fixture.configPath) {
|
||||
if !failed && sameManagedPath(destination, fixture.configPath) {
|
||||
failed = true
|
||||
return errors.New("injected Cursor config commit failure")
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ type DroidPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Droid commits its guard, audit runtime,
|
||||
// credentials, and hook documents in one transaction.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *DroidPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// PreflightInstall validates every hook source, policy layer, credential,
|
||||
// matcher, and runtime identity before any write.
|
||||
// PreflightInstall validates every source before the CLI creates runtime state.
|
||||
func (p *DroidPlugin) PreflightInstall(config InstallConfig) error {
|
||||
sources, err := readDroidSources()
|
||||
if err != nil {
|
||||
@@ -107,7 +105,7 @@ func (p *DroidPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtimeRoot, err := droidRuntimeRoot()
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,7 +131,7 @@ func (p *DroidPlugin) Status() (PluginStatus, error) {
|
||||
if droidHookBlocked(sources) != nil {
|
||||
return status, nil
|
||||
}
|
||||
runtimeRoot, err := droidRuntimeRoot()
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func droidAdditionsFor(
|
||||
document, target *droidDocument,
|
||||
groups 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 map[string]map[string]any{}
|
||||
|
||||
@@ -15,13 +15,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
droidAgentKey = "droid"
|
||||
droidGuardScript = "guard.js"
|
||||
droidAuditScript = "hook.js"
|
||||
droidHookTimeout = float64(10)
|
||||
droidOwnedFileMarker = "// Managed by Elydora"
|
||||
droidPOSIXApostrophe = `'"'"'`
|
||||
droidWindowsExitSuffix = "; exit $LASTEXITCODE"
|
||||
droidAgentKey = "droid"
|
||||
droidGuardScript = "guard.js"
|
||||
droidAuditScript = "hook.js"
|
||||
droidHookTimeout = float64(10)
|
||||
droidOwnedFileMarker = "// Managed by Elydora"
|
||||
)
|
||||
|
||||
var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"}
|
||||
@@ -35,11 +33,7 @@ type droidManagedRemoval struct {
|
||||
removeGroup bool
|
||||
}
|
||||
|
||||
type droidRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
type droidRuntimeContract = managedRuntimeContract
|
||||
|
||||
type droidRegexEntry struct {
|
||||
Label string `json:"label"`
|
||||
@@ -178,145 +172,21 @@ for (const entry of entries) {
|
||||
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 {
|
||||
return map[string]any{
|
||||
"matcher": "*",
|
||||
"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) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return parseDroidPOSIXCommand(command)
|
||||
}
|
||||
executable, script, ok := parseDroidPowerShellCommand(command)
|
||||
if ok || len(includeLegacy) == 0 || !includeLegacy[0] {
|
||||
executable, script, ok := parseShellCommand(command)
|
||||
if ok || runtime.GOOS != "windows" || len(includeLegacy) == 0 || !includeLegacy[0] {
|
||||
return executable, script, ok
|
||||
}
|
||||
return parseDroidLegacyWindowsCommand(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()
|
||||
return parseQuotedWindowsCommand(command)
|
||||
}
|
||||
|
||||
func managedDroidAgentID(
|
||||
@@ -333,29 +203,17 @@ func managedDroidAgentID(
|
||||
}
|
||||
executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...)
|
||||
if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) ||
|
||||
!isDroidNodeExecutable(executable) || !sameDroidFileName(filepath.Base(scriptPath), scriptName) {
|
||||
!isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
if !sameDroidPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
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 {
|
||||
removals := make([]droidManagedRemoval, 0)
|
||||
for _, contract := range []struct{ event, script string }{
|
||||
@@ -370,7 +228,7 @@ func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot strin
|
||||
managedID, managed := managedDroidAgentID(
|
||||
handlerValue.(map[string]any), contract.script, runtimeRoot, true,
|
||||
)
|
||||
if managed && (agentID == "" || sameDroidAgentID(managedID, agentID)) {
|
||||
if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) {
|
||||
indexes = append(indexes, handlerIndex)
|
||||
}
|
||||
}
|
||||
@@ -400,7 +258,7 @@ func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []dro
|
||||
contracts := make([]droidRuntimeContract, 0, len(guardIDs))
|
||||
for _, guardID := range guardIDs {
|
||||
for auditID := range audits {
|
||||
if !sameDroidAgentID(guardID, auditID) {
|
||||
if !sameManagedAgentID(guardID, auditID) {
|
||||
continue
|
||||
}
|
||||
root := filepath.Join(runtimeRoot, guardID)
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type droidRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
type preparedDroidInstallation struct {
|
||||
changes []*fileChange
|
||||
preconditions []filePrecondition
|
||||
paths *droidRuntimePaths
|
||||
paths *managedRuntimePaths
|
||||
}
|
||||
|
||||
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(
|
||||
config InstallConfig,
|
||||
sources *droidSources,
|
||||
) (*droidRuntimePaths, string, error) {
|
||||
) (*managedRuntimePaths, string, error) {
|
||||
if sources == nil || sources.root == nil || sources.policy == nil {
|
||||
return nil, "", fmt.Errorf("Factory Droid installation sources are required")
|
||||
}
|
||||
@@ -126,50 +45,25 @@ func preflightDroidInstallation(
|
||||
if err := validateDroidRegexes(hooks...); err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Factory Droid")
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
rendered *droidRenderedDocument,
|
||||
) (*fileChange, error) {
|
||||
@@ -198,7 +92,7 @@ func validateDroidRenderedSet(
|
||||
matches := 0
|
||||
for _, item := range rendered {
|
||||
if item != nil && item.document != nil &&
|
||||
sameDroidPath(item.document.filePath, document.filePath) {
|
||||
sameManagedPath(item.document.filePath, document.filePath) {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
@@ -217,7 +111,7 @@ func droidSourcePreconditions(
|
||||
for _, document := range droidSourceDocuments(sources) {
|
||||
changed := false
|
||||
for _, path := range changedPaths {
|
||||
if sameDroidPath(document.filePath, path) {
|
||||
if sameManagedPath(document.filePath, path) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
@@ -246,38 +140,13 @@ func prepareDroidInstallation(
|
||||
if err := validateDroidRenderedSet(sources, rendered); err != nil {
|
||||
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 {
|
||||
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))
|
||||
for _, document := range rendered {
|
||||
change, changeErr := prepareRenderedDroidChange(document)
|
||||
@@ -305,14 +174,7 @@ func writeDroidChanges(
|
||||
runtimeRoot, agentDirectory string,
|
||||
preconditions []filePrecondition,
|
||||
) error {
|
||||
hasChanges := false
|
||||
for _, change := range changes {
|
||||
if change != nil {
|
||||
hasChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasChanges {
|
||||
if !hasFileChanges(changes) {
|
||||
return writeChanges(changes, label, rename, preconditions...)
|
||||
}
|
||||
if err := assertFilePreconditions(preconditions, label); err != nil {
|
||||
@@ -365,7 +227,7 @@ func prepareDroidUninstall(
|
||||
for _, item := range rendered {
|
||||
changed := false
|
||||
for _, path := range changedPaths {
|
||||
if sameDroidPath(item.document.filePath, path) {
|
||||
if sameManagedPath(item.document.filePath, path) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
@@ -398,3 +260,8 @@ func commitDroidUninstall(
|
||||
prepared.preconditions,
|
||||
)
|
||||
}
|
||||
|
||||
func droidExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||
return []byte(generateGuardScript(droidAgentKey, agentID, "", false, "")),
|
||||
[]byte(buildHookScriptWithOutput(droidAgentKey, agentID, "", false, true))
|
||||
}
|
||||
@@ -1,251 +1,5 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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
|
||||
func droidRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||
return managedRuntimeFilesExist(contracts, droidAgentKey, "Factory Droid", droidExpectedScripts)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestDroidInstallWritesCurrentContainerAndCompleteRuntime(t *testing.T) {
|
||||
requireDroidNativeGroup(t, auditGroup)
|
||||
guardCommand := droidManagedHandler(t, hooks["PreToolUse"], fixture.guardPath)["command"].(string)
|
||||
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)
|
||||
}
|
||||
runtimeConfig := readDroidTestObject(t, fixture.runtimeConfig)
|
||||
|
||||
@@ -237,7 +237,7 @@ func droidManagedGroup(t *testing.T, groupsValue any, scriptPath string) map[str
|
||||
handler := requireDroidObject(t, handlerValue)
|
||||
command, _ := handler["command"].(string)
|
||||
_, configuredPath, managed := parseDroidCommand(command, true)
|
||||
if managed && sameDroidPath(configuredPath, scriptPath) {
|
||||
if managed && sameManagedPath(configuredPath, scriptPath) {
|
||||
return group
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ func droidManagedHandler(t *testing.T, groupsValue any, scriptPath string) map[s
|
||||
handler := requireDroidObject(t, handlerValue)
|
||||
command, _ := handler["command"].(string)
|
||||
_, configuredPath, managed := parseDroidCommand(command, true)
|
||||
if managed && sameDroidPath(configuredPath, scriptPath) {
|
||||
if managed && sameManagedPath(configuredPath, scriptPath) {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
@@ -344,3 +344,12 @@ func requireNoDroidStagingFiles(t *testing.T, root string) {
|
||||
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)
|
||||
failed := false
|
||||
rename := func(source, destination string) error {
|
||||
if !failed && sameDroidPath(destination, fixture.configPath) {
|
||||
if !failed && sameManagedPath(destination, fixture.configPath) {
|
||||
failed = true
|
||||
return errors.New("injected Droid hook commit failure")
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func TestDroidConcurrentActiveHookReplacementPreventsRuntimeCommit(t *testing.T)
|
||||
t.Fatalf("concurrent source error = %v", err)
|
||||
}
|
||||
for path, source := range before {
|
||||
if sameDroidPath(path, fixture.configPath) {
|
||||
if sameManagedPath(path, fixture.configPath) {
|
||||
continue
|
||||
}
|
||||
if readDroidTestFile(t, path) != source {
|
||||
@@ -205,7 +205,7 @@ func TestDroidSameContentReplacementIsRejectedByPhysicalIdentity(t *testing.T) {
|
||||
t.Fatal("same-content replacement changed")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read uninstall sources: %v", err)
|
||||
}
|
||||
runtimeRoot, err := droidRuntimeRoot()
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve runtime root: %v", err)
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func TestDroidUninstallRollsBackAllHookDocuments(t *testing.T) {
|
||||
}
|
||||
failed := false
|
||||
rename := func(source, destination string) error {
|
||||
if !failed && sameDroidPath(destination, fixture.settingsPath) {
|
||||
if !failed && sameManagedPath(destination, fixture.settingsPath) {
|
||||
failed = true
|
||||
return errors.New("injected Droid uninstall failure")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ type GeminiPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Gemini commits both generated runtimes and
|
||||
// the user settings document through one transaction.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *GeminiPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
@@ -92,13 +91,14 @@ func (p *GeminiPlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeGeminiChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Gemini CLI hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Gemini CLI configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -120,13 +120,14 @@ func (p *GeminiPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeGeminiChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Gemini CLI hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.filePath),
|
||||
"Gemini CLI configuration directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,83 +7,37 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type geminiRuntimeReference struct {
|
||||
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")
|
||||
}
|
||||
type geminiRuntimeReference = managedScriptReference
|
||||
|
||||
func buildGeminiCommand(runtimePath, scriptPath string) (string, error) {
|
||||
if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) {
|
||||
return "", fmt.Errorf(
|
||||
"Gemini CLI hook commands require absolute runtime and script paths",
|
||||
)
|
||||
return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil
|
||||
return posixSource(runtimePath, scriptPath), nil
|
||||
}
|
||||
source := "& " + quoteCodexPowerShell(runtimePath) + " " +
|
||||
quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
||||
return "& " + quoteCodexPowerShell(codexPowerShellPath()) +
|
||||
" -NoLogo -NoProfile -NonInteractive -EncodedCommand " +
|
||||
encodeCodexPowerShell(source), nil
|
||||
return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags +
|
||||
encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil
|
||||
}
|
||||
|
||||
func parseGeminiWindowsCommand(command string) (string, string, bool) {
|
||||
if !strings.HasPrefix(command, "& ") {
|
||||
return "", "", false
|
||||
}
|
||||
powerShell, next, ok := readCodexPowerShellArgument(command, 2)
|
||||
if !ok || !isAbsoluteWindowsPath(powerShell) ||
|
||||
!strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") {
|
||||
powerShell, next, ok := readPowerShellArgument(command, 2)
|
||||
if !ok || !isPowerShellExecutable(powerShell) ||
|
||||
!strings.HasPrefix(command[next:], powerShellEncodedFlags) {
|
||||
return "", "", false
|
||||
}
|
||||
prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand "
|
||||
if !strings.HasPrefix(command[next:], prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
source, ok := decodeCodexPowerShell(command[next+len(prefix):])
|
||||
source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):])
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return parseCodexPowerShellSource(source)
|
||||
return parsePowerShellSource(source)
|
||||
}
|
||||
|
||||
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 parseGeminiWindowsCommand(command)
|
||||
@@ -110,23 +64,10 @@ func geminiRuntimeReferenceForCommand(
|
||||
if !current && includeLegacy {
|
||||
runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command)
|
||||
}
|
||||
validRuntime := (current && filepath.IsAbs(runtimePath) &&
|
||||
isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node")
|
||||
if !validRuntime || !filepath.IsAbs(scriptPath) ||
|
||||
!sameGeminiFileName(filepath.Base(scriptPath), scriptName) {
|
||||
validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) ||
|
||||
(legacy && runtimePath == "node")
|
||||
if !validRuntime {
|
||||
return nil, nil
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
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
|
||||
return resolveManagedScript(scriptPath, scriptName)
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,11 +24,7 @@ type geminiManagedRemoval struct {
|
||||
removeGroup bool
|
||||
}
|
||||
|
||||
type geminiRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
type geminiRuntimeContract = managedRuntimeContract
|
||||
|
||||
func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) {
|
||||
command, err := buildGeminiCommand(runtimePath, scriptPath)
|
||||
@@ -151,7 +144,7 @@ func managedGeminiRemovals(
|
||||
return nil, err
|
||||
}
|
||||
if reference != nil &&
|
||||
(agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) {
|
||||
(agentID == "" || sameManagedAgentID(reference.agentID, agentID)) {
|
||||
indexes = append(indexes, handlerIndex)
|
||||
}
|
||||
}
|
||||
@@ -168,13 +161,6 @@ func managedGeminiRemovals(
|
||||
return removals, nil
|
||||
}
|
||||
|
||||
func geminiReferenceKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
return agentID
|
||||
}
|
||||
|
||||
func geminiReferencesForEvent(
|
||||
groups []any,
|
||||
scriptName string,
|
||||
@@ -196,7 +182,7 @@ func geminiReferencesForEvent(
|
||||
return nil, err
|
||||
}
|
||||
if reference != nil {
|
||||
key := geminiReferenceKey(reference.agentID)
|
||||
key := managedReferenceKey(reference.agentID)
|
||||
result[key] = append(result[key], *reference)
|
||||
}
|
||||
}
|
||||
@@ -243,11 +229,3 @@ func geminiRuntimeContracts(
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
import "fmt"
|
||||
|
||||
func preflightGeminiInstallation(
|
||||
config InstallConfig,
|
||||
document *geminiDocument,
|
||||
) (*geminiRuntimePaths, string, error) {
|
||||
) (*managedRuntimePaths, string, error) {
|
||||
if document == nil || document.filePath == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"Gemini CLI installation requires a user settings document",
|
||||
)
|
||||
return nil, "", fmt.Errorf("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 {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI")
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
config InstallConfig,
|
||||
paths *geminiRuntimePaths,
|
||||
paths *managedRuntimePaths,
|
||||
rendered *geminiRenderedDocument,
|
||||
) ([]*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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -67,38 +67,3 @@ func prepareRenderedGeminiChange(
|
||||
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
Reference in new issue
Block a user