verify-hosted-external-protocol-smoke.mjs
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