verify-hosted-external-protocol-smoke.mjs
416 lines 13.5 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 #!/usr/bin/env node
2 /**
3 * Phase 7D-L1 — hosted external agent protocol validate-only smoke (spec §16.1 E1–E6).
4 *
5 * Prerequisites:
6 * - Bridge: EXTERNAL_PROTOCOL_AUTHORIZED=true (Tier 3 flip)
7 * - Hub JWT with vault access (admin can approve proposals)
8 *
9 * Usage:
10 * KNOWTATION_HUB_TOKEN='<jwt>' KNOWTATION_HUB_VAULT_ID=default \
11 * node scripts/verify-hosted-external-protocol-smoke.mjs
12 *
13 * Optional:
14 * KNOWTATION_HUB_API=https://api.knowtation.store
15 * EXTERNAL_PROTOCOL_SMOKE_AGENT_ID=agent_7d_smoke01
16 * EXTERNAL_PROTOCOL_SMOKE_TASK_ID=task_7d_smoke_hello
17 */
18
19 import fs from 'node:fs';
20 import path from 'node:path';
21 import { fileURLToPath } from 'node:url';
22 import dotenv from 'dotenv';
23
24 const __dirname = path.dirname(fileURLToPath(import.meta.url));
25 const repoRoot = path.resolve(__dirname, '..');
26 dotenv.config({ path: path.join(repoRoot, '.env') });
27
28 const apiBase = (process.env.KNOWTATION_HUB_API || process.env.KNOWTATION_HUB_URL || 'https://api.knowtation.store').replace(/\/$/, '');
29 const vaultId = process.env.KNOWTATION_HUB_VAULT_ID || 'default';
30 const agentId = (process.env.EXTERNAL_PROTOCOL_SMOKE_AGENT_ID || 'agent_7d_smoke01').trim();
31 const taskId = (process.env.EXTERNAL_PROTOCOL_SMOKE_TASK_ID || 'task_7d_smoke_hello').trim();
32 const SMOKE_TIMEOUT_MS = 20_000;
33
34 /** @type {{ step: string, ok: boolean, detail: string }[]} */
35 const results = [];
36
37 function resolveToken() {
38 let token = process.env.KNOWTATION_HUB_TOKEN || process.env.KNOWTATION_AUTH_TOKEN || process.env.HUB_JWT || '';
39 const fp = (process.env.KNOWTATION_HUB_TOKEN_FILE || '').trim();
40 if (!token && fp) {
41 const expanded = fp.startsWith('~') ? path.join(process.env.HOME || '', fp.slice(1)) : fp;
42 token = fs.readFileSync(expanded, 'utf8').trim();
43 }
44 return token;
45 }
46
47 function record(step, ok, detail) {
48 results.push({ step, ok, detail });
49 console.log(`[${ok ? 'PASS' : 'FAIL'}] ${step}: ${detail}`);
50 }
51
52 function hubHeaders(extra = {}) {
53 const token = resolveToken();
54 const h = {
55 Accept: 'application/json',
56 'Content-Type': 'application/json',
57 'X-Vault-Id': vaultId,
58 ...extra,
59 };
60 if (token) h.Authorization = `Bearer ${token}`;
61 return h;
62 }
63
64 async function api(method, pathSuffix, body, extraHeaders = {}) {
65 const res = await fetch(`${apiBase}${pathSuffix}`, {
66 method,
67 headers: hubHeaders(extraHeaders),
68 body: body ? JSON.stringify(body) : undefined,
69 signal: AbortSignal.timeout(SMOKE_TIMEOUT_MS),
70 });
71 const text = await res.text();
72 let json = null;
73 try {
74 json = text ? JSON.parse(text) : null;
75 } catch {
76 json = { raw: text.slice(0, 200) };
77 }
78 return { status: res.status, json };
79 }
80
81 async function apiWithDelegation(method, pathSuffix, body, bearer) {
82 return api(method, pathSuffix, body, { 'X-Delegation-Bearer': bearer });
83 }
84
85 async function approveAndApplyProposal(proposalId) {
86 const approve = await api('POST', `/api/v1/proposals/${encodeURIComponent(proposalId)}/approve`);
87 if (approve.status < 200 || approve.status >= 300) {
88 return { ok: false, detail: `approve HTTP ${approve.status} ${approve.json?.code ?? ''}` };
89 }
90 const apply = await api(
91 'POST',
92 `/api/v1/delegation/proposals/${encodeURIComponent(proposalId)}/apply-approved`,
93 {},
94 );
95 if (apply.status < 200 || apply.status >= 300) {
96 return { ok: false, detail: `apply-approved HTTP ${apply.status} ${apply.json?.code ?? ''}` };
97 }
98 return { ok: true, detail: `proposal ${proposalId} applied` };
99 }
100
101 async function ensureExternalProviderIdentity() {
102 const list = await api('GET', `/api/v1/agents/identities?kind=external_provider&status=active`);
103 if (list.status !== 200) {
104 return { ok: false, detail: `identity list HTTP ${list.status}` };
105 }
106 const found = list.json?.identities?.find((entry) => entry.agent_id === agentId);
107 if (found) {
108 return { ok: true, detail: `existing ${agentId}` };
109 }
110
111 const register = await api('POST', '/api/v1/agents/identities', {
112 kind: 'external_provider',
113 agent_id: agentId,
114 label: '7D-L1 smoke external provider',
115 scope_ceiling: 'personal',
116 });
117 if (register.status !== 201 || !register.json?.proposal_id) {
118 return {
119 ok: false,
120 detail: `register HTTP ${register.status} ${register.json?.code ?? register.json?.error ?? ''}`,
121 };
122 }
123 const applied = await approveAndApplyProposal(register.json.proposal_id);
124 if (!applied.ok) return applied;
125 return { ok: true, detail: `registered ${agentId}` };
126 }
127
128 async function ensureConsent() {
129 const consentBody = {
130 delegate_agent_id: agentId,
131 scope: 'personal',
132 allowed_task_ids: [taskId],
133 };
134 const propose = await api('POST', '/api/v1/delegation/consents', consentBody);
135 if (propose.status !== 201 || !propose.json?.proposal_id) {
136 return {
137 ok: false,
138 consentId: null,
139 detail: `consent propose HTTP ${propose.status} ${propose.json?.code ?? ''}`,
140 };
141 }
142 const applied = await approveAndApplyProposal(propose.json.proposal_id);
143 if (!applied.ok) return { ok: false, consentId: null, detail: applied.detail };
144 const consentId = propose.json.consent_id || propose.json.proposal_id;
145 return { ok: true, consentId, detail: applied.detail };
146 }
147
148 async function ensureSmokeTask() {
149 const get = await api('GET', `/api/v1/tasks/${encodeURIComponent(taskId)}`);
150 if (get.status === 200 && get.json?.task) {
151 return { ok: true, detail: `task ${taskId} exists status=${get.json.task.status}` };
152 }
153
154 const propose = await api('POST', '/api/v1/tasks/proposals', {
155 proposal_kind: 'task_create',
156 intent: '7D-L1 smoke task — say hello from the queue',
157 task: {
158 task_id: taskId,
159 kind: 'personal',
160 scope: 'personal',
161 title: 'say hello from the queue',
162 workspace_id: 'ws-personal',
163 due_at: null,
164 assignee_ref: agentId,
165 assigner_ref: null,
166 artifact_links: [],
167 },
168 });
169 if (propose.status !== 201 || !propose.json?.proposal_id) {
170 return {
171 ok: false,
172 detail: `task propose HTTP ${propose.status} ${propose.json?.code ?? propose.json?.error ?? ''}`,
173 };
174 }
175 const approve = await api('POST', `/api/v1/proposals/${encodeURIComponent(propose.json.proposal_id)}/approve`);
176 if (approve.status < 200 || approve.status >= 300) {
177 return { ok: false, detail: `task approve HTTP ${approve.status}` };
178 }
179 const verify = await api('GET', `/api/v1/tasks/${encodeURIComponent(taskId)}`);
180 const applied =
181 approve.json?.task_index_applied === true ||
182 (verify.status === 200 && verify.json?.task?.task_id === taskId);
183 if (!applied) {
184 return {
185 ok: false,
186 detail: `task index not applied after approve: ${approve.json?.task_apply_error ?? verify.json?.code ?? 'unknown'}`,
187 };
188 }
189 return { ok: true, detail: `created task ${taskId} assignee=${agentId}` };
190 }
191
192 async function mintGrant(consentId) {
193 const mint = await api('POST', '/api/v1/delegation/grants', {
194 consent_id: consentId,
195 actor_agent_id: agentId,
196 task_ref: taskId,
197 ttl_seconds: 3600,
198 });
199 if (mint.status !== 201 || !mint.json?.bearer) {
200 return { ok: false, bearer: null, grantId: null, detail: `mint HTTP ${mint.status} ${mint.json?.code ?? ''}` };
201 }
202 return {
203 ok: true,
204 bearer: mint.json.bearer,
205 grantId: mint.json.grant?.grant_id ?? null,
206 detail: `grant ${mint.json.grant?.grant_id ?? 'minted'}`,
207 };
208 }
209
210 async function main() {
211 console.log(`7D-L1 external protocol smoke — ${apiBase} vault=${vaultId}`);
212
213 if (!resolveToken()) {
214 record('auth', false, 'KNOWTATION_HUB_TOKEN required');
215 process.exit(1);
216 }
217
218 const session = await api('GET', '/api/v1/auth/session');
219 if (session.status !== 200) {
220 record('auth', false, `session HTTP ${session.status} — refresh Hub JWT (~15 min TTL)`);
221 process.exit(1);
222 }
223 record('auth', true, `session OK role=${session.json?.role ?? 'unknown'}`);
224
225 const gateProbe = await apiWithDelegation('GET', '/api/v1/agent-protocol/tasks?status=pending', undefined, 'dgrnt_bearer_invalid00000000');
226 const protocolOn = gateProbe.status !== 501 || gateProbe.json?.code !== 'external_protocol_not_authorized';
227 record(
228 'E1 env protocol on',
229 protocolOn,
230 protocolOn
231 ? `protocol reachable HTTP ${gateProbe.status} (not 501 gate-off)`
232 : `HTTP 501 external_protocol_not_authorized — flip bridge EXTERNAL_PROTOCOL_AUTHORIZED`,
233 );
234 if (!protocolOn) {
235 summarize();
236 process.exit(1);
237 }
238
239 const identityStep = await ensureExternalProviderIdentity();
240 record('setup identity', identityStep.ok, identityStep.detail);
241 if (!identityStep.ok) {
242 summarize();
243 process.exit(1);
244 }
245
246 const taskStep = await ensureSmokeTask();
247 record('setup task', taskStep.ok, taskStep.detail);
248 if (!taskStep.ok) {
249 summarize();
250 process.exit(1);
251 }
252
253 const consentStep = await ensureConsent();
254 record('setup consent', consentStep.ok, consentStep.detail);
255 if (!consentStep.ok || !consentStep.consentId) {
256 summarize();
257 process.exit(1);
258 }
259
260 const grantStep = await mintGrant(consentStep.consentId);
261 record('setup grant', grantStep.ok, grantStep.detail);
262 if (!grantStep.ok || !grantStep.bearer) {
263 summarize();
264 process.exit(1);
265 }
266
267 const bearer = grantStep.bearer;
268
269 const list = await apiWithDelegation(
270 'GET',
271 `/api/v1/agent-protocol/tasks?status=pending&actor_agent_id=${encodeURIComponent(agentId)}`,
272 undefined,
273 bearer,
274 );
275 const e2Ok =
276 list.status === 200 &&
277 Array.isArray(list.json?.tasks) &&
278 list.json.tasks.some((t) => t.task_id === taskId);
279 record(
280 'E2 GET tasks',
281 e2Ok,
282 e2Ok ? `found ${taskId}` : `HTTP ${list.status} code=${list.json?.code ?? 'none'}`,
283 );
284
285 const claimBody = { idempotency_key: 'smoke_claim_7dl1_01', lease_ttl_seconds: 900 };
286 const claim = await apiWithDelegation(
287 'POST',
288 `/api/v1/agent-protocol/tasks/${encodeURIComponent(taskId)}/claim`,
289 claimBody,
290 bearer,
291 );
292 const claimOk =
293 claim.status === 200 &&
294 typeof claim.json?.pass_id === 'string' &&
295 typeof claim.json?.lease_expires_at === 'string';
296 record(
297 'E3 claim',
298 claimOk,
299 claimOk
300 ? `pass_id=${claim.json.pass_id.slice(0, 12)}…`
301 : `HTTP ${claim.status} code=${claim.json?.code ?? 'none'}`,
302 );
303
304 let e3Idempotent = false;
305 if (claimOk) {
306 const claimRetry = await apiWithDelegation(
307 'POST',
308 `/api/v1/agent-protocol/tasks/${encodeURIComponent(taskId)}/claim`,
309 claimBody,
310 bearer,
311 );
312 e3Idempotent =
313 claimRetry.status === 200 && claimRetry.json?.pass_id === claim.json.pass_id;
314 record(
315 'E3 idempotent retry',
316 e3Idempotent,
317 e3Idempotent ? 'same pass_id' : `HTTP ${claimRetry.status} pass mismatch`,
318 );
319 }
320
321 const passId = claim.json?.pass_id;
322 const needs = passId
323 ? await apiWithDelegation(
324 'POST',
325 `/api/v1/agent-protocol/tasks/${encodeURIComponent(taskId)}/needs-input`,
326 {
327 pass_id: passId,
328 idempotency_key: 'smoke_needs_7dl1_01',
329 blocking_question: 'Confirm this is the smoke task?',
330 boundary_that_triggered: 'ambiguity',
331 },
332 bearer,
333 )
334 : { status: 0, json: {} };
335 const e4Ok = needs.status === 200 && (needs.json?.status === 'blocked' || needs.json?.task_status === 'blocked');
336 record(
337 'E4 needs-input → blocked',
338 e4Ok,
339 e4Ok ? 'task blocked' : `HTTP ${needs.status} code=${needs.json?.code ?? 'none'}`,
340 );
341
342 const revokedMint = await mintGrant(consentStep.consentId);
343 let e5Ok = false;
344 if (revokedMint.ok && revokedMint.bearer && revokedMint.grantId) {
345 const revoke = await api(
346 'DELETE',
347 `/api/v1/delegation/grants/${encodeURIComponent(revokedMint.grantId)}`,
348 );
349 if (revoke.status < 200 || revoke.status >= 300) {
350 record(
351 'E5 revoked grant',
352 false,
353 `revoke HTTP ${revoke.status} ${revoke.json?.code ?? ''}`,
354 );
355 } else {
356 const revokedCall = await apiWithDelegation(
357 'GET',
358 `/api/v1/agent-protocol/tasks?status=pending&actor_agent_id=${encodeURIComponent(agentId)}`,
359 undefined,
360 revokedMint.bearer,
361 );
362 e5Ok = revokedCall.status === 403 && revokedCall.json?.code === 'delegation_revoked';
363 record(
364 'E5 revoked grant',
365 e5Ok,
366 e5Ok ? '403 delegation_revoked' : `HTTP ${revokedCall.status} code=${revokedCall.json?.code ?? 'none'}`,
367 );
368 }
369 } else {
370 record('E5 revoked grant', false, 'could not mint grant for revoke test');
371 }
372
373 const crossVault = await fetch(`${apiBase}/api/v1/agent-protocol/tasks?status=pending`, {
374 method: 'GET',
375 headers: {
376 Accept: 'application/json',
377 Authorization: `Bearer ${resolveToken()}`,
378 'X-Delegation-Bearer': bearer,
379 'X-Vault-Id': 'unknown-vault-smoke-7dl1',
380 },
381 signal: AbortSignal.timeout(SMOKE_TIMEOUT_MS),
382 });
383 const crossText = await crossVault.text();
384 let crossJson = {};
385 try {
386 crossJson = crossText ? JSON.parse(crossText) : {};
387 } catch {
388 crossJson = {};
389 }
390 const e6Ok =
391 crossVault.status === 403 &&
392 (crossJson.code === 'vault_mismatch' || crossJson.code === 'FORBIDDEN');
393 record(
394 'E6 cross-vault',
395 e6Ok,
396 e6Ok ? `403 ${crossJson.code}` : `HTTP ${crossVault.status} code=${crossJson.code ?? 'none'}`,
397 );
398
399 summarize();
400 process.exit(results.some((r) => !r.ok) ? 1 : 0);
401 }
402
403 function summarize() {
404 const failed = results.filter((r) => !r.ok);
405 console.log(`\nSummary: ${results.length - failed.length}/${results.length} PASS`);
406 if (failed.length) {
407 console.log('Failed:', failed.map((f) => f.step).join(', '));
408 } else {
409 console.log('7D-L1 validate-only smoke PASS (§16.1 E1–E6)');
410 }
411 }
412
413 main().catch((err) => {
414 console.error(err);
415 process.exit(1);
416 });
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago