Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,19 @@ The main entry point. Creates an agent that listens for job events and manages s
```typescript
const agent = await AcpAgent.create({
provider: providerAdapter, // required -- EVM or Solana provider
// Optional: inspect the exact job and counterparty before any fund()
// transaction is prepared. Throws and allow:false both fail closed.
fundPolicy: async ({ providerAddress, chainId, amount, job }) => {
const decision = await myCounterpartyPolicy({
providerAddress,
chainId,
amount,
capability: job.description,
});
return decision.allowed
? { allow: true, evidence: decision.evidence }
: { allow: false, reason: decision.reason };
},
});

agent.on("entry", async (session, entry) => {
Expand Down Expand Up @@ -212,6 +225,11 @@ await agent.stop();
| `agent.getAddress()` | Get the agent's wallet address |
| `agent.getSession(chainId, jobId)` | Get an active session |

When `fundPolicy` is configured, `session.fund()` invokes it once with the exact
provider wallet, chain, amount, and hydrated `AcpJob` before preparing any funding
transaction. The policy must return `{ allow: true }`; denial, an invalid decision,
or an exception aborts funding. Omitting the policy preserves existing behaviour.

### JobSession

Represents your participation in a single job. Tracks role, status, conversation history, and available actions.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "dist/index.js",
"scripts": {
"prepare": "tsc",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "tsc && node --test test/*.test.mjs",
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
Expand Down
25 changes: 24 additions & 1 deletion src/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ import {
type MultiHookConfig,
} from "./core/hookEncoding.js";
import { AssetToken } from "./core/assetToken.js";
import type { AcpJob } from "./acpJob.js";
import {
enforceFundPolicy,
type FundPolicy,
} from "./core/fundPolicy.js";
import { withReprepare } from "./core/reprepareRetry.js";
import { JobSession } from "./jobSession.js";
import { AcpApiClient } from "./events/acpApiClient.js";
Expand Down Expand Up @@ -72,6 +77,8 @@ export type EntryHandler = (
export type CreateAgentInput = CreateAcpClientInput & {
transport?: AcpChatTransport;
api?: AcpJobApi;
/** Optional fail-closed gate evaluated immediately before every fund(). */
fundPolicy?: FundPolicy;
};

export type SetBudgetParams = {
Expand Down Expand Up @@ -160,6 +167,7 @@ export class AcpAgent {
private readonly clients: Map<ChainFamily, AcpClient>;
private readonly transport: AcpChatTransport;
private readonly api: AcpJobApi;
private readonly fundPolicy: FundPolicy | undefined;
private started = false;
private entryHandler: EntryHandler | null = null;
private sessionMap = new Map<string, JobSession>();
Expand All @@ -169,20 +177,23 @@ export class AcpAgent {
clients: Map<ChainFamily, AcpClient>,
transport: AcpChatTransport,
api: AcpJobApi,
fundPolicy?: FundPolicy,
) {
this.clients = clients;
this.transport = transport;
this.api = api;
this.fundPolicy = fundPolicy;
}

static async create(input: CreateAgentInput): Promise<AcpAgent> {
const {
transport = new SseTransport(),
api = new AcpApiClient(),
fundPolicy,
...clientInput
} = input;
const clients = await createAcpClients(clientInput);
const agent = new AcpAgent(clients, transport, api);
const agent = new AcpAgent(clients, transport, api, fundPolicy);

const ctx = await agent.buildTransportContext();
if (transport instanceof AcpHttpClient) transport.setContext(ctx);
Expand Down Expand Up @@ -212,6 +223,18 @@ export class AcpAgent {
return this.api;
}

async enforceFundPolicy(job: AcpJob, amount: AssetToken): Promise<void> {
await enforceFundPolicy(this.fundPolicy, {
action: "fund",
job,
chainId: job.chainId,
jobId: job.id,
providerAddress: job.providerAddress,
clientAddress: job.clientAddress,
amount,
});
}

getSupportedChainIds(): number[] {
const ids: number[] = [];
for (const client of this.clients.values()) {
Expand Down
51 changes: 51 additions & 0 deletions src/core/fundPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { AcpJob } from "../acpJob.js";
import type { AssetToken } from "./assetToken.js";

/** The exact transaction context evaluated immediately before job funding. */
export type FundPolicyContext = {
action: "fund";
job: AcpJob;
chainId: number;
jobId: bigint;
providerAddress: string;
clientAddress: string;
amount: AssetToken;
};

export type FundPolicyDecision = {
allow: boolean;
reason?: string;
/** Optional machine-readable material the policy used to reach its decision. */
evidence?: unknown;
};

export type FundPolicy = (
context: FundPolicyContext
) => FundPolicyDecision | Promise<FundPolicyDecision>;

/** Raised before any funding transaction when a configured policy denies it. */
export class FundPolicyDeniedError extends Error {
readonly decision: FundPolicyDecision;

constructor(decision: FundPolicyDecision) {
super(decision.reason ?? "Funding denied by policy");
this.name = "FundPolicyDeniedError";
this.decision = decision;
}
}

export async function enforceFundPolicy(
policy: FundPolicy | undefined,
context: FundPolicyContext
): Promise<void> {
if (!policy) return;

// A policy failure is deliberately fail-closed: throws propagate and an
// explicit allow=true is required before the SDK prepares any transaction.
const decision = await policy(context);
if (!decision || decision.allow !== true) {
throw new FundPolicyDeniedError(
decision ?? { allow: false, reason: "Funding policy returned no decision" }
);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from "./core/chains.js";
export * from "./core/constants.js";
export * from "./core/assetToken.js";
export * from "./core/approvalGate.js";
export * from "./core/fundPolicy.js";

// Provider interfaces & adapters
export * from "./providers/types.js";
Expand Down
40 changes: 27 additions & 13 deletions src/jobSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,19 +349,19 @@ export class JobSession {
);
}

private detectConfiguredHooks(selector: Hex): {
private detectConfiguredHooks(selector: Hex, job = this._job): {
hasSub: boolean;
hasFund: boolean;
} {
if (!this._job) throw new Error("Job not loaded");
if (!job) throw new Error("Job not loaded");

const hook = this._job.hookAddress.toLowerCase();
const hook = job.hookAddress.toLowerCase();
const router = MULTI_HOOK_ROUTER_ADDRESSES[this.chainId]?.toLowerCase();
const subHook = SUBSCRIPTION_HOOK_ADDRESSES[this.chainId]?.toLowerCase();
const fundHook = FUND_TRANSFER_HOOK_ADDRESSES[this.chainId]?.toLowerCase();

if (hook === router) {
const configured = (this._job.hookConfigs ?? {})[selector];
const configured = (job.hookConfigs ?? {})[selector];
const lower = configured?.map((h) => h.toLowerCase()) ?? [];
return {
hasSub: lower.includes(subHook ?? ""),
Expand Down Expand Up @@ -504,23 +504,34 @@ export class JobSession {

async fund(amount?: AssetToken): Promise<void> {
if (!this._job) throw new Error("Job not loaded");
const effectiveAmount = amount ?? this._job.budget;
// Hold one immutable job reference across the asynchronous policy decision
// and every downstream funding branch. fetchJob() may refresh this._job
// while a slow policy is running; funding must use the exact snapshot that
// the policy approved.
const job = this._job;
const effectiveAmount = amount ?? job.budget;
const jobId = BigInt(this.jobId);

const hook = this._job.hookAddress.toLowerCase();
// Evaluate the exact provider wallet, chain and amount before any funding
// branch prepares or sends an on-chain transaction. A denied or failed
// policy is intentionally fail-closed.
await this.agent.enforceFundPolicy(job, effectiveAmount);

Comment thread
cursor[bot] marked this conversation as resolved.
const hook = job.hookAddress.toLowerCase();
const router = (
MULTI_HOOK_ROUTER_ADDRESSES[this.chainId] ?? ""
).toLowerCase();

if (router && hook === router) {
const hookConfigs = (this._job.hookConfigs ?? {})[ACP_SELECTORS.fund];
const hookConfigs = (job.hookConfigs ?? {})[ACP_SELECTORS.fund];
if (!hookConfigs || hookConfigs.length === 0) {
throw new Error(
"MultiHookRouter is attached but no sub-hooks are configured for the fund selector"
);
}
const { hasSub, hasFund } = this.detectConfiguredHooks(
ACP_SELECTORS.fund
ACP_SELECTORS.fund,
job
);

let subscriptionTerms:
Expand All @@ -537,7 +548,7 @@ export class JobSession {
}

if (hasFund) {
const intent = this._job.getFundRequestIntent();
const intent = job.getFundRequestIntent();
if (!intent) {
throw new Error(
"FundTransferHook is configured on the router but no fund request intent was recorded"
Expand All @@ -563,7 +574,10 @@ export class JobSession {
return;
}

const { hasSub, hasFund } = this.detectConfiguredHooks(ACP_SELECTORS.fund);
const { hasSub, hasFund } = this.detectConfiguredHooks(
ACP_SELECTORS.fund,
job
);

if (hasSub) {
const terms = await this.agent.getProposedSubscriptionTerms(
Expand All @@ -579,7 +593,7 @@ export class JobSession {
return;
}

const intent = this._job.getFundRequestIntent();
const intent = job.getFundRequestIntent();
if (intent && hasFund) {
const transferAmount = await intent.resolveAmount(
this.chainId,
Expand All @@ -591,15 +605,15 @@ export class JobSession {
amount: effectiveAmount,
transferAmount,
destination: intent.recipientAddress,
clientAddress: this._job.clientAddress,
clientAddress: job.clientAddress,
});
return;
}

await this.agent.internalFund(this.chainId, {
jobId,
amount: effectiveAmount,
clientAddress: this._job.clientAddress,
clientAddress: job.clientAddress,
});
}

Expand Down
106 changes: 106 additions & 0 deletions test/fundPolicy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
enforceFundPolicy,
FundPolicyDeniedError,
} from "../dist/core/fundPolicy.js";
import { JobSession } from "../dist/jobSession.js";

const context = {
action: "fund",
job: {},
chainId: 8453,
jobId: 17n,
providerAddress: "0x1111111111111111111111111111111111111111",
clientAddress: "0x2222222222222222222222222222222222222222",
amount: {},
};

test("allows only an explicit allow decision", async () => {
let received;
await enforceFundPolicy(async value => {
received = value;
return { allow: true, evidence: { source: "policy" } };
}, context);

assert.equal(received, context);
});

test("throws a typed error for an explicit denial", async () => {
const decision = { allow: false, reason: "counterparty rejected" };

await assert.rejects(
enforceFundPolicy(async () => decision, context),
error => {
assert.ok(error instanceof FundPolicyDeniedError);
assert.equal(error.message, decision.reason);
assert.equal(error.decision, decision);
return true;
},
);
});

test("fails closed when a policy returns no decision", async () => {
await assert.rejects(
enforceFundPolicy(async () => undefined, context),
/Funding policy returned no decision/,
);
});

test("propagates policy failures and preserves opt-in compatibility", async () => {
const failure = new Error("policy unavailable");
await assert.rejects(
enforceFundPolicy(async () => { throw failure; }, context),
error => error === failure,
);
await enforceFundPolicy(undefined, context);
});

test("fund uses the exact job snapshot approved by a slow policy", async () => {
let releasePolicy;
const policyPending = new Promise(resolve => {
releasePolicy = resolve;
});
let policyStarted;
const policyStartedPromise = new Promise(resolve => {
policyStarted = resolve;
});
let approvedJob;
let funded;
const agent = {
enforceFundPolicy: async job => {
approvedJob = job;
policyStarted();
await policyPending;
},
internalFund: async (_chainId, input) => {
funded = input;
},
};
const makeJob = clientAddress => ({
budget: { source: clientAddress },
clientAddress,
hookAddress: "0x0000000000000000000000000000000000000000",
hookConfigs: null,
getFundRequestIntent: () => null,
});
const approvedSnapshot = makeJob(
"0x1111111111111111111111111111111111111111",
);
const refreshedSnapshot = makeJob(
"0x2222222222222222222222222222222222222222",
);
const session = new JobSession(agent, [], "17", 8453, ["client"]);
session._job = approvedSnapshot;

const funding = session.fund();
await policyStartedPromise;
session._job = refreshedSnapshot;
releasePolicy();
await funding;

assert.equal(approvedJob, approvedSnapshot);
assert.equal(funded.clientAddress, approvedSnapshot.clientAddress);
assert.equal(funded.amount, approvedSnapshot.budget);
});