external-agent-routes.mjs
254 lines 8.7 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Hosted bridge REST routes for external agent protocol.
3 */
4
5 import {
6 handleGetTasks,
7 handleClaimTask,
8 handleCompleteTask,
9 handleNeedsInputTask,
10 handleHeartbeatTask,
11 } from '../../lib/agent/external-agent-protocol.mjs';
12 import { withExternalProtocolBlobSync } from './external-agent-blob-store.mjs';
13
14 const PROTOCOL_ERRORS = {
15 task_not_claimable: { status: 409, message: "Task is not in a claimable state" },
16 task_already_claimed: { status: 409, message: "Task was claimed by another agent" },
17 assignee_mismatch: { status: 403, message: "Caller is not the assignee of this task" },
18 lease_expired: { status: 410, message: "Claim lease has expired" },
19 delegation_revoked: { status: 403, message: "Delegation grant has been revoked" },
20 delegation_chain_invalid: { status: 403, message: "Delegation chain failed validation" },
21 delegation_task_pin_mismatch: { status: 403, message: "Grant does not pin this task" },
22 boundary_violation_prevented: { status: 422, message: "Action would violate the task boundary policy" },
23 rate_limited: { status: 429, message: "Rate limit exceeded" },
24 vault_mismatch: { status: 403, message: "Vault identifier mismatch" },
25 scope_ceiling_exceeded: { status: 403, message: "Scope ceiling exceeded for this provider kind" },
26 idempotency_key_conflict: { status: 409, message: "Idempotency key conflicts with a prior mutation" },
27 provider_session_stale: { status: 401, message: "Provider session is stale" },
28 receipt_content_rejected: { status: 400, message: "Receipt content failed critical validation" },
29 external_protocol_not_authorized: { status: 501, message: "External agent protocol is not enabled" },
30 invalid_idempotency_key: { status: 400, message: "Idempotency key missing or invalid" },
31 invalid_request: { status: 400, message: "Request body failed validation" },
32 };
33
34 /**
35 * @param {import('express').Response} res
36 * @param {string} code
37 */
38 function sendExternalProtocolError(res, code) {
39 const err = PROTOCOL_ERRORS[code] || PROTOCOL_ERRORS.invalid_request;
40 return res.status(err.status).json({ code, error: err.message });
41 }
42
43 /**
44 * @param {import('express').Response} res
45 * @param {{ ok: boolean, status?: number, code?: string, payload?: unknown } & Record<string, unknown>} result
46 * @param {number} [defaultStatus]
47 */
48 function sendProtocolSuccess(res, result, defaultStatus = 200) {
49 const httpStatus = typeof result.status === 'number' ? result.status : defaultStatus;
50 if (result.payload !== undefined) {
51 return res.status(httpStatus).json(result.payload);
52 }
53 /** @type {Record<string, unknown>} */
54 const body = { ...result };
55 delete body.ok;
56 delete body.code;
57 delete body.error;
58 delete body.payload;
59 if (typeof body.status === 'number') delete body.status;
60 return res.status(httpStatus).json(body);
61 }
62
63 /**
64 * @param {import('express').Express} app
65 * @param {{
66 * dataDir: string,
67 * requireBridgeAuth: import('express').RequestHandler,
68 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
69 * ok: boolean,
70 * status?: number,
71 * error?: string,
72 * code?: string,
73 * vaultId?: string,
74 * effectiveCanisterUid?: string,
75 * actorUid?: string,
76 * }>,
77 * }} deps
78 */
79 export function registerBridgeExternalAgentRoutes(app, deps) {
80 const { dataDir, requireBridgeAuth, resolveHostedBridgeContext } = deps;
81
82 /**
83 * @param {import('express').Request} req
84 */
85 async function vaultContext(req) {
86 const hctx = await resolveHostedBridgeContext(req, req.uid);
87 return hctx;
88 }
89
90 /**
91 * @param {import('express').Request} req
92 */
93 function blobStoreFromReq(req) {
94 return /** @type {{ blobStore?: import('./external-agent-blob-store.mjs').BlobStore | null }} */ (req).blobStore ?? null;
95 }
96
97 /**
98 * @param {import('express').Request} req
99 */
100 function extractDelegationBearer(req) {
101 const header = req.headers['x-delegation-bearer'];
102 if (typeof header === 'string' && header.trim()) {
103 return header.trim();
104 }
105 return null;
106 }
107
108 app.get('/api/v1/agent-protocol/tasks', requireBridgeAuth, async (req, res) => {
109 const hctx = await vaultContext(req);
110 if (!hctx.ok) return res.status(hctx.status || 403).json({ error: hctx.error, code: hctx.code });
111 const bearerToken = extractDelegationBearer(req);
112
113 try {
114 const result = await withExternalProtocolBlobSync({
115 blobStore: blobStoreFromReq(req),
116 dataDir,
117 run: () => handleGetTasks({
118 dataDir,
119 vaultId: hctx.vaultId,
120 userId: req.uid,
121 bearerToken,
122 query: req.query
123 }),
124 });
125
126 if (!result.ok) {
127 return sendExternalProtocolError(res, result.code);
128 }
129 return sendProtocolSuccess(res, result);
130 } catch (err) {
131 return res.status(500).json({ error: String(err), code: 'RUNTIME_ERROR' });
132 }
133 });
134
135 app.post('/api/v1/agent-protocol/tasks/:taskId/claim', requireBridgeAuth, async (req, res) => {
136 const hctx = await vaultContext(req);
137 if (!hctx.ok) return res.status(hctx.status || 403).json({ error: hctx.error, code: hctx.code });
138 const bearerToken = extractDelegationBearer(req);
139 const taskId = req.params.taskId;
140 const body = req.body && typeof req.body === 'object' ? req.body : {};
141
142 try {
143 const result = await withExternalProtocolBlobSync({
144 blobStore: blobStoreFromReq(req),
145 dataDir,
146 run: () => handleClaimTask({
147 dataDir,
148 vaultId: hctx.vaultId,
149 userId: req.uid,
150 bearerToken,
151 taskId,
152 body
153 }),
154 });
155
156 if (!result.ok) {
157 return sendExternalProtocolError(res, result.code);
158 }
159 return sendProtocolSuccess(res, result);
160 } catch (err) {
161 return res.status(500).json({ error: String(err), code: 'RUNTIME_ERROR' });
162 }
163 });
164
165 app.post('/api/v1/agent-protocol/tasks/:taskId/complete', requireBridgeAuth, async (req, res) => {
166 const hctx = await vaultContext(req);
167 if (!hctx.ok) return res.status(hctx.status || 403).json({ error: hctx.error, code: hctx.code });
168 const bearerToken = extractDelegationBearer(req);
169 const taskId = req.params.taskId;
170 const body = req.body && typeof req.body === 'object' ? req.body : {};
171
172 try {
173 const result = await withExternalProtocolBlobSync({
174 blobStore: blobStoreFromReq(req),
175 dataDir,
176 run: () => handleCompleteTask({
177 dataDir,
178 vaultId: hctx.vaultId,
179 userId: req.uid,
180 bearerToken,
181 taskId,
182 body
183 }),
184 });
185
186 if (!result.ok) {
187 return sendExternalProtocolError(res, result.code);
188 }
189 return sendProtocolSuccess(res, result);
190 } catch (err) {
191 return res.status(500).json({ error: String(err), code: 'RUNTIME_ERROR' });
192 }
193 });
194
195 app.post('/api/v1/agent-protocol/tasks/:taskId/needs-input', requireBridgeAuth, async (req, res) => {
196 const hctx = await vaultContext(req);
197 if (!hctx.ok) return res.status(hctx.status || 403).json({ error: hctx.error, code: hctx.code });
198 const bearerToken = extractDelegationBearer(req);
199 const taskId = req.params.taskId;
200 const body = req.body && typeof req.body === 'object' ? req.body : {};
201
202 try {
203 const result = await withExternalProtocolBlobSync({
204 blobStore: blobStoreFromReq(req),
205 dataDir,
206 run: () => handleNeedsInputTask({
207 dataDir,
208 vaultId: hctx.vaultId,
209 userId: req.uid,
210 bearerToken,
211 taskId,
212 body
213 }),
214 });
215
216 if (!result.ok) {
217 return sendExternalProtocolError(res, result.code);
218 }
219 return sendProtocolSuccess(res, result);
220 } catch (err) {
221 return res.status(500).json({ error: String(err), code: 'RUNTIME_ERROR' });
222 }
223 });
224
225 app.post('/api/v1/agent-protocol/tasks/:taskId/heartbeat', requireBridgeAuth, async (req, res) => {
226 const hctx = await vaultContext(req);
227 if (!hctx.ok) return res.status(hctx.status || 403).json({ error: hctx.error, code: hctx.code });
228 const bearerToken = extractDelegationBearer(req);
229 const taskId = req.params.taskId;
230 const body = req.body && typeof req.body === 'object' ? req.body : {};
231
232 try {
233 const result = await withExternalProtocolBlobSync({
234 blobStore: blobStoreFromReq(req),
235 dataDir,
236 run: () => handleHeartbeatTask({
237 dataDir,
238 vaultId: hctx.vaultId,
239 userId: req.uid,
240 bearerToken,
241 taskId,
242 body
243 }),
244 });
245
246 if (!result.ok) {
247 return sendExternalProtocolError(res, result.code);
248 }
249 return sendProtocolSuccess(res, result);
250 } catch (err) {
251 return res.status(500).json({ error: String(err), code: 'RUNTIME_ERROR' });
252 }
253 });
254 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago