import { PolicyEvaluator } from '../policy/policy-evaluator'; import { Wallet } from '../wallet/wallet'; import { VPBuilder } from '../wallet/vp-builder'; import { AuditLogger } from '../audit/audit-logger'; import { AgentdGatewayClient } from '../gateway/gateway-client'; import { ApprovalTokenService } from '../approval/approval-token'; import { ExecuteRequest, ExecuteResult, ListToolsResult, ExecutionContext } from './types'; import { SecretBackend } from '../env/secret-backend'; export declare class ExecutionEngine { private readonly policyEvaluator; private readonly wallet; private readonly vpBuilder; private readonly auditLogger; private readonly gatewayClient; private readonly context; private readonly tokenService; private readonly secretBackend?; private lastPolicySyncTime; private clientInfo?; private agentRegistered; /** * Wall-clock timestamp (ms) of the most recent registerAgent attempt, used * to throttle retries during a sustained outage so we don't hammer the API * with one POST per tool call. See REGISTRATION_RETRY_COOLDOWN_MS. */ private lastRegistrationAttemptAt; /** * In-flight registerAgent promise, exposed via `registrationInFlight` for * deterministic test waits. `null` when no fetch is pending. Tests await * this to flush the fire-and-forget catch handler without resorting to * arbitrary setTimeout sleeps that race the microtask queue. */ private inFlightRegistration; private recentOOBRequests; private static readonly OOB_COOLDOWN_MS; private static readonly OOB_MAP_MAX_SIZE; /** * Minimum interval between registerAgent attempts when the flag is unset * (i.e., the previous attempt failed non-409). 5s is large enough to avoid * a request storm against `POST /v1/agents/create` during a sustained * outage (ALB rolling, DB connection storm), and small enough that a * recovered API picks the agent back up within a couple of tool calls. */ private static readonly REGISTRATION_RETRY_COOLDOWN_MS; constructor(policyEvaluator: PolicyEvaluator, wallet: Wallet, vpBuilder: VPBuilder, auditLogger: AuditLogger | null, gatewayClient: AgentdGatewayClient, context: ExecutionContext, tokenService: ApprovalTokenService, secretBackend?: SecretBackend | undefined); /** * Record a successful policy sync timestamp (called by SyncScheduler). */ setLastPolicySyncTime(time: number): void; /** * Set MCP client info (from oninitialized callback). * Used to derive a meaningful agent name for deferred registration. */ setClientInfo(info: { name: string; version: string; }): void; /** * Ensure the agent is registered in the API. * Called lazily on first tool execution (fire-and-forget). * Uses clientInfo from MCP initialize handshake if available. * * Retry semantics (audit fix for audit_events vs /agents divergence): * - Optimistic flag: mark `agentRegistered = true` BEFORE the fetch so * concurrent `execute()` calls don't all fire duplicate registrations. * - On 409 (GatewayHttpError with status 409): leave the flag true — the * agent row already exists in the DB. We type-check via the typed * `GatewayHttpError.status` field rather than substring-matching the * message, so a future change to the error wording can't accidentally * reclassify "already exists" 409s as 5xx-style retries (or vice versa). * A string-match fallback (`'409' / 'already exists' / 'duplicate'`) * remains for older/non-typed Error instances thrown from other layers * (e.g. middleware / network helpers that haven't been converted). * - On any other failure (5xx, network, etc.): reset the flag to false so * the next `execute()` retries — subject to the retry cool-down below. * - Cool-down: at most one POST every REGISTRATION_RETRY_COOLDOWN_MS while * the flag stays false. Without this, a sustained 5xx (e.g. ALB rolling, * DB connection storm) would result in one POST per tool call. * - Retry horizon: there is intentionally NO upper bound on the number of * cool-down cycles. agentd is a long-lived daemon, so for a sustained * outage we want it to keep retrying (once per cool-down) and self-heal * the moment the gateway recovers — a hard cap would permanently brick * registration after N failures, which is strictly worse. Tool execution * itself does not block on registration (it is fire-and-forget), so an * unregistered agent degrades gracefully rather than deadlocking. * - No stuck-flag deadlock: `inFlightRegistration` is reset to `null` in * the `.finally()` below on EVERY settle (success, 409, or 5xx), so the * `agentRegistered=false` + in-flight-guard combination can never wedge * permanently — the next post-cool-down `execute()` always re-attempts. */ private ensureAgentRegistered; /** * Test-only accessor for the in-flight registerAgent promise. * * Awaiting this yields a deterministic wait for the fire-and-forget * `.catch()` handler to finish updating internal state (the * `agentRegistered` flag and the cool-down timestamp). Tests use this in * place of arbitrary `setTimeout` sleeps, which would race the microtask * queue and flake under load. * * Returns `null` when no registration is in flight. */ get registrationInFlight(): Promise | null; /** * Execute an action request. * This is the single entry point for all adapters (MCP, future IDE, SDK). */ execute(request: ExecuteRequest): Promise; /** * Ensure a valid VC exists for the requested action. * Follows the 4-step acquisition flow from spec §6.1: * * Step 1: Check wallet for valid VC (verify revocation via Gateway using VC jti) * Step 2: Check Gateway for approved requests * Step 3: Try auto-issue via Gateway * Step 4: Terminal approval prompt -> quick-approve via Gateway * * @param approvalContext — Cedar 一元化 Step 3.5 retry path. When the agent * re-invokes ensureVC after the user clicked 承認 in the approval UI, the * caller threads the ledger token + approval outcome ids here so Step 3's * `autoIssueVC` call carries them to the API. The API consumes the * single-use token and injects `context.approval.granted = true` into * Cedar evaluation, flipping the prior `auth_required` to `permit`. */ private ensureVC; /** * True when an error is a Cedar forbid 403 (policy_forbidden) from the gateway. * * Primary path: `gatewayClient.autoIssueVC` throws `GatewayHttpError` on any * non-ok, carrying the 403 status + body — that's the branch that fires today. * The plain-`Error` message fallback is intentionally retained (not dead): it * is cheap defensive coverage for a future caller that surfaces the forbid as * a wrapped/plain Error, and keeps parity with remote-mcp's message-only * `isPolicyForbiddenError`. The `policy_forbidden` token is snake_case and * unlikely to collide with unrelated error prose. */ private isPolicyForbiddenError; private policyForbiddenReason; private executeLocal; /** * Execute locally with an already-acquired VC (skips ensureVC). * Used by both the normal path (after ensureVC) and handleApprovalResponse (after quickApprove). */ private executeLocalWithVC; /** * Execute locally when Gateway is unreachable. * All 4 conditions from spec §7.6 must be true: * 1. Valid cached VC exists * 2. VC not expired * 3. Synced org policy not stale (30 min TTL) * 4. Local policy does not deny */ private executeLocalOffline; private executeViaGateway; private executeViaGatewayWithVC; /** * Handle a re-invocation with pendingRequestId (OOB approval polling). */ private handlePendingApproval; /** * Create an OOB (out-of-band) approval request. * Returns a URL for the user to approve in their browser. * * Called in two contexts: * - ensureVC Step 4: for high/critical risk actions that require browser approval. * - tryOOBFallbackAfterRetryExhaustion: for ANY risk level when a VC expires * mid-execution and retry is exhausted. In this case OOB is used as a fallback * because the normal in-band flow cannot recover an expired credential — the * user must re-authorize via browser regardless of risk level. */ private createOOBApprovalRequest; /** * Revoke the stale credential and attempt to create an OOB approval request * as a fallback after retry exhaustion. Returns an ExecuteResult with * waitingForApproval on success, or null if OOB creation fails (caller falls * through to the original error). */ private tryOOBFallbackAfterRetryExhaustion; /** * Create an in-band approval response for low/medium risk actions. * Generates an approval token for the MCP client to present back. */ private createInBandApprovalResponse; /** * Pre-request permissions for multiple actions (batch). * Triggers VC acquisition for each action without executing. * This allows the user to approve multiple actions at once. */ requestPermissions(actions: string[]): Promise; listAvailableTools(): Promise; /** * Resolve env vars for os.process.run from .env file. * Returns empty record if no env_profile specified or .env not found. */ private resolveProcessEnv; private getActionRiskLevel; private getDefaultVCTTL; private handleApprovalResponse; private getResourceType; /** * Extract specific resource ID from action input using ACTION_REGISTRY target_bindings. * For gateway (SaaS) actions, the resource ID (e.g., channel ID) is in the input parameters. * Returns undefined for actions without param-sourced bindings or when the param is absent * (e.g., target_bindings with required: false). */ private extractResourceIdFromInput; /** * #1009 — this OOB re-invocation check is agentd's own defense-in-depth * resource re-validation, separate from Gate-2's Cedar resolver in * packages/api. #893 taught Gate-2's JiraResourceResolver that a * project-scoped grant (e.g. "AIDENTITY") legitimately covers an * issue-scoped request (e.g. "AIDENTITY-127"), but that fix never touched * this agentd-side check, which stayed a plain string comparison. A grant * that Gate-2 correctly approved would then be rejected here on re-invoke, * permanently: the approval never resolves. Mirror the same hierarchy * check so both enforcement paths agree. * * TODO(provider-generic): this is Jira-only by construction — * `extractProjectKey` parses the Jira `KEY-123` shape, and the sole caller * gates on `r.provider === 'jira'` (~:1216). packages/api expresses the same * idea through `ResourceResolverRegistry` + `supportsHierarchicalMatch()`, * so a second provider that needs hierarchy (e.g. GitHub org → repo) would * have to be added in BOTH places, in two different shapes. * * Deliberately not abstracted yet: agentd is a local daemon that does not * share the API's DI container or resolver registry, so mirroring that * abstraction here means either duplicating the registry or extracting it * into the SDK — real work that one provider does not justify. Jira is * currently the only registered hierarchical resolver. * * When a second one appears, extract the resolver interface into the SDK * (alongside `extractProjectKey`, which already lives there) and have both * enforcement paths consume it, rather than adding a second `provider ===` * branch here. 🪤 The hazard this guards against is silent divergence: if * the two paths disagree about what a grant covers, Gate-2 approves and * agentd rejects on re-invoke, and the approval never resolves — exactly * the #1009 bug this method was written to fix. */ private resourceIdHierarchicallyMatches; /** * The single predicate deciding "does this approval cover this resource?". * * 🔴 Extracted because the check exists at TWO call sites — the pre-claim * check (~:1213, over `request.input`) and the post-claim re-validation * (~:1310, over the stored `actionParams`). They were byte-identical copies, * and the live 2026-07-30 calendar bug hit BOTH: fixing only one produced a * test that still failed, from the other site's near-identical message. * Two independent copies of one security decision is how they silently drift * (#1232 is the same lesson for terminal-status sets living in 3 places). */ private approvedResourceMatches; /** * `primary` is Google Calendar's alias for the token owner's OWN calendar, * so `primary` and that owner's calendar address name one resource. The * approval surfaces deliberately substitute the address for the alias — * `public/js/pages/approve.js:514-521` and * `slack-approve.service.ts:224-238` both do it, because asking a human to * approve a write to "primary" tells them nothing about which calendar. * * But nothing rewrites the request's stored actionParams to match, and * `canonicalResourceId` has no notion of the alias (resolving it needs the * Google OAuth token, i.e. a DB read that agentd and remote-mcp cannot do — * INV-1). So re-invocation compares `"owner@gmail.com"` against `"primary"`, * they differ byte-wise, and the approval can NEVER be satisfied. Reported * live 2026-07-30: the user approved, retried, and was rejected forever. * * 🔴 THIS IS NOT A WILDCARD. The alias only matches an id that looks like a * calendar ADDRESS, and only in the alias→address direction. A named shared * calendar (`team@group.calendar.google.com`) is a different resource and * still fails, which is what the third case in * `oob-approval-calendar-primary-alias.test.ts` pins — without that bound * this check would degrade into "any calendar approval covers any calendar". * * 🪤 Same failure SHAPE as #1009/#893 (Jira): Gate-2 approves, agentd's plain * `===` rejects on re-invoke, and the approval never resolves. That one got * `resourceIdHierarchicallyMatches`; this is the alias-equivalence sibling. * When a third case appears, extract a resolver interface into the SDK so * both enforcement paths consume one definition (see the TODO above) rather * than growing a third branch here. */ private calendarPrimaryAliasMatches; private isRecentOOBRequest; private trackOOBRequest; /** Verify a wallet VC: check revocation via Gateway, handle expiry. Returns the VC if valid, null if invalid. */ private verifyWalletVC; private handleResourceMismatch; private logAudit; } //# sourceMappingURL=execution-engine.d.ts.map