external-agent-protocol.mjs
692 lines 24.4 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 import fs from 'fs';
2 import { randomBytes } from 'crypto';
3 import { checkIdempotency, saveIdempotency, appendOperationalLog } from './external-agent-protocol-store.mjs';
4 import { loadFlowStore, saveFlowStore } from '../flow/flow-store.mjs';
5 import {
6 validateChain,
7 loadGrantsStore,
8 hashGrantBearer,
9 handleDelegationAuditAppendRequest
10 } from './delegation.mjs';
11
12 export const DEFAULT_LEASE_TTL_SECONDS = 900;
13 export const MAX_LEASE_TTL_SECONDS = 3600;
14 export const PROVIDER_STALE_AFTER_DAYS = 30;
15 export const LEASE_SWEEPER_INTERVAL_SECONDS = 60;
16
17 /**
18 * @param {unknown} v
19 * @returns {boolean|null}
20 */
21 function envTriState(v) {
22 if (v === '1' || v === 'true') return true;
23 if (v === '0' || v === 'false') return false;
24 return null;
25 }
26
27 /**
28 * Gate: external agent protocol is off until Tier 3 flip (7D-L1).
29 *
30 * @returns {boolean}
31 */
32 export function getExternalProtocolAuthorized() {
33 const fromEnv = envTriState(process.env.EXTERNAL_PROTOCOL_AUTHORIZED);
34 if (fromEnv !== null) return fromEnv;
35 return false;
36 }
37
38 const PROTOCOL_ERRORS = {
39 task_not_claimable: { status: 409, message: "Task is not in a claimable state" },
40 task_already_claimed: { status: 409, message: "Task was claimed by another agent" },
41 assignee_mismatch: { status: 403, message: "Caller is not the assignee of this task" },
42 lease_expired: { status: 410, message: "Claim lease has expired" },
43 delegation_revoked: { status: 403, message: "Delegation grant has been revoked" },
44 delegation_chain_invalid: { status: 403, message: "Delegation chain failed validation" },
45 delegation_task_pin_mismatch: { status: 403, message: "Grant does not pin this task" },
46 boundary_violation_prevented: { status: 422, message: "Action would violate the task boundary policy" },
47 rate_limited: { status: 429, message: "Rate limit exceeded" },
48 vault_mismatch: { status: 403, message: "Vault identifier mismatch" },
49 scope_ceiling_exceeded: { status: 403, message: "Scope ceiling exceeded for this provider kind" },
50 idempotency_key_conflict: { status: 409, message: "Idempotency key conflicts with a prior mutation" },
51 provider_session_stale: { status: 401, message: "Provider session is stale" },
52 receipt_content_rejected: { status: 400, message: "Receipt content failed critical validation" },
53 external_protocol_not_authorized: { status: 501, message: "External agent protocol is not enabled" },
54 invalid_idempotency_key: { status: 400, message: "Idempotency key missing or invalid" },
55 invalid_request: { status: 400, message: "Request body failed validation" }
56 };
57
58 const VALIDATE_CHAIN_MAP = {
59 DELEGATION_DISABLED: 'external_protocol_not_authorized',
60 DELEGATION_POLICY_FORBIDDEN: 'external_protocol_not_authorized',
61 DELEGATION_IDENTITY_DENIED: 'assignee_mismatch',
62 DELEGATION_GRANT_REVOKED: 'delegation_revoked',
63 DELEGATION_GRANT_EXPIRED: 'lease_expired',
64 DELEGATION_GRANT_EXHAUSTED: 'delegation_chain_invalid',
65 DELEGATION_ACTOR_MISMATCH: 'assignee_mismatch',
66 DELEGATION_PRINCIPAL_MISMATCH: 'delegation_chain_invalid',
67 DELEGATION_CONSENT_REVOKED: 'delegation_revoked',
68 DELEGATION_CONSENT_EXPIRED: 'delegation_revoked',
69 DELEGATION_IDENTITY_SCOPE_DENIED: 'scope_ceiling_exceeded',
70 DELEGATION_TASK_DENIED: 'delegation_task_pin_mismatch',
71 DELEGATION_GRANT_FLOW_MISMATCH: 'delegation_chain_invalid',
72 DELEGATION_CONSENT_REQUIRED: 'delegation_chain_invalid',
73 DELEGATION_CHAIN_INVALID: 'delegation_chain_invalid',
74 unknown_grant: 'delegation_chain_invalid',
75 BAD_REQUEST: 'invalid_request'
76 };
77
78 const claimLocks = new Map();
79
80 export async function withTaskLock(vaultId, taskId, fn) {
81 const lockKey = `${vaultId}#${taskId}`;
82 const prev = claimLocks.get(lockKey) ?? Promise.resolve();
83 let release;
84 const next = new Promise((resolve) => { release = resolve; });
85 claimLocks.set(lockKey, prev.then(() => next));
86 await prev;
87 try {
88 return await fn();
89 } finally {
90 release();
91 claimLocks.delete(lockKey);
92 }
93 }
94
95 export function protocolError(code) {
96 const err = PROTOCOL_ERRORS[code] || PROTOCOL_ERRORS.invalid_request;
97 return { ok: false, status: err.status, code, error: err.message };
98 }
99
100 /**
101 * @returns {{ ok: true } | { ok: false, status: number, code: string, error: string }}
102 */
103 function checkExternalProtocolGate() {
104 if (!getExternalProtocolAuthorized()) {
105 return protocolError('external_protocol_not_authorized');
106 }
107 return { ok: true };
108 }
109
110 function validateIdempotencyKey(key) {
111 return typeof key === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(key);
112 }
113
114 function scrubPii(text) {
115 if (typeof text !== 'string') return text;
116 return text
117 .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[email]')
118 .replace(/\+?\d{1,4}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}/g, '[phone]')
119 .replace(/\b(?:\d[ -]*?){13,16}\b/g, '[cc]')
120 .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[ssn]')
121 .replace(/[A-Za-z0-9+/=]{256,}/g, '[blob]');
122 }
123
124 export function taskViewForExternalProtocol(task, dataFlowMode) {
125 const isMin = dataFlowMode === 'minimized';
126 const view = {
127 task_id: task.task_id,
128 loop_ref: task.loop_ref ?? null,
129 occurrence_key: task.occurrence_key ?? null,
130 title: task.title,
131 intent: task.intent,
132 boundary_policy: task.boundary_policy ?? 'observe_only',
133 due_at: task.due_at ?? null,
134 kind: task.kind,
135 status: task.status,
136 artifact_links: Array.isArray(task.artifact_links)
137 ? task.artifact_links.map(l => ({ kind: l.kind, ref: l.ref }))
138 : []
139 };
140 return view;
141 }
142
143 export function resolveBearer(dataDir, vaultId, bearerToken) {
144 if (!bearerToken) return null;
145 const store = loadGrantsStore(dataDir);
146 const vault = store.vaults?.[vaultId];
147 if (!vault || !Array.isArray(vault.grants)) return null;
148 const hashed = hashGrantBearer(bearerToken);
149 return vault.grants.find(g => g.grant_bearer_hash === hashed) || null;
150 }
151
152 /**
153 * @param {ReturnType<typeof resolveBearer>} grant
154 * @returns {{ ok: false, status: number, code: string, error: string } | null}
155 */
156 function protocolErrorIfGrantRevoked(grant) {
157 if (grant && grant.revoked_at) {
158 return protocolError('delegation_revoked');
159 }
160 return null;
161 }
162
163 export async function handleClaimTask(input) {
164 const gate = checkExternalProtocolGate();
165 if (!gate.ok) return gate;
166
167 const { dataDir, vaultId, taskId, bearerToken } = input;
168 const body = input.body || {};
169 if (!validateIdempotencyKey(body.idempotency_key)) {
170 return protocolError('invalid_idempotency_key');
171 }
172
173 const grant = resolveBearer(dataDir, vaultId, bearerToken);
174 if (!grant) return protocolError('delegation_chain_invalid');
175 const revoked = protocolErrorIfGrantRevoked(grant);
176 if (revoked) return revoked;
177
178 const chain = validateChain({
179 dataDir,
180 vaultId,
181 actorAgentId: grant.actor_agent_id,
182 principalRef: grant.principal_ref,
183 grantId: grant.grant_id,
184 taskRef: taskId,
185 requireGrant: true
186 });
187
188 if (!chain.ok) {
189 return protocolError(VALIDATE_CHAIN_MAP[chain.code] || 'delegation_chain_invalid');
190 }
191
192 const identity = chain.identity;
193 if (identity.kind !== 'external_provider') return protocolError('assignee_mismatch');
194 if (identity.status !== 'active') return protocolError('provider_session_stale');
195
196 if (identity.scope_ceiling === 'org' && identity.provider !== 'self_hosted') {
197 return protocolError('scope_ceiling_exceeded');
198 }
199
200 const idempotencyEntry = await checkIdempotency(dataDir, vaultId, taskId, 'claim', body.idempotency_key);
201 if (idempotencyEntry) {
202 if (idempotencyEntry.conflict) return protocolError('idempotency_key_conflict');
203 return idempotencyEntry.response;
204 }
205
206 return await withTaskLock(vaultId, taskId, async () => {
207 const doubleCheck = await checkIdempotency(dataDir, vaultId, taskId, 'claim', body.idempotency_key);
208 if (doubleCheck) {
209 if (doubleCheck.conflict) return protocolError('idempotency_key_conflict');
210 return doubleCheck.response;
211 }
212
213 const store = loadFlowStore(dataDir);
214 const vault = store.vaults?.[vaultId];
215 if (!vault) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
216 const task = vault.tasks?.find(t => t.task_id === taskId);
217 if (!task) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
218
219 if (task.status !== 'pending' && !(task.status === 'blocked' && task.principal_cleared_flag)) {
220 if (task.status === 'in_progress') {
221 if (task.assignee_ref !== grant.actor_agent_id && task.assignee_ref !== '*') {
222 return protocolError('task_already_claimed');
223 }
224 }
225 return protocolError('task_not_claimable');
226 }
227
228 if (task.assignee_ref !== grant.actor_agent_id && task.assignee_ref !== '*') {
229 return protocolError('assignee_mismatch');
230 }
231
232 const passId = `extpass_${randomBytes(15).toString('base64url').replace(/=/g, '').toLowerCase().slice(0, 24)}`;
233
234 const reqTtl = typeof body.lease_ttl_seconds === 'number' ? body.lease_ttl_seconds : DEFAULT_LEASE_TTL_SECONDS;
235 const ttl = Math.min(reqTtl || DEFAULT_LEASE_TTL_SECONDS, MAX_LEASE_TTL_SECONDS);
236 const leaseExpiresAt = new Date(Date.now() + ttl * 1000).toISOString();
237
238 task.status = 'in_progress';
239 task.external_pass_id = passId;
240 task.external_lease_expires_at = leaseExpiresAt;
241 task.external_active_grant_id = grant.grant_id;
242 task.updated = new Date().toISOString();
243
244 saveFlowStore(dataDir, store);
245
246 handleDelegationAuditAppendRequest({
247 dataDir, vaultId,
248 actorAgentId: grant.actor_agent_id,
249 principalRef: grant.principal_ref,
250 grantId: grant.grant_id,
251 action: 'external_claim',
252 evidenceRefs: [`task:${taskId}`],
253 taskRef: taskId
254 });
255
256 const response = {
257 ok: true,
258 pass_id: passId,
259 lease_expires_at: leaseExpiresAt,
260 boundary_policy: task.boundary_policy || 'observe_only',
261 allowed_actions: [], // Placeholder, maybe populated elsewhere
262 task_view: taskViewForExternalProtocol(task, identity.data_flow_mode || 'minimized')
263 };
264
265 await saveIdempotency(dataDir, vaultId, taskId, 'claim', body.idempotency_key, {
266 status: 200,
267 body: response
268 }, ttl + 60);
269
270 return response;
271 });
272 }
273
274 export async function handleHeartbeatTask(input) {
275 const gate = checkExternalProtocolGate();
276 if (!gate.ok) return gate;
277
278 const { dataDir, vaultId, taskId, bearerToken } = input;
279 const body = input.body || {};
280 if (!validateIdempotencyKey(body.idempotency_key)) {
281 return protocolError('invalid_idempotency_key');
282 }
283
284 const grant = resolveBearer(dataDir, vaultId, bearerToken);
285 if (!grant) return protocolError('delegation_chain_invalid');
286 const revoked = protocolErrorIfGrantRevoked(grant);
287 if (revoked) return revoked;
288
289 const chain = validateChain({
290 dataDir,
291 vaultId,
292 actorAgentId: grant.actor_agent_id,
293 principalRef: grant.principal_ref,
294 grantId: grant.grant_id,
295 taskRef: taskId,
296 requireGrant: true
297 });
298
299 if (!chain.ok) {
300 return protocolError(VALIDATE_CHAIN_MAP[chain.code] || 'delegation_chain_invalid');
301 }
302
303 const identity = chain.identity;
304 if (identity.kind !== 'external_provider') return protocolError('assignee_mismatch');
305 if (identity.status !== 'active') return protocolError('provider_session_stale');
306
307 if (identity.scope_ceiling === 'org' && identity.provider !== 'self_hosted') {
308 return protocolError('scope_ceiling_exceeded');
309 }
310
311 const idempotencyEntry = await checkIdempotency(dataDir, vaultId, taskId, 'heartbeat', body.idempotency_key);
312 if (idempotencyEntry) {
313 if (idempotencyEntry.conflict) return protocolError('idempotency_key_conflict');
314 return idempotencyEntry.response;
315 }
316
317 return await withTaskLock(vaultId, taskId, async () => {
318 const doubleCheck = await checkIdempotency(dataDir, vaultId, taskId, 'heartbeat', body.idempotency_key);
319 if (doubleCheck) {
320 if (doubleCheck.conflict) return protocolError('idempotency_key_conflict');
321 return doubleCheck.response;
322 }
323
324 const store = loadFlowStore(dataDir);
325 const vault = store.vaults?.[vaultId];
326 if (!vault) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
327 const task = vault.tasks?.find(t => t.task_id === taskId);
328 if (!task) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
329
330 if (task.external_pass_id !== body.pass_id) {
331 return protocolError('assignee_mismatch');
332 }
333
334 if (!task.external_lease_expires_at || new Date(task.external_lease_expires_at).getTime() <= Date.now()) {
335 return protocolError('lease_expired');
336 }
337
338 const reqTtl = typeof body.lease_ttl_seconds === 'number' ? body.lease_ttl_seconds : DEFAULT_LEASE_TTL_SECONDS;
339 const extension = Math.min(reqTtl || DEFAULT_LEASE_TTL_SECONDS, MAX_LEASE_TTL_SECONDS);
340
341 const currentLeaseExp = new Date(task.external_lease_expires_at).getTime();
342 const newExp = Math.max(currentLeaseExp, Date.now()) + extension * 1000;
343
344 task.external_lease_expires_at = new Date(newExp).toISOString();
345 task.updated = new Date().toISOString();
346
347 saveFlowStore(dataDir, store);
348
349 const response = {
350 ok: true,
351 lease_expires_at: task.external_lease_expires_at,
352 grant_still_valid: true
353 };
354
355 await saveIdempotency(dataDir, vaultId, taskId, 'heartbeat', body.idempotency_key, {
356 status: 200,
357 body: response
358 }, extension + 60);
359
360 return response;
361 });
362 }
363
364 export async function handleCompleteTask(input) {
365 const gate = checkExternalProtocolGate();
366 if (!gate.ok) return gate;
367
368 const { dataDir, vaultId, taskId, bearerToken } = input;
369 const body = input.body || {};
370 if (!validateIdempotencyKey(body.idempotency_key)) {
371 return protocolError('invalid_idempotency_key');
372 }
373
374 const grant = resolveBearer(dataDir, vaultId, bearerToken);
375 if (!grant) return protocolError('delegation_chain_invalid');
376 const revoked = protocolErrorIfGrantRevoked(grant);
377 if (revoked) return revoked;
378
379 const chain = validateChain({
380 dataDir,
381 vaultId,
382 actorAgentId: grant.actor_agent_id,
383 principalRef: grant.principal_ref,
384 grantId: grant.grant_id,
385 taskRef: taskId,
386 requireGrant: true
387 });
388
389 if (!chain.ok) {
390 return protocolError(VALIDATE_CHAIN_MAP[chain.code] || 'delegation_chain_invalid');
391 }
392
393 const identity = chain.identity;
394 if (identity.kind !== 'external_provider') return protocolError('assignee_mismatch');
395 if (identity.status !== 'active') return protocolError('provider_session_stale');
396
397 if (identity.scope_ceiling === 'org' && identity.provider !== 'self_hosted') {
398 return protocolError('scope_ceiling_exceeded');
399 }
400
401 const idempotencyEntry = await checkIdempotency(dataDir, vaultId, taskId, 'complete', body.idempotency_key);
402 if (idempotencyEntry) {
403 if (idempotencyEntry.conflict) return protocolError('idempotency_key_conflict');
404 return idempotencyEntry.response;
405 }
406
407 return await withTaskLock(vaultId, taskId, async () => {
408 const doubleCheck = await checkIdempotency(dataDir, vaultId, taskId, 'complete', body.idempotency_key);
409 if (doubleCheck) {
410 if (doubleCheck.conflict) return protocolError('idempotency_key_conflict');
411 return doubleCheck.response;
412 }
413
414 const store = loadFlowStore(dataDir);
415 const vault = store.vaults?.[vaultId];
416 if (!vault) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
417 const task = vault.tasks?.find(t => t.task_id === taskId);
418 if (!task) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
419
420 if (task.external_pass_id !== body.pass_id) {
421 return protocolError('assignee_mismatch');
422 }
423
424 if (!task.external_lease_expires_at || new Date(task.external_lease_expires_at).getTime() <= Date.now()) {
425 return protocolError('lease_expired');
426 }
427
428 const boundary = task.boundary_policy || 'observe_only';
429 const outcome = body.outcome;
430 const artifacts = Array.isArray(body.artifact_links) ? body.artifact_links : [];
431 const evidence = Array.isArray(body.evidence_refs) ? body.evidence_refs : [];
432
433 let violation = false;
434 if (boundary === 'observe_only') {
435 if (outcome === 'completed' || artifacts.length > 0) violation = true;
436 } else if (boundary === 'draft_only') {
437 if (outcome === 'completed') violation = true;
438 if (artifacts.some(a => a.kind === 'media' || a.kind === 'review_item')) violation = true;
439 } else if (boundary === 'propose_only') {
440 const hasProposal = evidence.some(e => typeof e === 'string' && e.startsWith('proposal:'));
441 if (outcome === 'completed' && (!hasProposal || artifacts.length > 0)) {
442 if (!hasProposal || artifacts.length > 0) violation = true;
443 }
444 } else if (boundary === 'execute_with_consent') {
445 if (outcome === 'completed') {
446 const hasConsent = chain.grant?.execute_consent === true;
447 if (!hasConsent) violation = true;
448 }
449 }
450
451 if (violation) {
452 return protocolError('boundary_violation_prevented');
453 }
454
455 if (typeof body.receipt_md === 'string') {
456 task.external_receipt_md = scrubPii(body.receipt_md);
457 }
458
459 if (outcome === 'completed' || outcome === 'stopped_at_boundary') {
460 task.status = outcome === 'completed' ? 'done' : 'pending';
461 if (outcome === 'stopped_at_boundary') {
462 // Keep it pending or similar per boundary stop
463 }
464 task.external_pass_id = null;
465 task.external_lease_expires_at = null;
466 task.external_active_grant_id = null;
467 }
468
469 task.updated = new Date().toISOString();
470
471 saveFlowStore(dataDir, store);
472
473 const actionMap = {
474 'completed': 'external_complete',
475 'stopped_at_boundary': 'external_boundary_stop'
476 };
477
478 if (actionMap[outcome]) {
479 handleDelegationAuditAppendRequest({
480 dataDir, vaultId,
481 actorAgentId: grant.actor_agent_id,
482 principalRef: grant.principal_ref,
483 grantId: grant.grant_id,
484 action: actionMap[outcome],
485 evidenceRefs: evidence.length > 0 ? evidence : [`task:${taskId}`],
486 taskRef: taskId
487 });
488 }
489
490 const response = {
491 ok: true,
492 task_id: taskId,
493 status: task.status
494 };
495
496 await saveIdempotency(dataDir, vaultId, taskId, 'complete', body.idempotency_key, {
497 status: 200,
498 body: response
499 }, 86400);
500
501 return response;
502 });
503 }
504
505 export async function handleNeedsInputTask(input) {
506 const gate = checkExternalProtocolGate();
507 if (!gate.ok) return gate;
508
509 const { dataDir, vaultId, taskId, bearerToken } = input;
510 const body = input.body || {};
511 if (!validateIdempotencyKey(body.idempotency_key)) {
512 return protocolError('invalid_idempotency_key');
513 }
514
515 const grant = resolveBearer(dataDir, vaultId, bearerToken);
516 if (!grant) return protocolError('delegation_chain_invalid');
517 const revoked = protocolErrorIfGrantRevoked(grant);
518 if (revoked) return revoked;
519
520 const chain = validateChain({
521 dataDir,
522 vaultId,
523 actorAgentId: grant.actor_agent_id,
524 principalRef: grant.principal_ref,
525 grantId: grant.grant_id,
526 taskRef: taskId,
527 requireGrant: true
528 });
529
530 if (!chain.ok) {
531 return protocolError(VALIDATE_CHAIN_MAP[chain.code] || 'delegation_chain_invalid');
532 }
533
534 const identity = chain.identity;
535 if (identity.kind !== 'external_provider') return protocolError('assignee_mismatch');
536 if (identity.status !== 'active') return protocolError('provider_session_stale');
537
538 if (identity.scope_ceiling === 'org' && identity.provider !== 'self_hosted') {
539 return protocolError('scope_ceiling_exceeded');
540 }
541
542 const idempotencyEntry = await checkIdempotency(dataDir, vaultId, taskId, 'needs_input', body.idempotency_key);
543 if (idempotencyEntry) {
544 if (idempotencyEntry.conflict) return protocolError('idempotency_key_conflict');
545 return idempotencyEntry.response;
546 }
547
548 return await withTaskLock(vaultId, taskId, async () => {
549 const doubleCheck = await checkIdempotency(dataDir, vaultId, taskId, 'needs_input', body.idempotency_key);
550 if (doubleCheck) {
551 if (doubleCheck.conflict) return protocolError('idempotency_key_conflict');
552 return doubleCheck.response;
553 }
554
555 const store = loadFlowStore(dataDir);
556 const vault = store.vaults?.[vaultId];
557 if (!vault) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
558 const task = vault.tasks?.find(t => t.task_id === taskId);
559 if (!task) return { ok: false, status: 404, code: 'unknown_task', error: 'Task not found' };
560
561 if (task.external_pass_id !== body.pass_id) {
562 return protocolError('assignee_mismatch');
563 }
564
565 if (!task.external_lease_expires_at || new Date(task.external_lease_expires_at).getTime() <= Date.now()) {
566 return protocolError('lease_expired');
567 }
568
569 task.status = 'blocked';
570 task.external_pass_id = null;
571 task.external_lease_expires_at = null;
572 task.external_active_grant_id = null;
573
574 task.updated = new Date().toISOString();
575
576 saveFlowStore(dataDir, store);
577
578 handleDelegationAuditAppendRequest({
579 dataDir, vaultId,
580 actorAgentId: grant.actor_agent_id,
581 principalRef: grant.principal_ref,
582 grantId: grant.grant_id,
583 action: 'external_needs_input',
584 evidenceRefs: [`task:${taskId}`],
585 taskRef: taskId
586 });
587
588 const response = {
589 ok: true,
590 task_id: taskId,
591 status: task.status
592 };
593
594 await saveIdempotency(dataDir, vaultId, taskId, 'needs_input', body.idempotency_key, {
595 status: 200,
596 body: response
597 }, 86400);
598
599 return response;
600 });
601 }
602
603 export async function handleGetTasks(input) {
604 const gate = checkExternalProtocolGate();
605 if (!gate.ok) return gate;
606
607 const { dataDir, vaultId, bearerToken } = input;
608
609 const grant = resolveBearer(dataDir, vaultId, bearerToken);
610 if (!grant) return protocolError('delegation_chain_invalid');
611 const revoked = protocolErrorIfGrantRevoked(grant);
612 if (revoked) return revoked;
613
614 const chain = validateChain({
615 dataDir,
616 vaultId,
617 actorAgentId: grant.actor_agent_id,
618 principalRef: grant.principal_ref,
619 grantId: grant.grant_id,
620 requireGrant: true
621 });
622
623 if (!chain.ok) {
624 return protocolError(VALIDATE_CHAIN_MAP[chain.code] || 'delegation_chain_invalid');
625 }
626
627 const identity = chain.identity;
628 if (identity.kind !== 'external_provider') return protocolError('assignee_mismatch');
629 if (identity.status !== 'active') return protocolError('provider_session_stale');
630
631 if (identity.scope_ceiling === 'org' && identity.provider !== 'self_hosted') {
632 return protocolError('scope_ceiling_exceeded');
633 }
634
635 const store = loadFlowStore(dataDir);
636 const vault = store.vaults?.[vaultId];
637 if (!vault || !Array.isArray(vault.tasks)) {
638 return { ok: true, tasks: [] };
639 }
640
641 const tasks = vault.tasks.filter(t => {
642 return (t.assignee_ref === identity.agent_id || t.assignee_ref === '*') && t.status !== 'completed';
643 });
644
645 const mode = identity.data_flow_mode || 'minimized';
646
647 return {
648 ok: true,
649 tasks: tasks.map(t => taskViewForExternalProtocol(t, mode))
650 };
651 }
652
653 export async function sweepExpiredLeases(dataDir) {
654 const store = loadFlowStore(dataDir);
655 let mutated = false;
656
657 for (const [vaultId, vault] of Object.entries(store.vaults || {})) {
658 if (!Array.isArray(vault.tasks)) continue;
659
660 for (const task of vault.tasks) {
661 if (task.status === 'in_progress' && task.external_lease_expires_at) {
662 if (new Date(task.external_lease_expires_at).getTime() < Date.now()) {
663 await withTaskLock(vaultId, task.task_id, async () => {
664 // Re-fetch to avoid TOCTOU
665 const innerStore = loadFlowStore(dataDir);
666 const innerTask = innerStore.vaults?.[vaultId]?.tasks?.find(t => t.task_id === task.task_id);
667 if (innerTask && innerTask.status === 'in_progress' && innerTask.external_lease_expires_at && new Date(innerTask.external_lease_expires_at).getTime() < Date.now()) {
668 innerTask.status = 'pending';
669 innerTask.external_pass_id = null;
670 innerTask.external_lease_expires_at = null;
671
672 const grantId = innerTask.external_active_grant_id;
673 innerTask.external_active_grant_id = null;
674
675 saveFlowStore(dataDir, innerStore);
676 mutated = true;
677
678 appendOperationalLog(dataDir, {
679 kind: 'lease_expired',
680 task_id: innerTask.task_id,
681 grant_id: grantId,
682 occurred_at: new Date().toISOString()
683 });
684 }
685 });
686 }
687 }
688 }
689 }
690
691 return mutated;
692 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago