sealbridge 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +43 -0
- package/README.ja.md +397 -0
- package/README.md +567 -1
- package/dist/cli/dist/acp.d.ts +2 -0
- package/dist/cli/dist/acp.js +6 -0
- package/dist/cli/dist/acp.js.map +1 -0
- package/dist/cli/dist/approval-summary.d.ts +2 -0
- package/dist/cli/dist/approval-summary.js +32 -0
- package/dist/cli/dist/approval-summary.js.map +1 -0
- package/dist/cli/dist/board.d.ts +47 -0
- package/dist/cli/dist/board.js +255 -0
- package/dist/cli/dist/board.js.map +1 -0
- package/dist/cli/dist/contacts.d.ts +18 -0
- package/dist/cli/dist/contacts.js +154 -0
- package/dist/cli/dist/contacts.js.map +1 -0
- package/dist/cli/dist/doctor.d.ts +13 -0
- package/dist/cli/dist/doctor.js +30 -0
- package/dist/cli/dist/doctor.js.map +1 -0
- package/dist/cli/dist/error-messages.d.ts +2 -0
- package/dist/cli/dist/error-messages.js +24 -0
- package/dist/cli/dist/error-messages.js.map +1 -0
- package/dist/cli/dist/index.d.ts +2 -0
- package/dist/cli/dist/index.js +194 -0
- package/dist/cli/dist/index.js.map +1 -0
- package/dist/cli/dist/invite-share.d.ts +16 -0
- package/dist/cli/dist/invite-share.js +163 -0
- package/dist/cli/dist/invite-share.js.map +1 -0
- package/dist/cli/dist/mcp.d.ts +17 -0
- package/dist/cli/dist/mcp.js +105 -0
- package/dist/cli/dist/mcp.js.map +1 -0
- package/dist/cli/dist/network.d.ts +1 -0
- package/dist/cli/dist/network.js +396 -0
- package/dist/cli/dist/network.js.map +1 -0
- package/dist/cli/dist/public-settings.d.ts +27 -0
- package/dist/cli/dist/public-settings.js +33 -0
- package/dist/cli/dist/public-settings.js.map +1 -0
- package/dist/cli/dist/receipt.d.ts +39 -0
- package/dist/cli/dist/receipt.js +90 -0
- package/dist/cli/dist/receipt.js.map +1 -0
- package/dist/cli/dist/receive-hook.d.ts +2 -0
- package/dist/cli/dist/receive-hook.js +35 -0
- package/dist/cli/dist/receive-hook.js.map +1 -0
- package/dist/cli/dist/task-input.d.ts +2 -0
- package/dist/cli/dist/task-input.js +19 -0
- package/dist/cli/dist/task-input.js.map +1 -0
- package/dist/cli/dist/wake-hook.d.ts +3 -0
- package/dist/cli/dist/wake-hook.js +32 -0
- package/dist/cli/dist/wake-hook.js.map +1 -0
- package/dist/examples/two-owner-demo/alice-policy.json +4 -0
- package/dist/examples/two-owner-demo/bob-policy.json +4 -0
- package/dist/examples/two-owner-demo/dist/run-demo.d.ts +71 -0
- package/dist/examples/two-owner-demo/dist/run-demo.js +383 -0
- package/dist/examples/two-owner-demo/dist/run-demo.js.map +1 -0
- package/dist/package.json +1 -0
- package/dist/packages/adapters/dist/command.d.ts +122 -0
- package/dist/packages/adapters/dist/command.js +378 -0
- package/dist/packages/adapters/dist/command.js.map +1 -0
- package/dist/packages/adapters/dist/credential-proxy.d.ts +37 -0
- package/dist/packages/adapters/dist/credential-proxy.js +363 -0
- package/dist/packages/adapters/dist/credential-proxy.js.map +1 -0
- package/dist/packages/adapters/dist/index.d.ts +6 -0
- package/dist/packages/adapters/dist/index.js +6 -0
- package/dist/packages/adapters/dist/index.js.map +1 -0
- package/dist/packages/adapters/dist/mcp.d.ts +32 -0
- package/dist/packages/adapters/dist/mcp.js +87 -0
- package/dist/packages/adapters/dist/mcp.js.map +1 -0
- package/dist/packages/adapters/dist/openai.d.ts +23 -0
- package/dist/packages/adapters/dist/openai.js +81 -0
- package/dist/packages/adapters/dist/openai.js.map +1 -0
- package/dist/packages/adapters/dist/sandbox.d.ts +45 -0
- package/dist/packages/adapters/dist/sandbox.js +60 -0
- package/dist/packages/adapters/dist/sandbox.js.map +1 -0
- package/dist/packages/core/dist/capability.d.ts +36 -0
- package/dist/packages/core/dist/capability.js +203 -0
- package/dist/packages/core/dist/capability.js.map +1 -0
- package/dist/packages/core/dist/contract.d.ts +33 -0
- package/dist/packages/core/dist/contract.js +97 -0
- package/dist/packages/core/dist/contract.js.map +1 -0
- package/dist/packages/core/dist/crypto.d.ts +34 -0
- package/dist/packages/core/dist/crypto.js +329 -0
- package/dist/packages/core/dist/crypto.js.map +1 -0
- package/dist/packages/core/dist/discovery.d.ts +129 -0
- package/dist/packages/core/dist/discovery.js +527 -0
- package/dist/packages/core/dist/discovery.js.map +1 -0
- package/dist/packages/core/dist/edge.d.ts +4 -0
- package/dist/packages/core/dist/edge.js +6 -0
- package/dist/packages/core/dist/edge.js.map +1 -0
- package/dist/packages/core/dist/index.d.ts +9 -0
- package/dist/packages/core/dist/index.js +10 -0
- package/dist/packages/core/dist/index.js.map +1 -0
- package/dist/packages/core/dist/receipts.d.ts +9 -0
- package/dist/packages/core/dist/receipts.js +67 -0
- package/dist/packages/core/dist/receipts.js.map +1 -0
- package/dist/packages/core/dist/relay-auth.d.ts +40 -0
- package/dist/packages/core/dist/relay-auth.js +106 -0
- package/dist/packages/core/dist/relay-auth.js.map +1 -0
- package/dist/packages/core/dist/revocation.d.ts +58 -0
- package/dist/packages/core/dist/revocation.js +112 -0
- package/dist/packages/core/dist/revocation.js.map +1 -0
- package/dist/packages/core/dist/schema.d.ts +11 -0
- package/dist/packages/core/dist/schema.js +48 -0
- package/dist/packages/core/dist/schema.js.map +1 -0
- package/dist/packages/core/dist/types.d.ts +172 -0
- package/dist/packages/core/dist/types.js +2 -0
- package/dist/packages/core/dist/types.js.map +1 -0
- package/dist/packages/ledger/dist/index.d.ts +84 -0
- package/dist/packages/ledger/dist/index.js +269 -0
- package/dist/packages/ledger/dist/index.js.map +1 -0
- package/dist/packages/relay/dist/fetch.d.ts +250 -0
- package/dist/packages/relay/dist/fetch.js +1401 -0
- package/dist/packages/relay/dist/fetch.js.map +1 -0
- package/dist/packages/relay/dist/index.d.ts +103 -0
- package/dist/packages/relay/dist/index.js +855 -0
- package/dist/packages/relay/dist/index.js.map +1 -0
- package/dist/packages/relay/dist/journal.d.ts +77 -0
- package/dist/packages/relay/dist/journal.js +339 -0
- package/dist/packages/relay/dist/journal.js.map +1 -0
- package/dist/packages/relay/dist/notifications.d.ts +63 -0
- package/dist/packages/relay/dist/notifications.js +55 -0
- package/dist/packages/relay/dist/notifications.js.map +1 -0
- package/dist/packages/relay/dist/retry.d.ts +12 -0
- package/dist/packages/relay/dist/retry.js +59 -0
- package/dist/packages/relay/dist/retry.js.map +1 -0
- package/dist/packages/relay/dist/ssrf.d.ts +32 -0
- package/dist/packages/relay/dist/ssrf.js +213 -0
- package/dist/packages/relay/dist/ssrf.js.map +1 -0
- package/dist/packages/relay/dist/worker.d.ts +92 -0
- package/dist/packages/relay/dist/worker.js +460 -0
- package/dist/packages/relay/dist/worker.js.map +1 -0
- package/dist/packages/runtime/dist/a2a.d.ts +8 -0
- package/dist/packages/runtime/dist/a2a.js +69 -0
- package/dist/packages/runtime/dist/a2a.js.map +1 -0
- package/dist/packages/runtime/dist/adapter.d.ts +51 -0
- package/dist/packages/runtime/dist/adapter.js +2 -0
- package/dist/packages/runtime/dist/adapter.js.map +1 -0
- package/dist/packages/runtime/dist/approval-providers.d.ts +99 -0
- package/dist/packages/runtime/dist/approval-providers.js +269 -0
- package/dist/packages/runtime/dist/approval-providers.js.map +1 -0
- package/dist/packages/runtime/dist/daily-limit.d.ts +2 -0
- package/dist/packages/runtime/dist/daily-limit.js +54 -0
- package/dist/packages/runtime/dist/daily-limit.js.map +1 -0
- package/dist/packages/runtime/dist/gateway.d.ts +35 -0
- package/dist/packages/runtime/dist/gateway.js +92 -0
- package/dist/packages/runtime/dist/gateway.js.map +1 -0
- package/dist/packages/runtime/dist/inbox.d.ts +10 -0
- package/dist/packages/runtime/dist/inbox.js +24 -0
- package/dist/packages/runtime/dist/inbox.js.map +1 -0
- package/dist/packages/runtime/dist/index.d.ts +13 -0
- package/dist/packages/runtime/dist/index.js +14 -0
- package/dist/packages/runtime/dist/index.js.map +1 -0
- package/dist/packages/runtime/dist/mcp-server.d.ts +90 -0
- package/dist/packages/runtime/dist/mcp-server.js +647 -0
- package/dist/packages/runtime/dist/mcp-server.js.map +1 -0
- package/dist/packages/runtime/dist/mcp-tool-catalog.d.ts +3 -0
- package/dist/packages/runtime/dist/mcp-tool-catalog.js +507 -0
- package/dist/packages/runtime/dist/mcp-tool-catalog.js.map +1 -0
- package/dist/packages/runtime/dist/mcp.d.ts +73 -0
- package/dist/packages/runtime/dist/mcp.js +67 -0
- package/dist/packages/runtime/dist/mcp.js.map +1 -0
- package/dist/packages/runtime/dist/policy.d.ts +19 -0
- package/dist/packages/runtime/dist/policy.js +41 -0
- package/dist/packages/runtime/dist/policy.js.map +1 -0
- package/dist/packages/runtime/dist/quota.d.ts +39 -0
- package/dist/packages/runtime/dist/quota.js +147 -0
- package/dist/packages/runtime/dist/quota.js.map +1 -0
- package/dist/packages/runtime/dist/session-audit.d.ts +107 -0
- package/dist/packages/runtime/dist/session-audit.js +520 -0
- package/dist/packages/runtime/dist/session-audit.js.map +1 -0
- package/dist/packages/runtime/dist/session-timeouts.d.ts +33 -0
- package/dist/packages/runtime/dist/session-timeouts.js +62 -0
- package/dist/packages/runtime/dist/session-timeouts.js.map +1 -0
- package/dist/packages/runtime/dist/session.d.ts +323 -0
- package/dist/packages/runtime/dist/session.js +2252 -0
- package/dist/packages/runtime/dist/session.js.map +1 -0
- package/dist/packages/runtime/dist/task-journal.d.ts +36 -0
- package/dist/packages/runtime/dist/task-journal.js +104 -0
- package/dist/packages/runtime/dist/task-journal.js.map +1 -0
- package/dist/packages/runtime/dist/verifier.d.ts +64 -0
- package/dist/packages/runtime/dist/verifier.js +109 -0
- package/dist/packages/runtime/dist/verifier.js.map +1 -0
- package/dist/packages/sandbox/dist/container.d.ts +33 -0
- package/dist/packages/sandbox/dist/container.js +189 -0
- package/dist/packages/sandbox/dist/container.js.map +1 -0
- package/dist/packages/sandbox/dist/index.d.ts +34 -0
- package/dist/packages/sandbox/dist/index.js +179 -0
- package/dist/packages/sandbox/dist/index.js.map +1 -0
- package/dist/packages/sandbox/dist/permission.d.ts +58 -0
- package/dist/packages/sandbox/dist/permission.js +90 -0
- package/dist/packages/sandbox/dist/permission.js.map +1 -0
- package/dist/spec/README.ja.md +76 -0
- package/dist/spec/README.md +135 -0
- package/dist/spec/ack-receipt.schema.json +77 -0
- package/dist/spec/agent-card.schema.json +83 -0
- package/dist/spec/approval-receipt.schema.json +94 -0
- package/dist/spec/audit-entry.schema.json +101 -0
- package/dist/spec/capability-token.schema.json +133 -0
- package/dist/spec/envelope.schema.json +197 -0
- package/dist/spec/examples/sample-ack-receipt.json +16 -0
- package/dist/spec/examples/sample-agent-card.json +35 -0
- package/dist/spec/examples/sample-approval-receipt.json +18 -0
- package/dist/spec/examples/sample-audit-entry.json +37 -0
- package/dist/spec/examples/sample-capability-token.json +39 -0
- package/dist/spec/examples/sample-envelope.json +21 -0
- package/dist/spec/examples/sample-task-manifest.json +141 -0
- package/dist/spec/examples/sample-usage-receipt.json +19 -0
- package/dist/spec/task-manifest.schema.json +422 -0
- package/dist/spec/usage-receipt.schema.json +91 -0
- package/package.json +61 -5
|
@@ -0,0 +1,2252 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { consumeDailyTask } from './daily-limit.js';
|
|
4
|
+
import { existsSync, openSync, closeSync, fstatSync, fchmodSync, appendFileSync, constants, fsyncSync, readFileSync, } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { createContract, createCapability, createSignedAgentCard, createInviteToken, parseAndVerifyInviteToken, isInviteTarget, validateRelayEndpointUrl, signPayload, verifySigned, verifyEnvelope, verifyContract, verifyApproval, validateManifest, validateOutput, verifyCapabilityChain, capabilityIssuerControls, findDiscoveredAgentCard, discoverAgentCards, schemaNarrows, digest, openEnvelope, resolveDid, createRevocationNotice, verifyRevocationNotice, RevocationRegistry, signEnvelope, DEFAULT_CLOCK_SKEW_MS, OPEN_POLICY_FIELDS, } from '../../core/dist/index.js';
|
|
9
|
+
import { parseJsonl, verifyAudit } from '../../ledger/dist/index.js';
|
|
10
|
+
import { HttpRelayClient, waitForRelayRetry, relayDiagnostic, relayDeadline } from '../../relay/dist/index.js';
|
|
11
|
+
import { InteractiveCliApprovalProvider, ProposalRejectedError, evaluateContractAcceptance, } from './approval-providers.js';
|
|
12
|
+
import { OwnerGateway } from './gateway.js';
|
|
13
|
+
import { MCPProxy } from './mcp.js';
|
|
14
|
+
import { VerificationEngine, createDelivery } from './verifier.js';
|
|
15
|
+
import { asJson, receiveRelay, acknowledgeRelay, PeerRejectedError, SessionWire } from './session-audit.js';
|
|
16
|
+
import { SessionTimeouts, validateSessionTimeouts, resolveSessionTimeouts } from './session-timeouts.js';
|
|
17
|
+
import { TaskJournal } from './task-journal.js';
|
|
18
|
+
const policy = { requiredGates: [], autoApprove: [] };
|
|
19
|
+
/** Daemon workers stay finite unless the owner raises the cap. */
|
|
20
|
+
export const DEFAULT_MAX_CONCURRENT_TASKS = 8;
|
|
21
|
+
function acceptanceFrom(options) {
|
|
22
|
+
return {
|
|
23
|
+
allowedRequesterDids: options.allowedRequesterDids,
|
|
24
|
+
allowAnyRequester: options.allowAnyRequester,
|
|
25
|
+
maxInputTokens: options.maxInputTokens,
|
|
26
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
27
|
+
maxToolInvocations: options.maxToolInvocations,
|
|
28
|
+
maxWallClockSeconds: options.maxWallClockSeconds,
|
|
29
|
+
maxConcurrentTasks: options.maxConcurrentTasks,
|
|
30
|
+
maxTasksPerRequesterPerMinute: options.maxTasksPerRequesterPerMinute,
|
|
31
|
+
requesterRateLimit: options.requesterRateLimit,
|
|
32
|
+
publishToRegistry: options.publishToRegistry,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function assertPositive(name, value, minimum = 1) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return;
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < minimum)
|
|
39
|
+
throw new Error('INVALID_' + name);
|
|
40
|
+
}
|
|
41
|
+
function validateAcceptance(acceptance) {
|
|
42
|
+
assertPositive('MAX_INPUT_TOKENS', acceptance.maxInputTokens);
|
|
43
|
+
assertPositive('MAX_OUTPUT_TOKENS', acceptance.maxOutputTokens);
|
|
44
|
+
assertPositive('MAX_TOOL_INVOCATIONS', acceptance.maxToolInvocations);
|
|
45
|
+
assertPositive('MAX_WALL_CLOCK_SECONDS', acceptance.maxWallClockSeconds);
|
|
46
|
+
assertPositive('MAX_CONCURRENT_TASKS', acceptance.maxConcurrentTasks);
|
|
47
|
+
assertPositive('MAX_TASKS_PER_REQUESTER', acceptance.maxTasksPerRequesterPerMinute);
|
|
48
|
+
const rate = acceptance.requesterRateLimit;
|
|
49
|
+
if (rate &&
|
|
50
|
+
(!Number.isSafeInteger(rate.maxRequests) ||
|
|
51
|
+
rate.maxRequests < 1 ||
|
|
52
|
+
!Number.isSafeInteger(rate.windowMs) ||
|
|
53
|
+
rate.windowMs < 1))
|
|
54
|
+
throw new Error('INVALID_REQUESTER_RATE_LIMIT');
|
|
55
|
+
if (acceptance.allowedRequesterDids?.some((did) => typeof did !== 'string' || did.trim() === ''))
|
|
56
|
+
throw new Error('INVALID_REQUESTER_DID');
|
|
57
|
+
}
|
|
58
|
+
function transport(relay, owner, security) {
|
|
59
|
+
const client = typeof relay === 'string'
|
|
60
|
+
? new HttpRelayClient(relay.replace(/\/$/, ''), owner, security)
|
|
61
|
+
: relay;
|
|
62
|
+
client.setRecipientKey?.(owner);
|
|
63
|
+
const bound = client.withRecipient?.(owner) ?? client;
|
|
64
|
+
return {
|
|
65
|
+
get connectionStatus() { return bound.connectionStatus; },
|
|
66
|
+
send: (envelope) => relayDeadline(Promise.resolve(bound.send(envelope)), security?.requestTimeoutMs ?? 45_000),
|
|
67
|
+
receive: async (did, waitMs, signal, filter) => {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const readSignal = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
|
|
70
|
+
try {
|
|
71
|
+
return await relayDeadline(Promise.resolve(bound.receive(did, waitMs, readSignal, filter)), security?.requestTimeoutMs ?? 45_000, readSignal);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
controller.abort(error);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
...(bound.acknowledgeDelivery ? { acknowledgeDelivery: (did, id) => relayDeadline(Promise.resolve(bound.acknowledgeDelivery(did, id)), security?.requestTimeoutMs ?? 45_000) } : {}),
|
|
79
|
+
...(bound.deliveryStatus ? { deliveryStatus: (envelope, signal) => relayDeadline(Promise.resolve(bound.deliveryStatus(envelope, signal)), security?.requestTimeoutMs ?? 45_000, signal) } : {}),
|
|
80
|
+
...(bound.register ? { register: (card) => bound.register(card) } : {}),
|
|
81
|
+
...(bound.discover ? { discover: () => relayDeadline(Promise.resolve(bound.discover()), security?.requestTimeoutMs ?? 45_000) } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function freshAudit(path) {
|
|
85
|
+
if (path && existsSync(path))
|
|
86
|
+
throw new Error('AUDIT_FILE_ALREADY_EXISTS');
|
|
87
|
+
}
|
|
88
|
+
/** Cleartext is rejected unless the owner explicitly opts out for a legacy fixture. */
|
|
89
|
+
function sessionDecryptOptions(allowUnencryptedEnvelopes) {
|
|
90
|
+
return { requireEncrypted: allowUnencryptedEnvelopes !== true };
|
|
91
|
+
}
|
|
92
|
+
/** Per-task queue so one session cannot dequeue another task's envelopes. */
|
|
93
|
+
class TaskMailbox {
|
|
94
|
+
queue = [];
|
|
95
|
+
error;
|
|
96
|
+
close(error) {
|
|
97
|
+
this.error ??= error;
|
|
98
|
+
for (const wake of this.waiters.splice(0))
|
|
99
|
+
wake();
|
|
100
|
+
}
|
|
101
|
+
waiters = [];
|
|
102
|
+
push(envelope) {
|
|
103
|
+
this.queue.push(envelope);
|
|
104
|
+
this.waiters.shift()?.();
|
|
105
|
+
}
|
|
106
|
+
async take(signal) {
|
|
107
|
+
for (;;) {
|
|
108
|
+
if (this.error)
|
|
109
|
+
throw this.error;
|
|
110
|
+
signal?.throwIfAborted();
|
|
111
|
+
const next = this.queue.shift();
|
|
112
|
+
if (next)
|
|
113
|
+
return next;
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (error) => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
signal?.removeEventListener('abort', onAbort);
|
|
121
|
+
this.waiters = this.waiters.filter((waiter) => waiter !== onWake);
|
|
122
|
+
error ? reject(error) : resolve();
|
|
123
|
+
};
|
|
124
|
+
const onAbort = () => finish(signal?.reason instanceof Error ? signal.reason : new Error('SESSION_ABORTED'));
|
|
125
|
+
const onWake = () => finish();
|
|
126
|
+
this.waiters.push(onWake);
|
|
127
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
128
|
+
if (signal?.aborted)
|
|
129
|
+
onAbort();
|
|
130
|
+
else if (this.queue.length)
|
|
131
|
+
onWake();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function mailboxRelay(relay, mailbox) {
|
|
137
|
+
return {
|
|
138
|
+
send: (envelope) => relay.send(envelope),
|
|
139
|
+
...(relay.deliveryStatus ? { deliveryStatus: (envelope, signal) => relay.deliveryStatus(envelope, signal) } : {}),
|
|
140
|
+
...(relay.acknowledgeDelivery ? { acknowledgeDelivery: (did, id) => relay.acknowledgeDelivery(did, id) } : {}),
|
|
141
|
+
receive: (_recipientDid, _waitMs, signal) => mailbox.take(signal),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const WORKER_BOUND_EVENTS = new Set([
|
|
145
|
+
'PROPOSE',
|
|
146
|
+
'REVOKE',
|
|
147
|
+
'VERIFY_ACCEPT',
|
|
148
|
+
'VERIFY_REJECT',
|
|
149
|
+
'SETTLE',
|
|
150
|
+
'SETTLED',
|
|
151
|
+
'CANCEL',
|
|
152
|
+
'CHECKPOINT',
|
|
153
|
+
]);
|
|
154
|
+
const REQUESTER_BOUND_EVENTS = new Set([
|
|
155
|
+
'ACCEPT',
|
|
156
|
+
'ATTEST',
|
|
157
|
+
'START',
|
|
158
|
+
'PROGRESS',
|
|
159
|
+
'DELIVER',
|
|
160
|
+
'HEARTBEAT',
|
|
161
|
+
'CHECKPOINT_ACK',
|
|
162
|
+
]);
|
|
163
|
+
const activeRequesterInboxes = new Map();
|
|
164
|
+
const activeWorkerListeners = new Map();
|
|
165
|
+
function registerRequesterInbox(did, inbox) {
|
|
166
|
+
const set = activeRequesterInboxes.get(did) ?? new Set();
|
|
167
|
+
set.add(inbox);
|
|
168
|
+
activeRequesterInboxes.set(did, set);
|
|
169
|
+
}
|
|
170
|
+
function unregisterRequesterInbox(did, inbox) {
|
|
171
|
+
const set = activeRequesterInboxes.get(did);
|
|
172
|
+
if (!set)
|
|
173
|
+
return;
|
|
174
|
+
set.delete(inbox);
|
|
175
|
+
if (set.size === 0)
|
|
176
|
+
activeRequesterInboxes.delete(did);
|
|
177
|
+
}
|
|
178
|
+
function forwardToRequesterInbox(did, envelope) {
|
|
179
|
+
const inboxes = activeRequesterInboxes.get(did);
|
|
180
|
+
if (!inboxes)
|
|
181
|
+
return false;
|
|
182
|
+
for (const inbox of inboxes) {
|
|
183
|
+
if (inbox.deliverFromPeerRole(envelope))
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
function registerWorkerListener(did, fn) {
|
|
189
|
+
const set = activeWorkerListeners.get(did) ?? new Set();
|
|
190
|
+
set.add(fn);
|
|
191
|
+
activeWorkerListeners.set(did, set);
|
|
192
|
+
return () => {
|
|
193
|
+
set.delete(fn);
|
|
194
|
+
if (set.size === 0)
|
|
195
|
+
activeWorkerListeners.delete(did);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function forwardToWorkerListener(did, envelope) {
|
|
199
|
+
const listeners = activeWorkerListeners.get(did);
|
|
200
|
+
if (!listeners)
|
|
201
|
+
return false;
|
|
202
|
+
for (const fn of listeners) {
|
|
203
|
+
if (fn(envelope))
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* One reader for a recipient DID. Concurrent delegate() calls share this inbox
|
|
210
|
+
* and only see envelopes for their own taskId.
|
|
211
|
+
*/
|
|
212
|
+
class RecipientInbox {
|
|
213
|
+
relay;
|
|
214
|
+
did;
|
|
215
|
+
mailboxes = new Map();
|
|
216
|
+
wake;
|
|
217
|
+
readAbort;
|
|
218
|
+
started = false;
|
|
219
|
+
error;
|
|
220
|
+
constructor(relay, did) {
|
|
221
|
+
this.relay = relay;
|
|
222
|
+
this.did = did;
|
|
223
|
+
}
|
|
224
|
+
hasTask(taskId) {
|
|
225
|
+
return this.mailboxes.has(taskId);
|
|
226
|
+
}
|
|
227
|
+
deliverFromPeerRole(envelope) {
|
|
228
|
+
const mailbox = this.mailboxes.get(envelope.payload.taskId);
|
|
229
|
+
if (!mailbox)
|
|
230
|
+
return false;
|
|
231
|
+
mailbox.push(envelope);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
attach(taskId) {
|
|
235
|
+
if (this.error)
|
|
236
|
+
throw this.error;
|
|
237
|
+
const existing = this.mailboxes.get(taskId);
|
|
238
|
+
if (existing)
|
|
239
|
+
return existing;
|
|
240
|
+
const mailbox = new TaskMailbox();
|
|
241
|
+
this.mailboxes.set(taskId, mailbox);
|
|
242
|
+
registerRequesterInbox(this.did, this);
|
|
243
|
+
this.wake?.();
|
|
244
|
+
this.wake = undefined;
|
|
245
|
+
if (!this.started) {
|
|
246
|
+
this.started = true;
|
|
247
|
+
void this.pump();
|
|
248
|
+
}
|
|
249
|
+
return mailbox;
|
|
250
|
+
}
|
|
251
|
+
/** Drop the task mailbox. The shared reader stops dequeuing once none remain. */
|
|
252
|
+
detach(taskId) {
|
|
253
|
+
this.mailboxes.delete(taskId);
|
|
254
|
+
if (this.mailboxes.size === 0) {
|
|
255
|
+
unregisterRequesterInbox(this.did, this);
|
|
256
|
+
this.readAbort?.abort();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
isWorkerBoundFrame(envelope) {
|
|
260
|
+
const { eventType, seq } = envelope.payload;
|
|
261
|
+
if (WORKER_BOUND_EVENTS.has(eventType))
|
|
262
|
+
return true;
|
|
263
|
+
if (eventType === 'AUDIT_ENTRY' && seq === 1)
|
|
264
|
+
return true;
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
async pump() {
|
|
268
|
+
for (;;) {
|
|
269
|
+
if (this.mailboxes.size === 0) {
|
|
270
|
+
await new Promise((resolve) => {
|
|
271
|
+
if (this.mailboxes.size > 0) {
|
|
272
|
+
resolve();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.wake = resolve;
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
this.readAbort = controller;
|
|
281
|
+
try {
|
|
282
|
+
const envelope = await receiveRelay(this.relay, this.did, controller.signal, {
|
|
283
|
+
role: 'requester',
|
|
284
|
+
});
|
|
285
|
+
if (!this.mailboxes.has(envelope.payload.taskId) || this.isWorkerBoundFrame(envelope)) {
|
|
286
|
+
if (forwardToWorkerListener(this.did, envelope))
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
this.mailboxes.get(envelope.payload.taskId)?.push(envelope);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (controller.signal.aborted)
|
|
293
|
+
continue;
|
|
294
|
+
this.error = relayDiagnostic(error);
|
|
295
|
+
controller.abort(this.error);
|
|
296
|
+
for (const mailbox of this.mailboxes.values())
|
|
297
|
+
mailbox.close(this.error);
|
|
298
|
+
unregisterRequesterInbox(this.did, this);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const recipientInboxes = new WeakMap();
|
|
305
|
+
function recipientInbox(source, relay, did, relayUrl) {
|
|
306
|
+
// String relays include the URL so one client can switch servers. The same URL still shares
|
|
307
|
+
// one inbox. Object relays stay keyed only by owner DID.
|
|
308
|
+
const key = relayUrl === undefined ? did : did + '\0' + relayUrl;
|
|
309
|
+
let byDid = recipientInboxes.get(source);
|
|
310
|
+
if (!byDid) {
|
|
311
|
+
byDid = new Map();
|
|
312
|
+
recipientInboxes.set(source, byDid);
|
|
313
|
+
}
|
|
314
|
+
let inbox = byDid.get(key);
|
|
315
|
+
if (!inbox) {
|
|
316
|
+
inbox = new RecipientInbox(relay, did);
|
|
317
|
+
byDid.set(key, inbox);
|
|
318
|
+
}
|
|
319
|
+
return inbox;
|
|
320
|
+
}
|
|
321
|
+
/** Checks every backing list. Inbox notices and an owner-supplied registry both apply. */
|
|
322
|
+
class CompositeRevocation {
|
|
323
|
+
layers;
|
|
324
|
+
constructor(layers) {
|
|
325
|
+
this.layers = layers;
|
|
326
|
+
}
|
|
327
|
+
isRevoked(targetType, targetId) {
|
|
328
|
+
return this.layers.some((layer) => layer.isRevoked(targetType, targetId));
|
|
329
|
+
}
|
|
330
|
+
assertNotRevoked(targetType, targetId) {
|
|
331
|
+
for (const layer of this.layers)
|
|
332
|
+
layer.assertNotRevoked(targetType, targetId);
|
|
333
|
+
}
|
|
334
|
+
noticesFor(targetType, targetId) {
|
|
335
|
+
const notices = [];
|
|
336
|
+
for (const layer of this.layers) {
|
|
337
|
+
if (typeof layer.noticesFor !== 'function')
|
|
338
|
+
continue;
|
|
339
|
+
notices.push(...layer.noticesFor(targetType, targetId));
|
|
340
|
+
}
|
|
341
|
+
return notices;
|
|
342
|
+
}
|
|
343
|
+
/** A layer that cannot show issuers and still reports the id revoked. */
|
|
344
|
+
unexplainedRevocation(targetType, targetId) {
|
|
345
|
+
return this.layers.some((layer) => typeof layer.noticesFor !== 'function' && layer.isRevoked(targetType, targetId));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function asRevocationRegistry(list) {
|
|
349
|
+
if (!list)
|
|
350
|
+
return undefined;
|
|
351
|
+
const candidate = list;
|
|
352
|
+
if (typeof candidate.revoke !== 'function' || typeof candidate.isRevoked !== 'function')
|
|
353
|
+
return undefined;
|
|
354
|
+
return candidate;
|
|
355
|
+
}
|
|
356
|
+
export class WorkerNode {
|
|
357
|
+
options;
|
|
358
|
+
relay;
|
|
359
|
+
agentCard;
|
|
360
|
+
approvalProvider;
|
|
361
|
+
acceptance;
|
|
362
|
+
completed = 0;
|
|
363
|
+
activeTasks = 0;
|
|
364
|
+
taskJournal;
|
|
365
|
+
reconciled = false;
|
|
366
|
+
proposalTimes = new Map();
|
|
367
|
+
mailboxes = new Map();
|
|
368
|
+
forwardedQueue = [];
|
|
369
|
+
forwardedWaiters = [];
|
|
370
|
+
controller = new AbortController();
|
|
371
|
+
lastResult;
|
|
372
|
+
/** Notices learned from the relay inbox. Always present, even without options.revocation. */
|
|
373
|
+
inboxRevocations = new RevocationRegistry();
|
|
374
|
+
/** Capability notices whose issuer is not yet matched to a verified chain. */
|
|
375
|
+
pendingCapabilities = new Map();
|
|
376
|
+
/** Chains that passed verifyCapabilityChain, so a later REVOKE can hit callTool. */
|
|
377
|
+
liveChains = new Map();
|
|
378
|
+
/** Inbox registry composed with options.revocation when the caller passed one. */
|
|
379
|
+
revocation;
|
|
380
|
+
constructor(options) {
|
|
381
|
+
this.options = options;
|
|
382
|
+
validateSessionTimeouts(options);
|
|
383
|
+
if (options.inviteTtlSeconds !== undefined && (!Number.isSafeInteger(options.inviteTtlSeconds) ||
|
|
384
|
+
options.inviteTtlSeconds < 1 || options.inviteTtlSeconds > 2592000))
|
|
385
|
+
throw new Error('INVALID_INVITE_TTL');
|
|
386
|
+
const persistencePath = options.inboxPath ?? options.auditPath;
|
|
387
|
+
if (persistencePath)
|
|
388
|
+
this.taskJournal = new TaskJournal(persistencePath + '.tasks.jsonl', options.owner);
|
|
389
|
+
if (options.dailyLimit && (!options.dailyLimit.path || !Number.isSafeInteger(options.dailyLimit.maxTasks) || options.dailyLimit.maxTasks < 1))
|
|
390
|
+
throw new Error('INVALID_DAILY_LIMIT');
|
|
391
|
+
if (options.accessMode !== undefined && !['invite-only', 'open'].includes(options.accessMode))
|
|
392
|
+
throw new Error('INVALID_ACCESS_MODE');
|
|
393
|
+
this.acceptance = acceptanceFrom(options);
|
|
394
|
+
validateAcceptance(this.acceptance);
|
|
395
|
+
this.revocation = options.revocation
|
|
396
|
+
? new CompositeRevocation([this.inboxRevocations, options.revocation])
|
|
397
|
+
: this.inboxRevocations;
|
|
398
|
+
// Every start, runOnce, and pumpUntil read goes through this wrapper, so a
|
|
399
|
+
// leading REVOKE is ingested before the next PROPOSE is returned.
|
|
400
|
+
this.relay = this.revocationAwareRelay(transport(options.relay, options.owner, {
|
|
401
|
+
allowPrivateRelay: options.allowPrivateRelay,
|
|
402
|
+
lookup: options.lookup,
|
|
403
|
+
requestTimeoutMs: options.connectTimeoutMs,
|
|
404
|
+
}));
|
|
405
|
+
this.approvalProvider =
|
|
406
|
+
options.approvalProvider ?? new InteractiveCliApprovalProvider(options.owner);
|
|
407
|
+
const agentDid = options.agentDid ?? options.agentKey?.did ?? options.owner.did;
|
|
408
|
+
this.agentCard = createSignedAgentCard({
|
|
409
|
+
name: options.name ?? options.adapter.name,
|
|
410
|
+
agentDid,
|
|
411
|
+
relayEndpoint: typeof options.relay === 'string'
|
|
412
|
+
? options.relay
|
|
413
|
+
: (options.relayUrl ?? 'http://in-memory.invalid'),
|
|
414
|
+
skills: options.adapter.skills,
|
|
415
|
+
...(options.inviteTtlSeconds !== undefined ? {
|
|
416
|
+
expiresAt: new Date(Date.now() + options.inviteTtlSeconds * 1000).toISOString(),
|
|
417
|
+
} : {}),
|
|
418
|
+
...(options.accessMode ? { accessMode: options.accessMode } : {}),
|
|
419
|
+
...(options.openPolicy !== undefined ? { openPolicy: options.openPolicy } : {}),
|
|
420
|
+
}, options.owner, agentDid === options.owner.did ? undefined : options.agentKey);
|
|
421
|
+
}
|
|
422
|
+
invite(expiresInSeconds = this.options.inviteTtlSeconds ?? 86400) {
|
|
423
|
+
return createInviteToken({ agentCard: this.agentCard, expiresInSeconds }, this.options.owner);
|
|
424
|
+
}
|
|
425
|
+
async register() {
|
|
426
|
+
await this.relay.register?.(this.agentCard);
|
|
427
|
+
}
|
|
428
|
+
stop() {
|
|
429
|
+
this.controller.abort('WORKER_STOPPED');
|
|
430
|
+
this.listenerStatus('stopped');
|
|
431
|
+
}
|
|
432
|
+
/** Public registration is opt-in, including for PolicyAutoApprovalProvider. */
|
|
433
|
+
publishes() {
|
|
434
|
+
return this.options.publishToRegistry === true;
|
|
435
|
+
}
|
|
436
|
+
get concurrentLimit() {
|
|
437
|
+
return this.acceptance.maxConcurrentTasks ?? DEFAULT_MAX_CONCURRENT_TASKS;
|
|
438
|
+
}
|
|
439
|
+
rateBudget() {
|
|
440
|
+
if (this.acceptance.requesterRateLimit)
|
|
441
|
+
return this.acceptance.requesterRateLimit;
|
|
442
|
+
if (this.acceptance.maxTasksPerRequesterPerMinute === undefined)
|
|
443
|
+
return undefined;
|
|
444
|
+
return { maxRequests: this.acceptance.maxTasksPerRequesterPerMinute, windowMs: 60_000 };
|
|
445
|
+
}
|
|
446
|
+
/** Count a PROPOSE from this requester. Returns a reason when the window is full. */
|
|
447
|
+
consumeRate(requesterDid) {
|
|
448
|
+
const budget = this.rateBudget();
|
|
449
|
+
if (!budget)
|
|
450
|
+
return undefined;
|
|
451
|
+
const now = Date.now();
|
|
452
|
+
const recent = (this.proposalTimes.get(requesterDid) ?? []).filter((at) => now - at < budget.windowMs);
|
|
453
|
+
if (recent.length >= budget.maxRequests) {
|
|
454
|
+
this.proposalTimes.set(requesterDid, recent);
|
|
455
|
+
return ('per-requester rate limit exceeded for ' +
|
|
456
|
+
requesterDid +
|
|
457
|
+
' (' +
|
|
458
|
+
budget.maxRequests +
|
|
459
|
+
' requests / ' +
|
|
460
|
+
budget.windowMs +
|
|
461
|
+
'ms)');
|
|
462
|
+
}
|
|
463
|
+
recent.push(now);
|
|
464
|
+
this.proposalTimes.set(requesterDid, recent);
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
/** Budget, allowlist, and rate checks that can be decided before execution starts. */
|
|
468
|
+
intakeRejection(first) {
|
|
469
|
+
try {
|
|
470
|
+
const data = openEnvelope(first, this.options.owner, sessionDecryptOptions(this.options.allowUnencryptedEnvelopes));
|
|
471
|
+
const manifest = data.proposal?.payload;
|
|
472
|
+
const ownerDid = manifest?.requester?.ownerDid;
|
|
473
|
+
if (!manifest || ownerDid !== first.payload.senderDid)
|
|
474
|
+
return undefined;
|
|
475
|
+
const limited = this.consumeRate(ownerDid);
|
|
476
|
+
if (limited)
|
|
477
|
+
return limited;
|
|
478
|
+
const decision = evaluateContractAcceptance(this.acceptance, { manifest, from: first.payload.senderDid }, first.payload.senderDid);
|
|
479
|
+
return decision.approved ? undefined : decision.reason;
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
status;
|
|
486
|
+
listenerStatus(status, error) {
|
|
487
|
+
if (this.status === status && !error)
|
|
488
|
+
return;
|
|
489
|
+
this.status = status;
|
|
490
|
+
this.options.onStatus?.(status, error);
|
|
491
|
+
}
|
|
492
|
+
listen(signal) {
|
|
493
|
+
return receiveRelay(this.relay, this.options.owner.did, signal, { role: 'worker' }, (status) => this.listenerStatus(status === 'connected' ? this.relay.connectionStatus ?? status : status))
|
|
494
|
+
.catch((error) => { if (signal.aborted)
|
|
495
|
+
throw error; throw relayDiagnostic(error); });
|
|
496
|
+
}
|
|
497
|
+
async start(options = {}) {
|
|
498
|
+
// Register first so immediate discovery can resolve owner and agent aliases.
|
|
499
|
+
if (this.publishes())
|
|
500
|
+
await this.register();
|
|
501
|
+
await this.reconcileTasks();
|
|
502
|
+
const idleSignal = AbortSignal.any([
|
|
503
|
+
this.controller.signal,
|
|
504
|
+
...(options.signal ? [options.signal] : []),
|
|
505
|
+
]);
|
|
506
|
+
const jobs = new Set();
|
|
507
|
+
const unregister = registerWorkerListener(this.options.owner.did, (env) => {
|
|
508
|
+
this.forwardedQueue.push(env);
|
|
509
|
+
this.forwardedWaiters.shift()?.();
|
|
510
|
+
return true;
|
|
511
|
+
});
|
|
512
|
+
try {
|
|
513
|
+
while (!idleSignal.aborted) {
|
|
514
|
+
// revocationAwareRelay ingests a leading REVOKE before this read returns.
|
|
515
|
+
const envelope = await this.listen(idleSignal);
|
|
516
|
+
if (await this.recoverProposal(envelope)) {
|
|
517
|
+
if (options.once)
|
|
518
|
+
return this.lastResult;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (this.deliver(envelope))
|
|
522
|
+
continue;
|
|
523
|
+
const job = this.beginTask(envelope, idleSignal, options.once === true);
|
|
524
|
+
if (!job)
|
|
525
|
+
continue;
|
|
526
|
+
jobs.add(job);
|
|
527
|
+
void job.finally(() => jobs.delete(job)).catch(() => undefined);
|
|
528
|
+
if (options.once) {
|
|
529
|
+
await this.pumpUntil(job, idleSignal);
|
|
530
|
+
return this.lastResult;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (!idleSignal.aborted) {
|
|
536
|
+
// A rejected one-shot task may be followed by another start on this node.
|
|
537
|
+
// Only a terminal listener failure shuts down the worker and all of its tasks.
|
|
538
|
+
if (error instanceof Error && 'code' in error && error.code === 'RELAY_TERMINAL') {
|
|
539
|
+
this.listenerStatus('unavailable', error);
|
|
540
|
+
for (const mailbox of this.mailboxes.values())
|
|
541
|
+
mailbox.close(error);
|
|
542
|
+
this.controller.abort(error);
|
|
543
|
+
}
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
if (this.status !== 'unavailable')
|
|
549
|
+
this.listenerStatus('stopped');
|
|
550
|
+
unregister();
|
|
551
|
+
await Promise.allSettled([...jobs]);
|
|
552
|
+
}
|
|
553
|
+
return this.lastResult;
|
|
554
|
+
}
|
|
555
|
+
async notifyOutcome(record, outcome = record.outcome) {
|
|
556
|
+
const send = (recovery) => this.relay.send(signEnvelope({ taskId: record.taskId,
|
|
557
|
+
recipientDid: record.requesterDid, seq: 0, eventType: 'ERROR',
|
|
558
|
+
payload: asJson({ code: outcome.code, recovery }),
|
|
559
|
+
}, this.options.owner, { encrypt: true }));
|
|
560
|
+
const bytes = outcome.result ? Buffer.from(JSON.stringify(outcome.result)) : undefined;
|
|
561
|
+
// Cached audits can exceed a single relay envelope even when every original event fitted.
|
|
562
|
+
if (bytes && bytes.length > 48 * 1024) {
|
|
563
|
+
const size = 24 * 1024, count = Math.ceil(bytes.length / size), resultDigest = digest(outcome.result);
|
|
564
|
+
for (let index = 0; index < count; index++) {
|
|
565
|
+
await send({ proposalDigest: record.proposalDigest, part: { index, count, resultDigest,
|
|
566
|
+
data: bytes.subarray(index * size, (index + 1) * size).toString('base64') } });
|
|
567
|
+
// Yield to inbox readers so a cached audit does not fill the bounded relay queue.
|
|
568
|
+
if (index + 1 < count)
|
|
569
|
+
await waitForRelayRetry(10, this.controller.signal);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
await send({ proposalDigest: record.proposalDigest, ...(outcome.result ? { result: outcome.result } : {}) });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async reconcileTasks() {
|
|
577
|
+
if (this.reconciled || !this.taskJournal)
|
|
578
|
+
return;
|
|
579
|
+
this.taskJournal.load();
|
|
580
|
+
for (const saved of this.taskJournal.tasks.values()) {
|
|
581
|
+
let record = saved;
|
|
582
|
+
if (record.phase !== 'terminal') {
|
|
583
|
+
const result = this.sealedTaskResult(record);
|
|
584
|
+
// Adapters expose executeTask only. Unknown execution/tool results cannot be retried safely.
|
|
585
|
+
record = { ...record, phase: 'terminal', outcome: result
|
|
586
|
+
? { code: result.state === 'SETTLED' ? 'TASK_SETTLED' : 'TASK_CANCELLED', result }
|
|
587
|
+
: { code: 'TASK_INTERRUPTED' }, notificationPending: true };
|
|
588
|
+
this.taskJournal.save(record);
|
|
589
|
+
if (!result)
|
|
590
|
+
this.options.onEvent?.('INTERRUPTED', record.taskId, { code: 'TASK_INTERRUPTED' });
|
|
591
|
+
}
|
|
592
|
+
if (record.notificationPending) {
|
|
593
|
+
await this.notifyOutcome(record);
|
|
594
|
+
this.taskJournal.save({ ...record, notificationPending: false });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
this.reconciled = true;
|
|
598
|
+
}
|
|
599
|
+
sealedTaskResult(record) {
|
|
600
|
+
if (!record.auditPath)
|
|
601
|
+
return;
|
|
602
|
+
try {
|
|
603
|
+
const bundle = parseJsonl(readFileSync(record.auditPath, 'utf8'));
|
|
604
|
+
if (!verifyAudit(bundle, { taskId: record.taskId, actors: [record.requesterDid, this.options.owner.did] }).valid)
|
|
605
|
+
return;
|
|
606
|
+
const proposal = bundle.entries.find((entry) => entry.body.eventType === 'PROPOSE')?.body.payload;
|
|
607
|
+
if (!proposal || digest({ manifest: proposal.proposal.payload, input: proposal.input }) !== record.proposalDigest)
|
|
608
|
+
return;
|
|
609
|
+
const last = bundle.entries.at(-1)?.body.eventType;
|
|
610
|
+
if (last === 'CANCEL')
|
|
611
|
+
return { taskId: record.taskId, state: 'CANCELLED', deliverable: null, bundle };
|
|
612
|
+
const delivery = bundle.entries.find((entry) => entry.body.eventType === 'DELIVER')?.body.payload;
|
|
613
|
+
if (last === 'SETTLED' && delivery)
|
|
614
|
+
return { taskId: record.taskId, state: 'SETTLED', deliverable: delivery.artifact.payload.data, bundle };
|
|
615
|
+
}
|
|
616
|
+
catch { /* An unsealed or partial audit is evidence to preserve, not authorization to rerun. */ }
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
async recoverProposal(envelope) {
|
|
620
|
+
if (!this.taskJournal || envelope.payload.eventType !== 'PROPOSE' || envelope.payload.seq !== 0 ||
|
|
621
|
+
!verifyEnvelope(envelope, this.options.owner.did))
|
|
622
|
+
return false;
|
|
623
|
+
let data;
|
|
624
|
+
try {
|
|
625
|
+
data = openEnvelope(envelope, this.options.owner, sessionDecryptOptions(this.options.allowUnencryptedEnvelopes));
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
const manifest = data?.proposal?.payload;
|
|
631
|
+
if (!manifest || !verifySigned(data.proposal, envelope.payload.senderDid) ||
|
|
632
|
+
manifest.taskId !== envelope.payload.taskId || manifest.requester?.ownerDid !== envelope.payload.senderDid)
|
|
633
|
+
return false;
|
|
634
|
+
const proposalDigest = digest({ manifest, input: data.input });
|
|
635
|
+
let record = this.taskJournal.tasks.get(manifest.taskId);
|
|
636
|
+
// Older inboxes have intake evidence but no execution checkpoint. Never execute those tasks again.
|
|
637
|
+
if (!record && this.options.inboxPath && existsSync(this.options.inboxPath)) {
|
|
638
|
+
for (const line of readFileSync(this.options.inboxPath, 'utf8').split('\n')) {
|
|
639
|
+
let old;
|
|
640
|
+
try {
|
|
641
|
+
old = JSON.parse(line);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (old?.taskId === manifest.taskId && old.requesterOwnerDid === envelope.payload.senderDid) {
|
|
647
|
+
record = { version: 1, taskId: manifest.taskId, requesterDid: envelope.payload.senderDid,
|
|
648
|
+
envelopeId: envelope.payload.envelopeId, proposalDigest, phase: 'terminal', invocations: [],
|
|
649
|
+
outcome: { code: 'TASK_INTERRUPTED' } };
|
|
650
|
+
this.taskJournal.save(record);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (!record)
|
|
656
|
+
return false;
|
|
657
|
+
this.lastResult = undefined;
|
|
658
|
+
if (record.requesterDid !== envelope.payload.senderDid || record.proposalDigest !== proposalDigest) {
|
|
659
|
+
await this.notifyOutcome({ ...record, requesterDid: envelope.payload.senderDid, proposalDigest }, { code: 'TASK_ID_CONFLICT' });
|
|
660
|
+
}
|
|
661
|
+
else if (record.outcome) {
|
|
662
|
+
await this.notifyOutcome(record);
|
|
663
|
+
this.lastResult = record.outcome.result;
|
|
664
|
+
}
|
|
665
|
+
else if (!this.mailboxes.has(record.taskId)) {
|
|
666
|
+
throw new Error('TASK_RECOVERY_REQUIRED');
|
|
667
|
+
}
|
|
668
|
+
// The outcome must be sent successfully before a retried intake is removed.
|
|
669
|
+
await acknowledgeRelay(this.relay, envelope);
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
deliver(envelope) {
|
|
673
|
+
const mailbox = this.mailboxes.get(envelope.payload.taskId);
|
|
674
|
+
if (!mailbox)
|
|
675
|
+
return false;
|
|
676
|
+
mailbox.push(envelope);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
/** Local signed diagnostics are separate from the bilateral hash-chain ledger. */
|
|
680
|
+
reportRejection(taskId, error) {
|
|
681
|
+
const diagnostic = rejectionBody(error);
|
|
682
|
+
if (this.options.auditPath) {
|
|
683
|
+
const fd = openSync(this.options.auditPath + '.errors.jsonl', constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
684
|
+
try {
|
|
685
|
+
if (!fstatSync(fd).isFile())
|
|
686
|
+
throw new Error('INVALID_AUDIT_FILE');
|
|
687
|
+
fchmodSync(fd, 0o600);
|
|
688
|
+
appendFileSync(fd, JSON.stringify(signPayload({ event: 'REJECTED', taskId,
|
|
689
|
+
timestamp: new Date().toISOString(), ...diagnostic }, this.options.owner)) + '\n');
|
|
690
|
+
}
|
|
691
|
+
finally {
|
|
692
|
+
closeSync(fd);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
this.options.onEvent?.('REJECTED', taskId, diagnostic);
|
|
696
|
+
}
|
|
697
|
+
beginTask(first, idleSignal, once) {
|
|
698
|
+
const verified = verifyEnvelope(first, this.options.owner.did);
|
|
699
|
+
const proposal = verified && first.payload.eventType === 'PROPOSE' && first.payload.seq === 0;
|
|
700
|
+
if (!proposal) {
|
|
701
|
+
// A rejected proposal is answered with ERROR, and the requester echoes ERROR.
|
|
702
|
+
// That trailing frame must not be treated as the next task, including after
|
|
703
|
+
// a --once worker restarts and reads the relay queue again. Unsigned or
|
|
704
|
+
// non-zero PROPOSE frames still fail a one-shot session immediately.
|
|
705
|
+
if (once && (!verified || (first.payload.eventType === 'PROPOSE' && first.payload.seq !== 0)))
|
|
706
|
+
throw new Error('INVALID_PROPOSAL_ENVELOPE');
|
|
707
|
+
if (verified && first.payload.eventType !== 'ERROR')
|
|
708
|
+
this.options.onEvent?.('REJECTED', first.payload.taskId, { code: 'INVALID_PROPOSAL_ENVELOPE' });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
let rejection = this.intakeRejection(first);
|
|
712
|
+
let reserved = false;
|
|
713
|
+
if (!rejection && this.activeTasks >= this.concurrentLimit)
|
|
714
|
+
rejection = 'maxConcurrentTasks ' + this.concurrentLimit + ' reached';
|
|
715
|
+
else if (!rejection) {
|
|
716
|
+
this.activeTasks += 1;
|
|
717
|
+
reserved = true;
|
|
718
|
+
}
|
|
719
|
+
const mailbox = new TaskMailbox();
|
|
720
|
+
this.mailboxes.set(first.payload.taskId, mailbox);
|
|
721
|
+
const job = this.executeTask(first, idleSignal, mailboxRelay(this.relay, mailbox), rejection)
|
|
722
|
+
.then((result) => {
|
|
723
|
+
this.lastResult = result;
|
|
724
|
+
})
|
|
725
|
+
.catch((error) => {
|
|
726
|
+
if (once)
|
|
727
|
+
throw error;
|
|
728
|
+
})
|
|
729
|
+
.finally(() => {
|
|
730
|
+
if (reserved)
|
|
731
|
+
this.activeTasks -= 1;
|
|
732
|
+
this.mailboxes.delete(first.payload.taskId);
|
|
733
|
+
});
|
|
734
|
+
return job;
|
|
735
|
+
}
|
|
736
|
+
/** Keep reading the shared inbox until a single task finishes, without dropping its peer's mail. */
|
|
737
|
+
async pumpUntil(job, idleSignal) {
|
|
738
|
+
const wait = new AbortController();
|
|
739
|
+
let failure;
|
|
740
|
+
const finished = job.then(() => undefined, (error) => {
|
|
741
|
+
failure = error;
|
|
742
|
+
});
|
|
743
|
+
try {
|
|
744
|
+
while (!idleSignal.aborted && failure === undefined) {
|
|
745
|
+
const incoming = this.listen(AbortSignal.any([idleSignal, wait.signal])).then((envelope) => envelope, (error) => {
|
|
746
|
+
if (!idleSignal.aborted && !wait.signal.aborted)
|
|
747
|
+
failure = error;
|
|
748
|
+
return undefined;
|
|
749
|
+
});
|
|
750
|
+
const envelope = await Promise.race([finished, incoming]);
|
|
751
|
+
if (failure !== undefined)
|
|
752
|
+
break;
|
|
753
|
+
// A REVOKE queued behind the proposal is ingested inside receive and is
|
|
754
|
+
// not delivered here. The next session envelope is what remains.
|
|
755
|
+
if (!envelope)
|
|
756
|
+
return;
|
|
757
|
+
if (await this.recoverProposal(envelope))
|
|
758
|
+
continue;
|
|
759
|
+
if (!this.deliver(envelope) && verifyEnvelope(envelope, this.options.owner.did))
|
|
760
|
+
this.options.onEvent?.('REJECTED', envelope.payload.taskId, { code: 'INVALID_SESSION_ENVELOPE' });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
finally {
|
|
764
|
+
wait.abort();
|
|
765
|
+
}
|
|
766
|
+
if (failure !== undefined)
|
|
767
|
+
throw failure;
|
|
768
|
+
}
|
|
769
|
+
async runOnce(signal) {
|
|
770
|
+
await this.reconcileTasks();
|
|
771
|
+
const idleSignal = AbortSignal.any([this.controller.signal, ...(signal ? [signal] : [])]);
|
|
772
|
+
// One or more leading REVOKE envelopes are consumed before the task PROPOSE.
|
|
773
|
+
const first = await receiveRelay(this.relay, this.options.owner.did, idleSignal, {
|
|
774
|
+
role: 'worker',
|
|
775
|
+
});
|
|
776
|
+
if (await this.recoverProposal(first)) {
|
|
777
|
+
if (this.lastResult?.taskId === first.payload.taskId)
|
|
778
|
+
return this.lastResult;
|
|
779
|
+
throw new Error(this.taskJournal?.tasks.get(first.payload.taskId)?.outcome?.code ?? 'TASK_INTERRUPTED');
|
|
780
|
+
}
|
|
781
|
+
return this.executeTask(first, idleSignal, this.relay);
|
|
782
|
+
}
|
|
783
|
+
async executeTask(first, idleSignal, relay, earlyRejection) {
|
|
784
|
+
if (!verifyEnvelope(first, this.options.owner.did) ||
|
|
785
|
+
first.payload.eventType !== 'PROPOSE' ||
|
|
786
|
+
first.payload.seq !== 0)
|
|
787
|
+
throw new Error('INVALID_PROPOSAL_ENVELOPE');
|
|
788
|
+
// PROPOSE arrives before the session wire is constructed. Open it with the same rule,
|
|
789
|
+
// and return an encrypted ERROR frame if pre-wire validation fails so the requester does not hang.
|
|
790
|
+
let data;
|
|
791
|
+
let m;
|
|
792
|
+
let auditPath;
|
|
793
|
+
let checkpoint;
|
|
794
|
+
const saveCheckpoint = (patch) => {
|
|
795
|
+
if (!checkpoint || !this.taskJournal)
|
|
796
|
+
return;
|
|
797
|
+
checkpoint = { ...checkpoint, ...patch };
|
|
798
|
+
this.taskJournal.save(checkpoint);
|
|
799
|
+
};
|
|
800
|
+
const saveOutcome = (outcome) => {
|
|
801
|
+
saveCheckpoint({ phase: 'terminal', outcome, notificationPending: true });
|
|
802
|
+
};
|
|
803
|
+
try {
|
|
804
|
+
data = openEnvelope(first, this.options.owner, sessionDecryptOptions(this.options.allowUnencryptedEnvelopes));
|
|
805
|
+
const manifest = data?.proposal?.payload;
|
|
806
|
+
if (!manifest ||
|
|
807
|
+
first.payload.senderDid !== manifest.requester.ownerDid ||
|
|
808
|
+
first.payload.taskId !== manifest.taskId)
|
|
809
|
+
throw new Error('INVALID_PROPOSAL');
|
|
810
|
+
if (!verifySigned(data.proposal, manifest.requester.ownerDid) ||
|
|
811
|
+
manifest.worker?.ownerDid !== this.options.owner.did ||
|
|
812
|
+
manifest.worker.agentDid !== this.agentCard.payload.agentDid ||
|
|
813
|
+
Date.parse(manifest.deadline) <= Date.now() ||
|
|
814
|
+
Date.parse(manifest.createdAt) > Date.now() + DEFAULT_CLOCK_SKEW_MS)
|
|
815
|
+
throw new Error('INVALID_PROPOSAL');
|
|
816
|
+
m = manifest;
|
|
817
|
+
auditPath = this.options.auditPath
|
|
818
|
+
? this.completed++ === 0 && !existsSync(this.options.auditPath)
|
|
819
|
+
? this.options.auditPath
|
|
820
|
+
: this.options.auditPath.replace(/(?:\.jsonl)?$/, '.' + m.taskId + '.jsonl')
|
|
821
|
+
: undefined;
|
|
822
|
+
freshAudit(auditPath);
|
|
823
|
+
if (this.taskJournal) {
|
|
824
|
+
checkpoint = { version: 1, taskId: m.taskId, requesterDid: m.requester.ownerDid,
|
|
825
|
+
envelopeId: first.payload.envelopeId, proposalDigest: digest({ manifest: m, input: data.input }),
|
|
826
|
+
phase: 'received', auditPath, invocations: [] };
|
|
827
|
+
this.taskJournal.save(checkpoint);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch (e) {
|
|
831
|
+
this.reportRejection(first.payload.taskId, e);
|
|
832
|
+
try {
|
|
833
|
+
await relay.send(signEnvelope({
|
|
834
|
+
taskId: first.payload.taskId,
|
|
835
|
+
recipientDid: first.payload.senderDid,
|
|
836
|
+
seq: 0,
|
|
837
|
+
eventType: 'ERROR',
|
|
838
|
+
payload: asJson(rejectionBody(e)),
|
|
839
|
+
}, this.options.owner, { encrypt: true }));
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// Ignore transport errors while reporting pre-wire rejection.
|
|
843
|
+
}
|
|
844
|
+
throw e;
|
|
845
|
+
}
|
|
846
|
+
const timeouts = new SessionTimeouts(this.options, m.deadline, idleSignal);
|
|
847
|
+
const activeSignal = timeouts.signal;
|
|
848
|
+
const wire = new SessionWire(relay, this.options.owner, m.requester.ownerDid, m.taskId, [m.requester.ownerDid, this.options.owner.did], activeSignal, auditPath, this.options.allowUnencryptedEnvelopes === true);
|
|
849
|
+
const cancelController = new AbortController();
|
|
850
|
+
wire.earlyCancel = () => cancelController.abort('CANCELLED');
|
|
851
|
+
let heartbeat;
|
|
852
|
+
let heartbeatStopped = false;
|
|
853
|
+
const sendHeartbeat = async () => {
|
|
854
|
+
try {
|
|
855
|
+
await wire.heartbeat();
|
|
856
|
+
}
|
|
857
|
+
catch { /* The supervised reader reports relay availability. */ }
|
|
858
|
+
if (!heartbeatStopped && !activeSignal.aborted) {
|
|
859
|
+
heartbeat = setTimeout(() => void sendHeartbeat(), this.options.heartbeatIntervalMs ?? 15_000);
|
|
860
|
+
heartbeat.unref();
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
let gateway, workspace;
|
|
864
|
+
let preserveWorkspace = false;
|
|
865
|
+
let trackedTaskId;
|
|
866
|
+
// Progress metrics are applied synchronously. The tail only serializes wire frames.
|
|
867
|
+
let progressTail = Promise.resolve();
|
|
868
|
+
let progressFailure;
|
|
869
|
+
let wireFailureSent = false;
|
|
870
|
+
try {
|
|
871
|
+
await wire.receiveEvent('PROPOSE', first);
|
|
872
|
+
this.assertInviteActive(data);
|
|
873
|
+
if (this.options.accessMode === 'open') {
|
|
874
|
+
for (const field of OPEN_POLICY_FIELDS) {
|
|
875
|
+
const limit = this.agentCard.payload.openPolicy?.[field];
|
|
876
|
+
if (limit !== undefined && m.budget[field] > limit)
|
|
877
|
+
throw diagnosticError('TASK_SCOPE_REJECTED', 'budget.' + field, limit, m.budget[field], 'Use a budget within the advertised policy.');
|
|
878
|
+
}
|
|
879
|
+
const skills = this.agentCard.payload.skills;
|
|
880
|
+
const selected = m.task.skillId ? skills.filter((skill) => skill.id === m.task.skillId) : skills;
|
|
881
|
+
if ((m.task.skillId && selected.length === 0) ||
|
|
882
|
+
(m.task.taskType && !selected.some((skill) => skill.taskTypes?.includes(m.task.taskType))) ||
|
|
883
|
+
(m.task.skillId && !m.task.taskType && selected.some((skill) => skill.taskTypes?.length)))
|
|
884
|
+
throw diagnosticError('TASK_SCOPE_REJECTED', 'task.scope', skills, m.task, 'Select an advertised skill and task type, or omit both for direct delegation.');
|
|
885
|
+
}
|
|
886
|
+
const capabilityContext = {
|
|
887
|
+
rootDid: m.requester.ownerDid,
|
|
888
|
+
audience: this.options.owner.did,
|
|
889
|
+
taskId: m.taskId,
|
|
890
|
+
};
|
|
891
|
+
if (!Array.isArray(data.capabilityChains))
|
|
892
|
+
throw new Error('CAPABILITY_VIOLATION');
|
|
893
|
+
for (const chain of data.capabilityChains)
|
|
894
|
+
verifyCapabilityChain(chain, capabilityContext);
|
|
895
|
+
this.assertCapabilitiesActive(data.capabilityChains, capabilityContext);
|
|
896
|
+
this.rememberChains(m.taskId, data.capabilityChains, capabilityContext);
|
|
897
|
+
trackedTaskId = m.taskId;
|
|
898
|
+
const decision = evaluateContractAcceptance(this.acceptance, { manifest: m, from: first.payload.senderDid }, first.payload.senderDid);
|
|
899
|
+
const reason = earlyRejection ?? (decision.approved ? undefined : decision.reason);
|
|
900
|
+
if (reason)
|
|
901
|
+
throw new ProposalRejectedError(reason);
|
|
902
|
+
validateManifest(m);
|
|
903
|
+
if (!validateOutput(m.io.inputSchema, data.input))
|
|
904
|
+
throw new Error('INVALID_PROPOSAL');
|
|
905
|
+
if (this.options.executionTimeoutMs !== undefined && m.budget.maxWallClockSeconds * 1000 > this.options.executionTimeoutMs)
|
|
906
|
+
throw diagnosticError('INCOMPATIBLE_TIMEOUTS', 'budget.maxWallClockSeconds', this.options.executionTimeoutMs / 1000, m.budget.maxWallClockSeconds, 'Reduce the execution budget or raise the worker execution timeout before accepting.');
|
|
907
|
+
if (this.options.taskTimeoutMs !== undefined && m.budget.maxWallClockSeconds * 1000 > this.options.taskTimeoutMs)
|
|
908
|
+
throw diagnosticError('INCOMPATIBLE_TIMEOUTS', 'budget.maxWallClockSeconds', this.options.taskTimeoutMs / 1000, m.budget.maxWallClockSeconds, 'Reduce the execution budget or raise the worker overall task timeout before accepting.');
|
|
909
|
+
if (data.peerIdleTimeoutMs !== undefined &&
|
|
910
|
+
(!Number.isSafeInteger(data.peerIdleTimeoutMs) || data.peerIdleTimeoutMs < 1 ||
|
|
911
|
+
(this.options.heartbeatIntervalMs ?? 15_000) >= data.peerIdleTimeoutMs))
|
|
912
|
+
throw diagnosticError('INCOMPATIBLE_TIMEOUTS', 'peerIdleTimeoutMs', { greaterThan: this.options.heartbeatIntervalMs ?? 15_000 }, data.peerIdleTimeoutMs, 'Raise the requester idle timeout or shorten the worker heartbeat interval before accepting.');
|
|
913
|
+
const contract = createContract(m, data.proposal, this.options.owner);
|
|
914
|
+
const approval = await abortable(this.approvalProvider.approve({
|
|
915
|
+
taskId: m.taskId,
|
|
916
|
+
gate: 'contract_acceptance',
|
|
917
|
+
target: contract.contractDigest,
|
|
918
|
+
details: {
|
|
919
|
+
manifest: m,
|
|
920
|
+
input: data.input,
|
|
921
|
+
capabilities: data.capabilityChains,
|
|
922
|
+
from: first.payload.senderDid,
|
|
923
|
+
},
|
|
924
|
+
signal: activeSignal,
|
|
925
|
+
}), activeSignal);
|
|
926
|
+
gateway = new OwnerGateway(this.options.owner, contract, policy, this.revocation);
|
|
927
|
+
const accepted = gateway.accept(approval);
|
|
928
|
+
saveCheckpoint({ phase: 'accepted' });
|
|
929
|
+
if (this.options.dailyLimit)
|
|
930
|
+
consumeDailyTask(this.options.dailyLimit.path, this.options.dailyLimit.maxTasks, m.taskId);
|
|
931
|
+
if (this.options.inboxPath) {
|
|
932
|
+
const record = JSON.stringify({
|
|
933
|
+
envelopeId: first.payload.envelopeId,
|
|
934
|
+
receivedAt: new Date().toISOString(),
|
|
935
|
+
taskId: m.taskId,
|
|
936
|
+
requesterOwnerDid: m.requester.ownerDid,
|
|
937
|
+
requesterAgentDid: m.requester.agentDid,
|
|
938
|
+
title: m.task.title,
|
|
939
|
+
description: m.task.description,
|
|
940
|
+
input: data.input,
|
|
941
|
+
}) + '\n';
|
|
942
|
+
const fd = openSync(this.options.inboxPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND |
|
|
943
|
+
constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
944
|
+
try {
|
|
945
|
+
if (!fstatSync(fd).isFile())
|
|
946
|
+
throw new Error('INVALID_INBOX_FILE');
|
|
947
|
+
fchmodSync(fd, 0o600);
|
|
948
|
+
appendFileSync(fd, record);
|
|
949
|
+
fsyncSync(fd);
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
closeSync(fd);
|
|
953
|
+
}
|
|
954
|
+
await acknowledgeRelay(relay, first);
|
|
955
|
+
// Notification errors must not fail a persisted task.
|
|
956
|
+
try {
|
|
957
|
+
void Promise.resolve(this.options.onInboxRecord?.(record)).catch(() => undefined);
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// Synchronous notification failures are also best effort.
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
await wire.sendEvent('ACCEPT', { contract, transition: accepted, approval });
|
|
964
|
+
this.options.onEvent?.('ACCEPT', m.taskId);
|
|
965
|
+
await wire.sendEvent('START', gateway.transition('START'));
|
|
966
|
+
heartbeat = setTimeout(() => void sendHeartbeat(), this.options.heartbeatIntervalMs ?? 15_000);
|
|
967
|
+
heartbeat.unref();
|
|
968
|
+
timeouts.running();
|
|
969
|
+
this.options.onEvent?.('IN_PROGRESS', m.taskId);
|
|
970
|
+
const extra = this.options.adapter;
|
|
971
|
+
const tools = this.options.tools ?? extra.tools ?? {}, backend = this.options.toolBackend ?? extra.toolBackend;
|
|
972
|
+
if (!Array.isArray(data.capabilityChains) || data.capabilityChains.length > 32)
|
|
973
|
+
throw new Error('CAPABILITY_VIOLATION: invalid chains');
|
|
974
|
+
const toolsSeen = new Set();
|
|
975
|
+
const proxies = data.capabilityChains.map((chain) => {
|
|
976
|
+
const leaf = verifyCapabilityChain(chain, {
|
|
977
|
+
rootDid: m.requester.ownerDid,
|
|
978
|
+
audience: this.options.owner.did,
|
|
979
|
+
taskId: m.taskId,
|
|
980
|
+
});
|
|
981
|
+
const grant = m.permissions.grantedCapabilities.find((g) => g.resource === leaf.payload.resource &&
|
|
982
|
+
leaf.payload.allowedTools.every((t) => g.allowedTools.includes(t)) &&
|
|
983
|
+
leaf.payload.maxCalls <= g.maxInvocations &&
|
|
984
|
+
Date.parse(leaf.payload.expiresAt) <= Date.parse(g.expiresAt));
|
|
985
|
+
if (!grant ||
|
|
986
|
+
leaf.payload.maxDepth > m.permissions.maxDelegationDepth ||
|
|
987
|
+
leaf.payload.allowedTools.some((t) => toolsSeen.has(t) ||
|
|
988
|
+
!schemaNarrows(leaf.payload.argConstraints[t], grant.argConstraints ?? { type: 'object' })))
|
|
989
|
+
throw new Error('CAPABILITY_VIOLATION: outside manifest');
|
|
990
|
+
for (const tool of leaf.payload.allowedTools)
|
|
991
|
+
toolsSeen.add(tool);
|
|
992
|
+
return new MCPProxy({
|
|
993
|
+
chain,
|
|
994
|
+
context: {
|
|
995
|
+
rootDid: m.requester.ownerDid,
|
|
996
|
+
audience: this.options.owner.did,
|
|
997
|
+
taskId: m.taskId,
|
|
998
|
+
},
|
|
999
|
+
tools,
|
|
1000
|
+
backend: backend ?? {
|
|
1001
|
+
callTool: async () => {
|
|
1002
|
+
throw new Error('MCP_BACKEND_REQUIRED');
|
|
1003
|
+
},
|
|
1004
|
+
},
|
|
1005
|
+
gate: gateway.approvals,
|
|
1006
|
+
meter: gateway.quota,
|
|
1007
|
+
guard: gateway.capabilities,
|
|
1008
|
+
isActive: () => gateway.machine.state === 'IN_PROGRESS',
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
workspace = await mkdtemp(join(tmpdir(), 'acp-adapter-'));
|
|
1012
|
+
saveCheckpoint({ phase: 'running', workspace });
|
|
1013
|
+
let invocationCount = 0;
|
|
1014
|
+
const executionSignal = AbortSignal.any([
|
|
1015
|
+
activeSignal,
|
|
1016
|
+
gateway.quota.controller.signal,
|
|
1017
|
+
cancelController.signal,
|
|
1018
|
+
]);
|
|
1019
|
+
let effectiveIsolationLevel = 'host_adapter';
|
|
1020
|
+
let sandboxBoundary;
|
|
1021
|
+
// Concurrent reportProgress calls must not put two PROGRESS frames on the wire
|
|
1022
|
+
// before the first bilateral ACK. The queue also keeps DELIVER behind them.
|
|
1023
|
+
const enqueueProgress = (operation, force = false) => {
|
|
1024
|
+
const run = progressTail.then(async () => {
|
|
1025
|
+
if (!force) {
|
|
1026
|
+
if (progressFailure)
|
|
1027
|
+
throw progressFailure;
|
|
1028
|
+
if (executionSignal.aborted)
|
|
1029
|
+
throw new Error('EXECUTION_ABORTED: ' + String(executionSignal.reason));
|
|
1030
|
+
}
|
|
1031
|
+
await operation();
|
|
1032
|
+
});
|
|
1033
|
+
progressTail = run.then(() => undefined, (error) => {
|
|
1034
|
+
progressFailure ??= error;
|
|
1035
|
+
});
|
|
1036
|
+
void run.catch(() => undefined);
|
|
1037
|
+
return run;
|
|
1038
|
+
};
|
|
1039
|
+
const context = {
|
|
1040
|
+
taskId: m.taskId,
|
|
1041
|
+
manifest: structuredClone(m),
|
|
1042
|
+
input: structuredClone(data.input),
|
|
1043
|
+
workspaceDir: workspace,
|
|
1044
|
+
signal: executionSignal,
|
|
1045
|
+
reportUsage: (delta) => gateway.quota.recordMetrics(delta),
|
|
1046
|
+
reportProgress: (progress) => {
|
|
1047
|
+
// An invalid payload poisons the session even when the adapter catches the rejection.
|
|
1048
|
+
// Its metrics are not charged.
|
|
1049
|
+
const invalidProgress = () => {
|
|
1050
|
+
const error = new Error('INVALID_PROGRESS');
|
|
1051
|
+
progressFailure ??= error;
|
|
1052
|
+
return Promise.reject(error);
|
|
1053
|
+
};
|
|
1054
|
+
// Validate and charge quota before returning, so a later call on this turn
|
|
1055
|
+
// observes BUDGET_EXCEEDED without waiting for the wire queue.
|
|
1056
|
+
if (!progress || typeof progress.step !== 'string' || progress.step.length === 0)
|
|
1057
|
+
return invalidProgress();
|
|
1058
|
+
if (progress.percent !== undefined && typeof progress.percent !== 'number')
|
|
1059
|
+
return invalidProgress();
|
|
1060
|
+
let metricsError;
|
|
1061
|
+
if (progress.metrics) {
|
|
1062
|
+
const delta = {};
|
|
1063
|
+
if (progress.metrics.inputTokens !== undefined)
|
|
1064
|
+
delta.inputTokens = progress.metrics.inputTokens;
|
|
1065
|
+
if (progress.metrics.outputTokens !== undefined)
|
|
1066
|
+
delta.outputTokens = progress.metrics.outputTokens;
|
|
1067
|
+
if (progress.metrics.toolInvocations !== undefined)
|
|
1068
|
+
delta.toolCalls = progress.metrics.toolInvocations;
|
|
1069
|
+
if (progress.metrics.elapsedMs !== undefined)
|
|
1070
|
+
delta.wallTimeMs = progress.metrics.elapsedMs;
|
|
1071
|
+
if (Object.keys(delta).length > 0) {
|
|
1072
|
+
try {
|
|
1073
|
+
gateway.quota.recordMetrics(delta);
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
metricsError = error;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (metricsError) {
|
|
1081
|
+
progressFailure ??= metricsError;
|
|
1082
|
+
const code = safeError(metricsError);
|
|
1083
|
+
return enqueueProgress(async () => {
|
|
1084
|
+
// Wait until any in-flight PROGRESS handshake finishes, then reject the session.
|
|
1085
|
+
wireFailureSent = true;
|
|
1086
|
+
await wire.rawSend('ERROR', { code });
|
|
1087
|
+
throw metricsError;
|
|
1088
|
+
}, true);
|
|
1089
|
+
}
|
|
1090
|
+
return enqueueProgress(async () => {
|
|
1091
|
+
if (progressFailure)
|
|
1092
|
+
throw progressFailure;
|
|
1093
|
+
if (executionSignal.aborted)
|
|
1094
|
+
throw new Error('EXECUTION_ABORTED: ' + String(executionSignal.reason));
|
|
1095
|
+
await wire.sendEvent('PROGRESS', progress);
|
|
1096
|
+
});
|
|
1097
|
+
},
|
|
1098
|
+
reportIsolation: (level, boundary) => {
|
|
1099
|
+
effectiveIsolationLevel = level;
|
|
1100
|
+
sandboxBoundary = boundary;
|
|
1101
|
+
},
|
|
1102
|
+
callTool: async (tool, args) => {
|
|
1103
|
+
executionSignal.throwIfAborted();
|
|
1104
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
1105
|
+
throw new Error('CAPABILITY_VIOLATION: arguments');
|
|
1106
|
+
const index = data.capabilityChains.findIndex((c) => c.at(-1).payload.allowedTools.includes(tool));
|
|
1107
|
+
if (index < 0)
|
|
1108
|
+
throw new Error('CAPABILITY_VIOLATION: unauthorized tool');
|
|
1109
|
+
const approval = tools[tool] && !tools[tool].readOnly
|
|
1110
|
+
? await this.approvalProvider.approve({
|
|
1111
|
+
taskId: m.taskId,
|
|
1112
|
+
gate: 'external_side_effect',
|
|
1113
|
+
target: { name: tool, args },
|
|
1114
|
+
details: { manifest: m },
|
|
1115
|
+
signal: executionSignal,
|
|
1116
|
+
})
|
|
1117
|
+
: undefined;
|
|
1118
|
+
executionSignal.throwIfAborted();
|
|
1119
|
+
const invocation = { id: m.taskId + ':' + invocationCount++, tool,
|
|
1120
|
+
argsDigest: digest(args), completed: false };
|
|
1121
|
+
saveCheckpoint({ invocations: [...(checkpoint?.invocations ?? []), invocation] });
|
|
1122
|
+
const result = asJson(await proxies[index].callTool(tool, args, approval));
|
|
1123
|
+
// A backend may finish after cancellation. Leave its uncertain result for inspection.
|
|
1124
|
+
if (!executionSignal.aborted)
|
|
1125
|
+
saveCheckpoint({ invocations: (checkpoint?.invocations ?? []).map((call) => call.id === invocation.id ? { ...call, completed: true } : call) });
|
|
1126
|
+
return result;
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
const output = await abortable(this.options.adapter.executeTask(context), executionSignal);
|
|
1130
|
+
if (!this.options.inboxPath)
|
|
1131
|
+
await acknowledgeRelay(relay, first);
|
|
1132
|
+
// A task may return while a queued progress update is still waiting for its ACK.
|
|
1133
|
+
// Skip the drain once the task is already aborted, cancelled, or over budget.
|
|
1134
|
+
if (!executionSignal.aborted && !wire.cancelHandled) {
|
|
1135
|
+
await progressTail;
|
|
1136
|
+
if (progressFailure)
|
|
1137
|
+
throw progressFailure;
|
|
1138
|
+
}
|
|
1139
|
+
executionSignal.throwIfAborted();
|
|
1140
|
+
gateway.quota.record();
|
|
1141
|
+
const delivery = createDelivery(m.taskId, contract.contractDigest, output, gateway.quota.receipt(this.options.owner), this.options.owner, {
|
|
1142
|
+
effectiveIsolationLevel,
|
|
1143
|
+
...(sandboxBoundary ? { sandboxBoundary } : {}),
|
|
1144
|
+
});
|
|
1145
|
+
const transition = gateway.transition('DELIVER');
|
|
1146
|
+
saveCheckpoint({ phase: 'delivered' });
|
|
1147
|
+
await wire.sendEvent('DELIVER', { ...delivery, transition });
|
|
1148
|
+
this.options.onEvent?.('DELIVER', m.taskId);
|
|
1149
|
+
timeouts.wait();
|
|
1150
|
+
const verification = await wire.receiveEvent(['VERIFY_ACCEPT', 'VERIFY_REJECT']);
|
|
1151
|
+
gateway.machine.apply(verification.payload.payload);
|
|
1152
|
+
if (verification.payload.eventType === 'VERIFY_REJECT')
|
|
1153
|
+
throw new Error('VERIFICATION_REJECTED');
|
|
1154
|
+
const settled = await wire.receiveEvent('SETTLED');
|
|
1155
|
+
gateway.machine.apply(settled.payload.payload);
|
|
1156
|
+
const bundle = await wire.finishWorker((sealed) => {
|
|
1157
|
+
saveOutcome({ code: 'TASK_SETTLED', result: { taskId: m.taskId, state: 'SETTLED', deliverable: output, bundle: sealed } });
|
|
1158
|
+
});
|
|
1159
|
+
saveCheckpoint({ notificationPending: false });
|
|
1160
|
+
this.options.onEvent?.('SETTLED', m.taskId);
|
|
1161
|
+
return { taskId: m.taskId, state: 'SETTLED', deliverable: output, bundle };
|
|
1162
|
+
}
|
|
1163
|
+
catch (e) {
|
|
1164
|
+
if (checkpoint?.outcome?.result) {
|
|
1165
|
+
// The bilateral checkpoint is already durable even if its final relay send failed.
|
|
1166
|
+
await this.notifyOutcome(checkpoint).then(() => saveCheckpoint({ notificationPending: false })).catch(() => { });
|
|
1167
|
+
return checkpoint.outcome.result;
|
|
1168
|
+
}
|
|
1169
|
+
if (gateway &&
|
|
1170
|
+
(wire.cancelHandled || cancelController.signal.aborted) &&
|
|
1171
|
+
['ACCEPTED', 'IN_PROGRESS', 'CANCELLED'].includes(gateway.machine.state)) {
|
|
1172
|
+
try {
|
|
1173
|
+
await wire.waitUntilCancelHandled();
|
|
1174
|
+
const body = wire.lastCancel?.payload.payload;
|
|
1175
|
+
if (!body || body.cancelledBy !== m.requester.ownerDid || !body.transition)
|
|
1176
|
+
throw new Error('INVALID_CANCEL');
|
|
1177
|
+
if (gateway.machine.state !== 'CANCELLED')
|
|
1178
|
+
gateway.machine.apply(body.transition);
|
|
1179
|
+
const bundle = await wire.finishWorker((sealed) => {
|
|
1180
|
+
saveOutcome({ code: 'TASK_CANCELLED', result: { taskId: m.taskId, state: 'CANCELLED', deliverable: null, bundle: sealed } });
|
|
1181
|
+
});
|
|
1182
|
+
saveCheckpoint({ notificationPending: false });
|
|
1183
|
+
this.options.onEvent?.('CANCELLED', m.taskId);
|
|
1184
|
+
return { taskId: m.taskId, state: 'CANCELLED', deliverable: null, bundle };
|
|
1185
|
+
}
|
|
1186
|
+
catch (inner) {
|
|
1187
|
+
await wire.rawSend('ERROR', { code: safeError(inner) }).catch(() => { });
|
|
1188
|
+
throw inner;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
await progressTail;
|
|
1192
|
+
preserveWorkspace = activeSignal.aborted || idleSignal.aborted;
|
|
1193
|
+
if (idleSignal.aborted)
|
|
1194
|
+
throw e;
|
|
1195
|
+
const diagnostic = preserveWorkspace ? { code: 'TASK_INTERRUPTED' } : rejectionBody(progressFailure ?? e);
|
|
1196
|
+
saveOutcome(diagnostic);
|
|
1197
|
+
this.reportRejection(m.taskId, progressFailure ?? e);
|
|
1198
|
+
if (!wireFailureSent) {
|
|
1199
|
+
wireFailureSent = true;
|
|
1200
|
+
await wire.rawSend('ERROR', diagnostic)
|
|
1201
|
+
.then(() => saveCheckpoint({ notificationPending: false })).catch(() => { });
|
|
1202
|
+
}
|
|
1203
|
+
// A quota abort already surfaces as EXECUTION_ABORTED. Keep that error.
|
|
1204
|
+
// A caught INVALID_PROGRESS still fails the session with the sticky error.
|
|
1205
|
+
if (progressFailure instanceof Error &&
|
|
1206
|
+
!(e instanceof Error && e.message.startsWith('EXECUTION_ABORTED')))
|
|
1207
|
+
throw progressFailure;
|
|
1208
|
+
throw e;
|
|
1209
|
+
}
|
|
1210
|
+
finally {
|
|
1211
|
+
heartbeatStopped = true;
|
|
1212
|
+
clearTimeout(heartbeat);
|
|
1213
|
+
timeouts.close();
|
|
1214
|
+
wire.close();
|
|
1215
|
+
if (trackedTaskId)
|
|
1216
|
+
this.liveChains.delete(trackedTaskId);
|
|
1217
|
+
gateway?.close();
|
|
1218
|
+
if (idleSignal.aborted && checkpoint?.phase !== 'terminal')
|
|
1219
|
+
preserveWorkspace = true;
|
|
1220
|
+
if (workspace && !preserveWorkspace) {
|
|
1221
|
+
try {
|
|
1222
|
+
await rm(workspace, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
const code = error?.code ?? 'WORKSPACE_CLEANUP_FAILED';
|
|
1226
|
+
// Reporting residual files must not replace settlement or the execution error.
|
|
1227
|
+
try {
|
|
1228
|
+
if (this.options.onWorkspaceCleanupError)
|
|
1229
|
+
void Promise.resolve(this.options.onWorkspaceCleanupError({ taskId: m.taskId, workspaceDir: workspace, code }))
|
|
1230
|
+
.catch(() => { });
|
|
1231
|
+
else
|
|
1232
|
+
console.warn('Workspace cleanup incomplete (' + code + '): ' + workspace);
|
|
1233
|
+
}
|
|
1234
|
+
catch { }
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Hide REVOKE envelopes from task reads. start, runOnce, and pumpUntil all
|
|
1241
|
+
* receive through this relay, so a FIFO head of one or more REVOKE messages
|
|
1242
|
+
* is applied before the following PROPOSE is observed.
|
|
1243
|
+
*/
|
|
1244
|
+
isRequesterBoundFrame(envelope) {
|
|
1245
|
+
const { eventType, seq, taskId } = envelope.payload;
|
|
1246
|
+
if (REQUESTER_BOUND_EVENTS.has(eventType))
|
|
1247
|
+
return true;
|
|
1248
|
+
if (seq === 0 && (eventType === 'AUDIT_ACK' || eventType === 'ERROR'))
|
|
1249
|
+
return true;
|
|
1250
|
+
if (eventType !== 'PROPOSE' && eventType !== 'REVOKE' && !this.mailboxes.has(taskId))
|
|
1251
|
+
return true;
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
revocationAwareRelay(relay) {
|
|
1255
|
+
const receive = async (recipientDid, waitMs, signal, filter) => {
|
|
1256
|
+
for (;;) {
|
|
1257
|
+
const forwarded = this.forwardedQueue.shift();
|
|
1258
|
+
if (forwarded) {
|
|
1259
|
+
if (this.consumeRevocation(forwarded))
|
|
1260
|
+
continue;
|
|
1261
|
+
return forwarded;
|
|
1262
|
+
}
|
|
1263
|
+
const envelope = await relay.receive(recipientDid, waitMs, signal, filter ?? { role: 'worker' });
|
|
1264
|
+
if (!envelope) {
|
|
1265
|
+
const afterWait = this.forwardedQueue.shift();
|
|
1266
|
+
if (afterWait) {
|
|
1267
|
+
if (this.consumeRevocation(afterWait))
|
|
1268
|
+
continue;
|
|
1269
|
+
return afterWait;
|
|
1270
|
+
}
|
|
1271
|
+
return undefined;
|
|
1272
|
+
}
|
|
1273
|
+
if (this.consumeRevocation(envelope)) {
|
|
1274
|
+
await acknowledgeRelay(relay, envelope);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (this.isRequesterBoundFrame(envelope) &&
|
|
1278
|
+
forwardToRequesterInbox(this.options.owner.did, envelope))
|
|
1279
|
+
continue;
|
|
1280
|
+
return envelope;
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
return {
|
|
1284
|
+
get connectionStatus() { return relay.connectionStatus; },
|
|
1285
|
+
send: (envelope) => relay.send(envelope),
|
|
1286
|
+
...(relay.acknowledgeDelivery ? { acknowledgeDelivery: (did, id) => relay.acknowledgeDelivery(did, id) } : {}),
|
|
1287
|
+
receive,
|
|
1288
|
+
...(relay.register ? { register: (card) => relay.register(card) } : {}),
|
|
1289
|
+
...(relay.discover ? { discover: () => relay.discover() } : {}),
|
|
1290
|
+
...(relay.setRecipientKey
|
|
1291
|
+
? { setRecipientKey: (key) => relay.setRecipientKey(key) }
|
|
1292
|
+
: {}),
|
|
1293
|
+
...(relay.withRecipient
|
|
1294
|
+
? {
|
|
1295
|
+
withRecipient: (key) => this.revocationAwareRelay(relay.withRecipient(key)),
|
|
1296
|
+
}
|
|
1297
|
+
: {}),
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
/** True when the envelope was a REVOKE and must not be treated as a proposal. */
|
|
1301
|
+
consumeRevocation(envelope) {
|
|
1302
|
+
if (envelope.payload?.eventType !== 'REVOKE')
|
|
1303
|
+
return false;
|
|
1304
|
+
try {
|
|
1305
|
+
this.ingestRevocation(envelope);
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
// Forged, undecryptable, or unauthorized notices are ignored. They must
|
|
1309
|
+
// not abort start or runOnce and must not be written into the registry.
|
|
1310
|
+
}
|
|
1311
|
+
return true;
|
|
1312
|
+
}
|
|
1313
|
+
ingestRevocation(envelope) {
|
|
1314
|
+
if (!verifyEnvelope(envelope, this.options.owner.did))
|
|
1315
|
+
return;
|
|
1316
|
+
const notice = openEnvelope(envelope, this.options.owner, sessionDecryptOptions(this.options.allowUnencryptedEnvelopes));
|
|
1317
|
+
if (!verifyRevocationNotice(notice, envelope.payload.senderDid))
|
|
1318
|
+
return;
|
|
1319
|
+
if (notice.payload.issuerDid !== envelope.payload.senderDid)
|
|
1320
|
+
return;
|
|
1321
|
+
if (notice.payload.targetType === 'invite') {
|
|
1322
|
+
// Invites are minted by the worker owner. Another DID cannot revoke them.
|
|
1323
|
+
if (notice.payload.issuerDid !== this.options.owner.did)
|
|
1324
|
+
return;
|
|
1325
|
+
this.recordNotice(notice);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (notice.payload.targetType !== 'capability')
|
|
1329
|
+
return;
|
|
1330
|
+
// Only a chain that still verifies can promote a notice into the registry.
|
|
1331
|
+
const live = this.verifiedLiveEntries();
|
|
1332
|
+
if (this.capabilityNoticeAuthorized(notice, live)) {
|
|
1333
|
+
this.recordNotice(notice);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const pending = this.pendingCapabilities.get(notice.payload.targetId) ?? [];
|
|
1337
|
+
pending.push(structuredClone(notice));
|
|
1338
|
+
this.pendingCapabilities.set(notice.payload.targetId, pending);
|
|
1339
|
+
}
|
|
1340
|
+
recordNotice(notice) {
|
|
1341
|
+
// Append even when another issuer already revoked this id. Replacing that
|
|
1342
|
+
// notice would let a colliding token erase a real issuer's revocation.
|
|
1343
|
+
this.inboxRevocations.revoke(notice);
|
|
1344
|
+
const external = asRevocationRegistry(this.options.revocation);
|
|
1345
|
+
if (!external || external === this.inboxRevocations)
|
|
1346
|
+
return;
|
|
1347
|
+
try {
|
|
1348
|
+
external.revoke(notice);
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
// The caller registry can refuse a notice. The inbox copy still applies.
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
assertInviteActive(data) {
|
|
1355
|
+
const token = data.inviteToken;
|
|
1356
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
1357
|
+
if (this.options.accessMode !== 'open' &&
|
|
1358
|
+
!this.options.allowedRequesterDids?.includes(data.proposal.payload.requester.ownerDid))
|
|
1359
|
+
throw new Error('INVITE_REQUIRED');
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
let invite;
|
|
1363
|
+
try {
|
|
1364
|
+
invite = parseAndVerifyInviteToken(token, Date.now(), { allowPrivateRelay: true });
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
if (this.options.accessMode === 'open' && error instanceof Error && error.message === 'INVITE_EXPIRED')
|
|
1368
|
+
return;
|
|
1369
|
+
throw error;
|
|
1370
|
+
}
|
|
1371
|
+
if (invite.ownerDid !== this.options.owner.did ||
|
|
1372
|
+
invite.agentDid !== this.agentCard.payload.agentDid ||
|
|
1373
|
+
invite.relayUrl !== this.agentCard.payload.relayEndpoint)
|
|
1374
|
+
throw new Error('INVITE_WORKER_MISMATCH');
|
|
1375
|
+
this.revocation.assertNotRevoked('invite', digest(invite));
|
|
1376
|
+
this.revocation.assertNotRevoked('invite', token);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Before ACCEPT, every verified capability chain is checked against revocation
|
|
1380
|
+
* notices. A notice is stored and applied only when its issuer signed that
|
|
1381
|
+
* token or an ancestor in a chain that has passed verifyCapabilityChain.
|
|
1382
|
+
* An unverified chain, including one whose payload.issuer was rewritten,
|
|
1383
|
+
* cannot promote a pending notice into the registry.
|
|
1384
|
+
*/
|
|
1385
|
+
assertCapabilitiesActive(chains, context) {
|
|
1386
|
+
const verified = this.verifiedEntries(chains, context);
|
|
1387
|
+
this.promotePending(verified);
|
|
1388
|
+
for (const entry of verified) {
|
|
1389
|
+
if (this.verifiedChainRevoked(entry.chain))
|
|
1390
|
+
throw new Error('TOKEN_REVOKED');
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
verifiedEntries(chains, context) {
|
|
1394
|
+
if (!Array.isArray(chains))
|
|
1395
|
+
return [];
|
|
1396
|
+
const entries = [];
|
|
1397
|
+
for (const chain of chains) {
|
|
1398
|
+
if (!Array.isArray(chain))
|
|
1399
|
+
continue;
|
|
1400
|
+
try {
|
|
1401
|
+
verifyCapabilityChain(chain, context);
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
// Signature, linkage, or root failures must not authorize a revocation.
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
entries.push({ chain, context: { ...context } });
|
|
1408
|
+
}
|
|
1409
|
+
return entries;
|
|
1410
|
+
}
|
|
1411
|
+
promotePending(entries) {
|
|
1412
|
+
for (const [targetId, notices] of [...this.pendingCapabilities.entries()]) {
|
|
1413
|
+
const keep = [];
|
|
1414
|
+
for (const notice of notices) {
|
|
1415
|
+
if (this.capabilityNoticeAuthorized(notice, entries))
|
|
1416
|
+
this.recordNotice(notice);
|
|
1417
|
+
else
|
|
1418
|
+
keep.push(notice);
|
|
1419
|
+
}
|
|
1420
|
+
if (keep.length === 0)
|
|
1421
|
+
this.pendingCapabilities.delete(targetId);
|
|
1422
|
+
else
|
|
1423
|
+
this.pendingCapabilities.set(targetId, keep);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
verifiedChainRevoked(chain) {
|
|
1427
|
+
for (let index = 0; index < chain.length; index++) {
|
|
1428
|
+
const token = chain[index];
|
|
1429
|
+
if (!token?.payload)
|
|
1430
|
+
continue;
|
|
1431
|
+
const targetIds = [token.payload.id];
|
|
1432
|
+
try {
|
|
1433
|
+
targetIds.push(digest(token));
|
|
1434
|
+
}
|
|
1435
|
+
catch {
|
|
1436
|
+
// An unhashable token is not a revocation target.
|
|
1437
|
+
}
|
|
1438
|
+
for (const targetId of targetIds) {
|
|
1439
|
+
if (typeof targetId !== 'string' || targetId.length === 0)
|
|
1440
|
+
continue;
|
|
1441
|
+
if (this.capabilityTargetRevoked(chain, index, targetId))
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return false;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Pending and stored notices apply only when their issuer controls this
|
|
1449
|
+
* verified token. A bare target-id hit from another issuer does not.
|
|
1450
|
+
*/
|
|
1451
|
+
capabilityTargetRevoked(chain, index, targetId) {
|
|
1452
|
+
const pending = this.pendingCapabilities.get(targetId) ?? [];
|
|
1453
|
+
if (pending.some((notice) => capabilityIssuerControls(chain, index, notice.payload.issuerDid)))
|
|
1454
|
+
return true;
|
|
1455
|
+
const listed = [
|
|
1456
|
+
...this.listedNotices(this.inboxRevocations, targetId),
|
|
1457
|
+
...this.listedNotices(this.options.revocation, targetId),
|
|
1458
|
+
];
|
|
1459
|
+
if (listed.some((notice) => capabilityIssuerControls(chain, index, notice.payload.issuerDid)))
|
|
1460
|
+
return true;
|
|
1461
|
+
const external = this.options.revocation;
|
|
1462
|
+
if (external &&
|
|
1463
|
+
typeof external.noticesFor !== 'function' &&
|
|
1464
|
+
external.isRevoked('capability', targetId))
|
|
1465
|
+
return true;
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
listedNotices(list, targetId) {
|
|
1469
|
+
if (!list || typeof list.noticesFor !== 'function')
|
|
1470
|
+
return [];
|
|
1471
|
+
return list.noticesFor('capability', targetId);
|
|
1472
|
+
}
|
|
1473
|
+
rememberChains(taskId, chains, context) {
|
|
1474
|
+
const stored = [];
|
|
1475
|
+
if (Array.isArray(chains)) {
|
|
1476
|
+
for (const chain of chains) {
|
|
1477
|
+
if (!Array.isArray(chain))
|
|
1478
|
+
continue;
|
|
1479
|
+
try {
|
|
1480
|
+
verifyCapabilityChain(chain, context);
|
|
1481
|
+
}
|
|
1482
|
+
catch {
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
stored.push({
|
|
1486
|
+
chain: chain.map((token) => structuredClone(token)),
|
|
1487
|
+
context: { ...context },
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
this.liveChains.set(taskId, stored);
|
|
1492
|
+
}
|
|
1493
|
+
verifiedLiveEntries() {
|
|
1494
|
+
const entries = [];
|
|
1495
|
+
for (const group of this.liveChains.values()) {
|
|
1496
|
+
for (const entry of group) {
|
|
1497
|
+
try {
|
|
1498
|
+
verifyCapabilityChain(entry.chain, entry.context);
|
|
1499
|
+
entries.push(entry);
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
// A chain that no longer verifies cannot authorize a revocation.
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return entries;
|
|
1507
|
+
}
|
|
1508
|
+
capabilityNoticeAuthorized(notice, entries) {
|
|
1509
|
+
if (notice.payload.targetType !== 'capability')
|
|
1510
|
+
return false;
|
|
1511
|
+
for (const entry of entries) {
|
|
1512
|
+
try {
|
|
1513
|
+
verifyCapabilityChain(entry.chain, entry.context);
|
|
1514
|
+
}
|
|
1515
|
+
catch {
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
for (let index = 0; index < entry.chain.length; index++) {
|
|
1519
|
+
if (!this.sameCapabilityTarget(entry.chain[index], notice.payload.targetId))
|
|
1520
|
+
continue;
|
|
1521
|
+
if (capabilityIssuerControls(entry.chain, index, notice.payload.issuerDid))
|
|
1522
|
+
return true;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
sameCapabilityTarget(token, targetId) {
|
|
1528
|
+
if (!token?.payload || typeof token.payload !== 'object')
|
|
1529
|
+
return false;
|
|
1530
|
+
if (token.payload.id === targetId)
|
|
1531
|
+
return true;
|
|
1532
|
+
try {
|
|
1533
|
+
return digest(token) === targetId;
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
return false;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
class DiagnosticError extends Error {
|
|
1541
|
+
detail;
|
|
1542
|
+
constructor(code, detail) {
|
|
1543
|
+
super(code);
|
|
1544
|
+
this.detail = detail;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
function diagnosticError(code, field, expected, actual, hint) {
|
|
1548
|
+
return new DiagnosticError(code, { field, expected: digest(expected), actual: digest(actual), hint });
|
|
1549
|
+
}
|
|
1550
|
+
function rejectionBody(error) {
|
|
1551
|
+
if (error instanceof DiagnosticError)
|
|
1552
|
+
return { code: safeError(error), detail: error.detail };
|
|
1553
|
+
if (error instanceof ProposalRejectedError)
|
|
1554
|
+
return { code: error.code, reason: error.reason };
|
|
1555
|
+
return { code: safeError(error) };
|
|
1556
|
+
}
|
|
1557
|
+
function safeError(e) {
|
|
1558
|
+
const s = String(e);
|
|
1559
|
+
for (const code of ['INCOMPATIBLE_TIMEOUTS', 'CLAIM_LOST', 'DAILY_LIMIT_REACHED', 'DAILY_TASK_REPLAY', 'DAILY_LIMIT_CLOCK_ROLLBACK', 'DAILY_LIMIT_BUSY', 'DAILY_LIMIT_INVALID', 'UNENCRYPTED_ENVELOPE_REJECTED', 'INVITE_REQUIRED', 'INVITE_EXPIRED', 'INVITE_WORKER_MISMATCH', 'TASK_SCOPE_REJECTED'])
|
|
1560
|
+
if (s === 'Error: ' + code || s.startsWith('Error: ' + code + ':'))
|
|
1561
|
+
return code;
|
|
1562
|
+
if (s.includes('TOKEN_REVOKED'))
|
|
1563
|
+
return 'TOKEN_REVOKED';
|
|
1564
|
+
return (['BUDGET', 'CAPABILITY', 'APPROVAL', 'HITL', 'INVALID', 'VERIFICATION', 'SCHEMA', 'ABORT'].find((code) => s.includes(code)) ?? 'EXECUTION_FAILED');
|
|
1565
|
+
}
|
|
1566
|
+
async function abortable(promise, signal) {
|
|
1567
|
+
// Attach before throwIfAborted. A quota abort can reject the adapter in the same turn.
|
|
1568
|
+
promise.catch(() => undefined);
|
|
1569
|
+
signal.throwIfAborted();
|
|
1570
|
+
let listener = () => { };
|
|
1571
|
+
try {
|
|
1572
|
+
return await Promise.race([
|
|
1573
|
+
promise,
|
|
1574
|
+
new Promise((_, reject) => {
|
|
1575
|
+
listener = () => reject(new Error('EXECUTION_ABORTED: ' + String(signal.reason)));
|
|
1576
|
+
signal.addEventListener('abort', listener, { once: true });
|
|
1577
|
+
if (signal.aborted)
|
|
1578
|
+
listener();
|
|
1579
|
+
}),
|
|
1580
|
+
]);
|
|
1581
|
+
}
|
|
1582
|
+
finally {
|
|
1583
|
+
signal.removeEventListener('abort', listener);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/** Completion failure preserves local verification without claiming a sealed checkpoint. */
|
|
1587
|
+
export class IncompleteSettlementError extends Error {
|
|
1588
|
+
incompleteReceipt;
|
|
1589
|
+
code;
|
|
1590
|
+
detail;
|
|
1591
|
+
recovery;
|
|
1592
|
+
constructor(incompleteReceipt, cause) {
|
|
1593
|
+
super(incompleteReceipt.completionError, { cause });
|
|
1594
|
+
this.incompleteReceipt = incompleteReceipt;
|
|
1595
|
+
this.name = 'IncompleteSettlementError';
|
|
1596
|
+
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string')
|
|
1597
|
+
this.code = cause.code;
|
|
1598
|
+
if (cause instanceof PeerRejectedError) {
|
|
1599
|
+
this.detail = cause.detail;
|
|
1600
|
+
this.recovery = cause.recovery;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Queue a signed REVOKE envelope on a worker inbox.
|
|
1606
|
+
* The payload is a SignedRevocationNotice. Encryption is on unless encrypt is false,
|
|
1607
|
+
* so an offline worker reads the notice in FIFO order before a later PROPOSE.
|
|
1608
|
+
*/
|
|
1609
|
+
export async function deliverRevocation(options) {
|
|
1610
|
+
const notice = options.notice;
|
|
1611
|
+
if (!verifyRevocationNotice(notice, options.from.did) ||
|
|
1612
|
+
notice.payload.issuerDid !== options.from.did)
|
|
1613
|
+
throw new Error('INVALID_REVOCATION');
|
|
1614
|
+
resolveDid(options.recipientDid);
|
|
1615
|
+
const relayOption = options.relay;
|
|
1616
|
+
if (!relayOption)
|
|
1617
|
+
throw new Error('RELAY_REQUIRED');
|
|
1618
|
+
if (typeof relayOption === 'string')
|
|
1619
|
+
validateRelayEndpointUrl(relayOption, { allowPrivateRelay: options.allowPrivateRelay });
|
|
1620
|
+
else if ('url' in relayOption && typeof relayOption.url === 'string')
|
|
1621
|
+
validateRelayEndpointUrl(relayOption.url, {
|
|
1622
|
+
allowPrivateRelay: options.allowPrivateRelay,
|
|
1623
|
+
});
|
|
1624
|
+
const relay = transport(relayOption, options.from, {
|
|
1625
|
+
allowPrivateRelay: options.allowPrivateRelay,
|
|
1626
|
+
lookup: options.lookup,
|
|
1627
|
+
});
|
|
1628
|
+
const taskId = options.taskId && options.taskId.length > 0
|
|
1629
|
+
? options.taskId
|
|
1630
|
+
: 'revoke_' + notice.payload.revocationId;
|
|
1631
|
+
const envelope = signEnvelope({
|
|
1632
|
+
taskId,
|
|
1633
|
+
recipientDid: options.recipientDid,
|
|
1634
|
+
seq: options.seq ?? 0,
|
|
1635
|
+
eventType: 'REVOKE',
|
|
1636
|
+
payload: asJson(notice),
|
|
1637
|
+
}, options.from, { encrypt: options.encrypt !== false });
|
|
1638
|
+
await relay.send(envelope);
|
|
1639
|
+
return envelope;
|
|
1640
|
+
}
|
|
1641
|
+
export class RequesterClient {
|
|
1642
|
+
options;
|
|
1643
|
+
approvalProvider;
|
|
1644
|
+
constructor(options) {
|
|
1645
|
+
this.options = options;
|
|
1646
|
+
validateSessionTimeouts(options);
|
|
1647
|
+
this.approvalProvider =
|
|
1648
|
+
options.approvalProvider ?? new InteractiveCliApprovalProvider(options.owner);
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Send an encrypted REVOKE for an invite or capability to a worker inbox.
|
|
1652
|
+
* `to` is a worker DID or an acp-invite token. The notice is signed by this client.
|
|
1653
|
+
*/
|
|
1654
|
+
async sendRevocation(options) {
|
|
1655
|
+
const invite = isInviteTarget(options.to)
|
|
1656
|
+
? parseAndVerifyInviteToken(options.to, Date.now(), {
|
|
1657
|
+
allowPrivateRelay: this.options.allowPrivateRelay,
|
|
1658
|
+
})
|
|
1659
|
+
: undefined;
|
|
1660
|
+
const relayOption = options.relay ?? this.options.relay ?? invite?.relayUrl;
|
|
1661
|
+
if (!relayOption)
|
|
1662
|
+
throw new Error('RELAY_REQUIRED');
|
|
1663
|
+
const recipientDid = invite?.ownerDid ?? options.to;
|
|
1664
|
+
const notice = options.notice ??
|
|
1665
|
+
createRevocationNotice({
|
|
1666
|
+
targetType: options.targetType,
|
|
1667
|
+
targetId: options.targetId ?? '',
|
|
1668
|
+
reason: options.reason ?? '',
|
|
1669
|
+
}, this.options.owner);
|
|
1670
|
+
return deliverRevocation({
|
|
1671
|
+
relay: relayOption,
|
|
1672
|
+
from: this.options.owner,
|
|
1673
|
+
recipientDid,
|
|
1674
|
+
notice,
|
|
1675
|
+
taskId: options.taskId,
|
|
1676
|
+
encrypt: options.encrypt,
|
|
1677
|
+
allowPrivateRelay: this.options.allowPrivateRelay,
|
|
1678
|
+
lookup: this.options.lookup,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
async delegate(options, hooks) {
|
|
1682
|
+
const timeoutOptions = resolveSessionTimeouts(this.options, options);
|
|
1683
|
+
validateSessionTimeouts(timeoutOptions);
|
|
1684
|
+
if (timeoutOptions.executionTimeoutMs !== undefined && timeoutOptions.executionTimeoutMs < 1000)
|
|
1685
|
+
throw new Error('INVALID_TIMEOUT: executionTimeoutMs must be at least 1000 (whole-second contract budget)');
|
|
1686
|
+
const onProgress = hooks?.onProgress ?? options.onProgress;
|
|
1687
|
+
const invite = isInviteTarget(options.to)
|
|
1688
|
+
? parseAndVerifyInviteToken(options.to, Date.now(), {
|
|
1689
|
+
allowPrivateRelay: this.options.allowPrivateRelay,
|
|
1690
|
+
})
|
|
1691
|
+
: undefined;
|
|
1692
|
+
const relayOption = options.relay ?? this.options.relay ?? invite?.relayUrl;
|
|
1693
|
+
if (!relayOption)
|
|
1694
|
+
throw new Error('RELAY_REQUIRED');
|
|
1695
|
+
if (typeof relayOption === 'string')
|
|
1696
|
+
validateRelayEndpointUrl(relayOption, { allowPrivateRelay: this.options.allowPrivateRelay });
|
|
1697
|
+
else if ('url' in relayOption && typeof relayOption.url === 'string')
|
|
1698
|
+
validateRelayEndpointUrl(relayOption.url, {
|
|
1699
|
+
allowPrivateRelay: this.options.allowPrivateRelay,
|
|
1700
|
+
});
|
|
1701
|
+
const relay = transport(relayOption, this.options.owner, {
|
|
1702
|
+
allowPrivateRelay: this.options.allowPrivateRelay,
|
|
1703
|
+
lookup: this.options.lookup,
|
|
1704
|
+
requestTimeoutMs: timeoutOptions.connectTimeoutMs,
|
|
1705
|
+
}), owner = this.options.owner, inbox = typeof relayOption === 'string'
|
|
1706
|
+
? recipientInbox(this, relay, owner.did, relayOption.replace(/\/$/, ''))
|
|
1707
|
+
: recipientInbox(relayOption, relay, owner.did);
|
|
1708
|
+
const rawCards = !invite && relay.discover ? await relay.discover() : [];
|
|
1709
|
+
if (!Array.isArray(rawCards))
|
|
1710
|
+
throw new Error('INVALID_AGENT_CARD');
|
|
1711
|
+
if (invite && options.discover)
|
|
1712
|
+
throw new Error('CONFLICTING_DISCOVERY_TARGET');
|
|
1713
|
+
const card = options.discover
|
|
1714
|
+
? discoverAgentCards(rawCards, { ...options.discover, accessMode: 'open',
|
|
1715
|
+
...(options.to ? { did: options.to } : {}) })[0]
|
|
1716
|
+
: findDiscoveredAgentCard(rawCards, options.to);
|
|
1717
|
+
if (options.discover && !card)
|
|
1718
|
+
throw new Error('OPEN_WORKER_NOT_FOUND');
|
|
1719
|
+
const peer = invite?.ownerDid ?? card?.payload.ownerDid ?? options.to;
|
|
1720
|
+
const manifest = options.manifest ??
|
|
1721
|
+
buildTaskManifest({
|
|
1722
|
+
...options,
|
|
1723
|
+
taskTimeoutMs: timeoutOptions.taskTimeoutMs ?? timeoutOptions.timeoutMs,
|
|
1724
|
+
budget: { ...options.budget, ...(timeoutOptions.executionTimeoutMs !== undefined
|
|
1725
|
+
? { maxWallClockSeconds: options.budget?.maxWallClockSeconds ?? Math.floor(timeoutOptions.executionTimeoutMs / 1000) } : {}) },
|
|
1726
|
+
ownerDid: owner.did,
|
|
1727
|
+
workerDid: peer,
|
|
1728
|
+
agentDid: invite?.agentDid ?? card?.payload.agentDid ?? peer,
|
|
1729
|
+
skillId: options.discover?.skillId,
|
|
1730
|
+
taskType: options.discover?.taskType ?? (card?.payload.skills.length === 1 &&
|
|
1731
|
+
card.payload.skills[0].taskTypes?.length === 1 ? card.payload.skills[0].taskTypes[0] : undefined),
|
|
1732
|
+
});
|
|
1733
|
+
// Cap only implicit defaults; explicit budgets remain subject to worker admission.
|
|
1734
|
+
if (!options.manifest && card?.payload.accessMode === 'open') {
|
|
1735
|
+
for (const field of OPEN_POLICY_FIELDS) {
|
|
1736
|
+
const limit = card.payload.openPolicy?.[field];
|
|
1737
|
+
if (limit !== undefined && options.budget?.[field] === undefined)
|
|
1738
|
+
manifest.budget[field] = Math.min(manifest.budget[field], limit);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
if (options.manifest && timeoutOptions.executionTimeoutMs !== undefined &&
|
|
1742
|
+
manifest.budget.maxWallClockSeconds * 1000 > timeoutOptions.executionTimeoutMs)
|
|
1743
|
+
throw new Error('INCOMPATIBLE_TIMEOUTS: manifest budget exceeds executionTimeoutMs');
|
|
1744
|
+
if (options.budget?.maxWallClockSeconds !== undefined && timeoutOptions.executionTimeoutMs !== undefined &&
|
|
1745
|
+
options.budget.maxWallClockSeconds * 1000 > timeoutOptions.executionTimeoutMs)
|
|
1746
|
+
throw new Error('INCOMPATIBLE_TIMEOUTS: requested budget exceeds executionTimeoutMs');
|
|
1747
|
+
validateManifest(manifest);
|
|
1748
|
+
if (manifest.requester.ownerDid !== owner.did ||
|
|
1749
|
+
manifest.worker?.ownerDid !== peer ||
|
|
1750
|
+
Date.parse(manifest.deadline) <= Date.now())
|
|
1751
|
+
throw new Error('INVALID_MANIFEST_PARTIES_OR_DEADLINE');
|
|
1752
|
+
if (options.minimumIsolationLevel) {
|
|
1753
|
+
manifest.sandboxPolicy = {
|
|
1754
|
+
...manifest.sandboxPolicy,
|
|
1755
|
+
isolationLevel: options.minimumIsolationLevel,
|
|
1756
|
+
};
|
|
1757
|
+
if (options.minimumIsolationLevel === 'oci_container')
|
|
1758
|
+
manifest.verification = { ...manifest.verification, requireExecutionAttestation: true };
|
|
1759
|
+
}
|
|
1760
|
+
if (!validateOutput(manifest.io.inputSchema, options.input))
|
|
1761
|
+
throw new Error('INPUT_SCHEMA');
|
|
1762
|
+
const auditPath = options.auditPath ?? this.options.auditPath;
|
|
1763
|
+
freshAudit(auditPath);
|
|
1764
|
+
const bridge = new AbortController();
|
|
1765
|
+
const mailbox = inbox.attach(manifest.taskId);
|
|
1766
|
+
const timeouts = new SessionTimeouts(timeoutOptions, manifest.deadline, bridge.signal);
|
|
1767
|
+
const signal = timeouts.signal;
|
|
1768
|
+
const wire = new SessionWire(mailboxRelay(relay, mailbox), owner, peer, manifest.taskId, [owner.did, peer], signal, auditPath, this.options.allowUnencryptedEnvelopes === true);
|
|
1769
|
+
if (onProgress) {
|
|
1770
|
+
wire.onProgress = async (progress, envelope) => {
|
|
1771
|
+
await onProgress(progress, {
|
|
1772
|
+
taskId: envelope.payload.taskId,
|
|
1773
|
+
seq: envelope.payload.seq,
|
|
1774
|
+
eventType: envelope.payload.eventType,
|
|
1775
|
+
senderDid: envelope.payload.senderDid,
|
|
1776
|
+
recipientDid: envelope.payload.recipientDid,
|
|
1777
|
+
payload: progress,
|
|
1778
|
+
});
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
let verifiedReceipt;
|
|
1782
|
+
let gateway;
|
|
1783
|
+
let deferredCancel = false;
|
|
1784
|
+
let cancelStarted = false;
|
|
1785
|
+
const startCancel = () => {
|
|
1786
|
+
if (cancelStarted || !gateway)
|
|
1787
|
+
return;
|
|
1788
|
+
const state = gateway.machine.state;
|
|
1789
|
+
if (state !== 'ACCEPTED' && state !== 'IN_PROGRESS')
|
|
1790
|
+
return;
|
|
1791
|
+
cancelStarted = true;
|
|
1792
|
+
const transition = gateway.transition('CANCEL');
|
|
1793
|
+
void wire
|
|
1794
|
+
.cancelSession({
|
|
1795
|
+
reason: String(options.signal?.reason ?? 'cancelled'),
|
|
1796
|
+
cancelledBy: owner.did,
|
|
1797
|
+
transition,
|
|
1798
|
+
})
|
|
1799
|
+
.catch(() => undefined);
|
|
1800
|
+
};
|
|
1801
|
+
const onUserAbort = () => {
|
|
1802
|
+
const state = gateway?.machine.state;
|
|
1803
|
+
if (state === 'IN_PROGRESS')
|
|
1804
|
+
startCancel();
|
|
1805
|
+
else if (state === 'ACCEPTED')
|
|
1806
|
+
deferredCancel = true;
|
|
1807
|
+
else
|
|
1808
|
+
bridge.abort(options.signal?.reason);
|
|
1809
|
+
};
|
|
1810
|
+
options.signal?.addEventListener('abort', onUserAbort);
|
|
1811
|
+
if (options.signal?.aborted)
|
|
1812
|
+
onUserAbort();
|
|
1813
|
+
try {
|
|
1814
|
+
const capabilityChains = options.capabilityChains ??
|
|
1815
|
+
manifest.permissions.grantedCapabilities.map((g) => {
|
|
1816
|
+
const root = createCapability({
|
|
1817
|
+
audience: owner.did,
|
|
1818
|
+
taskId: manifest.taskId,
|
|
1819
|
+
resource: g.resource,
|
|
1820
|
+
allowedTools: g.allowedTools,
|
|
1821
|
+
argConstraints: Object.fromEntries(g.allowedTools.map((t) => [t, g.argConstraints ?? { type: 'object' }])),
|
|
1822
|
+
maxCalls: g.maxInvocations,
|
|
1823
|
+
notBefore: manifest.createdAt,
|
|
1824
|
+
expiresAt: g.expiresAt,
|
|
1825
|
+
maxDepth: 1,
|
|
1826
|
+
}, owner);
|
|
1827
|
+
const leaf = createCapability({ ...root.payload, audience: peer, maxDepth: 0 }, owner, root);
|
|
1828
|
+
return [root, leaf];
|
|
1829
|
+
});
|
|
1830
|
+
await wire.sendEvent('PROPOSE', {
|
|
1831
|
+
proposal: signPayload(manifest, owner),
|
|
1832
|
+
input: options.input,
|
|
1833
|
+
capabilityChains,
|
|
1834
|
+
peerIdleTimeoutMs: timeoutOptions.transportIdleTimeoutMs ?? 90_000,
|
|
1835
|
+
...(invite ? { inviteToken: options.to } : {}),
|
|
1836
|
+
});
|
|
1837
|
+
const accept = (await wire.receiveEvent('ACCEPT')).payload.payload;
|
|
1838
|
+
if (!verifyContract(accept.contract) ||
|
|
1839
|
+
digest(accept.contract.manifest) !== digest(manifest) ||
|
|
1840
|
+
!verifyApproval(accept.approval, peer, manifest.taskId, 'contract_acceptance', accept.contract.contractDigest))
|
|
1841
|
+
throw new Error('INVALID_ACCEPTANCE');
|
|
1842
|
+
gateway = new OwnerGateway(owner, accept.contract, policy);
|
|
1843
|
+
gateway.machine.apply(accept.transition);
|
|
1844
|
+
gateway.machine.apply((await wire.receiveEvent('START')).payload.payload);
|
|
1845
|
+
if (options.signal?.aborted || deferredCancel)
|
|
1846
|
+
startCancel();
|
|
1847
|
+
timeouts.executing(timeoutOptions.executionTimeoutMs ?? manifest.budget.maxWallClockSeconds * 1000);
|
|
1848
|
+
wire.onActivity = () => timeouts.activity();
|
|
1849
|
+
const envelope = await wire.receiveEvent('DELIVER'), data = envelope.payload.payload;
|
|
1850
|
+
wire.onActivity = undefined;
|
|
1851
|
+
timeouts.wait();
|
|
1852
|
+
gateway.machine.apply(data.transition);
|
|
1853
|
+
hooks?.onDelivered?.();
|
|
1854
|
+
const approval = () => this.approvalProvider.approve({
|
|
1855
|
+
taskId: manifest.taskId,
|
|
1856
|
+
gate: 'final_verification',
|
|
1857
|
+
target: data,
|
|
1858
|
+
details: { manifest, deliverableDigest: data.artifact.payload.digest },
|
|
1859
|
+
signal,
|
|
1860
|
+
});
|
|
1861
|
+
const { result, verification, settlement } = await abortable(gateway.verifyAndSettle(wire.sealed(envelope), this.options.verificationEngine ?? new VerificationEngine(), approval, { allowUnencryptedEnvelopes: this.options.allowUnencryptedEnvelopes === true }), signal);
|
|
1862
|
+
if (!result.accepted || !settlement) {
|
|
1863
|
+
await wire.sendEvent('VERIFY_REJECT', verification);
|
|
1864
|
+
throw new Error('VERIFICATION_REJECTED: ' + result.reason);
|
|
1865
|
+
}
|
|
1866
|
+
verifiedReceipt = {
|
|
1867
|
+
workerDid: manifest.worker.agentDid,
|
|
1868
|
+
taskId: manifest.taskId,
|
|
1869
|
+
outputDigest: digest(data.artifact.payload.data),
|
|
1870
|
+
deliverable: data.artifact.payload.data,
|
|
1871
|
+
accepted: true,
|
|
1872
|
+
checkpointComplete: false,
|
|
1873
|
+
delivery: wire.sealed(envelope),
|
|
1874
|
+
verification: result,
|
|
1875
|
+
};
|
|
1876
|
+
await wire.sendEvent('VERIFY_ACCEPT', verification);
|
|
1877
|
+
await wire.sendEvent('SETTLED', settlement);
|
|
1878
|
+
return {
|
|
1879
|
+
taskId: manifest.taskId,
|
|
1880
|
+
state: 'SETTLED',
|
|
1881
|
+
deliverable: data.artifact.payload.data,
|
|
1882
|
+
bundle: await wire.finishRequester(),
|
|
1883
|
+
verification: result,
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
catch (e) {
|
|
1887
|
+
try {
|
|
1888
|
+
if (e instanceof PeerRejectedError && e.recovery) {
|
|
1889
|
+
if (e.recovery.proposalDigest !== digest({ manifest, input: options.input }))
|
|
1890
|
+
throw new Error('INVALID_RECOVERY_OUTCOME');
|
|
1891
|
+
const cached = e.recovery.result;
|
|
1892
|
+
if (cached) {
|
|
1893
|
+
const proposed = cached.bundle?.entries?.find((entry) => entry.body.eventType === 'PROPOSE')
|
|
1894
|
+
?.body.payload;
|
|
1895
|
+
const accepted = cached.bundle?.entries?.find((entry) => entry.body.eventType === 'ACCEPT')
|
|
1896
|
+
?.body.payload;
|
|
1897
|
+
const delivered = cached.bundle?.entries?.find((entry) => entry.body.eventType === 'DELIVER')
|
|
1898
|
+
?.body.payload;
|
|
1899
|
+
if (cached.taskId !== manifest.taskId || !['SETTLED', 'CANCELLED'].includes(cached.state) ||
|
|
1900
|
+
!verifyAudit(cached.bundle, { taskId: manifest.taskId, actors: [owner.did, peer] }).valid ||
|
|
1901
|
+
!proposed || digest({ manifest: proposed.proposal.payload, input: proposed.input }) !== e.recovery.proposalDigest ||
|
|
1902
|
+
!accepted || !verifyContract(accepted.contract) || digest(accepted.contract.manifest) !== digest(manifest) ||
|
|
1903
|
+
(cached.state === 'CANCELLED' && (e.code !== 'TASK_CANCELLED' || cached.deliverable !== null ||
|
|
1904
|
+
cached.bundle.entries.at(-1)?.body.eventType !== 'CANCEL')) ||
|
|
1905
|
+
(cached.state === 'SETTLED' && (!delivered ||
|
|
1906
|
+
e.code !== 'TASK_SETTLED' ||
|
|
1907
|
+
digest(delivered.artifact.payload.data) !== digest(cached.deliverable) ||
|
|
1908
|
+
cached.bundle.entries.at(-1)?.body.eventType !== 'SETTLED')))
|
|
1909
|
+
throw new Error('INVALID_RECOVERY_OUTCOME');
|
|
1910
|
+
if (cached.state === 'SETTLED')
|
|
1911
|
+
hooks?.onDelivered?.();
|
|
1912
|
+
return cached;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (verifiedReceipt)
|
|
1916
|
+
throw e;
|
|
1917
|
+
// A closed recipient inbox is terminal for every attached task. Do not wait
|
|
1918
|
+
// for another relay send (or cancellation handshake) on the failed connection.
|
|
1919
|
+
if (e instanceof Error && 'code' in e && ['RELAY_TERMINAL', 'RELAY_ENVELOPE_EXPIRED'].includes(String(e.code)))
|
|
1920
|
+
throw e;
|
|
1921
|
+
if (cancelStarted) {
|
|
1922
|
+
try {
|
|
1923
|
+
await wire.cancelSession();
|
|
1924
|
+
const bundle = await wire.finishRequester();
|
|
1925
|
+
throw Object.assign(new Error('CANCELLED'), { bundle });
|
|
1926
|
+
}
|
|
1927
|
+
catch (inner) {
|
|
1928
|
+
if (inner instanceof Error && inner.message === 'CANCELLED')
|
|
1929
|
+
throw inner;
|
|
1930
|
+
await wire.rawSend('ERROR', { code: safeError(inner) }).catch(() => { });
|
|
1931
|
+
throw inner;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
await wire.rawSend('ERROR', { code: safeError(e) }).catch(() => { });
|
|
1935
|
+
throw e;
|
|
1936
|
+
}
|
|
1937
|
+
catch (error) {
|
|
1938
|
+
if (!verifiedReceipt)
|
|
1939
|
+
throw error;
|
|
1940
|
+
throw new IncompleteSettlementError({
|
|
1941
|
+
...verifiedReceipt,
|
|
1942
|
+
entries: wire.entries.slice(),
|
|
1943
|
+
completionError: error instanceof Error ? error.message : String(error),
|
|
1944
|
+
}, error);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
finally {
|
|
1948
|
+
timeouts.close();
|
|
1949
|
+
wire.close();
|
|
1950
|
+
options.signal?.removeEventListener('abort', onUserAbort);
|
|
1951
|
+
inbox.detach(manifest.taskId);
|
|
1952
|
+
gateway?.close();
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
async delegateMulti(options) {
|
|
1956
|
+
if (!Array.isArray(options.workers) || options.workers.length === 0)
|
|
1957
|
+
throw new Error('WORKERS_REQUIRED');
|
|
1958
|
+
if (options.mode !== 'race' && options.mode !== 'quorum')
|
|
1959
|
+
throw new Error('INVALID_DELEGATION_MODE');
|
|
1960
|
+
const completionTimeoutMs = options.completionTimeoutMs ?? 45_000;
|
|
1961
|
+
assertPositive('COMPLETION_TIMEOUT', completionTimeoutMs);
|
|
1962
|
+
if (completionTimeoutMs > 2_147_483_647)
|
|
1963
|
+
throw new Error('INVALID_COMPLETION_TIMEOUT');
|
|
1964
|
+
const count = options.workers.length;
|
|
1965
|
+
const quorumSize = options.quorumSize ?? Math.floor(count / 2) + 1;
|
|
1966
|
+
if (options.mode === 'quorum' &&
|
|
1967
|
+
(!Number.isInteger(quorumSize) || quorumSize < 1 || quorumSize > count))
|
|
1968
|
+
throw new Error('INVALID_QUORUM_SIZE');
|
|
1969
|
+
const resolved = options.workers.map((worker) => resolveWorkerTarget(worker, this.options.allowPrivateRelay));
|
|
1970
|
+
await canonicalizeDiscoveredWorkers(resolved, {
|
|
1971
|
+
relay: this.options.relay,
|
|
1972
|
+
owner: this.options.owner,
|
|
1973
|
+
allowPrivateRelay: this.options.allowPrivateRelay,
|
|
1974
|
+
lookup: this.options.lookup,
|
|
1975
|
+
});
|
|
1976
|
+
const seenWorkers = new Set();
|
|
1977
|
+
for (const worker of resolved) {
|
|
1978
|
+
if (seenWorkers.has(worker.workerDid))
|
|
1979
|
+
throw new Error('DUPLICATE_WORKER_TARGET');
|
|
1980
|
+
seenWorkers.add(worker.workerDid);
|
|
1981
|
+
}
|
|
1982
|
+
const controllers = options.workers.map(() => new AbortController());
|
|
1983
|
+
const finished = options.workers.map(() => false);
|
|
1984
|
+
const delivered = options.workers.map(() => false);
|
|
1985
|
+
const completionTimers = [];
|
|
1986
|
+
const receipts = [];
|
|
1987
|
+
const incompleteReceipts = [];
|
|
1988
|
+
let failures = 0;
|
|
1989
|
+
let winnerDigest;
|
|
1990
|
+
const abortUnfinished = (reason) => {
|
|
1991
|
+
controllers.forEach((controller, index) => {
|
|
1992
|
+
if (finished[index])
|
|
1993
|
+
return;
|
|
1994
|
+
if (!delivered[index])
|
|
1995
|
+
controller.abort(reason);
|
|
1996
|
+
else if (!completionTimers[index]) {
|
|
1997
|
+
// DELIVER has left execution: preserve verification and bilateral checkpointing.
|
|
1998
|
+
completionTimers[index] = setTimeout(() => controller.abort(new Error('COMPLETION_TIMEOUT')), completionTimeoutMs);
|
|
1999
|
+
completionTimers[index].unref();
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
};
|
|
2003
|
+
const jobs = resolved.map(async (worker, index) => {
|
|
2004
|
+
const signal = options.signal
|
|
2005
|
+
? AbortSignal.any([options.signal, controllers[index].signal])
|
|
2006
|
+
: controllers[index].signal;
|
|
2007
|
+
try {
|
|
2008
|
+
const result = await this.delegate({
|
|
2009
|
+
to: worker.inviteToken ?? worker.workerDid,
|
|
2010
|
+
relay: worker.relayUrl ?? this.options.relay,
|
|
2011
|
+
title: options.title,
|
|
2012
|
+
description: options.description,
|
|
2013
|
+
input: options.input,
|
|
2014
|
+
outputSchema: options.outputSchema,
|
|
2015
|
+
executionTimeoutMs: options.executionTimeoutMs,
|
|
2016
|
+
timeoutMs: options.timeoutMs,
|
|
2017
|
+
taskTimeoutMs: options.taskTimeoutMs,
|
|
2018
|
+
waitTimeoutMs: options.waitTimeoutMs,
|
|
2019
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
2020
|
+
transportIdleTimeoutMs: options.transportIdleTimeoutMs,
|
|
2021
|
+
heartbeatIntervalMs: options.heartbeatIntervalMs,
|
|
2022
|
+
budget: options.budget,
|
|
2023
|
+
capabilities: options.capabilities,
|
|
2024
|
+
minimumIsolationLevel: options.minimumIsolationLevel,
|
|
2025
|
+
signal,
|
|
2026
|
+
onProgress: options.onProgress,
|
|
2027
|
+
auditPath: options.auditPath
|
|
2028
|
+
? options.auditPath.replace(/(\.jsonl)?$/, '.' + index + '.jsonl')
|
|
2029
|
+
: undefined,
|
|
2030
|
+
}, { onDelivered: () => { delivered[index] = true; } });
|
|
2031
|
+
finished[index] = true;
|
|
2032
|
+
// SETTLED always carries a verified deliverable, including JSON null.
|
|
2033
|
+
if (result.state !== 'SETTLED')
|
|
2034
|
+
throw new Error('VERIFICATION_REJECTED');
|
|
2035
|
+
const workerDid = verifiedWorkerDid(result.bundle, worker.workerDid);
|
|
2036
|
+
const outputDigest = digest(result.deliverable);
|
|
2037
|
+
receipts.push({
|
|
2038
|
+
workerDid,
|
|
2039
|
+
taskId: result.taskId,
|
|
2040
|
+
outputDigest,
|
|
2041
|
+
deliverable: result.deliverable,
|
|
2042
|
+
bundle: result.bundle,
|
|
2043
|
+
accepted: true,
|
|
2044
|
+
});
|
|
2045
|
+
if (options.mode === 'quorum' && !winnerDigest) {
|
|
2046
|
+
const distinct = new Set(receipts
|
|
2047
|
+
.filter((receipt) => receipt.outputDigest === outputDigest)
|
|
2048
|
+
.map((receipt) => receipt.workerDid));
|
|
2049
|
+
if (distinct.size >= quorumSize) {
|
|
2050
|
+
winnerDigest = outputDigest;
|
|
2051
|
+
abortUnfinished('QUORUM_REACHED');
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
return { index, result, outputDigest, workerDid };
|
|
2055
|
+
}
|
|
2056
|
+
catch (error) {
|
|
2057
|
+
if (error instanceof IncompleteSettlementError)
|
|
2058
|
+
incompleteReceipts.push(error.incompleteReceipt);
|
|
2059
|
+
finished[index] = true;
|
|
2060
|
+
failures += 1;
|
|
2061
|
+
if (options.mode === 'quorum' && !winnerDigest && failures > count - quorumSize)
|
|
2062
|
+
abortUnfinished('QUORUM_UNREACHABLE');
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
finally {
|
|
2066
|
+
clearTimeout(completionTimers[index]);
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
if (options.mode === 'race') {
|
|
2070
|
+
let winner;
|
|
2071
|
+
try {
|
|
2072
|
+
winner = await Promise.any(jobs);
|
|
2073
|
+
}
|
|
2074
|
+
catch {
|
|
2075
|
+
throw Object.assign(new Error('RACE_FAILED'), { receipts, incompleteReceipts });
|
|
2076
|
+
}
|
|
2077
|
+
abortUnfinished('RACE_LOST');
|
|
2078
|
+
await Promise.allSettled(jobs);
|
|
2079
|
+
const outputDigest = winner.outputDigest;
|
|
2080
|
+
return {
|
|
2081
|
+
mode: 'race',
|
|
2082
|
+
status: 'SETTLED',
|
|
2083
|
+
deliverable: winner.result.deliverable,
|
|
2084
|
+
outputDigest,
|
|
2085
|
+
taskIds: receipts.map((receipt) => receipt.taskId),
|
|
2086
|
+
winners: [
|
|
2087
|
+
{
|
|
2088
|
+
workerDid: winner.workerDid,
|
|
2089
|
+
taskId: winner.result.taskId,
|
|
2090
|
+
outputDigest,
|
|
2091
|
+
},
|
|
2092
|
+
],
|
|
2093
|
+
receipts,
|
|
2094
|
+
incompleteReceipts,
|
|
2095
|
+
cancelled: resolved
|
|
2096
|
+
.filter((_, index) => index !== winner.index && controllers[index].signal.aborted && !delivered[index])
|
|
2097
|
+
.map((worker) => worker.workerDid),
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
await Promise.allSettled(jobs);
|
|
2101
|
+
if (!winnerDigest)
|
|
2102
|
+
throw Object.assign(new Error('QUORUM_NOT_REACHED'), { receipts, incompleteReceipts });
|
|
2103
|
+
const seenWinners = new Set();
|
|
2104
|
+
const winners = receipts.filter((receipt) => {
|
|
2105
|
+
if (receipt.outputDigest !== winnerDigest || seenWinners.has(receipt.workerDid))
|
|
2106
|
+
return false;
|
|
2107
|
+
seenWinners.add(receipt.workerDid);
|
|
2108
|
+
return true;
|
|
2109
|
+
});
|
|
2110
|
+
return {
|
|
2111
|
+
mode: 'quorum',
|
|
2112
|
+
status: 'SETTLED',
|
|
2113
|
+
deliverable: winners[0].deliverable,
|
|
2114
|
+
outputDigest: winnerDigest,
|
|
2115
|
+
taskIds: receipts.map((receipt) => receipt.taskId),
|
|
2116
|
+
winners: winners.map((receipt) => ({
|
|
2117
|
+
workerDid: receipt.workerDid,
|
|
2118
|
+
taskId: receipt.taskId,
|
|
2119
|
+
outputDigest: receipt.outputDigest,
|
|
2120
|
+
})),
|
|
2121
|
+
receipts,
|
|
2122
|
+
incompleteReceipts,
|
|
2123
|
+
cancelled: resolved
|
|
2124
|
+
.filter((_, index) => controllers[index].signal.aborted && !delivered[index])
|
|
2125
|
+
.map((worker) => worker.workerDid),
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
function canonicalInviteToken(token) {
|
|
2130
|
+
if (typeof token === 'string')
|
|
2131
|
+
return token;
|
|
2132
|
+
return 'acp-invite:' + Buffer.from(JSON.stringify(token)).toString('base64url');
|
|
2133
|
+
}
|
|
2134
|
+
function resolveWorkerTarget(target, allowPrivateRelay) {
|
|
2135
|
+
let workerDid = target.workerDid;
|
|
2136
|
+
let inviteToken;
|
|
2137
|
+
if (target.inviteToken !== undefined && target.inviteToken !== '') {
|
|
2138
|
+
inviteToken = canonicalInviteToken(target.inviteToken);
|
|
2139
|
+
const invite = parseAndVerifyInviteToken(inviteToken, Date.now(), { allowPrivateRelay });
|
|
2140
|
+
const verified = invite.agentCard.payload.agentDid;
|
|
2141
|
+
if (verified !== invite.agentDid)
|
|
2142
|
+
throw new Error('WORKER_DID_MISMATCH');
|
|
2143
|
+
if (workerDid && workerDid !== verified)
|
|
2144
|
+
throw new Error('WORKER_DID_MISMATCH');
|
|
2145
|
+
workerDid = verified;
|
|
2146
|
+
}
|
|
2147
|
+
else {
|
|
2148
|
+
if (typeof workerDid !== 'string' || workerDid.length === 0)
|
|
2149
|
+
throw new Error('INVALID_DID');
|
|
2150
|
+
resolveDid(workerDid);
|
|
2151
|
+
}
|
|
2152
|
+
return { workerDid, inviteToken, relayUrl: target.relayUrl };
|
|
2153
|
+
}
|
|
2154
|
+
function verifiedWorkerDid(bundle, expectedDid) {
|
|
2155
|
+
if (!verifyAudit(bundle).valid)
|
|
2156
|
+
throw new Error('VERIFICATION_REJECTED');
|
|
2157
|
+
const accept = bundle.entries.find((entry) => entry.body.eventType === 'ACCEPT');
|
|
2158
|
+
const payload = accept?.body.payload;
|
|
2159
|
+
const worker = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
2160
|
+
? payload.contract?.manifest?.worker
|
|
2161
|
+
: undefined;
|
|
2162
|
+
const actors = bundle.checkpoint.actor.payload.actors;
|
|
2163
|
+
if (!worker ||
|
|
2164
|
+
(worker.agentDid !== expectedDid && worker.ownerDid !== expectedDid) ||
|
|
2165
|
+
!actors.includes(worker.ownerDid) ||
|
|
2166
|
+
!verifySigned(bundle.checkpoint.actor, actors[0]))
|
|
2167
|
+
throw new Error('WORKER_DID_MISMATCH');
|
|
2168
|
+
// Receipts and quorum use the card's agentDid, even when the caller addressed the owner DID.
|
|
2169
|
+
return worker.agentDid;
|
|
2170
|
+
}
|
|
2171
|
+
async function canonicalizeDiscoveredWorkers(targets, options) {
|
|
2172
|
+
const pending = targets.filter((target) => !target.inviteToken);
|
|
2173
|
+
if (pending.length === 0)
|
|
2174
|
+
return;
|
|
2175
|
+
const relays = new Map();
|
|
2176
|
+
for (const target of pending) {
|
|
2177
|
+
const relay = target.relayUrl ?? options.relay;
|
|
2178
|
+
if (!relay)
|
|
2179
|
+
continue;
|
|
2180
|
+
const group = relays.get(relay) ?? [];
|
|
2181
|
+
group.push(target);
|
|
2182
|
+
relays.set(relay, group);
|
|
2183
|
+
}
|
|
2184
|
+
await Promise.all([...relays.entries()].map(async ([relayOption, group]) => {
|
|
2185
|
+
try {
|
|
2186
|
+
const relay = transport(relayOption, options.owner, {
|
|
2187
|
+
allowPrivateRelay: options.allowPrivateRelay,
|
|
2188
|
+
lookup: options.lookup,
|
|
2189
|
+
});
|
|
2190
|
+
if (!relay.discover)
|
|
2191
|
+
return;
|
|
2192
|
+
const cards = await relay.discover();
|
|
2193
|
+
if (!Array.isArray(cards))
|
|
2194
|
+
return;
|
|
2195
|
+
const now = Date.now();
|
|
2196
|
+
for (const target of group) {
|
|
2197
|
+
const card = findDiscoveredAgentCard(cards, target.workerDid, now);
|
|
2198
|
+
if (card)
|
|
2199
|
+
target.workerDid = card.payload.agentDid;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
catch (error) {
|
|
2203
|
+
if (error instanceof Error && error.message === 'AMBIGUOUS_AGENT_CARD_OWNER')
|
|
2204
|
+
throw error;
|
|
2205
|
+
// Discovery only collapses aliases. Settlement still checks the signed manifest.
|
|
2206
|
+
}
|
|
2207
|
+
}));
|
|
2208
|
+
}
|
|
2209
|
+
export function buildTaskManifest(options) {
|
|
2210
|
+
const createdAt = new Date().toISOString(), deadline = new Date(Date.now() + (options.taskTimeoutMs ?? 600000)).toISOString();
|
|
2211
|
+
return {
|
|
2212
|
+
protocolVersion: '0.1.0',
|
|
2213
|
+
taskId: 'task_' + randomUUID().replaceAll('-', ''),
|
|
2214
|
+
createdAt,
|
|
2215
|
+
deadline,
|
|
2216
|
+
requester: { ownerDid: options.ownerDid, agentDid: options.ownerDid },
|
|
2217
|
+
worker: { ownerDid: options.workerDid, agentDid: options.agentDid ?? options.workerDid },
|
|
2218
|
+
task: {
|
|
2219
|
+
...(options.skillId ? { skillId: options.skillId } : {}),
|
|
2220
|
+
...(options.taskType ? { taskType: options.taskType } : {}),
|
|
2221
|
+
title: options.title,
|
|
2222
|
+
description: options.description,
|
|
2223
|
+
acceptanceCriteria: [
|
|
2224
|
+
{ id: 'ac_schema', description: 'Output satisfies the agreed JSON schema', required: true },
|
|
2225
|
+
],
|
|
2226
|
+
},
|
|
2227
|
+
io: { inputSchema: {}, outputSchema: options.outputSchema ?? {} },
|
|
2228
|
+
verification: {
|
|
2229
|
+
mode: 'hybrid',
|
|
2230
|
+
requireUsageReceipt: true,
|
|
2231
|
+
requireExecutionAttestation: true,
|
|
2232
|
+
maxRetries: 0,
|
|
2233
|
+
},
|
|
2234
|
+
budget: {
|
|
2235
|
+
costBearer: 'worker_byok',
|
|
2236
|
+
maxInputTokens: 50000,
|
|
2237
|
+
maxOutputTokens: 10000,
|
|
2238
|
+
maxToolInvocations: 10,
|
|
2239
|
+
maxWallClockSeconds: 60,
|
|
2240
|
+
onOverrun: 'hard_stop',
|
|
2241
|
+
...options.budget,
|
|
2242
|
+
},
|
|
2243
|
+
permissions: { maxDelegationDepth: 0, grantedCapabilities: options.capabilities ?? [] },
|
|
2244
|
+
sandboxPolicy: {
|
|
2245
|
+
isolationLevel: 'subprocess_restricted',
|
|
2246
|
+
networkAccess: 'none',
|
|
2247
|
+
filesystemScope: 'ephemeral_workspace_only',
|
|
2248
|
+
},
|
|
2249
|
+
hitlPolicy: { requiredGates: ['GATE_CONTRACT_ACCEPT', 'GATE_DELIVERY_ACCEPT'] },
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
//# sourceMappingURL=session.js.map
|