Deployed version
This commit is contained in:
@@ -5,18 +5,25 @@ import type {
|
||||
MCPletTool,
|
||||
MCPletToolResult,
|
||||
MCPletErrorCode,
|
||||
MCPletAuthPayload,
|
||||
} from '../types/index.js';
|
||||
import type { PoolRegistry } from '../pools/pool-registry.js';
|
||||
import type { LLMAdapter, LLMMessage, LLMToolDef, LLMToolCall } from '../llm/llm-adapter.js';
|
||||
import type { PasskeyServer, PasskeyPlatformService } from '../passkey/index.js';
|
||||
import type { PasskeyPlatformService } from '../passkey/index.js';
|
||||
import type { MCPletRouter } from '../host/mcplet-router.js';
|
||||
import { AuditLog } from '../host/audit-log.js';
|
||||
|
||||
/** Common interface for all passkey server variants (local, remote, demo). */
|
||||
export interface IPasskeyCeremonyServer {
|
||||
startCeremony(promptMessage: string, username?: string): Promise<MCPletAuthPayload>;
|
||||
getPlatformService(): PasskeyPlatformService;
|
||||
}
|
||||
|
||||
export interface AgentDeps {
|
||||
poolRegistry: PoolRegistry;
|
||||
mcpRouter: MCPletRouter;
|
||||
llm: LLMAdapter;
|
||||
passkeyServer?: PasskeyServer;
|
||||
passkeyServer?: IPasskeyCeremonyServer;
|
||||
passkeyPlatformService?: PasskeyPlatformService;
|
||||
auditLog: AuditLog;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { PoolRegistry } from '../pools/pool-registry.js';
|
||||
import type { AuditLog } from '../host/audit-log.js';
|
||||
import type { A2ALocalBus } from '../a2a/local-bus.js';
|
||||
import type { DirectorAgent } from '../agents/director-agent.js';
|
||||
import type { RemotePasskeyServer } from '../passkey/remote-passkey-server.js';
|
||||
import type { MCPletAuthPayload } from '../types/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -17,11 +19,12 @@ export class DashboardServer {
|
||||
private readonly auditLog: AuditLog,
|
||||
private readonly localBus: A2ALocalBus,
|
||||
private readonly directorAgent?: DirectorAgent,
|
||||
private readonly remotePasskeyServer?: RemotePasskeyServer,
|
||||
) {}
|
||||
|
||||
start(port: number): void {
|
||||
this.server = http.createServer((req, res) => {
|
||||
this.handle(req, res);
|
||||
void this.handle(req, res);
|
||||
});
|
||||
this.server.listen(port, () => {
|
||||
console.log(`[dashboard] Listening on http://localhost:${port}`);
|
||||
@@ -32,7 +35,7 @@ export class DashboardServer {
|
||||
this.server?.close();
|
||||
}
|
||||
|
||||
private handle(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', `http://localhost`);
|
||||
|
||||
if (url.pathname === '/api/tools') {
|
||||
@@ -58,12 +61,52 @@ export class DashboardServer {
|
||||
sendJson(res, 202, { message: 'Director cycle triggered. Watch /api/audit for progress.' });
|
||||
return;
|
||||
}
|
||||
// ── Remote Passkey ceremony routes ──
|
||||
if (url.pathname === '/api/passkey/pending') {
|
||||
if (!this.remotePasskeyServer) {
|
||||
sendJson(res, 404, { error: 'Remote passkey not configured' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, this.remotePasskeyServer.getPendingCeremonies());
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/api/passkey/complete') {
|
||||
if (!this.remotePasskeyServer) {
|
||||
sendJson(res, 404, { error: 'Remote passkey not configured' });
|
||||
return;
|
||||
}
|
||||
let body: { token: string; assertion: MCPletAuthPayload };
|
||||
try {
|
||||
body = await parseJsonBody<typeof body>(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
const ok = this.remotePasskeyServer.completeCeremony(body.token, body.assertion);
|
||||
sendJson(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'Token not found or expired' });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/passkey/ceremony') {
|
||||
if (!this.remotePasskeyServer) {
|
||||
res.writeHead(404);
|
||||
res.end('Remote passkey not configured');
|
||||
return;
|
||||
}
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
servePasskeyCeremonyPage(
|
||||
res, token,
|
||||
this.remotePasskeyServer.getFido2ServerUrl(),
|
||||
this.remotePasskeyServer.getRpId(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/favicon.ico') {
|
||||
serveFavicon(res);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||
serveDashboardPage(res);
|
||||
serveDashboardPage(res, !!this.remotePasskeyServer);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +136,7 @@ function serveFavicon(res: http.ServerResponse): void {
|
||||
}
|
||||
}
|
||||
|
||||
function serveDashboardPage(res: http.ServerResponse): void {
|
||||
function serveDashboardPage(res: http.ServerResponse, passkeyEnabled: boolean): void {
|
||||
const staticPath = path.resolve(__dirname, '../../public/dashboard/index.html');
|
||||
if (fs.existsSync(staticPath)) {
|
||||
const html = fs.readFileSync(staticPath, 'utf-8');
|
||||
@@ -101,11 +144,23 @@ function serveDashboardPage(res: http.ServerResponse): void {
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(inlineDashboard());
|
||||
res.end(inlineDashboard(passkeyEnabled));
|
||||
}
|
||||
}
|
||||
|
||||
function inlineDashboard(): string {
|
||||
function parseJsonBody<T>(req: http.IncomingMessage): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(data) as T); }
|
||||
catch { reject(new Error('Invalid JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function inlineDashboard(passkeyEnabled: boolean): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -238,6 +293,29 @@ function inlineDashboard(): string {
|
||||
.audit-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.audit-scroll::-webkit-scrollbar-thumb { background: rgba(99,102,241,.3); border-radius: 3px; }
|
||||
|
||||
/* ── Passkey banner ── */
|
||||
.passkey-banner {
|
||||
background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.35);
|
||||
border-radius: 10px; padding: 16px 20px; margin-bottom: 24px;
|
||||
display: none;
|
||||
}
|
||||
.passkey-banner.visible { display: block; }
|
||||
.passkey-banner h3 { margin: 0 0 10px; font-size: 14px; color: #fbbf24; font-weight: 600; }
|
||||
.passkey-item {
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(251,191,36,.12);
|
||||
}
|
||||
.passkey-item:last-child { border-bottom: none; }
|
||||
.passkey-item .prompt { flex: 1; font-size: 13px; color: #e2e8f0; }
|
||||
.passkey-item .ts { font-size: 11px; }
|
||||
.passkey-approve-btn {
|
||||
padding: 6px 16px; border: none; border-radius: 6px; font-size: 12px;
|
||||
font-weight: 600; cursor: pointer; color: #000;
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
transition: transform .1s;
|
||||
}
|
||||
.passkey-approve-btn:hover { transform: translateY(-1px); }
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer { text-align: center; padding: 20px; font-size: 12px; color: #475569; }
|
||||
</style>
|
||||
@@ -265,6 +343,11 @@ function inlineDashboard(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="passkeyBanner" class="passkey-banner">
|
||||
<h3>Passkey Approval Required</h3>
|
||||
<div id="passkeyList"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Registered MCPlet Tools</h2>
|
||||
<table>
|
||||
@@ -354,9 +437,311 @@ function inlineDashboard(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Passkey polling (remote mode) ──
|
||||
var PASSKEY_ENABLED = ${passkeyEnabled};
|
||||
|
||||
async function loadPasskey() {
|
||||
if (!PASSKEY_ENABLED) return;
|
||||
try {
|
||||
var pending = await fetch('/api/passkey/pending').then(function(r){ return r.json(); });
|
||||
var banner = document.getElementById('passkeyBanner');
|
||||
var list = document.getElementById('passkeyList');
|
||||
if (!pending.length) {
|
||||
banner.className = 'passkey-banner';
|
||||
list.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
banner.className = 'passkey-banner visible';
|
||||
list.innerHTML = pending.map(function(p) {
|
||||
return '<div class="passkey-item">'
|
||||
+ '<span class="prompt">' + p.promptMessage + '</span>'
|
||||
+ '<span class="ts">' + formatLocalTime(p.createdAt) + '</span>'
|
||||
+ '<button class="passkey-approve-btn" onclick="openCeremony(\\'' + p.token + '\\')">Approve</button>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function openCeremony(token) {
|
||||
window.open('/passkey/ceremony?token=' + token, 'passkey_ceremony',
|
||||
'width=480,height=600,menubar=no,toolbar=no');
|
||||
}
|
||||
|
||||
load();
|
||||
loadPasskey();
|
||||
setInterval(load, 10000);
|
||||
setInterval(loadPasskey, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ─── Remote Passkey Ceremony Page ──────────────────────────────────────
|
||||
|
||||
function servePasskeyCeremonyPage(
|
||||
res: http.ServerResponse,
|
||||
token: string,
|
||||
fido2ServerUrl: string,
|
||||
rpId: string,
|
||||
): void {
|
||||
const html = buildCeremonyPage(token, fido2ServerUrl, rpId);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function buildCeremonyPage(token: string, fido2ServerUrl: string, rpId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Passkey Approval — MCPletA2A</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex;
|
||||
align-items: center; justify-content: center; height: 100vh; margin: 0;
|
||||
background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border-radius: 12px; padding: 36px 40px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.4); max-width: 440px;
|
||||
width: 100%; text-align: center; border: 1px solid rgba(99,102,241,.2); }
|
||||
h2 { margin: 0 0 8px; font-size: 20px; color: #a5b4fc; }
|
||||
.msg { margin-bottom: 20px; font-size: 14px; color: #94a3b8; line-height: 1.5; }
|
||||
.field { margin-bottom: 20px; text-align: left; }
|
||||
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: #94a3b8; }
|
||||
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid #334155;
|
||||
border-radius: 6px; outline: none; background: #0f172a; color: #e2e8f0; }
|
||||
input:focus { border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,.25); }
|
||||
.btn { width: 100%; padding: 14px; font-size: 16px; cursor: pointer; border: none;
|
||||
border-radius: 8px; font-weight: 600; transition: all 0.2s; }
|
||||
.btn-primary { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; }
|
||||
.btn-primary:hover { box-shadow: 0 4px 16px rgba(99,102,241,.4); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.cancel { margin-top: 12px; color: #64748b; background: none; border: none;
|
||||
cursor: pointer; font-size: 13px; }
|
||||
.status { margin-top: 16px; font-size: 13px; min-height: 18px; }
|
||||
.error { color: #fb7185; }
|
||||
.success { color: #34d399; }
|
||||
.info { color: #818cf8; }
|
||||
.step { color: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Passkey Approval</h2>
|
||||
<p class="msg">Authenticate with your passkey to approve the pending action.</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="userId">User ID</label>
|
||||
<input type="text" id="userId" placeholder="your-username" autocomplete="username webauthn">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="startBtn">Authenticate</button>
|
||||
<br>
|
||||
<button class="cancel" id="cancelBtn">Cancel</button>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var TOKEN = '${escapeHtml(token)}';
|
||||
var FIDO2_SVR = '${escapeHtml(fido2ServerUrl)}';
|
||||
var RP_ID = '${escapeHtml(rpId)}';
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var startBtn = document.getElementById('startBtn');
|
||||
var userIdInput = document.getElementById('userId');
|
||||
|
||||
function setStatus(msg, type) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status ' + (type || '');
|
||||
}
|
||||
|
||||
// ── Base64URL helpers ──
|
||||
function toB64U(s) { return s.split('=')[0].replace(/[+]/g,'-').replace(/[/]/g,'_'); }
|
||||
function fromB64U(s) {
|
||||
s = s.replace(/-/g,'+').replace(/_/g,'/');
|
||||
var p = s.length % 4;
|
||||
if (p) { if (p===1) throw new Error('bad b64u'); s += '===='.slice(p); }
|
||||
return s;
|
||||
}
|
||||
function b64ToBuf(b) {
|
||||
var d = atob(b), a = new Uint8Array(d.length);
|
||||
for (var i=0;i<d.length;i++) a[i]=d.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
function bufToStr(b) { return String.fromCharCode.apply(null, new Uint8Array(b)); }
|
||||
function strToBuf(s) { return new Uint8Array([].map.call(s,function(c){return c.charCodeAt(0)})).buffer; }
|
||||
|
||||
async function fido2Post(path, body) {
|
||||
var r = await fetch(FIDO2_SVR + path, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function isUserRegistered(userId) {
|
||||
try {
|
||||
var resp = await fido2Post('/assertion/options', {
|
||||
username: userId,
|
||||
authenticatorSelection: { userVerification: 'preferred' },
|
||||
rp: { id: RP_ID }
|
||||
});
|
||||
return resp.status === 'ok';
|
||||
} catch(e) { return false; }
|
||||
}
|
||||
|
||||
async function doRegister(userId) {
|
||||
setStatus('Getting registration options...', 'step');
|
||||
var opts = await fido2Post('/attestation/options', {
|
||||
username: userId,
|
||||
displayName: encodeURIComponent(userId),
|
||||
authenticatorSelection: { userVerification:'preferred', requireResidentKey:false },
|
||||
rp: { id: RP_ID }
|
||||
});
|
||||
if (opts.status === 'failed') throw new Error(opts.errorMessage || 'Server error');
|
||||
|
||||
var pk = {
|
||||
rp: opts.rp,
|
||||
user: { id: strToBuf(opts.user.id), name: opts.user.name||opts.user.id, displayName: opts.user.displayName||opts.user.name||opts.user.id },
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
timeout: opts.timeout,
|
||||
challenge: b64ToBuf(fromB64U(opts.challenge))
|
||||
};
|
||||
if (opts.excludeCredentials) pk.excludeCredentials = opts.excludeCredentials.map(function(x){ return {id:b64ToBuf(fromB64U(x.id)),type:x.type}; });
|
||||
if (opts.authenticatorSelection) pk.authenticatorSelection = opts.authenticatorSelection;
|
||||
|
||||
setStatus('Use your authenticator to register...', 'info');
|
||||
var cred = await navigator.credentials.create({ publicKey: pk });
|
||||
if (!cred) throw new Error('Credential not returned');
|
||||
|
||||
setStatus('Verifying registration...', 'step');
|
||||
var att = {
|
||||
id: cred.id,
|
||||
rawId: toB64U(btoa(bufToStr(cred.rawId))),
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: toB64U(btoa(bufToStr(cred.response.clientDataJSON))),
|
||||
attestationObject: toB64U(btoa(bufToStr(cred.response.attestationObject)))
|
||||
}
|
||||
};
|
||||
if (cred.response.getTransports) att.transports = cred.response.getTransports();
|
||||
|
||||
var result = await fido2Post('/attestation/result', att);
|
||||
if (result.status !== 'ok') throw new Error(result.errorMessage || 'Registration failed');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function doAuthenticate(userId) {
|
||||
setStatus('Getting authentication options...', 'step');
|
||||
var opts = await fido2Post('/assertion/options', {
|
||||
username: userId,
|
||||
authenticatorSelection: { userVerification:'preferred' },
|
||||
rp: { id: RP_ID }
|
||||
});
|
||||
if (opts.status === 'failed') throw new Error(opts.errorMessage || 'Server error');
|
||||
|
||||
var allowCreds = (opts.allowCredentials||[]).map(function(x){
|
||||
return { id: b64ToBuf(fromB64U(x.id)), type: x.type, transports: x.transports };
|
||||
});
|
||||
|
||||
setStatus('Use your authenticator...', 'info');
|
||||
var cred = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: b64ToBuf(fromB64U(opts.challenge)),
|
||||
timeout: opts.timeout,
|
||||
rpId: opts.rpId || RP_ID,
|
||||
userVerification: opts.userVerification || 'preferred',
|
||||
allowCredentials: allowCreds
|
||||
}
|
||||
});
|
||||
if (!cred) throw new Error('Credential not returned');
|
||||
|
||||
setStatus('Verifying authentication...', 'step');
|
||||
var auth = {
|
||||
id: cred.id,
|
||||
rawId: Array.from(new Uint8Array(cred.rawId)),
|
||||
type: cred.type,
|
||||
response: {
|
||||
authenticatorData: toB64U(btoa(bufToStr(cred.response.authenticatorData))),
|
||||
clientDataJSON: toB64U(btoa(bufToStr(cred.response.clientDataJSON))),
|
||||
signature: toB64U(btoa(bufToStr(cred.response.signature))),
|
||||
userHandle: toB64U(btoa(bufToStr(cred.response.userHandle)))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await fido2Post('/assertion/result', auth);
|
||||
if (result.status !== 'ok') throw new Error(result.errorMessage || 'Authentication failed');
|
||||
|
||||
return { opts: opts, auth: auth, result: result };
|
||||
}
|
||||
|
||||
async function startFlow() {
|
||||
var userId = userIdInput.value.trim();
|
||||
if (!userId) { setStatus('Please enter your User ID', 'error'); return; }
|
||||
|
||||
startBtn.disabled = true;
|
||||
userIdInput.disabled = true;
|
||||
|
||||
try {
|
||||
setStatus('Checking user...', 'step');
|
||||
var registered = await isUserRegistered(userId);
|
||||
|
||||
if (!registered) {
|
||||
setStatus('Passkey not registered. Registering...', 'info');
|
||||
await doRegister(userId);
|
||||
setStatus('Registered! Now authenticating...', 'success');
|
||||
}
|
||||
|
||||
var authData = await doAuthenticate(userId);
|
||||
|
||||
var assertion = {
|
||||
type: 'passkey_assertion',
|
||||
challenge: authData.opts.challenge,
|
||||
clientDataJSON: authData.auth.response.clientDataJSON,
|
||||
authenticatorData: authData.auth.response.authenticatorData,
|
||||
signature: authData.auth.response.signature,
|
||||
userHandle: authData.auth.response.userHandle,
|
||||
session: authData.result.session,
|
||||
username: authData.result.username
|
||||
};
|
||||
|
||||
setStatus('Submitting approval...', 'step');
|
||||
var r = await fetch('/api/passkey/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN, assertion: assertion })
|
||||
});
|
||||
var j = await r.json();
|
||||
|
||||
if (r.ok) {
|
||||
setStatus('Approved! This window will close.', 'success');
|
||||
setTimeout(function(){ window.close(); }, 2000);
|
||||
} else {
|
||||
setStatus('Error: ' + (j.error || 'Unknown'), 'error');
|
||||
startBtn.disabled = false;
|
||||
userIdInput.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
setStatus('Operation cancelled', 'error');
|
||||
} else {
|
||||
setStatus('Error: ' + e.message, 'error');
|
||||
}
|
||||
startBtn.disabled = false;
|
||||
userIdInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', startFlow);
|
||||
userIdInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') startFlow(); });
|
||||
document.getElementById('cancelBtn').addEventListener('click', function() { window.close(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import { createLLMAdapter } from '../llm/claude-adapter.js';
|
||||
import type { LLMAdapter } from '../llm/llm-adapter.js';
|
||||
import { A2ALocalBus, type IAgent } from '../a2a/local-bus.js';
|
||||
import { A2AExternalEndpoint } from '../a2a/external-endpoint.js';
|
||||
import { PasskeyServer, PasskeyAPIServer, PasskeyPlatformService } from '../passkey/index.js';
|
||||
import { PasskeyServer, PasskeyAPIServer, PasskeyPlatformService, RemotePasskeyServer, DemoPasskeyServer } from '../passkey/index.js';
|
||||
import { DashboardServer } from '../dashboard/dashboard-server.js';
|
||||
import { DirectorAgent } from '../agents/director-agent.js';
|
||||
import { AuditLog } from './audit-log.js';
|
||||
import type { AgentDeps } from '../agents/base-agent.js';
|
||||
import type { AgentDeps, IPasskeyCeremonyServer } from '../agents/base-agent.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AgentConstructor = new (agentId: string, accessiblePools: string[], deps: any) => IAgent;
|
||||
@@ -29,9 +29,10 @@ export class MCPletHost {
|
||||
private llm!: LLMAdapter;
|
||||
private localBus!: A2ALocalBus;
|
||||
private auditLog!: AuditLog;
|
||||
private passkeyServer?: PasskeyServer;
|
||||
private passkeyServer?: IPasskeyCeremonyServer;
|
||||
private passkeyPlatformService?: PasskeyPlatformService;
|
||||
private passkeyAPIServer?: PasskeyAPIServer;
|
||||
private remotePasskeyServer?: RemotePasskeyServer;
|
||||
private dashboardServer?: DashboardServer;
|
||||
private externalEndpoint?: A2AExternalEndpoint;
|
||||
private directorAgent?: DirectorAgent;
|
||||
@@ -49,13 +50,14 @@ export class MCPletHost {
|
||||
if (config.passkey) {
|
||||
const rpId = config.passkey.rpId || 'localhost';
|
||||
const fido2ServerUrl = config.passkey.fido2ServerUrl || 'https://fido2.epk.amipro.me';
|
||||
const origin = config.passkey.mode === 'https'
|
||||
const mode = config.passkey.mode;
|
||||
const origin = (mode === 'https' || mode === 'remote')
|
||||
? `https://${rpId}`
|
||||
: 'http://127.0.0.1';
|
||||
|
||||
// Create Passkey Platform Service (delegates to external FIDO2 server)
|
||||
this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
||||
console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl})`);
|
||||
console.log(`[host] PasskeyPlatformService initialized (rpId: ${rpId}, fido2: ${fido2ServerUrl}, mode: ${mode})`);
|
||||
|
||||
// Create and start Passkey API Server
|
||||
this.passkeyAPIServer = new PasskeyAPIServer(
|
||||
@@ -64,10 +66,22 @@ export class MCPletHost {
|
||||
origin,
|
||||
);
|
||||
const apiPort = config.passkey.apiPort || 8443;
|
||||
this.passkeyAPIServer.start(apiPort);
|
||||
const apiHost = mode === 'remote' ? '0.0.0.0' : '127.0.0.1';
|
||||
this.passkeyAPIServer.start(apiPort, apiHost);
|
||||
|
||||
// PasskeyServer for interactive browser ceremonies
|
||||
this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl);
|
||||
// PasskeyServer — mode-dependent
|
||||
if (mode === 'remote') {
|
||||
const remote = new RemotePasskeyServer(rpId, fido2ServerUrl);
|
||||
this.passkeyServer = remote;
|
||||
this.remotePasskeyServer = remote;
|
||||
console.log('[host] PasskeyServer: remote mode (approve via Dashboard)');
|
||||
} else if (mode === 'demo') {
|
||||
this.passkeyServer = new DemoPasskeyServer(rpId, fido2ServerUrl);
|
||||
console.log('[host] PasskeyServer: demo mode (auto-approve)');
|
||||
} else {
|
||||
this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl);
|
||||
console.log('[host] PasskeyServer: local browser mode');
|
||||
}
|
||||
}
|
||||
|
||||
// Connect MCPlet servers declared in config (each is a stdio child process)
|
||||
@@ -100,6 +114,7 @@ export class MCPletHost {
|
||||
if (config.dashboard) {
|
||||
this.dashboardServer = new DashboardServer(
|
||||
this.poolRegistry, this.auditLog, this.localBus, this.directorAgent,
|
||||
this.remotePasskeyServer,
|
||||
);
|
||||
this.dashboardServer.start(config.dashboard.port);
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ export class PasskeyAPIServer {
|
||||
private readonly origin: string,
|
||||
) {}
|
||||
|
||||
start(port: number): void {
|
||||
start(port: number, host: string = '127.0.0.1'): void {
|
||||
this.server = http.createServer((req, res) => {
|
||||
void this.handleRequest(req, res);
|
||||
});
|
||||
|
||||
this.server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[passkey-api] Server listening on http://127.0.0.1:${port}`);
|
||||
this.server.listen(port, host, () => {
|
||||
console.log(`[passkey-api] Server listening on http://${host}:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export { PasskeyPlatformService } from './platform-service.js';
|
||||
// Servers
|
||||
export { PasskeyServer } from './passkey-server.js';
|
||||
export { PasskeyAPIServer } from './api-server.js';
|
||||
export { RemotePasskeyServer, DemoPasskeyServer } from './remote-passkey-server.js';
|
||||
export type { PendingCeremonyInfo } from './remote-passkey-server.js';
|
||||
|
||||
// Storage (for local credential tracking if needed)
|
||||
export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js';
|
||||
|
||||
168
platform_impl/src/passkey/remote-passkey-server.ts
Normal file
168
platform_impl/src/passkey/remote-passkey-server.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { MCPletAuthPayload } from '../types/index.js';
|
||||
import { PasskeyPlatformService } from './platform-service.js';
|
||||
|
||||
/**
|
||||
* Pending passkey ceremony — waiting for remote operator approval via Dashboard.
|
||||
*/
|
||||
export interface PendingCeremony {
|
||||
token: string;
|
||||
promptMessage: string;
|
||||
username?: string;
|
||||
createdAt: string;
|
||||
resolve: (payload: MCPletAuthPayload) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable view of a pending ceremony (for Dashboard API).
|
||||
*/
|
||||
export interface PendingCeremonyInfo {
|
||||
token: string;
|
||||
promptMessage: string;
|
||||
username?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemotePasskeyServer — Passkey ceremony via Dashboard Web UI.
|
||||
*
|
||||
* Instead of opening a local browser, creates a pending ceremony that
|
||||
* the Dashboard polls for. The operator completes the WebAuthn ceremony
|
||||
* in their remote browser, and the Dashboard POSTs the assertion back.
|
||||
*/
|
||||
export class RemotePasskeyServer {
|
||||
private readonly timeoutMs: number;
|
||||
private readonly pending = new Map<string, PendingCeremony>();
|
||||
private platformService: PasskeyPlatformService;
|
||||
|
||||
constructor(
|
||||
rpId: string = 'localhost',
|
||||
fido2ServerUrl: string = 'https://fido2.epk.amipro.me',
|
||||
timeoutMs = 180_000,
|
||||
) {
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.platformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
||||
}
|
||||
|
||||
getPlatformService(): PasskeyPlatformService {
|
||||
return this.platformService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a passkey ceremony. Returns a Promise that resolves when
|
||||
* the remote operator completes the WebAuthn flow via the Dashboard.
|
||||
*/
|
||||
async startCeremony(
|
||||
promptMessage: string,
|
||||
username?: string,
|
||||
): Promise<MCPletAuthPayload> {
|
||||
const token = randomUUID();
|
||||
|
||||
return new Promise<MCPletAuthPayload>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(token);
|
||||
reject(new Error('Remote passkey ceremony timed out'));
|
||||
}, this.timeoutMs);
|
||||
|
||||
const ceremony: PendingCeremony = {
|
||||
token,
|
||||
promptMessage,
|
||||
username,
|
||||
createdAt: new Date().toISOString(),
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
};
|
||||
|
||||
this.pending.set(token, ceremony);
|
||||
console.log(`[passkey-remote] Ceremony pending: ${token} — "${promptMessage}"`);
|
||||
console.log(`[passkey-remote] Approve via Dashboard: /passkey/ceremony?token=${token}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending ceremonies (for Dashboard polling).
|
||||
*/
|
||||
getPendingCeremonies(): PendingCeremonyInfo[] {
|
||||
return Array.from(this.pending.values()).map((c) => ({
|
||||
token: c.token,
|
||||
promptMessage: c.promptMessage,
|
||||
username: c.username,
|
||||
createdAt: c.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a pending ceremony with the WebAuthn assertion from the Dashboard.
|
||||
*/
|
||||
completeCeremony(token: string, payload: MCPletAuthPayload): boolean {
|
||||
const ceremony = this.pending.get(token);
|
||||
if (!ceremony) return false;
|
||||
|
||||
clearTimeout(ceremony.timer);
|
||||
this.pending.delete(token);
|
||||
ceremony.resolve(payload);
|
||||
console.log(`[passkey-remote] Ceremony completed: ${token}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending ceremony.
|
||||
*/
|
||||
cancelCeremony(token: string): boolean {
|
||||
const ceremony = this.pending.get(token);
|
||||
if (!ceremony) return false;
|
||||
|
||||
clearTimeout(ceremony.timer);
|
||||
this.pending.delete(token);
|
||||
ceremony.reject(new Error('Passkey ceremony cancelled by operator'));
|
||||
console.log(`[passkey-remote] Ceremony cancelled: ${token}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** FIDO2 server URL (needed by the ceremony page). */
|
||||
getFido2ServerUrl(): string {
|
||||
return this.platformService.getFido2ServerUrl();
|
||||
}
|
||||
|
||||
/** RP ID (needed by the ceremony page). */
|
||||
getRpId(): string {
|
||||
return this.platformService.getRpId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DemoPasskeyServer — Auto-approves all passkey ceremonies with a mock assertion.
|
||||
* Useful for CI testing or quick demos without real WebAuthn hardware.
|
||||
*/
|
||||
export class DemoPasskeyServer {
|
||||
private platformService: PasskeyPlatformService;
|
||||
|
||||
constructor(
|
||||
rpId: string = 'localhost',
|
||||
fido2ServerUrl: string = 'https://fido2.epk.amipro.me',
|
||||
) {
|
||||
this.platformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
||||
}
|
||||
|
||||
getPlatformService(): PasskeyPlatformService {
|
||||
return this.platformService;
|
||||
}
|
||||
|
||||
async startCeremony(
|
||||
promptMessage: string,
|
||||
_username?: string,
|
||||
): Promise<MCPletAuthPayload> {
|
||||
console.log(`[passkey-demo] Auto-approving: "${promptMessage}"`);
|
||||
return {
|
||||
type: 'passkey_assertion',
|
||||
challenge: 'demo-challenge',
|
||||
clientDataJSON: 'demo-clientDataJSON',
|
||||
authenticatorData: 'demo-authenticatorData',
|
||||
signature: 'demo-signature',
|
||||
userHandle: 'demo-user',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export interface LLMConfig {
|
||||
}
|
||||
|
||||
export interface PasskeyConfig {
|
||||
mode: 'localhost' | 'https';
|
||||
mode: 'localhost' | 'https' | 'remote' | 'demo';
|
||||
rpId: string;
|
||||
fido2ServerUrl?: string;
|
||||
apiPort?: number; // Port for REST API (default: 8443)
|
||||
|
||||
Reference in New Issue
Block a user