@dsh-enhanced/assistant-actions 0.1.30
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/LICENSE +21 -0
- package/README.md +247 -0
- package/cordis.patch.yml +3 -0
- package/lib/broker-cli.d.ts +70 -0
- package/lib/broker-cli.d.ts.map +1 -0
- package/lib/broker-cli.js +255 -0
- package/lib/broker-cli.js.map +1 -0
- package/lib/broker-client.d.ts +54 -0
- package/lib/broker-client.d.ts.map +1 -0
- package/lib/broker-client.js +266 -0
- package/lib/broker-client.js.map +1 -0
- package/lib/broker-core.d.ts +51 -0
- package/lib/broker-core.d.ts.map +1 -0
- package/lib/broker-core.js +441 -0
- package/lib/broker-core.js.map +1 -0
- package/lib/broker-ledger.d.ts +131 -0
- package/lib/broker-ledger.d.ts.map +1 -0
- package/lib/broker-ledger.js +925 -0
- package/lib/broker-ledger.js.map +1 -0
- package/lib/broker-peer-linux.d.ts +23 -0
- package/lib/broker-peer-linux.d.ts.map +1 -0
- package/lib/broker-peer-linux.js +68 -0
- package/lib/broker-peer-linux.js.map +1 -0
- package/lib/broker-protocol.d.ts +419 -0
- package/lib/broker-protocol.d.ts.map +1 -0
- package/lib/broker-protocol.js +786 -0
- package/lib/broker-protocol.js.map +1 -0
- package/lib/broker-server.d.ts +77 -0
- package/lib/broker-server.d.ts.map +1 -0
- package/lib/broker-server.js +467 -0
- package/lib/broker-server.js.map +1 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +36 -0
- package/lib/cli.js.map +1 -0
- package/lib/config.d.ts +54 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +165 -0
- package/lib/config.js.map +1 -0
- package/lib/external-delivery-receipt.d.ts +11 -0
- package/lib/external-delivery-receipt.d.ts.map +1 -0
- package/lib/external-delivery-receipt.js +25 -0
- package/lib/external-delivery-receipt.js.map +1 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.d.ts.map +1 -0
- package/lib/github.js +611 -0
- package/lib/github.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +23 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +78 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +1064 -0
- package/lib/ledger.js.map +1 -0
- package/lib/repository-readback.d.ts +34 -0
- package/lib/repository-readback.d.ts.map +1 -0
- package/lib/repository-readback.js +193 -0
- package/lib/repository-readback.js.map +1 -0
- package/lib/service.d.ts +86 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +1259 -0
- package/lib/service.js.map +1 -0
- package/lib/types.d.ts +174 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/verified-delivery.d.ts +104 -0
- package/lib/verified-delivery.d.ts.map +1 -0
- package/lib/verified-delivery.js +262 -0
- package/lib/verified-delivery.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +123 -0
package/lib/service.js
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
2
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
|
+
import { validateTaskAcceptanceContract, validateTaskVerificationReceipt } from '@dsh-enhanced/task-acceptance-contract';
|
|
4
|
+
import { createHash, createPrivateKey, createPublicKey, randomUUID } from 'node:crypto';
|
|
5
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { Config, commitBytes, normalizeCommit, normalizeCompensation, normalizeVerifiedDelivery, validateConfig } from './config.js';
|
|
8
|
+
import { VerifiedDeliveryRuntime } from './verified-delivery.js';
|
|
9
|
+
import { normalizeRepositoryReadback, validateRepositoryReadbackRequirements } from './repository-readback.js';
|
|
10
|
+
import { ActionLedger, normalizeWorkflow } from './ledger.js';
|
|
11
|
+
import { commitOnGitHub, createBranchOnGitHub, createCompensatingCommitOnGitHub, createPullRequestOnGitHub, inspectGitHub, readGitHubPreimage } from './github.js';
|
|
12
|
+
import { BrokerClientError, requestGitHubBroker } from './broker-client.js';
|
|
13
|
+
import { brokerDigest, canonicalBrokerJson } from './broker-protocol.js';
|
|
14
|
+
import { captureExternalDeliveryReceipt, verifyExternalDeliveryReceipt } from './external-delivery-receipt.js';
|
|
15
|
+
export { Config };
|
|
16
|
+
const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
17
|
+
const principalDigest = (value) => createHash('sha256').update(value).digest('hex');
|
|
18
|
+
const workflowTransport = { branch: createBranchOnGitHub, pullRequest: createPullRequestOnGitHub, inspect: inspectGitHub };
|
|
19
|
+
const compensationTransport = { capture: readGitHubPreimage, commit: createCompensatingCommitOnGitHub };
|
|
20
|
+
const ROLLBACK_BUDGET_METRIC = 'github-compensations';
|
|
21
|
+
const EXTERNAL_UNSUPPORTED = 'assistant-actions: external-operation-unsupported';
|
|
22
|
+
function sameDirectory(left, right) {
|
|
23
|
+
return left.path === right.path && left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.gid === right.gid && left.mode === right.mode;
|
|
24
|
+
}
|
|
25
|
+
function inspectKeyDirectory(path) {
|
|
26
|
+
const before = lstatSync(path, { bigint: true }), canonical = realpathSync(path), after = lstatSync(path, { bigint: true });
|
|
27
|
+
const first = { path, dev: before.dev, ino: before.ino, uid: before.uid, gid: before.gid, mode: Number(before.mode & 4095n) };
|
|
28
|
+
const second = { path, dev: after.dev, ino: after.ino, uid: after.uid, gid: after.gid, mode: Number(after.mode & 4095n) };
|
|
29
|
+
if (canonical !== path || before.isSymbolicLink() || after.isSymbolicLink() || !before.isDirectory() || !after.isDirectory() || !sameDirectory(first, second))
|
|
30
|
+
throw new Error('assistant-actions: unsafe key path ancestry');
|
|
31
|
+
return first;
|
|
32
|
+
}
|
|
33
|
+
function inspectKeyAncestorChain(path, expectedUid, expectedGid) {
|
|
34
|
+
let cursor = path, leaf = true;
|
|
35
|
+
const result = [];
|
|
36
|
+
for (;;) {
|
|
37
|
+
const identity = inspectKeyDirectory(cursor);
|
|
38
|
+
const stickyRoot = identity.uid === 0n && (identity.mode & 0o1000) !== 0 && (identity.mode & 0o022) !== 0;
|
|
39
|
+
if (!stickyRoot && (identity.mode & 0o022) !== 0
|
|
40
|
+
|| leaf && (identity.uid !== BigInt(expectedUid) || identity.gid !== BigInt(expectedGid) || (identity.mode & 0o077) !== 0))
|
|
41
|
+
throw new Error('assistant-actions: unsafe key path ancestry');
|
|
42
|
+
result.push(identity);
|
|
43
|
+
const parent = dirname(cursor);
|
|
44
|
+
if (parent === cursor)
|
|
45
|
+
return Object.freeze(result);
|
|
46
|
+
cursor = parent;
|
|
47
|
+
leaf = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function sameAncestorChain(left, right) {
|
|
51
|
+
return left.length === right.length && left.every((value, index) => sameDirectory(value, right[index]));
|
|
52
|
+
}
|
|
53
|
+
export function readPinnedKeyFile(path, kind, hooks = {}) {
|
|
54
|
+
const expectedUid = process.geteuid?.(), expectedGid = process.getegid?.();
|
|
55
|
+
if (expectedUid === undefined || expectedGid === undefined)
|
|
56
|
+
throw new Error(`assistant-actions: unsafe ${kind} key platform`);
|
|
57
|
+
const parentPath = dirname(path), ancestors = inspectKeyAncestorChain(parentPath, expectedUid, expectedGid);
|
|
58
|
+
hooks.afterAncestorSnapshot?.();
|
|
59
|
+
if (!sameAncestorChain(ancestors, inspectKeyAncestorChain(parentPath, expectedUid, expectedGid)))
|
|
60
|
+
throw new Error(`assistant-actions: ${kind} key ancestor changed before open`);
|
|
61
|
+
const before = lstatSync(path, { bigint: true });
|
|
62
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.uid !== BigInt(expectedUid)
|
|
63
|
+
|| before.gid !== BigInt(expectedGid) || Number(before.mode & 4095n) !== 0o600
|
|
64
|
+
|| before.size < 1n || before.size > 16384n)
|
|
65
|
+
throw new Error(`assistant-actions: unsafe ${kind} key file`);
|
|
66
|
+
let descriptor;
|
|
67
|
+
let bytes;
|
|
68
|
+
try {
|
|
69
|
+
hooks.afterFileSnapshot?.();
|
|
70
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
71
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
72
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.uid !== before.uid || opened.gid !== before.gid
|
|
73
|
+
|| opened.mode !== before.mode || opened.nlink !== before.nlink || opened.size !== before.size || opened.mtimeNs !== before.mtimeNs || opened.ctimeNs !== before.ctimeNs
|
|
74
|
+
|| !sameAncestorChain(ancestors, inspectKeyAncestorChain(parentPath, expectedUid, expectedGid)))
|
|
75
|
+
throw new Error(`assistant-actions: ${kind} key or ancestor changed while opening`);
|
|
76
|
+
hooks.afterOpen?.();
|
|
77
|
+
bytes = readFileSync(descriptor);
|
|
78
|
+
hooks.afterRead?.();
|
|
79
|
+
const after = fstatSync(descriptor, { bigint: true }), pathAfter = lstatSync(path, { bigint: true });
|
|
80
|
+
const ancestorsAfter = inspectKeyAncestorChain(parentPath, expectedUid, expectedGid);
|
|
81
|
+
if (!sameAncestorChain(ancestors, ancestorsAfter))
|
|
82
|
+
throw new Error(`assistant-actions: ${kind} key parent changed while reading`);
|
|
83
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.uid !== opened.uid || after.gid !== opened.gid
|
|
84
|
+
|| after.mode !== opened.mode || after.nlink !== opened.nlink || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs
|
|
85
|
+
|| pathAfter.dev !== opened.dev || pathAfter.ino !== opened.ino || pathAfter.uid !== opened.uid || pathAfter.gid !== opened.gid
|
|
86
|
+
|| pathAfter.mode !== opened.mode || pathAfter.nlink !== opened.nlink || pathAfter.size !== opened.size || pathAfter.mtimeNs !== opened.mtimeNs || pathAfter.ctimeNs !== opened.ctimeNs
|
|
87
|
+
|| pathAfter.isSymbolicLink() || !pathAfter.isFile())
|
|
88
|
+
throw new Error(`assistant-actions: ${kind} key changed while reading`);
|
|
89
|
+
const key = kind === 'private' ? createPrivateKey(bytes) : createPublicKey(bytes);
|
|
90
|
+
if (key.type !== kind || key.asymmetricKeyType !== 'ed25519')
|
|
91
|
+
throw new Error(`assistant-actions: ${kind} key must be Ed25519`);
|
|
92
|
+
return key;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (bytes !== undefined)
|
|
96
|
+
bytes.fill(0);
|
|
97
|
+
if (descriptor !== undefined)
|
|
98
|
+
closeSync(descriptor);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Trusted Host control plane, never mounted or callable from the offline worker. */
|
|
102
|
+
export class AssistantActionsService extends Service {
|
|
103
|
+
commit;
|
|
104
|
+
workflow;
|
|
105
|
+
compensation;
|
|
106
|
+
static Config = Config;
|
|
107
|
+
#ledger;
|
|
108
|
+
#authority;
|
|
109
|
+
#externalGrants = new Map();
|
|
110
|
+
#mode;
|
|
111
|
+
#externalClient;
|
|
112
|
+
#externalFacadeGeneration = randomUUID();
|
|
113
|
+
#externalObservedGeneration;
|
|
114
|
+
#externalObservedEpochs;
|
|
115
|
+
#externalDispatch;
|
|
116
|
+
#pending = new Map();
|
|
117
|
+
#pendingCompensations = new Map();
|
|
118
|
+
#pendingExternal = new Set();
|
|
119
|
+
#pendingCompensationReleases = [];
|
|
120
|
+
#active = true;
|
|
121
|
+
#verified;
|
|
122
|
+
constructor(ctx, input = {}, commit = commitOnGitHub, workflow = workflowTransport, compensation = compensationTransport, externalDispatch = requestGitHubBroker) {
|
|
123
|
+
super(ctx, 'assistantActions');
|
|
124
|
+
this.commit = commit;
|
|
125
|
+
this.workflow = workflow;
|
|
126
|
+
this.compensation = compensation;
|
|
127
|
+
const config = validateConfig(input);
|
|
128
|
+
this.#mode = config.broker.mode;
|
|
129
|
+
this.#externalDispatch = externalDispatch;
|
|
130
|
+
if (config.broker.mode === 'external-unix-v1') {
|
|
131
|
+
for (const grant of config.externalGrants)
|
|
132
|
+
this.#externalGrants.set(grant.id, grant);
|
|
133
|
+
const serverPublicKey = readPinnedKeyFile(config.broker.brokerPublicKeyPath, 'public');
|
|
134
|
+
const clientPrivateKey = readPinnedKeyFile(config.broker.clientSigningKeyPath, 'private');
|
|
135
|
+
this.#externalClient = Object.freeze({
|
|
136
|
+
socketPath: config.broker.actionSocketPath, serverPublicKey, clientPrivateKey, clientKeyId: config.broker.clientKeyId,
|
|
137
|
+
source: Object.freeze({ kind: 'assistant-actions-host', instanceId: config.broker.clientInstanceId, generation: config.broker.clientGeneration }),
|
|
138
|
+
timeoutMs: config.broker.requestTimeoutMs, maxHelloTtlMs: config.broker.helloTtlMs, expectedSocketUid: config.broker.expectedSocketUid,
|
|
139
|
+
expectedSocketGid: config.broker.expectedSocketGid, expectedSocketMode: config.broker.expectedSocketMode, expectedServerInstanceId: config.broker.brokerId,
|
|
140
|
+
expectedSocketParentUid: config.broker.expectedSocketParentUid, expectedSocketParentGid: config.broker.expectedSocketParentGid, expectedSocketParentMode: config.broker.expectedSocketParentMode,
|
|
141
|
+
expectedBrokerPeerUid: config.broker.expectedBrokerPeerUid, expectedBrokerPeerGid: config.broker.expectedBrokerPeerGid,
|
|
142
|
+
minimumServerGeneration: config.broker.minimumBrokerGeneration,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
mkdirSync(config.stateRoot, { recursive: true, mode: 0o700 });
|
|
147
|
+
const root = lstatSync(config.stateRoot);
|
|
148
|
+
if (!root.isDirectory() || root.uid !== process.getuid?.() || realpathSync(config.stateRoot) !== config.stateRoot)
|
|
149
|
+
throw new Error('assistant-actions: private owned state root required');
|
|
150
|
+
chmodSync(config.stateRoot, 0o700);
|
|
151
|
+
const ledger = new ActionLedger(join(config.stateRoot, 'ledger.sqlite'));
|
|
152
|
+
this.#ledger = ledger;
|
|
153
|
+
try {
|
|
154
|
+
this.#authority = ledger.claimController(randomUUID(), 30_000);
|
|
155
|
+
ledger.syncGrants(config.grants, this.#authority);
|
|
156
|
+
ledger.recover(this.#authority);
|
|
157
|
+
this.#pendingCompensationReleases = ledger.recoverCompensations(this.#authority).neverDispatched;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
ledger.close();
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const timer = setInterval(() => {
|
|
165
|
+
if (!this.#ledger || !this.#authority)
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
if (this.#active && this.#ledger.renewController(this.#authority, 30_000))
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
catch { /* Stop when the fence is lost. */ }
|
|
172
|
+
this.#active = false;
|
|
173
|
+
for (const operation of this.#pending.values())
|
|
174
|
+
operation.abort.abort();
|
|
175
|
+
for (const operation of this.#pendingCompensations.values())
|
|
176
|
+
operation.abort.abort();
|
|
177
|
+
}, 5000);
|
|
178
|
+
timer.unref();
|
|
179
|
+
ctx.effect(() => async () => {
|
|
180
|
+
this.#active = false;
|
|
181
|
+
clearInterval(timer);
|
|
182
|
+
await this.#verified?.close();
|
|
183
|
+
for (const operation of this.#pending.values())
|
|
184
|
+
operation.abort.abort();
|
|
185
|
+
for (const operation of this.#pendingCompensations.values())
|
|
186
|
+
operation.abort.abort();
|
|
187
|
+
for (const operation of this.#pendingExternal)
|
|
188
|
+
operation.abort.abort(new Error('assistant-actions disposed'));
|
|
189
|
+
await Promise.allSettled([...this.#pending.values()].map(operation => operation.done));
|
|
190
|
+
await Promise.allSettled([...this.#pendingCompensations.values()].map(operation => operation.done));
|
|
191
|
+
await Promise.allSettled([...this.#pendingExternal].map(operation => operation.done));
|
|
192
|
+
this.#externalClient = undefined;
|
|
193
|
+
if (this.#ledger && this.#authority)
|
|
194
|
+
try {
|
|
195
|
+
this.#ledger.releaseController(this.#authority);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
this.#ledger.close();
|
|
199
|
+
}
|
|
200
|
+
}, 'assistant-actions.controller');
|
|
201
|
+
// Configured scopes keep their restricted execution route even after grants
|
|
202
|
+
// expire or are revoked. Only trusted fixed brokers can use Host authority.
|
|
203
|
+
ctx.inject(['tools'], runtime => runtime.on('tools/execute', async (execution, next) => {
|
|
204
|
+
const header = execution.agent?.session.header;
|
|
205
|
+
const scoped = config.broker.mode === 'embedded-compat'
|
|
206
|
+
? config.grants.some(grant => grant.workspace === header?.cwd && grant.agentPreset === header?.agentPreset)
|
|
207
|
+
: config.externalGrants.some(grant => grant.owner.workspace === header?.cwd && grant.owner.preset === header?.agentPreset);
|
|
208
|
+
if (header && scoped
|
|
209
|
+
&& !['isolation_run', 'isolation_grants', 'goal_context', 'goal_checkpoint'].includes(execution.name)
|
|
210
|
+
&& !(['action_github_grants', 'action_github_deliver', 'action_github_delivery_status', 'action_github_commit', 'action_github_branch', 'action_github_pr', 'action_github_inspect', 'action_github_compensate', 'action_github_compensation_status', 'goal_create', 'goal_schedule', 'goal_strategy', 'goal_wait_event'].includes(execution.name) && this.ctx.get('assistantPolicy')?.isPreauthorizedTool(execution)))
|
|
211
|
+
throw new Error('assistant-actions: this scope requires isolated execution or an authorized broker');
|
|
212
|
+
return await next();
|
|
213
|
+
}));
|
|
214
|
+
// Reconcile rollback reservations orphaned by a crash. Only rows that never
|
|
215
|
+
// reached the local dispatch flip are reported (capturing/prepared), so no
|
|
216
|
+
// POST could have landed and their open holds are safe to release. A
|
|
217
|
+
// finalized hold is never unwound (release throws invalid-state); such a
|
|
218
|
+
// mismatch fails closed and is left for the budget period to roll over.
|
|
219
|
+
if (config.broker.mode === 'embedded-compat')
|
|
220
|
+
ctx.inject(['assistantPolicy'], runtime => {
|
|
221
|
+
for (const ref of this.#pendingCompensationReleases) {
|
|
222
|
+
try {
|
|
223
|
+
runtime.assistantPolicy.releaseByIdempotencyKey(`compensation:${ref.id}`);
|
|
224
|
+
}
|
|
225
|
+
catch { /* retain the hold; fail closed */ }
|
|
226
|
+
}
|
|
227
|
+
this.#pendingCompensationReleases = [];
|
|
228
|
+
});
|
|
229
|
+
const toolDependencies = config.broker.mode === 'embedded-compat'
|
|
230
|
+
? ['tools', 'agents', 'assistantPolicy', 'assistantDelivery', 'credentialsKeychain']
|
|
231
|
+
: ['tools', 'agents', 'assistantPolicy', 'assistantDelivery'];
|
|
232
|
+
ctx.inject(toolDependencies, runtime => {
|
|
233
|
+
if (config.broker.mode === 'embedded-compat' ? config.grants.length === 0 : config.externalGrants.length === 0)
|
|
234
|
+
return;
|
|
235
|
+
const grants = () => config.broker.mode === 'embedded-compat'
|
|
236
|
+
? config.grants.flatMap(grant => { const current = this.#ledger.grant(grant.id); return current !== undefined && current.expiresAt > Date.now() ? [current] : []; })
|
|
237
|
+
: config.externalGrants.filter(grant => grant.expiresAt > Date.now());
|
|
238
|
+
const visibleGrants = (agent) => {
|
|
239
|
+
const current = grants();
|
|
240
|
+
return current.flatMap(grant => {
|
|
241
|
+
try {
|
|
242
|
+
if (config.broker.mode === 'external-unix-v1') {
|
|
243
|
+
const external = grant;
|
|
244
|
+
this.#externalContext(agent, external.id);
|
|
245
|
+
return [{ grantId: external.id, repository: external.destination.repository, branch: external.destination.branch, paths: external.destination.paths, expiresAt: external.expiresAt, maxActions: external.maxActions, verifiedDelivery: !!external.verifiedDelivery,
|
|
246
|
+
...(external.verifiedDelivery ? { acceptance: external.verifiedDelivery.acceptance ?? 'goal-outcome' } : {}),
|
|
247
|
+
...(external.destination.baseBranch ? { workflow: { baseBranch: external.destination.baseBranch, allowBranchCreate: false, allowPullRequest: external.allowedOperations.includes('pull-request') } } : {}),
|
|
248
|
+
allowedOperations: external.allowedOperations, allowedInspectKinds: external.allowedInspectKinds,
|
|
249
|
+
...(external.destination.baseBranch === undefined ? {} : { baseBranch: external.destination.baseBranch }) }];
|
|
250
|
+
}
|
|
251
|
+
const embedded = grant;
|
|
252
|
+
const identity = this.#identity(agent, embedded.id);
|
|
253
|
+
if (!this.#allows(embedded, identity, { grantId: embedded.id, operation: 'inspect', idempotencyKey: 'discovery', kind: 'repository' }))
|
|
254
|
+
return [];
|
|
255
|
+
return [{ grantId: embedded.id, repository: embedded.repository, branch: embedded.branch, paths: embedded.paths, expiresAt: embedded.expiresAt, maxActions: embedded.maxActions, verifiedDelivery: !!embedded.verifiedDelivery,
|
|
256
|
+
...(embedded.verifiedDelivery ? { acceptance: embedded.verifiedDelivery.acceptance ?? 'goal-outcome' } : {}), ...(embedded.repoWorkflow ? { workflow: embedded.repoWorkflow } : {}) }];
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
const discovery = defineTool({ name: 'action_github_grants', description: 'Read currently owner-authorized GitHub repository grants, exact paths and delivery mode. No credentials or new authority are returned. verifiedDelivery grants require action_github_deliver during an admitted artifact Goal round.', parameters: {},
|
|
264
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, output) => [{ type: 'text', text: output.result }] },
|
|
265
|
+
execute: async (_args, execution) => ({ result: JSON.stringify(visibleGrants(execution.agent)) }),
|
|
266
|
+
});
|
|
267
|
+
runtime.tools.register(discovery);
|
|
268
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, discovery, execution => !execution.signal.aborted && execution.arguments !== null && typeof execution.arguments === 'object' && !Array.isArray(execution.arguments) && Object.keys(execution.arguments).length === 0 && visibleGrants(execution.agent).length > 0);
|
|
269
|
+
const tool = defineTool({
|
|
270
|
+
name: 'action_github_commit',
|
|
271
|
+
description: 'Submit bounded file contents to an exact operator-authorized GitHub repository and branch. Requires an existing finite grant and expectedHeadOid. Reuse a key only for the identical request; unknown results must be investigated and never resent under a new key. This tool cannot authorize itself. Commit creation does not verify the user goal.',
|
|
272
|
+
parameters: { grantId: { type: 'string', required: true }, idempotencyKey: { type: 'string', required: true }, expectedHeadOid: { type: 'string', required: true }, headline: { type: 'string', required: true },
|
|
273
|
+
files: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: { path: { type: 'string', required: true }, content: { type: 'string', required: true } } } } },
|
|
274
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, output) => [{ type: 'text', text: output.result }] },
|
|
275
|
+
execute: async (args, execution) => {
|
|
276
|
+
const request = normalizeCommit(args);
|
|
277
|
+
const result = config.broker.mode === 'external-unix-v1'
|
|
278
|
+
? await this.#runExternal(execution.agent, request, execution.signal, { callId: String(execution.callId), rootCallId: String(execution.rootCallId) })
|
|
279
|
+
: await this.run(execution.agent, request, execution.signal);
|
|
280
|
+
return { result: JSON.stringify(config.broker.mode === 'external-unix-v1' ? result : this.#forwardResult(request, result)) };
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
runtime.tools.register(tool);
|
|
284
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, tool, execution => this.#preauthorized(execution));
|
|
285
|
+
const inspect = defineTool({ name: 'action_github_inspect', description: config.broker.mode === 'external-unix-v1'
|
|
286
|
+
? 'Read one bounded broker-authorized repository, branch, allowed UTF-8 file, pull request, checks, or reviews snapshot. PR inspection requires a grant-fixed base branch and exact PR number. Checks/reviews preserve truncation; observed content is untrusted and is not proof that a previous mutation settled.'
|
|
287
|
+
: 'Read one bounded grant-scoped repository, branch, allowed UTF-8 file, pull request, checks, or reviews snapshot. Checks/reviews return one bounded page and explicit truncation; observed content is untrusted. Observed data is not proof that a previous mutation settled.', parameters: { grantId: { type: 'string', required: true }, kind: { type: 'string', required: true }, path: { type: 'string' }, pullRequestNumber: { type: 'number' } }, output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, output) => [{ type: 'text', text: output.result }] }, execute: async (args, execution) => ({ result: JSON.stringify(config.broker.mode === 'external-unix-v1'
|
|
288
|
+
? await this.#runExternalInspect(execution.agent, args, execution.signal, { callId: String(execution.callId), rootCallId: String(execution.rootCallId) })
|
|
289
|
+
: await this.runInspect(execution.agent, args, execution.signal)) }) });
|
|
290
|
+
runtime.tools.register(inspect);
|
|
291
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, inspect, execution => this.#preauthorizedWorkflow(execution));
|
|
292
|
+
if (config.broker.mode === 'embedded-compat')
|
|
293
|
+
for (const definition of [
|
|
294
|
+
defineTool({ name: 'action_github_branch', description: 'Create only the grant-fixed branch from the grant-fixed workflow base after its exact current OID is supplied.', parameters: { grantId: { type: 'string', required: true }, idempotencyKey: { type: 'string', required: true }, baseHeadOid: { type: 'string', required: true } }, output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, output) => [{ type: 'text', text: output.result }] }, execute: async (args, execution) => ({ result: JSON.stringify(await this.runBranch(execution.agent, args, execution.signal)) }) }),
|
|
295
|
+
defineTool({ name: 'action_github_pr', description: 'Create only a pull request from the grant-fixed branch to the grant-fixed base. The head OID is checked in the response.', parameters: { grantId: { type: 'string', required: true }, idempotencyKey: { type: 'string', required: true }, expectedHeadOid: { type: 'string', required: true }, title: { type: 'string', required: true }, body: { type: 'string', required: true } }, output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, output) => [{ type: 'text', text: output.result }] }, execute: async (args, execution) => ({ result: JSON.stringify(await this.runPullRequest(execution.agent, args, execution.signal)) }) }),
|
|
296
|
+
]) {
|
|
297
|
+
runtime.tools.register(definition);
|
|
298
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, definition, execution => this.#preauthorizedWorkflow(execution));
|
|
299
|
+
}
|
|
300
|
+
if (config.broker.mode === 'embedded-compat' && config.grants.some(grant => grant.rollback?.allowRollback === true)) {
|
|
301
|
+
const output = { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, value) => [{ type: 'text', text: value.result }] };
|
|
302
|
+
const compensate = defineTool({ name: 'action_github_compensate', description: 'Create at most one bounded compensating commit for an exact succeeded forward receipt. The Host reads the immutable parent preimage; callers cannot provide files or preimages. Reuse only the identical key and receipt. Unknown is terminal and must never be replayed.',
|
|
303
|
+
parameters: { grantId: { type: 'string', required: true }, idempotencyKey: { type: 'string', required: true }, forwardActionId: { type: 'string', required: true }, forwardActionVersion: { type: 'number', required: true }, forwardRequestDigest: { type: 'string', required: true }, forwardCommitOid: { type: 'string', required: true } }, output,
|
|
304
|
+
execute: async (args, execution) => ({ result: JSON.stringify(await this.runCompensation(execution.agent, args, execution.signal)) }),
|
|
305
|
+
});
|
|
306
|
+
runtime.tools.register(compensate);
|
|
307
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, compensate, execution => this.#preauthorizedCompensation(execution));
|
|
308
|
+
const status = defineTool({ name: 'action_github_compensation_status', description: 'Read one compensation status in this exact Session and grant. This never returns captured file preimages.',
|
|
309
|
+
parameters: { grantId: { type: 'string', required: true }, actionId: { type: 'string', required: true } }, output,
|
|
310
|
+
execute: async (args, execution) => ({ result: JSON.stringify(this.compensationStatus(execution.agent, args)) }),
|
|
311
|
+
});
|
|
312
|
+
runtime.tools.register(status);
|
|
313
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, status, execution => this.#preauthorizedCompensationStatus(execution));
|
|
314
|
+
}
|
|
315
|
+
if ((config.broker.mode === 'embedded-compat' ? config.grants : config.externalGrants).some(grant => grant.verifiedDelivery)) {
|
|
316
|
+
const output = { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_args, value) => [{ type: 'text', text: value.result }] };
|
|
317
|
+
const deliver = defineTool({ name: 'action_github_deliver', description: 'During the current native artifact goal round, register a finite GitHub delivery intent. Export all listed paths with isolation_run. Host submits exact independently accepted artifacts after the grant acceptance mode: goal-outcome requires step and whole-goal verification (default); goal-step allows intermediate delivery after step verification while the original goal waits. This call only queues intent and never proves whole-goal completion. Never supply file content. Optional pullRequest opens the grant-fixed branch-to-base PR after the commit. Query action_github_delivery_status for the actual result; unknown must not be resent.',
|
|
318
|
+
parameters: { grantId: { type: 'string', required: true }, idempotencyKey: { type: 'string', required: true }, expectedHeadOid: { type: 'string', required: true }, headline: { type: 'string', required: true }, paths: { type: 'array', required: true, items: { type: 'string' } }, pullRequest: { type: 'object', additionalProperties: false, properties: { title: { type: 'string', required: true }, body: { type: 'string', required: true } } } }, output,
|
|
319
|
+
execute: async (args, execution) => ({ result: JSON.stringify(this.prepareVerifiedDelivery(execution.agent, args)) }),
|
|
320
|
+
});
|
|
321
|
+
runtime.tools.register(deliver);
|
|
322
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, deliver, execution => { try {
|
|
323
|
+
if (execution.signal.aborted)
|
|
324
|
+
return false;
|
|
325
|
+
this.#captureDelivery(execution.agent, normalizeVerifiedDelivery(execution.arguments));
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
} });
|
|
331
|
+
const status = defineTool({ name: 'action_github_delivery_status', description: 'Read this Session’s durable verified delivery result. Queued is not committed; unknown must not be resent.', parameters: { grantId: { type: 'string', required: true }, idempotencyKey: { type: 'string', required: true } }, output,
|
|
332
|
+
execute: async (args, execution) => { const identity = this.#identity(execution.agent, args.grantId); return { result: JSON.stringify(this.#verified.get(String(execution.agent.session.id), args.grantId, args.idempotencyKey, identity) ?? { status: 'not-found' }) }; },
|
|
333
|
+
});
|
|
334
|
+
runtime.tools.register(status);
|
|
335
|
+
runtime.assistantPolicy.registerPreauthorizedTool(runtime, status, execution => { try {
|
|
336
|
+
const args = execution.arguments;
|
|
337
|
+
return !execution.signal.aborted && !!this.#identity(execution.agent, args.grantId);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return false;
|
|
341
|
+
} });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
if ((config.broker.mode === 'embedded-compat' ? config.grants : config.externalGrants).some(grant => grant.verifiedDelivery)) {
|
|
345
|
+
mkdirSync(config.stateRoot, { recursive: true, mode: 0o700 });
|
|
346
|
+
const root = lstatSync(config.stateRoot);
|
|
347
|
+
if (!root.isDirectory() || root.uid !== process.getuid?.() || realpathSync(config.stateRoot) !== config.stateRoot)
|
|
348
|
+
throw new Error('assistant-actions: private owned state root required');
|
|
349
|
+
chmodSync(config.stateRoot, 0o700);
|
|
350
|
+
this.#verified = new VerifiedDeliveryRuntime(ctx, config.stateRoot, {
|
|
351
|
+
capture: (agent, request) => this.#captureDelivery(agent, request), inspect: intent => this.#inspectDelivery(intent),
|
|
352
|
+
deliver: (intent, snapshot, signal) => this.#deliverVerified(intent, snapshot, signal),
|
|
353
|
+
notify: (intent, value) => this.#notifyVerifiedDelivery(intent, value),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
get brokerMode() { return this.#mode; }
|
|
358
|
+
health = () => Object.freeze({
|
|
359
|
+
active: this.#active, mode: this.brokerMode, projectedGrants: this.#mode === 'external-unix-v1' ? this.#externalGrants.size : 0,
|
|
360
|
+
supportedOperations: Object.freeze(this.#mode === 'external-unix-v1' ? ['commit', 'pull-request', 'inspect', 'verified-delivery', 'repository-readback'] : ['commit', 'branch', 'pull-request', 'inspect', 'compensate', 'verified-delivery', 'repository-readback']),
|
|
361
|
+
});
|
|
362
|
+
#external() {
|
|
363
|
+
if (!this.#active || !this.#externalClient)
|
|
364
|
+
throw new Error('assistant-actions: external broker unavailable');
|
|
365
|
+
return this.#externalClient;
|
|
366
|
+
}
|
|
367
|
+
#externalContext(agent, grantId) {
|
|
368
|
+
if (!agent || this.ctx.get('agents')?.get(agent.id) !== agent)
|
|
369
|
+
throw new Error('assistant-actions: exact live agent required');
|
|
370
|
+
const attestation = this.ctx.get('assistantDelivery')?.preferencePrincipalForAgent(agent);
|
|
371
|
+
const grant = this.#externalGrants.get(grantId);
|
|
372
|
+
if (!attestation || !grant || grant.expiresAt <= Date.now() || attestation.sessionId !== String(agent.session.id)
|
|
373
|
+
|| attestation.scope.workspace !== agent.session.header.cwd || attestation.scope.preset !== agent.session.header.agentPreset)
|
|
374
|
+
throw new Error('assistant-actions: authenticated owner required');
|
|
375
|
+
const owner = { principalDigest: principalDigest(attestation.principalId), ...attestation.principalLineage, workspace: attestation.scope.workspace,
|
|
376
|
+
preset: attestation.scope.preset, bindingId: attestation.bindingId, bindingVersion: attestation.bindingVersion, bindingGeneration: attestation.bindingGeneration };
|
|
377
|
+
if (digest(owner) !== digest(grant.owner) || grant.sessionId !== String(agent.session.id))
|
|
378
|
+
throw new Error('assistant-actions: external grant projection does not match current owner');
|
|
379
|
+
if (this.ctx.get('assistantPolicy')?.evaluateAgent(agent, 'execute', { kind: 'tool', id: `action:github:${grantId}` }).effect !== 'allow')
|
|
380
|
+
throw new Error('assistant-actions: policy denied');
|
|
381
|
+
return { grant, owner, identity: { principalDigest: owner.principalDigest, principalRecordId: owner.principalRecordId, principalVersion: owner.principalVersion, workspace: owner.workspace, agentPreset: owner.preset } };
|
|
382
|
+
}
|
|
383
|
+
#externalActionId(grant, sessionId, operation, idempotencyKey) {
|
|
384
|
+
return brokerDigest({ protocol: 'assistant-actions/external-action-id/v1', clientKeyId: this.#external().clientKeyId, grantId: grant.id, grantRevision: grant.revision, sessionId, operation, idempotencyKey });
|
|
385
|
+
}
|
|
386
|
+
async #externalRequest(agent, grant, owner, operation, payload, signal, invocation, idempotencyKey, background) {
|
|
387
|
+
signal.throwIfAborted();
|
|
388
|
+
const client = this.#external(), sessionId = background?.sessionId ?? String(agent.session.id);
|
|
389
|
+
const actionId = this.#externalActionId(grant, sessionId, operation, idempotencyKey);
|
|
390
|
+
const policy = this.ctx.get('assistantPolicy');
|
|
391
|
+
const decision = background ? undefined : policy?.authorizeAgent(agent, 'execute', { kind: 'tool', id: `action:github:${grant.id}` }, { idempotencyKey: `external-action:${actionId}` });
|
|
392
|
+
if (background ? !background.authorize(actionId) : decision?.effect !== 'allow')
|
|
393
|
+
throw new Error('assistant-actions: external broker policy denied');
|
|
394
|
+
const intent = {
|
|
395
|
+
actionId, grantId: grant.id, grantRevision: grant.revision, grantDigest: grant.grantDigest, owner, sessionId, agentId: background?.agentId ?? String(agent.id),
|
|
396
|
+
rootCallId: invocation.rootCallId, callId: invocation.callId, operation, source: grant.source,
|
|
397
|
+
destination: { classification: 'github-repository', repository: grant.destination.repository, branch: grant.destination.branch,
|
|
398
|
+
...(grant.destination.baseBranch === undefined ? {} : { baseBranch: grant.destination.baseBranch }) }, payload, deadline: Math.min(grant.expiresAt, Date.now() + (client.timeoutMs ?? 30_000)),
|
|
399
|
+
budget: { reservationId: actionId, actions: 1, bytes: Buffer.byteLength(canonicalBrokerJson(payload)), costMetric: 'github-api-units',
|
|
400
|
+
maxCostUnits: operation === 'inspect' && 'kind' in payload && ['checks', 'reviews'].includes(payload.kind) ? 2 : 1 },
|
|
401
|
+
};
|
|
402
|
+
const authorization = new AbortController();
|
|
403
|
+
const stillAuthorized = () => {
|
|
404
|
+
try {
|
|
405
|
+
const current = background ? (background.current(), { grant: this.#externalGrants.get(grant.id), owner: grant.owner }) : this.#externalContext(agent, grant.id);
|
|
406
|
+
return this.#active && !signal.aborted && !authorization.signal.aborted && this.#externalClient === client
|
|
407
|
+
&& current.grant.grantDigest === grant.grantDigest && digest(current.owner) === digest(owner);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const timer = setInterval(() => { if (!stillAuthorized())
|
|
414
|
+
authorization.abort(new Error('assistant-actions external authorization ended')); }, 100);
|
|
415
|
+
timer.unref();
|
|
416
|
+
let signed;
|
|
417
|
+
const requestClient = { ...client, beforeWrite: (hello, request, gateSignal) => {
|
|
418
|
+
signed = { hello, request };
|
|
419
|
+
gateSignal.throwIfAborted();
|
|
420
|
+
if (!stillAuthorized() || request.actionId !== actionId || request.grantDigest !== grant.grantDigest || digest(request.owner) !== digest(owner))
|
|
421
|
+
throw new Error('assistant-actions: external authorization ended before dispatch');
|
|
422
|
+
} };
|
|
423
|
+
const pending = Promise.resolve().then(() => this.#externalDispatch(requestClient, intent, AbortSignal.any([signal, authorization.signal])));
|
|
424
|
+
const operationState = { abort: authorization, done: pending, client };
|
|
425
|
+
this.#pendingExternal.add(operationState);
|
|
426
|
+
try {
|
|
427
|
+
const response = await pending;
|
|
428
|
+
this.#externalObservedGeneration = response.generation;
|
|
429
|
+
if (signed && Number.isSafeInteger(signed.hello.policyEpoch) && Number.isSafeInteger(signed.hello.emergencyEpoch))
|
|
430
|
+
this.#externalObservedEpochs = brokerDigest([signed.hello.policyEpoch, signed.hello.emergencyEpoch]);
|
|
431
|
+
if (!stillAuthorized())
|
|
432
|
+
return { result: { actionId, status: response.dispatched ? 'unknown' : 'failed', reason: 'external-broker-authorization-ended' } };
|
|
433
|
+
const result = { actionId: response.actionId, status: response.status,
|
|
434
|
+
...(response.result?.operation === 'commit' ? { commitOid: response.result.commitOid, branch: response.result.branch } : {}),
|
|
435
|
+
...(response.result?.operation === 'pull-request' ? { pullRequestNumber: response.result.pullRequestNumber, branch: response.result.branch } : {}),
|
|
436
|
+
...(response.error ? { reason: `external-broker-${response.error.code}` } : {}) };
|
|
437
|
+
return { result, generation: response.generation, ...(signed ? { receipt: captureExternalDeliveryReceipt(signed.hello, signed.request, response) } : {}), ...(response.status === 'succeeded' && response.result?.operation === 'inspect' ? { observed: structuredClone(response.result.observed) } : {}) };
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof BrokerClientError)
|
|
441
|
+
return { result: { actionId, status: error.postDispatchUnknown ? 'unknown' : 'failed', reason: `external-broker-${error.code}` } };
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
clearInterval(timer);
|
|
446
|
+
this.#pendingExternal.delete(operationState);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async #runExternal(agent, request, signal, invocation) {
|
|
450
|
+
const { grant, owner, identity } = this.#externalContext(agent, request.grantId);
|
|
451
|
+
if (grant.verifiedDelivery || !this.#allowsExternal(grant, identity, request))
|
|
452
|
+
throw new Error('assistant-actions: request not granted');
|
|
453
|
+
return (await this.#externalRequest(agent, grant, owner, 'commit', { expectedHeadOid: request.expectedHeadOid, headline: request.headline, files: request.files }, signal, invocation, request.idempotencyKey)).result;
|
|
454
|
+
}
|
|
455
|
+
async #runExternalInspect(agent, input, signal, invocation) {
|
|
456
|
+
const request = normalizeWorkflow({ ...input, operation: 'inspect', idempotencyKey: invocation.callId });
|
|
457
|
+
if (!('operation' in request))
|
|
458
|
+
throw new Error('assistant-actions: invalid inspection');
|
|
459
|
+
const { grant, owner, identity } = this.#externalContext(agent, request.grantId);
|
|
460
|
+
if (!this.#allowsExternal(grant, identity, input))
|
|
461
|
+
throw new Error('assistant-actions: request not granted');
|
|
462
|
+
const payload = { kind: request.kind, ...(request.path === undefined ? {} : { path: request.path }), ...(request.pullRequestNumber === undefined ? {} : { pullRequestNumber: request.pullRequestNumber }) };
|
|
463
|
+
const { result, observed } = await this.#externalRequest(agent, grant, owner, 'inspect', payload, signal, invocation, request.idempotencyKey);
|
|
464
|
+
return { result, ...(observed === undefined ? {} : { observed }) };
|
|
465
|
+
}
|
|
466
|
+
#identity(agent, grantId) {
|
|
467
|
+
if (this.#mode === 'external-unix-v1')
|
|
468
|
+
return this.#externalContext(agent, grantId).identity;
|
|
469
|
+
if (!this.#active || !this.#ledger.hasController(this.#authority))
|
|
470
|
+
throw new Error('assistant-actions: controller unavailable');
|
|
471
|
+
if (!agent || this.ctx.get('agents')?.get(agent.id) !== agent)
|
|
472
|
+
throw new Error('assistant-actions: exact live agent required');
|
|
473
|
+
const owner = this.ctx.get('assistantDelivery')?.preferencePrincipalForAgent(agent);
|
|
474
|
+
if (!owner || owner.scope.workspace !== agent.session.header.cwd || owner.scope.preset !== agent.session.header.agentPreset)
|
|
475
|
+
throw new Error('assistant-actions: authenticated owner required');
|
|
476
|
+
if (this.ctx.get('assistantPolicy')?.evaluateAgent(agent, 'execute', { kind: 'tool', id: `action:github:${grantId}` }).effect !== 'allow')
|
|
477
|
+
throw new Error('assistant-actions: policy denied');
|
|
478
|
+
return { principalDigest: principalDigest(owner.principalId), ...owner.principalLineage, workspace: owner.scope.workspace, agentPreset: owner.scope.preset };
|
|
479
|
+
}
|
|
480
|
+
#compensationIdentity(agent, grantId) {
|
|
481
|
+
if (!this.#active || !this.#ledger.hasController(this.#authority))
|
|
482
|
+
throw new Error('assistant-actions: controller unavailable');
|
|
483
|
+
if (!agent || this.ctx.get('agents')?.get(agent.id) !== agent)
|
|
484
|
+
throw new Error('assistant-actions: exact live agent required');
|
|
485
|
+
const owner = this.ctx.get('assistantDelivery')?.preferencePrincipalForAgent(agent);
|
|
486
|
+
if (!owner || owner.scope.workspace !== agent.session.header.cwd || owner.scope.preset !== agent.session.header.agentPreset)
|
|
487
|
+
throw new Error('assistant-actions: authenticated owner required');
|
|
488
|
+
const grant = this.#ledger.grant(grantId);
|
|
489
|
+
const rollback = grant?.rollback;
|
|
490
|
+
const policy = this.ctx.get('assistantPolicy');
|
|
491
|
+
if (!grant || !rollback?.allowRollback || grant.expiresAt <= Date.now()
|
|
492
|
+
|| policy?.getBudgetConfig(rollback.budgetId)?.metric !== ROLLBACK_BUDGET_METRIC
|
|
493
|
+
|| policy.evaluateAgent(agent, 'compensate', { kind: 'tool', id: `action:github-rollback:${grantId}` }).effect !== 'allow')
|
|
494
|
+
throw new Error('assistant-actions: compensation denied');
|
|
495
|
+
return { principalDigest: principalDigest(owner.principalId), ...owner.principalLineage, workspace: owner.scope.workspace, agentPreset: owner.scope.preset };
|
|
496
|
+
}
|
|
497
|
+
#reserveCompensation(agent, record, expectedReservationId) {
|
|
498
|
+
try {
|
|
499
|
+
const grant = this.#ledger.grant(record.grantId);
|
|
500
|
+
const rollback = grant?.rollback;
|
|
501
|
+
const policy = this.ctx.get('assistantPolicy');
|
|
502
|
+
if (!rollback?.allowRollback || policy?.getBudgetConfig(rollback.budgetId)?.metric !== ROLLBACK_BUDGET_METRIC)
|
|
503
|
+
return undefined;
|
|
504
|
+
// evaluateAgent only decides; the two-phase reserve below is what holds
|
|
505
|
+
// budget capacity, so an authorization that never reaches the dispatch
|
|
506
|
+
// POST can be released instead of permanently spending the action.
|
|
507
|
+
const decision = policy.evaluateAgent(agent, 'compensate', { kind: 'tool', id: `action:github-rollback:${record.grantId}` });
|
|
508
|
+
if (decision.effect !== 'allow' || decision.budget?.id !== rollback.budgetId)
|
|
509
|
+
return undefined;
|
|
510
|
+
const preset = agent?.session.header.agentPreset;
|
|
511
|
+
const workspace = agent?.session.header.cwd;
|
|
512
|
+
if (!agent || preset === undefined || preset === '' || workspace === undefined)
|
|
513
|
+
return undefined;
|
|
514
|
+
const reservation = policy.reserve({
|
|
515
|
+
budgetId: rollback.budgetId,
|
|
516
|
+
subject: { kind: 'agent', id: preset, workspace },
|
|
517
|
+
amount: decision.budget.amount,
|
|
518
|
+
idempotencyKey: `compensation:${record.id}`,
|
|
519
|
+
});
|
|
520
|
+
// A still-open reservation is accepted both fresh (new capturing row) and
|
|
521
|
+
// as a replay (resuming a prepared row whose earlier attempt stopped
|
|
522
|
+
// before dispatch). A released/finalized replay is fail-closed: the
|
|
523
|
+
// capacity was already settled and a sealed prepared row is never reused.
|
|
524
|
+
if (reservation.status !== 'reserved')
|
|
525
|
+
return undefined;
|
|
526
|
+
// The pre-dispatch re-check must replay the very same open reservation.
|
|
527
|
+
if (expectedReservationId !== undefined && (!reservation.replayed || reservation.reservationId !== expectedReservationId))
|
|
528
|
+
return undefined;
|
|
529
|
+
return { reservationId: reservation.reservationId, amount: decision.budget.amount };
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
#forwardResult(request, result) {
|
|
536
|
+
if (result.status !== 'succeeded' || !result.commitOid)
|
|
537
|
+
return result;
|
|
538
|
+
const record = this.#ledger.get(result.actionId);
|
|
539
|
+
const grant = this.#ledger.grant(request.grantId);
|
|
540
|
+
if (!grant?.rollback?.allowRollback || !record || record.kind !== 'commit' || record.status !== 'succeeded'
|
|
541
|
+
|| record.grantId !== request.grantId || record.result?.commitOid !== result.commitOid || digest(record.result) !== digest(result))
|
|
542
|
+
return result;
|
|
543
|
+
return { ...result, forwardReceipt: { actionId: record.id, version: record.version, requestDigest: record.requestDigest, commitOid: result.commitOid } };
|
|
544
|
+
}
|
|
545
|
+
prepareVerifiedDelivery = (agent, input) => {
|
|
546
|
+
if (!this.#verified)
|
|
547
|
+
throw new Error('assistant-actions: verified delivery unavailable');
|
|
548
|
+
return this.#verified.prepare(agent, normalizeVerifiedDelivery(input));
|
|
549
|
+
};
|
|
550
|
+
/** Stable only while this fenced broker remains active; used by Verifier around an untrusted remote read. */
|
|
551
|
+
repositoryReadbackGeneration = () => {
|
|
552
|
+
if (this.#mode === 'external-unix-v1')
|
|
553
|
+
return this.#active ? digest({ facade: this.#externalFacadeGeneration, generation: this.#externalObservedGeneration ?? this.#external().minimumServerGeneration, epochs: this.#externalObservedEpochs }) : '';
|
|
554
|
+
return this.#active && this.#ledger.hasController(this.#authority) ? digest(this.#authority) : '';
|
|
555
|
+
};
|
|
556
|
+
readRepositoryGoalOutcome = async (input, signal) => {
|
|
557
|
+
signal.throwIfAborted();
|
|
558
|
+
const verifier = this.ctx.get('assistantVerifier', false);
|
|
559
|
+
if (!verifier)
|
|
560
|
+
throw new Error('assistant-actions: repository readback authority unavailable');
|
|
561
|
+
const binding = verifier.inspectRepositoryReadbackAuthority(input.contractId, input.authorityId, input.authorityDigest);
|
|
562
|
+
const bindingDigest = digest(binding);
|
|
563
|
+
const context = () => {
|
|
564
|
+
const currentVerifier = this.ctx.get('assistantVerifier', false);
|
|
565
|
+
if (!currentVerifier)
|
|
566
|
+
throw new Error('assistant-actions: repository readback authority unavailable');
|
|
567
|
+
const current = currentVerifier.inspectRepositoryReadbackAuthority(input.contractId, input.authorityId, input.authorityDigest);
|
|
568
|
+
if (digest(current) !== bindingDigest)
|
|
569
|
+
throw new Error('assistant-actions: repository readback authority changed');
|
|
570
|
+
return this.#repositoryReadbackContext(current.contract, current.authority);
|
|
571
|
+
};
|
|
572
|
+
const initial = context();
|
|
573
|
+
if (!Number.isSafeInteger(initial.authority.timeoutMs) || initial.authority.timeoutMs <= 0 || !Number.isSafeInteger(initial.authority.freshnessMs) || initial.authority.freshnessMs <= 0)
|
|
574
|
+
throw new Error('assistant-actions: repository readback bounds invalid');
|
|
575
|
+
const observedAt = Date.now();
|
|
576
|
+
const deadline = AbortSignal.any([signal, AbortSignal.timeout(initial.authority.timeoutMs)]);
|
|
577
|
+
const pullRequestNumber = initial.pullRequestNumber;
|
|
578
|
+
const [checks, reviews, pullRequest, branchSnapshot] = [
|
|
579
|
+
await this.#readRepositoryInspection(context, 'checks', pullRequestNumber, deadline),
|
|
580
|
+
await this.#readRepositoryInspection(context, 'reviews', pullRequestNumber, deadline),
|
|
581
|
+
await this.#readRepositoryInspection(context, 'pull-request', pullRequestNumber, deadline),
|
|
582
|
+
await this.#readRepositoryInspection(context, 'branch', undefined, deadline),
|
|
583
|
+
];
|
|
584
|
+
const final = context();
|
|
585
|
+
if (digest(initial) !== digest(final) || Date.now() - observedAt > final.authority.freshnessMs)
|
|
586
|
+
throw new Error('assistant-actions: repository readback authority changed');
|
|
587
|
+
const normalized = normalizeRepositoryReadback({ repository: final.authority.repository, branch: final.authority.branch, baseBranch: final.authority.baseBranch,
|
|
588
|
+
commitOid: final.commitOid, pullRequestNumber: final.pullRequestNumber, requirements: final.requirements, checks, reviews, pullRequest, branchSnapshot });
|
|
589
|
+
const headConfirmed = normalized.headOid === final.commitOid;
|
|
590
|
+
const ci = !headConfirmed ? 'unknown' : normalized.ci;
|
|
591
|
+
const review = !headConfirmed ? 'unknown' : normalized.review;
|
|
592
|
+
return Object.freeze({ objectId: normalized.objectId, headOid: normalized.headOid, ci, review, pullRequest: normalized.pullRequest,
|
|
593
|
+
ready: ci === 'passed' && review === 'approved' && (normalized.pullRequest === 'open' || normalized.pullRequest === 'merged') });
|
|
594
|
+
};
|
|
595
|
+
#repositoryReadbackContext(contract, authority) {
|
|
596
|
+
if (!this.#active || !this.#verified || contract.expiresAt <= Date.now() || contract.task.kind !== 'goal-outcome' || authority.kind !== 'repository-readback')
|
|
597
|
+
throw new Error('assistant-actions: repository readback binding invalid');
|
|
598
|
+
const goal = contract.task.goal;
|
|
599
|
+
const latest = this.#verified.latestForGoal({ id: goal.id, sessionId: goal.sessionId, nativeGoalId: goal.nativeGoalId, definitionVersion: goal.definitionVersion, definitionDigest: goal.definitionDigest }, authority.grantId, contract.owner, contract.scope);
|
|
600
|
+
if (!latest || latest.state !== 'succeeded' || latest.intent.security.acceptance !== 'goal-step' || !latest.outcome)
|
|
601
|
+
throw new Error('assistant-actions: repository delivery not settled');
|
|
602
|
+
const security = latest.intent.security;
|
|
603
|
+
if (security.goalId !== goal.id || security.nativeGoalId === undefined || security.nativeGoalId !== goal.nativeGoalId || security.sessionId !== goal.sessionId || security.definitionVersion !== goal.definitionVersion || security.definitionDigest !== goal.definitionDigest
|
|
604
|
+
|| security.grantId !== authority.grantId || security.grantRevision !== authority.grantRevision || security.identity.principalRecordId !== contract.owner.principalRecordId || security.identity.principalVersion !== contract.owner.principalVersion
|
|
605
|
+
|| security.identity.workspace !== contract.scope.workspace || security.identity.agentPreset !== contract.scope.preset)
|
|
606
|
+
throw new Error('assistant-actions: repository delivery binding changed');
|
|
607
|
+
const grant = this.#deliveryGrant(authority.grantId);
|
|
608
|
+
if (!grant || grant.revision !== authority.grantRevision || grant.repository !== authority.repository || grant.branch !== authority.branch || grant.repoWorkflow?.baseBranch !== authority.baseBranch)
|
|
609
|
+
throw new Error('assistant-actions: repository grant changed');
|
|
610
|
+
const outcome = latest.outcome;
|
|
611
|
+
const commitOid = outcome.commit.commitOid, pullRequestOutcome = outcome.pullRequest, pullRequestNumber = pullRequestOutcome?.pullRequestNumber;
|
|
612
|
+
if (this.#mode === 'external-unix-v1')
|
|
613
|
+
this.#verifyExternalDelivery(latest.intent, outcome);
|
|
614
|
+
else {
|
|
615
|
+
const commit = this.#ledger.get(outcome.commit.actionId);
|
|
616
|
+
const pullRequest = pullRequestOutcome && this.#ledger.get(pullRequestOutcome.actionId);
|
|
617
|
+
if (outcome.commit.status !== 'succeeded' || !commitOid || !commit || commit.kind !== 'commit' || commit.status !== 'succeeded' || digest(commit.result) !== digest(outcome.commit)
|
|
618
|
+
|| commit.grantId !== grant.id || commit.grantRevision !== grant.revision || commit.sessionId !== security.sessionId || digest(commit.identity) !== digest(security.identity)
|
|
619
|
+
|| !pullRequestOutcome || pullRequestOutcome.status !== 'succeeded' || !pullRequestNumber || !pullRequest || pullRequest.kind !== 'pull-request' || pullRequest.status !== 'succeeded' || digest(pullRequest.result) !== digest(pullRequestOutcome)
|
|
620
|
+
|| pullRequest.grantId !== grant.id || pullRequest.grantRevision !== grant.revision || pullRequest.sessionId !== security.sessionId || digest(pullRequest.identity) !== digest(security.identity))
|
|
621
|
+
throw new Error('assistant-actions: repository delivery receipt invalid');
|
|
622
|
+
}
|
|
623
|
+
if (outcome.commit.status !== 'succeeded' || !commitOid || pullRequestOutcome?.status !== 'succeeded' || !pullRequestNumber)
|
|
624
|
+
throw new Error('assistant-actions: repository delivery receipt invalid');
|
|
625
|
+
this.#deliveryIdentity(security);
|
|
626
|
+
const goals = this.ctx.get('assistantGoals', false);
|
|
627
|
+
const evidence = goals?.inspectOwnerGoalExecution?.({ ownerRouteId: security.ownerRouteId, principalId: security.principalId, workspace: security.identity.workspace, preset: security.identity.agentPreset, sessionId: security.sessionId, goalId: security.goalId });
|
|
628
|
+
const phase = evidence?.storedGoal?.nativeAtLastObservation?.phase;
|
|
629
|
+
if (!evidence || phase === undefined || !['active', 'paused', 'complete', 'blocked'].includes(phase) || evidence.storedGoal?.definition?.version !== goal.definitionVersion || evidence.storedGoal?.definition?.digest !== goal.definitionDigest
|
|
630
|
+
|| evidence.storedGoal?.nativeAtLastObservation?.sessionId !== goal.sessionId || evidence.storedGoal?.nativeAtLastObservation?.goalId !== goal.nativeGoalId)
|
|
631
|
+
throw new Error('assistant-actions: repository goal changed');
|
|
632
|
+
const assessment = evidence.outcomeAssessments?.find(item => item.contract.id === contract.id && item.contract.digest === contract.digest);
|
|
633
|
+
const source = evidence.executionRuns?.find(run => run.intent.runId === security.runId);
|
|
634
|
+
const assessmentRun = assessment?.triggerRunId === null || assessment?.triggerRunId === undefined ? undefined : evidence.executionRuns?.find(run => run.intent.runId === assessment.triggerRunId);
|
|
635
|
+
const exactRun = (run) => run !== undefined && run.intent.scope.workspace === contract.scope.workspace && run.intent.scope.preset === contract.scope.preset
|
|
636
|
+
&& run.intent.scope.principalId === security.principalId && run.dispatchedAt !== undefined && run.intent.scope.principalRecordId === contract.owner.principalRecordId && run.intent.scope.principalVersion === contract.owner.principalVersion
|
|
637
|
+
&& run.intent.task.goal.id === goal.id && run.intent.task.goal.definitionVersion === goal.definitionVersion && run.intent.task.goal.definitionDigest === goal.definitionDigest
|
|
638
|
+
&& run.intent.task.goal.sessionId === goal.sessionId && run.intent.task.goal.nativeGoalId === goal.nativeGoalId && run.execution?.status === 'succeeded' && run.execution.quiescent;
|
|
639
|
+
if (!assessment || !source || !assessmentRun || assessment.execution?.status !== 'succeeded' || !assessment.execution.quiescent || !exactRun(source) || !exactRun(assessmentRun))
|
|
640
|
+
throw new Error('assistant-actions: repository goal execution changed');
|
|
641
|
+
if (source.intent.admission.round > assessmentRun.intent.admission.round)
|
|
642
|
+
throw new Error('assistant-actions: repository goal execution changed');
|
|
643
|
+
const acceptedSource = evidence.acceptedTasks?.find(value => value.contractId === source.acceptance?.contractId && value.state === 'done');
|
|
644
|
+
if (!acceptedSource)
|
|
645
|
+
throw new Error('assistant-actions: repository source acceptance unavailable');
|
|
646
|
+
const sourceContract = validateTaskAcceptanceContract(acceptedSource.contract);
|
|
647
|
+
const sourceReceipt = validateTaskVerificationReceipt(sourceContract, acceptedSource.receipt);
|
|
648
|
+
if (sourceContract.digest !== source.acceptance?.contractDigest || digest(sourceContract.task) !== digest(source.intent.task)
|
|
649
|
+
|| sourceContract.owner.principalRecordId !== contract.owner.principalRecordId || sourceContract.owner.principalVersion !== contract.owner.principalVersion
|
|
650
|
+
|| digest(sourceContract.scope) !== digest(contract.scope) || sourceReceipt.objectiveStatus !== 'achieved')
|
|
651
|
+
throw new Error('assistant-actions: repository source acceptance changed');
|
|
652
|
+
const evidenceDigest = digest({ source, assessment, assessmentRun, acceptedSource });
|
|
653
|
+
return Object.freeze({ authority: Object.freeze({ grantId: authority.grantId, grantRevision: authority.grantRevision, repository: authority.repository, branch: authority.branch, baseBranch: authority.baseBranch, timeoutMs: authority.timeoutMs, freshnessMs: authority.freshnessMs }),
|
|
654
|
+
evidenceDigest, requirements: validateRepositoryReadbackRequirements({ requiredChecks: authority.requiredChecks, reviewerIds: authority.reviewerIds, minApprovals: authority.minApprovals }), security, intent: latest.intent, commitOid, pullRequestNumber });
|
|
655
|
+
}
|
|
656
|
+
async #readRepositoryInspection(context, kind, pullRequestNumber, signal) {
|
|
657
|
+
let observed;
|
|
658
|
+
const start = context();
|
|
659
|
+
if (this.#mode === 'external-unix-v1') {
|
|
660
|
+
const grant = this.#externalGrants.get(start.authority.grantId);
|
|
661
|
+
if (!grant.allowedOperations.includes('inspect') || !grant.allowedInspectKinds.includes(kind))
|
|
662
|
+
throw new Error('assistant-actions: repository inspection not granted');
|
|
663
|
+
const authorization = this.#externalDeliveryAuthorization(start.intent);
|
|
664
|
+
const current = authorization.current;
|
|
665
|
+
authorization.current = () => { current(); if (digest(context()) !== digest(start))
|
|
666
|
+
throw new Error('assistant-actions: repository readback changed'); };
|
|
667
|
+
const generation = this.#externalObservedGeneration, epochs = this.#externalObservedEpochs, callId = `readback:${randomUUID()}`;
|
|
668
|
+
const response = await this.#externalRequest(undefined, grant, grant.owner, 'inspect', { kind, ...(pullRequestNumber === undefined ? {} : { pullRequestNumber }) }, signal, { callId, rootCallId: start.intent.id }, callId, authorization);
|
|
669
|
+
if (response.result.status !== 'succeeded' || response.observed === undefined || generation !== undefined && generation !== response.generation || epochs !== undefined && epochs !== this.#externalObservedEpochs)
|
|
670
|
+
throw new Error('assistant-actions: repository inspection unavailable or broker changed');
|
|
671
|
+
authorization.current();
|
|
672
|
+
return response.observed;
|
|
673
|
+
}
|
|
674
|
+
const request = normalizeWorkflow({ grantId: start.authority.grantId, operation: 'inspect', idempotencyKey: randomUUID(), kind, ...(pullRequestNumber === undefined ? {} : { pullRequestNumber }) });
|
|
675
|
+
if (!('operation' in request))
|
|
676
|
+
throw new Error('assistant-actions: repository inspection invalid');
|
|
677
|
+
const authorization = { sessionId: start.security.sessionId,
|
|
678
|
+
identity: () => { const current = context(); if (digest(current) !== digest(start))
|
|
679
|
+
throw new Error('assistant-actions: repository readback changed'); return current.security.identity; },
|
|
680
|
+
authorize: record => this.ctx.get('assistantPolicy')?.authorize(this.#deliveryPolicy(start.security), { idempotencyKey: `action:${record.id}` }).effect === 'allow' };
|
|
681
|
+
const result = await this.#runAuthorized(authorization, request, signal, async (actionId, grant, token, combined) => {
|
|
682
|
+
const reply = await this.workflow.inspect({ grant, kind: request.kind, ...(request.pullRequestNumber === undefined ? {} : { pullRequestNumber: request.pullRequestNumber }), token, signal: combined });
|
|
683
|
+
observed = reply?.observed;
|
|
684
|
+
return reply ? { actionId, status: 'succeeded' } : { actionId, status: 'failed', reason: 'github-inspect-failed' };
|
|
685
|
+
});
|
|
686
|
+
if (result.status !== 'succeeded' || observed === undefined)
|
|
687
|
+
throw new Error('assistant-actions: repository inspection unavailable');
|
|
688
|
+
context();
|
|
689
|
+
return observed;
|
|
690
|
+
}
|
|
691
|
+
#deliveryGrant(id) {
|
|
692
|
+
if (this.#mode !== 'external-unix-v1')
|
|
693
|
+
return this.#ledger.grant(id);
|
|
694
|
+
const grant = this.#externalGrants.get(id);
|
|
695
|
+
if (!grant)
|
|
696
|
+
return undefined;
|
|
697
|
+
return { id: grant.id, revision: grant.revision, principalDigest: grant.owner.principalDigest, principalRecordId: grant.owner.principalRecordId,
|
|
698
|
+
principalVersion: grant.owner.principalVersion, workspace: grant.owner.workspace, agentPreset: grant.owner.preset,
|
|
699
|
+
repository: grant.destination.repository, branch: grant.destination.branch, paths: [...grant.destination.paths], expiresAt: grant.expiresAt,
|
|
700
|
+
maxActions: grant.maxActions, maxTotalBytes: grant.maxTotalBytes, ...(grant.verifiedDelivery ? { verifiedDelivery: grant.verifiedDelivery } : {}),
|
|
701
|
+
...(grant.destination.baseBranch ? { repoWorkflow: { baseBranch: grant.destination.baseBranch, allowBranchCreate: false, allowPullRequest: grant.allowedOperations.includes('pull-request') } } : {}) };
|
|
702
|
+
}
|
|
703
|
+
#captureDelivery(agent, request) {
|
|
704
|
+
const identity = this.#identity(agent, request.grantId);
|
|
705
|
+
const grant = this.#deliveryGrant(request.grantId);
|
|
706
|
+
if (!grant?.verifiedDelivery || grant.expiresAt <= Date.now() || digest(identity) !== digest({ principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset })
|
|
707
|
+
|| !request.paths.every(path => grant.paths.includes(path)) || request.pullRequest && !grant.repoWorkflow?.allowPullRequest)
|
|
708
|
+
throw new Error('assistant-actions: verified delivery is not granted');
|
|
709
|
+
const delivery = this.ctx.get('assistantDelivery');
|
|
710
|
+
const owner = delivery.preferencePrincipalForAgent(agent);
|
|
711
|
+
const goals = this.ctx.get('assistantGoals', false);
|
|
712
|
+
const context = goals?.taskContext(agent);
|
|
713
|
+
const current = context ? goals.inspectWorkflowRunContext(agent, context.goal.id) : undefined;
|
|
714
|
+
const nativeGoalId = current?.nativeGoalId;
|
|
715
|
+
if (!current)
|
|
716
|
+
throw new Error('assistant-actions: admitted native goal round required');
|
|
717
|
+
const routeReceipt = delivery.validateOwnerRoute({ authorityId: grant.verifiedDelivery.ownerRouteId, principalId: owner.principalId, workspace: identity.workspace, agentPreset: identity.agentPreset });
|
|
718
|
+
if (routeReceipt.principalRecordId !== identity.principalRecordId || routeReceipt.principalVersion !== identity.principalVersion
|
|
719
|
+
|| routeReceipt.bindingVersion !== owner.bindingVersion || routeReceipt.generation !== owner.bindingGeneration)
|
|
720
|
+
throw new Error('assistant-actions: current owner route mismatch');
|
|
721
|
+
const security = { principalId: owner.principalId, identity, sessionId: String(agent.session.id),
|
|
722
|
+
goalId: current.goalId, ...(typeof nativeGoalId === 'string' && nativeGoalId.length > 0 ? { nativeGoalId } : {}), runId: current.goalExecutionRunId, definitionDigest: current.definition.digest, definitionVersion: current.definition.version,
|
|
723
|
+
grantId: grant.id, grantRevision: grant.revision, ...(this.#mode === 'external-unix-v1' ? { externalGrantDigest: this.#externalGrants.get(grant.id).grantDigest } : {}), ownerRouteId: grant.verifiedDelivery.ownerRouteId, budgetId: grant.verifiedDelivery.budgetId,
|
|
724
|
+
expiresAt: grant.expiresAt, routeReceipt, ...(grant.verifiedDelivery.acceptance ? { acceptance: grant.verifiedDelivery.acceptance } : {}) };
|
|
725
|
+
this.#deliveryIdentity(security);
|
|
726
|
+
return security;
|
|
727
|
+
}
|
|
728
|
+
#deliveryPolicy(security) {
|
|
729
|
+
return { subject: { kind: 'background', id: 'dsh-enhanced-assistant-actions', workspace: security.identity.workspace, principal: security.principalId },
|
|
730
|
+
action: 'execute', resource: { kind: 'tool', id: `action:github:${security.grantId}` }, context: { initiator: 'background' } };
|
|
731
|
+
}
|
|
732
|
+
#deliveryIdentity(security) {
|
|
733
|
+
if (!this.#active || this.#mode !== 'external-unix-v1' && !this.#ledger.hasController(this.#authority))
|
|
734
|
+
throw new Error('assistant-actions: controller unavailable');
|
|
735
|
+
const grant = this.#deliveryGrant(security.grantId);
|
|
736
|
+
if (!grant?.verifiedDelivery || grant.revision !== security.grantRevision || grant.expiresAt <= Date.now()
|
|
737
|
+
|| grant.verifiedDelivery.ownerRouteId !== security.ownerRouteId || grant.verifiedDelivery.budgetId !== security.budgetId
|
|
738
|
+
|| digest(security.identity) !== digest({ principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset })
|
|
739
|
+
|| Date.now() >= security.expiresAt || (grant.verifiedDelivery.acceptance ?? 'goal-outcome') !== (security.acceptance ?? 'goal-outcome'))
|
|
740
|
+
throw new Error('assistant-actions: delivery grant ended');
|
|
741
|
+
const route = this.ctx.get('assistantDelivery')?.validateOwnerRoute({ authorityId: security.ownerRouteId, principalId: security.principalId,
|
|
742
|
+
workspace: security.identity.workspace, agentPreset: security.identity.agentPreset });
|
|
743
|
+
if (digest(route) !== digest(security.routeReceipt) || this.ctx.get('assistantPolicy')?.evaluate(this.#deliveryPolicy(security)).effect !== 'allow')
|
|
744
|
+
throw new Error('assistant-actions: delivery authority changed');
|
|
745
|
+
if (this.#mode === 'external-unix-v1') {
|
|
746
|
+
const external = this.#externalGrants.get(grant.id);
|
|
747
|
+
const resolved = this.ctx.get('assistantDelivery')?.resolveOwnerRoute(security.ownerRouteId);
|
|
748
|
+
if (security.externalGrantDigest !== external.grantDigest || external.sessionId !== security.sessionId
|
|
749
|
+
|| resolved?.binding.id !== external.owner.bindingId || resolved.binding.sessionId !== security.sessionId
|
|
750
|
+
|| resolved.snapshot.bindingVersion !== external.owner.bindingVersion || resolved.snapshot.generation !== external.owner.bindingGeneration)
|
|
751
|
+
throw new Error('assistant-actions: external delivery binding changed');
|
|
752
|
+
}
|
|
753
|
+
else if (security.externalGrantDigest !== undefined)
|
|
754
|
+
throw new Error('assistant-actions: delivery backend changed');
|
|
755
|
+
return security.identity;
|
|
756
|
+
}
|
|
757
|
+
#inspectDelivery(intent, historicalReadback = false) {
|
|
758
|
+
const security = intent.security;
|
|
759
|
+
this.#deliveryIdentity(security);
|
|
760
|
+
const goals = this.ctx.get('assistantGoals', false);
|
|
761
|
+
if (!goals)
|
|
762
|
+
throw new Error('assistant-actions: Goals unavailable');
|
|
763
|
+
const input = { ownerRouteId: security.ownerRouteId, principalId: security.principalId, workspace: security.identity.workspace,
|
|
764
|
+
preset: security.identity.agentPreset, sessionId: security.sessionId, goalId: security.goalId };
|
|
765
|
+
const evidence = goals.inspectOwnerGoalExecution(input);
|
|
766
|
+
const intermediate = security.acceptance === 'goal-step';
|
|
767
|
+
if (evidence.storedGoal.definition.digest !== security.definitionDigest || evidence.storedGoal.definition.version !== security.definitionVersion
|
|
768
|
+
|| !(intermediate ? (historicalReadback ? ['active', 'paused', 'complete', 'blocked'] : ['active', 'paused', 'complete']) : ['active', 'complete']).includes(evidence.storedGoal.nativeAtLastObservation.phase))
|
|
769
|
+
throw new Error('assistant-actions: delivery goal changed');
|
|
770
|
+
if (intermediate) {
|
|
771
|
+
if (typeof goals.inspectOwnerAcceptedStepArtifacts !== 'function')
|
|
772
|
+
throw new Error('assistant-actions: upgrade Goals for accepted step delivery');
|
|
773
|
+
const run = evidence.executionRuns.find(item => item.intent.runId === security.runId);
|
|
774
|
+
if (!run?.acceptance)
|
|
775
|
+
throw new Error('assistant-actions: delivery step missing');
|
|
776
|
+
if (run.execution !== undefined && (run.execution.status !== 'succeeded' || !run.execution.quiescent))
|
|
777
|
+
throw new Error('assistant-actions: delivery step execution invalid');
|
|
778
|
+
const step = evidence.acceptedTasks.find(item => item.contractId === run.acceptance.contractId);
|
|
779
|
+
if (run.execution === undefined || step === undefined || step.state === 'unavailable' || step.state === 'pending' || step?.state === 'verifying' || step?.state === 'awaiting-execution')
|
|
780
|
+
return undefined;
|
|
781
|
+
const snapshot = goals.inspectOwnerAcceptedStepArtifacts({ ...input, runId: security.runId, paths: intent.request.paths });
|
|
782
|
+
this.#deliveryIdentity(security);
|
|
783
|
+
return snapshot;
|
|
784
|
+
}
|
|
785
|
+
if (evidence.outcome?.status !== 'achieved') {
|
|
786
|
+
if (evidence.storedGoal.nativeAtLastObservation.phase === 'complete')
|
|
787
|
+
throw new Error('assistant-actions: current acceptance missing');
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
const snapshot = goals.inspectOwnerVerifiedArtifacts({ ...input, runId: security.runId, paths: intent.request.paths });
|
|
791
|
+
this.#deliveryIdentity(security);
|
|
792
|
+
return snapshot;
|
|
793
|
+
}
|
|
794
|
+
#notifyVerifiedDelivery(intent, value) {
|
|
795
|
+
const security = intent.security;
|
|
796
|
+
this.#deliveryIdentity(security);
|
|
797
|
+
const delivery = this.ctx.get('assistantDelivery');
|
|
798
|
+
if (typeof delivery?.enqueueOwnerNotification !== 'function')
|
|
799
|
+
throw new Error('assistant-actions: owner notification delivery unavailable');
|
|
800
|
+
delivery.enqueueOwnerNotification({ sourceId: 'assistant-actions-verified-delivery/v1', ownerRouteId: security.ownerRouteId,
|
|
801
|
+
scope: { principalId: security.principalId, principalRecordId: security.identity.principalRecordId, principalVersion: security.identity.principalVersion,
|
|
802
|
+
workspace: security.identity.workspace, preset: security.identity.agentPreset }, sessionId: security.sessionId,
|
|
803
|
+
idempotencyKey: value.idempotencyKey, text: value.text, expiresAt: security.expiresAt });
|
|
804
|
+
}
|
|
805
|
+
#externalDeliveryAuthorization(intent, frozen) {
|
|
806
|
+
const current = () => {
|
|
807
|
+
this.#deliveryIdentity(intent.security);
|
|
808
|
+
if (frozen !== undefined) {
|
|
809
|
+
const snapshot = this.#inspectDelivery(intent);
|
|
810
|
+
if (!snapshot || digest(snapshot) !== frozen)
|
|
811
|
+
throw new Error('assistant-actions: accepted artifacts changed');
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
return { sessionId: intent.security.sessionId, agentId: `delivery:${intent.id}`, current,
|
|
815
|
+
authorize: actionId => { current(); return this.ctx.get('assistantPolicy')?.authorize(this.#deliveryPolicy(intent.security), { idempotencyKey: `external-action:${actionId}` }).effect === 'allow'; } };
|
|
816
|
+
}
|
|
817
|
+
async #deliverExternalVerified(intent, snapshot, signal) {
|
|
818
|
+
const security = intent.security, authorization = this.#externalDeliveryAuthorization(intent, digest(snapshot));
|
|
819
|
+
authorization.current();
|
|
820
|
+
const grant = this.#externalGrants.get(security.grantId);
|
|
821
|
+
if (!grant.allowedOperations.includes('commit') || snapshot.files.some(file => !grant.destination.paths.includes(file.path))
|
|
822
|
+
|| intent.request.pullRequest && !grant.allowedOperations.includes('pull-request'))
|
|
823
|
+
throw new Error('assistant-actions: external delivery not granted');
|
|
824
|
+
const commitRequest = normalizeCommit({ grantId: grant.id, idempotencyKey: `${intent.id}:commit`, expectedHeadOid: intent.request.expectedHeadOid,
|
|
825
|
+
headline: intent.request.headline, files: snapshot.files.map(file => ({ path: file.path, content: file.content })) });
|
|
826
|
+
const committed = await this.#externalRequest(undefined, grant, grant.owner, 'commit', { expectedHeadOid: commitRequest.expectedHeadOid, headline: commitRequest.headline, files: commitRequest.files }, signal, { callId: `${intent.id}:commit`, rootCallId: intent.id }, commitRequest.idempotencyKey, authorization);
|
|
827
|
+
const commit = committed.result;
|
|
828
|
+
if (commit.status !== 'succeeded')
|
|
829
|
+
return { commit };
|
|
830
|
+
if (!committed.receipt || !commit.commitOid)
|
|
831
|
+
return { commit: { ...commit, status: 'unknown', reason: 'external-delivery-receipt-unavailable' } };
|
|
832
|
+
const brokerReceipts = { commit: committed.receipt };
|
|
833
|
+
if (!intent.request.pullRequest)
|
|
834
|
+
return { commit, brokerReceipts };
|
|
835
|
+
authorization.current();
|
|
836
|
+
const pr = normalizeWorkflow({ grantId: grant.id, idempotencyKey: `${intent.id}:pr`, expectedHeadOid: commit.commitOid, ...intent.request.pullRequest });
|
|
837
|
+
const created = await this.#externalRequest(undefined, grant, grant.owner, 'pull-request', { expectedHeadOid: pr.expectedHeadOid, title: pr.title, body: pr.body }, signal, { callId: `${intent.id}:pr`, rootCallId: intent.id }, pr.idempotencyKey, authorization);
|
|
838
|
+
if (created.result.status === 'succeeded' && !created.receipt)
|
|
839
|
+
return { commit, pullRequest: { ...created.result, status: 'unknown', reason: 'external-delivery-receipt-unavailable' }, brokerReceipts };
|
|
840
|
+
return { commit, pullRequest: created.result, brokerReceipts: { ...brokerReceipts, ...(created.receipt ? { pullRequest: created.receipt } : {}) } };
|
|
841
|
+
}
|
|
842
|
+
#verifyExternalDelivery(intent, outcome) {
|
|
843
|
+
this.#deliveryIdentity(intent.security);
|
|
844
|
+
const grant = this.#externalGrants.get(intent.security.grantId), receipts = outcome.brokerReceipts;
|
|
845
|
+
const snapshot = this.#inspectDelivery(intent, true);
|
|
846
|
+
if (!receipts?.commit || !receipts.pullRequest || !snapshot || !intent.request.pullRequest || !outcome.commit.commitOid || !outcome.pullRequest)
|
|
847
|
+
throw new Error('assistant-actions: external delivery receipt unavailable');
|
|
848
|
+
const destination = { classification: 'github-repository', repository: grant.destination.repository, branch: grant.destination.branch,
|
|
849
|
+
...(grant.destination.baseBranch === undefined ? {} : { baseBranch: grant.destination.baseBranch }) };
|
|
850
|
+
const common = { grantId: grant.id, grantRevision: grant.revision, grantDigest: grant.grantDigest, owner: grant.owner, sessionId: intent.security.sessionId, source: grant.source, destination };
|
|
851
|
+
const commit = verifyExternalDeliveryReceipt(receipts.commit, { expectedHeadOid: intent.request.expectedHeadOid, headline: intent.request.headline,
|
|
852
|
+
files: snapshot.files.map(file => ({ path: file.path, content: file.content })) }, { ...common, operation: 'commit', actionId: this.#externalActionId(grant, intent.security.sessionId, 'commit', `${intent.id}:commit`) }, this.#external());
|
|
853
|
+
const pr = verifyExternalDeliveryReceipt(receipts.pullRequest, { expectedHeadOid: outcome.commit.commitOid, ...intent.request.pullRequest }, { ...common, operation: 'pull-request', actionId: this.#externalActionId(grant, intent.security.sessionId, 'pull-request', `${intent.id}:pr`) }, this.#external());
|
|
854
|
+
if (commit.result?.operation !== 'commit' || pr.result?.operation !== 'pull-request' || commit.actionId !== outcome.commit.actionId || pr.actionId !== outcome.pullRequest.actionId
|
|
855
|
+
|| commit.result.commitOid !== outcome.commit.commitOid || pr.result.pullRequestNumber !== outcome.pullRequest.pullRequestNumber)
|
|
856
|
+
throw new Error('assistant-actions: external delivery receipt changed');
|
|
857
|
+
}
|
|
858
|
+
async #deliverVerified(intent, snapshot, signal) {
|
|
859
|
+
const security = intent.security, frozen = digest(snapshot);
|
|
860
|
+
if (this.#mode === 'external-unix-v1')
|
|
861
|
+
return await this.#deliverExternalVerified(intent, snapshot, signal);
|
|
862
|
+
const authorization = { sessionId: security.sessionId,
|
|
863
|
+
identity: () => {
|
|
864
|
+
const current = this.#inspectDelivery(intent);
|
|
865
|
+
if (!current || digest(current) !== frozen)
|
|
866
|
+
throw new Error('assistant-actions: accepted artifacts changed');
|
|
867
|
+
return this.#deliveryIdentity(security);
|
|
868
|
+
},
|
|
869
|
+
authorize: record => this.ctx.get('assistantPolicy')?.authorize(this.#deliveryPolicy(security), { idempotencyKey: `action:${record.id}` }).effect === 'allow',
|
|
870
|
+
};
|
|
871
|
+
const request = normalizeCommit({ grantId: security.grantId, idempotencyKey: `${intent.id}:commit`, expectedHeadOid: intent.request.expectedHeadOid,
|
|
872
|
+
headline: intent.request.headline, files: snapshot.files.map(file => ({ path: file.path, content: file.content })) });
|
|
873
|
+
const commit = await this.#runAuthorized(authorization, request, signal, async (actionId, grant, token, combined) => this.commit({ actionId, grant, request, token, signal: combined }));
|
|
874
|
+
if (commit.status !== 'succeeded' || !intent.request.pullRequest)
|
|
875
|
+
return { commit };
|
|
876
|
+
if (!commit.commitOid)
|
|
877
|
+
return { commit, pullRequest: { actionId: intent.id, status: 'unknown', reason: 'commit-oid-unavailable' } };
|
|
878
|
+
const pr = normalizeWorkflow({ grantId: security.grantId, idempotencyKey: `${intent.id}:pr`, expectedHeadOid: commit.commitOid, ...intent.request.pullRequest });
|
|
879
|
+
const pullRequest = await this.#runAuthorized(authorization, pr, signal, async (actionId, grant, token, combined) => this.workflow.pullRequest({ actionId, grant, expectedHeadOid: pr.expectedHeadOid, title: pr.title, body: pr.body, token, signal: combined }));
|
|
880
|
+
return { commit, pullRequest };
|
|
881
|
+
}
|
|
882
|
+
#preauthorized(execution) {
|
|
883
|
+
try {
|
|
884
|
+
if (execution.signal.aborted)
|
|
885
|
+
return false;
|
|
886
|
+
const request = normalizeCommit(execution.arguments);
|
|
887
|
+
const identity = this.#identity(execution.agent, request.grantId);
|
|
888
|
+
if (this.#mode === 'external-unix-v1')
|
|
889
|
+
return this.#allowsExternal(this.#externalGrants.get(request.grantId), identity, request);
|
|
890
|
+
const grant = this.#ledger.grant(request.grantId);
|
|
891
|
+
return !!grant && !grant.verifiedDelivery && grant.expiresAt > Date.now() && grant.principalDigest === identity.principalDigest
|
|
892
|
+
&& grant.principalRecordId === identity.principalRecordId && grant.principalVersion === identity.principalVersion
|
|
893
|
+
&& grant.workspace === identity.workspace && grant.agentPreset === identity.agentPreset
|
|
894
|
+
&& request.files.every(file => grant.paths.includes(file.path)) && commitBytes(request) <= grant.maxTotalBytes;
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
#allows(grant, identity, request) {
|
|
901
|
+
if (!grant || grant.expiresAt <= Date.now() || digest(identity) !== digest({ principalDigest: grant.principalDigest, principalRecordId: grant.principalRecordId, principalVersion: grant.principalVersion, workspace: grant.workspace, agentPreset: grant.agentPreset }))
|
|
902
|
+
return false;
|
|
903
|
+
if ('files' in request)
|
|
904
|
+
return request.files.every(file => grant.paths.includes(file.path)) && commitBytes(request) <= grant.maxTotalBytes;
|
|
905
|
+
if ('baseHeadOid' in request)
|
|
906
|
+
return grant.repoWorkflow?.allowBranchCreate === true;
|
|
907
|
+
if ('title' in request)
|
|
908
|
+
return grant.repoWorkflow?.allowPullRequest === true;
|
|
909
|
+
return (request.kind !== 'file' || grant.paths.includes(request.path)) && (!['pull-request', 'checks', 'reviews'].includes(request.kind) || !!grant.repoWorkflow);
|
|
910
|
+
}
|
|
911
|
+
#allowsExternal(grant, identity, request) {
|
|
912
|
+
if (!grant || grant.expiresAt <= Date.now() || digest(identity) !== digest({ principalDigest: grant.owner.principalDigest, principalRecordId: grant.owner.principalRecordId, principalVersion: grant.owner.principalVersion, workspace: grant.owner.workspace, agentPreset: grant.owner.preset }))
|
|
913
|
+
return false;
|
|
914
|
+
if ('files' in request)
|
|
915
|
+
return !grant.verifiedDelivery && grant.allowedOperations.includes('commit') && request.files.every(file => grant.destination.paths.includes(file.path)) && commitBytes(request) <= grant.maxTotalBytes;
|
|
916
|
+
return grant.allowedOperations.includes('inspect') && grant.allowedInspectKinds.includes(request.kind) && (request.kind !== 'file' || grant.destination.paths.includes(request.path))
|
|
917
|
+
&& (!['pull-request', 'checks', 'reviews'].includes(request.kind) || typeof grant.destination.baseBranch === 'string');
|
|
918
|
+
}
|
|
919
|
+
#preauthorizedWorkflow(execution) {
|
|
920
|
+
try {
|
|
921
|
+
if (execution.signal.aborted)
|
|
922
|
+
return false;
|
|
923
|
+
const input = execution.arguments;
|
|
924
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
925
|
+
return false;
|
|
926
|
+
const request = normalizeWorkflow(execution.name === 'action_github_inspect' ? { ...input, operation: 'inspect', idempotencyKey: 'preauthorization' } : input);
|
|
927
|
+
if (this.#mode === 'external-unix-v1')
|
|
928
|
+
return 'operation' in request && this.#allowsExternal(this.#externalGrants.get(request.grantId), this.#identity(execution.agent, request.grantId), request);
|
|
929
|
+
if (!('operation' in request) && this.#ledger.grant(request.grantId)?.verifiedDelivery)
|
|
930
|
+
return false;
|
|
931
|
+
return this.#allows(this.#ledger.grant(request.grantId), this.#identity(execution.agent, request.grantId), request);
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
#preauthorizedCompensation(execution) {
|
|
938
|
+
try {
|
|
939
|
+
if (execution.signal.aborted)
|
|
940
|
+
return false;
|
|
941
|
+
const request = normalizeCompensation(execution.arguments);
|
|
942
|
+
const identity = this.#compensationIdentity(execution.agent, request.grantId);
|
|
943
|
+
const forward = this.#ledger.get(request.forwardActionId);
|
|
944
|
+
return !!forward && forward.kind === 'commit' && forward.status === 'succeeded' && forward.sessionId === String(execution.agent?.session.id)
|
|
945
|
+
&& forward.grantId === request.grantId && forward.version === request.forwardActionVersion
|
|
946
|
+
&& forward.requestDigest === request.forwardRequestDigest && forward.result?.commitOid === request.forwardCommitOid
|
|
947
|
+
&& digest(forward.identity) === digest(identity);
|
|
948
|
+
}
|
|
949
|
+
catch {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
#preauthorizedCompensationStatus(execution) {
|
|
954
|
+
try {
|
|
955
|
+
if (execution.signal.aborted || !execution.arguments || typeof execution.arguments !== 'object' || Array.isArray(execution.arguments)
|
|
956
|
+
|| Object.keys(execution.arguments).length !== 2)
|
|
957
|
+
return false;
|
|
958
|
+
const { grantId, actionId } = execution.arguments;
|
|
959
|
+
if (typeof grantId !== 'string' || typeof actionId !== 'string')
|
|
960
|
+
return false;
|
|
961
|
+
const identity = this.#compensationIdentity(execution.agent, grantId);
|
|
962
|
+
const record = this.#ledger.getCompensation(actionId);
|
|
963
|
+
return !!record && record.grantId === grantId && record.sessionId === String(execution.agent?.session.id) && digest(record.identity) === digest(identity);
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
run = async (agent, input, signal) => {
|
|
970
|
+
const request = normalizeCommit(input);
|
|
971
|
+
if (this.#mode === 'external-unix-v1') {
|
|
972
|
+
const callId = `api:${brokerDigest(request.idempotencyKey)}`;
|
|
973
|
+
return await this.#runExternal(agent, request, signal, { callId, rootCallId: callId });
|
|
974
|
+
}
|
|
975
|
+
if (this.#ledger.grant(request.grantId)?.verifiedDelivery)
|
|
976
|
+
throw new Error('assistant-actions: this grant requires independently verified delivery');
|
|
977
|
+
return await this.#runWorkflow(agent, request, signal, async (actionId, grant, token, combined) => await this.commit({ actionId, grant, request, token, signal: combined }));
|
|
978
|
+
};
|
|
979
|
+
runCompensation = async (agent, input, signal) => {
|
|
980
|
+
if (this.#mode === 'external-unix-v1')
|
|
981
|
+
throw new Error(EXTERNAL_UNSUPPORTED);
|
|
982
|
+
signal.throwIfAborted();
|
|
983
|
+
const request = normalizeCompensation(input);
|
|
984
|
+
const identity = this.#compensationIdentity(agent, request.grantId);
|
|
985
|
+
const sessionId = String(agent?.session.id);
|
|
986
|
+
const { record } = this.#ledger.prepareCompensation({ identity, sessionId, request, authority: this.#authority });
|
|
987
|
+
const terminal = this.#publicCompensation(record);
|
|
988
|
+
if (terminal)
|
|
989
|
+
return terminal;
|
|
990
|
+
const current = this.#pendingCompensations.get(record.id);
|
|
991
|
+
if (current)
|
|
992
|
+
return this.#publicCompensationResult(await current.done);
|
|
993
|
+
const abort = new AbortController();
|
|
994
|
+
const done = this.#executeCompensation(agent, identity, record, AbortSignal.any([signal, abort.signal]));
|
|
995
|
+
this.#pendingCompensations.set(record.id, { abort, done });
|
|
996
|
+
try {
|
|
997
|
+
const result = await done;
|
|
998
|
+
if (digest(this.#compensationIdentity(agent, request.grantId)) !== digest(identity))
|
|
999
|
+
throw new Error('assistant-actions: owner changed');
|
|
1000
|
+
return this.#publicCompensationResult(result);
|
|
1001
|
+
}
|
|
1002
|
+
finally {
|
|
1003
|
+
this.#pendingCompensations.delete(record.id);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
compensationStatus = (agent, input) => {
|
|
1007
|
+
if (this.#mode === 'external-unix-v1')
|
|
1008
|
+
throw new Error(EXTERNAL_UNSUPPORTED);
|
|
1009
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 2
|
|
1010
|
+
|| typeof input.grantId !== 'string' || typeof input.actionId !== 'string')
|
|
1011
|
+
throw new Error('assistant-actions: invalid compensation status request');
|
|
1012
|
+
const identity = this.#compensationIdentity(agent, input.grantId);
|
|
1013
|
+
const record = this.#ledger.getCompensation(input.actionId);
|
|
1014
|
+
if (!record)
|
|
1015
|
+
return { status: 'not-found' };
|
|
1016
|
+
if (record.grantId !== input.grantId || record.sessionId !== String(agent?.session.id) || digest(record.identity) !== digest(identity))
|
|
1017
|
+
throw new Error('assistant-actions: compensation status denied');
|
|
1018
|
+
return this.#publicCompensation(record) ?? { actionId: record.id, status: record.status, repository: record.repository, branch: record.branch, parentOid: record.forwardCommitOid };
|
|
1019
|
+
};
|
|
1020
|
+
#publicCompensation(record) {
|
|
1021
|
+
if (!record.result)
|
|
1022
|
+
return undefined;
|
|
1023
|
+
return this.#publicCompensationResult(record.result);
|
|
1024
|
+
}
|
|
1025
|
+
#publicCompensationResult(result) {
|
|
1026
|
+
return Object.freeze({ actionId: result.actionId, status: result.status, repository: result.repository, branch: result.branch, parentOid: result.parentOid,
|
|
1027
|
+
...(result.resultOid === undefined ? {} : { resultOid: result.resultOid }), ...(result.reason === undefined ? {} : { reason: result.reason }) });
|
|
1028
|
+
}
|
|
1029
|
+
async #executeCompensation(agent, identity, initial, signal) {
|
|
1030
|
+
let record = initial;
|
|
1031
|
+
// Two-phase rollback budget: capacity is reserved before the preimage is
|
|
1032
|
+
// captured and only charged (finalized) once the local dispatch boundary
|
|
1033
|
+
// is crossed. A failure before dispatch releases the hold, so a denied or
|
|
1034
|
+
// failed attempt cannot permanently burn the subject's rollback budget.
|
|
1035
|
+
let reservation;
|
|
1036
|
+
let reservationSettled = false;
|
|
1037
|
+
let dispatched = false;
|
|
1038
|
+
const abort = new AbortController();
|
|
1039
|
+
const stillAuthorized = () => {
|
|
1040
|
+
try {
|
|
1041
|
+
const grant = this.#ledger.grant(record.grantId);
|
|
1042
|
+
return !signal.aborted && !!grant?.rollback?.allowRollback && grant.revision === record.grantRevision && grant.expiresAt > Date.now()
|
|
1043
|
+
&& digest(this.#compensationIdentity(agent, record.grantId)) === digest(identity);
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
const timer = setInterval(() => { if (!stillAuthorized())
|
|
1050
|
+
abort.abort(); }, 100);
|
|
1051
|
+
timer.unref();
|
|
1052
|
+
try {
|
|
1053
|
+
const grant = this.#ledger.grant(record.grantId);
|
|
1054
|
+
const credentials = this.ctx.get('credentialsKeychain');
|
|
1055
|
+
if (!grant || !credentials || !stillAuthorized())
|
|
1056
|
+
throw new Error('assistant-actions: compensation unavailable');
|
|
1057
|
+
// Accepts a fresh reservation for a capturing row and an open replay when
|
|
1058
|
+
// resuming a prepared row; a released/finalized replay is denied.
|
|
1059
|
+
reservation = this.#reserveCompensation(agent, record);
|
|
1060
|
+
if (!reservation)
|
|
1061
|
+
throw new Error('assistant-actions: compensation budget denied');
|
|
1062
|
+
return await credentials.withSecret(this.ctx, { handleId: grant.credentialHandle, purpose: 'github.compensate', idempotencyKey: `compensation:${record.id}`, ttlMs: Math.min(30_000, record.expiresAt - Date.now()) }, async (token, credentialSignal) => {
|
|
1063
|
+
const combined = AbortSignal.any([signal, abort.signal, credentialSignal, AbortSignal.timeout(Math.max(1, record.expiresAt - Date.now()))]);
|
|
1064
|
+
combined.throwIfAborted();
|
|
1065
|
+
if (!stillAuthorized())
|
|
1066
|
+
throw new Error('assistant-actions: compensation authorization ended');
|
|
1067
|
+
if (record.status === 'capturing') {
|
|
1068
|
+
const preimage = await this.compensation.capture({ grant, commitOid: record.parentOid, paths: record.paths, token, signal: combined });
|
|
1069
|
+
if (!preimage || !stillAuthorized() || combined.aborted)
|
|
1070
|
+
throw new Error('assistant-actions: compensation capture unavailable');
|
|
1071
|
+
record = this.#ledger.captureCompensation(record.id, record.version, preimage, this.#authority);
|
|
1072
|
+
}
|
|
1073
|
+
if (record.status !== 'prepared' || !record.preimage)
|
|
1074
|
+
throw new Error('assistant-actions: compensation is not dispatchable');
|
|
1075
|
+
// Re-decide and confirm the same open reservation still holds before the
|
|
1076
|
+
// local dispatch flip that authorizes the network POST.
|
|
1077
|
+
if (!stillAuthorized() || !this.#reserveCompensation(agent, record, reservation.reservationId))
|
|
1078
|
+
throw new Error('assistant-actions: compensation authorization ended');
|
|
1079
|
+
record = this.#ledger.dispatchCompensation(record.id, record.version, this.#authority);
|
|
1080
|
+
dispatched = true;
|
|
1081
|
+
let outcome;
|
|
1082
|
+
try {
|
|
1083
|
+
outcome = await this.compensation.commit({ actionId: record.id, grant, forwardCommitOid: record.forwardCommitOid, preimage: record.preimage, token, signal: combined });
|
|
1084
|
+
if (!stillAuthorized() || combined.aborted)
|
|
1085
|
+
outcome = { actionId: record.id, status: 'unknown', repository: record.repository, branch: record.branch, parentOid: record.forwardCommitOid, actionMarker: `dsh-compensation:${record.id}`, reason: 'authorization-ended-after-dispatch' };
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
outcome = { actionId: record.id, status: 'unknown', repository: record.repository, branch: record.branch, parentOid: record.forwardCommitOid, actionMarker: `dsh-compensation:${record.id}`, reason: 'dispatch-unconfirmed-no-replay' };
|
|
1089
|
+
}
|
|
1090
|
+
const settled = this.#ledger.settleCompensation(record.id, record.version, outcome, this.#authority).result;
|
|
1091
|
+
// The dispatch boundary is behind us and the POST may already have
|
|
1092
|
+
// landed (every terminal outcome, including head-conflict and unknown,
|
|
1093
|
+
// is a real attempt). Charge the reservation; never release it here.
|
|
1094
|
+
try {
|
|
1095
|
+
this.ctx.get('assistantPolicy')?.finalize(reservation.reservationId, reservation.amount);
|
|
1096
|
+
reservationSettled = true;
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
// A failed finalize leaves a conservative reserved hold (fails closed
|
|
1100
|
+
// until the budget period rolls); never mask the settled compensation.
|
|
1101
|
+
throw new Error(`assistant-actions: compensation budget finalize failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1102
|
+
}
|
|
1103
|
+
return settled;
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
catch (error) {
|
|
1107
|
+
if (reservation && !reservationSettled) {
|
|
1108
|
+
try {
|
|
1109
|
+
const policy = this.ctx.get('assistantPolicy');
|
|
1110
|
+
if (dispatched)
|
|
1111
|
+
policy?.finalize(reservation.reservationId, reservation.amount);
|
|
1112
|
+
else
|
|
1113
|
+
policy?.release(reservation.reservationId);
|
|
1114
|
+
reservationSettled = true;
|
|
1115
|
+
}
|
|
1116
|
+
catch { /* A retained reserved hold fails closed; preserve the original error. */ }
|
|
1117
|
+
}
|
|
1118
|
+
if (!dispatched && record.status === 'capturing') {
|
|
1119
|
+
// A capturing row is only a pre-dispatch placeholder: no preimage was
|
|
1120
|
+
// sealed and no compensation POST could have been sent. Drop it so a
|
|
1121
|
+
// denial, abort or capture failure neither wedges the forward action's
|
|
1122
|
+
// one-compensation slot nor permanently consumes a rollback action.
|
|
1123
|
+
try {
|
|
1124
|
+
this.#ledger.discardCompensation(record.id, record.version, this.#authority);
|
|
1125
|
+
}
|
|
1126
|
+
catch { }
|
|
1127
|
+
}
|
|
1128
|
+
else if (!dispatched && record.status === 'prepared') {
|
|
1129
|
+
// A preimage was sealed but the dispatch flip never happened, so no
|
|
1130
|
+
// POST could have landed. Terminalize to unknown live (the same
|
|
1131
|
+
// no-replay outcome crash recovery would assign) rather than leaving a
|
|
1132
|
+
// zombie 'prepared' that only the next process restart clears. The open
|
|
1133
|
+
// reservation for this never-dispatched row is released above.
|
|
1134
|
+
try {
|
|
1135
|
+
this.#ledger.abandonPreparedCompensation(record.id, record.version, this.#authority, 'authorization-ended-before-dispatch');
|
|
1136
|
+
}
|
|
1137
|
+
catch { }
|
|
1138
|
+
}
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
finally {
|
|
1142
|
+
clearInterval(timer);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async #runWorkflow(agent, request, signal, operation) {
|
|
1146
|
+
return await this.#runAuthorized({ sessionId: String(agent?.session.id),
|
|
1147
|
+
identity: () => this.#identity(agent, request.grantId),
|
|
1148
|
+
authorize: record => this.ctx.get('assistantPolicy')?.authorizeAgent(agent, 'execute', { kind: 'tool', id: `action:github:${record.grantId}` }, { idempotencyKey: `action:${record.id}` }).effect === 'allow',
|
|
1149
|
+
}, request, signal, operation);
|
|
1150
|
+
}
|
|
1151
|
+
async #runAuthorized(authorization, request, signal, operation) {
|
|
1152
|
+
signal.throwIfAborted();
|
|
1153
|
+
const identity = authorization.identity();
|
|
1154
|
+
if (!this.#allows(this.#ledger.grant(request.grantId), identity, request))
|
|
1155
|
+
throw new Error('assistant-actions: request not granted');
|
|
1156
|
+
const bytes = 'files' in request ? commitBytes(request) : Buffer.byteLength(JSON.stringify(request));
|
|
1157
|
+
const { record, created } = this.#ledger.prepare({ identity, sessionId: authorization.sessionId, request, bytes, authority: this.#authority });
|
|
1158
|
+
if (!created) {
|
|
1159
|
+
const pending = this.#pending.get(record.id);
|
|
1160
|
+
const result = pending ? await pending.done : record.result ?? { actionId: record.id, status: 'unknown', reason: 'action-in-progress-no-replay' };
|
|
1161
|
+
if (digest(authorization.identity()) !== digest(identity))
|
|
1162
|
+
throw new Error('assistant-actions: owner changed');
|
|
1163
|
+
return structuredClone(result);
|
|
1164
|
+
}
|
|
1165
|
+
const abort = new AbortController();
|
|
1166
|
+
const done = this.#execute(authorization, identity, record, operation, AbortSignal.any([signal, abort.signal]));
|
|
1167
|
+
this.#pending.set(record.id, { abort, done });
|
|
1168
|
+
try {
|
|
1169
|
+
const result = await done;
|
|
1170
|
+
if (digest(authorization.identity()) !== digest(identity))
|
|
1171
|
+
throw new Error('assistant-actions: owner changed');
|
|
1172
|
+
return structuredClone(result);
|
|
1173
|
+
}
|
|
1174
|
+
finally {
|
|
1175
|
+
this.#pending.delete(record.id);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
async #execute(authorization, identity, initial, operation, signal) {
|
|
1179
|
+
let record = initial;
|
|
1180
|
+
const abort = new AbortController();
|
|
1181
|
+
const authorized = () => {
|
|
1182
|
+
try {
|
|
1183
|
+
return !signal.aborted && this.#ledger.usable(record.id) && digest(authorization.identity()) === digest(identity);
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
const timer = setInterval(() => { if (!authorized())
|
|
1190
|
+
abort.abort(); }, 100);
|
|
1191
|
+
timer.unref();
|
|
1192
|
+
let result = { actionId: record.id, status: 'failed', reason: 'preparation-failed' };
|
|
1193
|
+
try {
|
|
1194
|
+
const grant = this.#ledger.grant(record.grantId);
|
|
1195
|
+
const credentials = this.ctx.get('credentialsKeychain');
|
|
1196
|
+
if (!grant || !credentials || !authorized())
|
|
1197
|
+
throw new Error('unavailable');
|
|
1198
|
+
if (!authorization.authorize(record))
|
|
1199
|
+
throw new Error('policy budget denied');
|
|
1200
|
+
result = await credentials.withSecret(this.ctx, { handleId: grant.credentialHandle, purpose: 'github.commit', idempotencyKey: `action:${record.id}`, ttlMs: Math.min(30_000, record.expiresAt - Date.now()) }, async (token, credentialSignal) => {
|
|
1201
|
+
const combined = AbortSignal.any([signal, abort.signal, credentialSignal, AbortSignal.timeout(Math.max(1, record.expiresAt - Date.now()))]);
|
|
1202
|
+
combined.throwIfAborted();
|
|
1203
|
+
if (!authorized())
|
|
1204
|
+
throw new Error('authorization ended');
|
|
1205
|
+
record = this.#ledger.dispatch(record.id, record.version, this.#authority);
|
|
1206
|
+
const outcome = await operation(record.id, grant, token, combined);
|
|
1207
|
+
return authorized() && !combined.aborted ? outcome : { actionId: record.id, status: 'unknown', reason: 'authorization-ended-after-dispatch' };
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
if (record.status === 'dispatched')
|
|
1212
|
+
result = { actionId: record.id, status: 'unknown', reason: 'dispatch-unconfirmed-no-replay' };
|
|
1213
|
+
}
|
|
1214
|
+
finally {
|
|
1215
|
+
clearInterval(timer);
|
|
1216
|
+
}
|
|
1217
|
+
this.#ledger.settle(record.id, record.version, result, this.#authority);
|
|
1218
|
+
return result;
|
|
1219
|
+
}
|
|
1220
|
+
runBranch = async (agent, input, signal) => {
|
|
1221
|
+
if (this.#mode === 'external-unix-v1')
|
|
1222
|
+
throw new Error(EXTERNAL_UNSUPPORTED);
|
|
1223
|
+
const request = normalizeWorkflow(input);
|
|
1224
|
+
if (this.#ledger.grant(request.grantId)?.verifiedDelivery)
|
|
1225
|
+
throw new Error('assistant-actions: this grant requires independently verified delivery');
|
|
1226
|
+
if (!('baseHeadOid' in request))
|
|
1227
|
+
throw new Error('assistant-actions: invalid branch request');
|
|
1228
|
+
return await this.#runWorkflow(agent, request, signal, async (actionId, grant, token, combined) => await this.workflow.branch({ actionId, grant, baseHeadOid: request.baseHeadOid, token, signal: combined }));
|
|
1229
|
+
};
|
|
1230
|
+
runPullRequest = async (agent, input, signal) => {
|
|
1231
|
+
if (this.#mode === 'external-unix-v1')
|
|
1232
|
+
throw new Error(EXTERNAL_UNSUPPORTED);
|
|
1233
|
+
const request = normalizeWorkflow(input);
|
|
1234
|
+
if (this.#ledger.grant(request.grantId)?.verifiedDelivery)
|
|
1235
|
+
throw new Error('assistant-actions: this grant requires independently verified delivery');
|
|
1236
|
+
if (!('title' in request))
|
|
1237
|
+
throw new Error('assistant-actions: invalid pull request');
|
|
1238
|
+
return await this.#runWorkflow(agent, request, signal, async (actionId, grant, token, combined) => await this.workflow.pullRequest({ actionId, grant, expectedHeadOid: request.expectedHeadOid, title: request.title, body: request.body, token, signal: combined }));
|
|
1239
|
+
};
|
|
1240
|
+
runInspect = async (agent, input, signal) => {
|
|
1241
|
+
if (this.#mode === 'external-unix-v1') {
|
|
1242
|
+
const callId = `api:${randomUUID()}`;
|
|
1243
|
+
return await this.#runExternalInspect(agent, input, signal, { callId, rootCallId: callId });
|
|
1244
|
+
}
|
|
1245
|
+
// Each read is a distinct, durably charged action, never a synthetic write.
|
|
1246
|
+
const request = normalizeWorkflow({ ...input, operation: 'inspect', idempotencyKey: randomUUID() });
|
|
1247
|
+
if (!('operation' in request))
|
|
1248
|
+
throw new Error('assistant-actions: invalid inspection');
|
|
1249
|
+
let observed;
|
|
1250
|
+
const result = await this.#runWorkflow(agent, request, signal, async (actionId, grant, token, combined) => {
|
|
1251
|
+
const reply = await this.workflow.inspect({ grant, kind: request.kind, ...(request.path === undefined ? {} : { path: request.path }), ...(request.pullRequestNumber === undefined ? {} : { pullRequestNumber: request.pullRequestNumber }), token, signal: combined });
|
|
1252
|
+
observed = reply?.observed;
|
|
1253
|
+
return reply ? { actionId, status: 'succeeded' } : { actionId, status: 'failed', reason: 'github-inspect-failed' };
|
|
1254
|
+
});
|
|
1255
|
+
// A late response after revocation must not release repository content.
|
|
1256
|
+
return { result, ...(result.status === 'succeeded' ? { observed } : {}) };
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
//# sourceMappingURL=service.js.map
|