Deployed version

This commit is contained in:
qingjie.du
2026-04-01 10:43:30 +09:00
parent ec5eb0a447
commit 388da1df29
14 changed files with 807 additions and 21 deletions

3
.gitignore vendored
View File

@@ -137,3 +137,6 @@ dist
.pnp.*
.DS_Store
# Deploy secrets
deploy/.env

View File

@@ -111,6 +111,93 @@ node dist/mcplets/media-pool/sns/index.js
---
## Production Deployment (a2a-demo.mcplet.ai)
Demo is deployed on a VPS (153.126.215.188) with nginx + Let's Encrypt SSL.
### Architecture
```
External Users
↓ HTTPS
nginx (443, Let's Encrypt auto-renew)
├── / → Dashboard :4000
├── /a2a/* → A2A External Endpoint :4001
└── /passkey-api/* → Passkey API :8443
MCPletA2A (systemd service)
├── Mock Service :5100 (internal only)
└── Platform Host → 8 MCPlet servers (stdio)
```
### Access
- URL: https://a2a-demo.mcplet.ai
- Basic Auth: `admin` / `m2a69988`
- A2A API endpoint (`/a2a/*`) does not require Basic Auth (uses Bearer token)
### Service Management
```bash
# Status / logs
sudo systemctl status mcplet-a2a
journalctl -u mcplet-a2a -f
# Restart / stop
sudo systemctl restart mcplet-a2a
sudo systemctl stop mcplet-a2a
```
### Key Files on VPS
| Path | Description |
|------|-------------|
| `/home/ubuntu/MCPletA2A/` | Project root |
| `/home/ubuntu/MCPletA2A/deploy/.env` | API keys (OPENROUTER_API_KEY) |
| `/home/ubuntu/MCPletA2A/deploy/start-vps.sh` | Startup script (used by systemd) |
| `/etc/systemd/system/mcplet-a2a.service` | systemd unit file |
| `/etc/nginx/sites-available/a2a-demo.conf` | nginx reverse proxy config |
| `/etc/letsencrypt/live/a2a-demo.mcplet.ai/` | SSL certificate (auto-renews) |
### Updating
```bash
# On local machine: push changes, then on VPS:
cd /home/ubuntu/MCPletA2A
git pull
cd platform_impl && npm run build
cd ../reference_impl && npm run build
sudo systemctl restart mcplet-a2a
```
Or from local machine via rsync:
```bash
rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.logs' --exclude='.pids' --exclude='.env' \
-e ssh . ubuntu@153.126.215.188:/home/ubuntu/MCPletA2A/
ssh ubuntu@153.126.215.188 "cd /home/ubuntu/MCPletA2A/platform_impl && npm run build && cd ../reference_impl && npm run build && sudo systemctl restart mcplet-a2a"
```
### Passkey Modes
| Mode | Config | Behavior |
|------|--------|----------|
| `https` | Local dev | Opens browser on server machine |
| `remote` | Production | Approval via Dashboard web UI notification |
| `demo` | Testing | Auto-approves all passkey ceremonies |
Current production config: `reference_impl/config/reference.yaml``passkey.mode: remote`
### SSL Certificate
Managed by certbot. Auto-renews before expiry. To manually check:
```bash
sudo certbot certificates
sudo certbot renew --dry-run
```
---
## Spec Compliance
| Requirement | Implementation |

6
deploy/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# MCPletA2A — Environment Variables
# Copy to .env and fill in values: cp .env.example .env
# LLM API Key (choose one)
# ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...

19
deploy/mcplet-a2a.service Normal file
View File

@@ -0,0 +1,19 @@
[Unit]
Description=MCPletA2A Platform Host
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/MCPletA2A
EnvironmentFile=/home/ubuntu/MCPletA2A/deploy/.env
Environment=REF_IMPL_DIST=/home/ubuntu/MCPletA2A/reference_impl/dist
Environment=MCPLET_CONFIG=/home/ubuntu/MCPletA2A/reference_impl/config/reference.yaml
Environment=MCPLET_AGENT_MODULE=file:///home/ubuntu/MCPletA2A/reference_impl/dist/agents/register.js
Environment=NODE_ENV=production
ExecStart=/bin/bash /home/ubuntu/MCPletA2A/deploy/start-vps.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,58 @@
# MCPletA2A — nginx reverse proxy for a2a-demo.mcplet.ai
# Install: sudo cp deploy/nginx-a2a-demo.conf /etc/nginx/sites-available/a2a-demo.conf
# sudo ln -s /etc/nginx/sites-available/a2a-demo.conf /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
server {
listen 80;
listen [::]:80;
server_name a2a-demo.mcplet.ai;
# Certbot will add HTTPS redirect here
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name a2a-demo.mcplet.ai;
# Certbot will fill these in
# ssl_certificate /etc/letsencrypt/live/a2a-demo.mcplet.ai/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/a2a-demo.mcplet.ai/privkey.pem;
# A2A External Endpoint
location /a2a/ {
proxy_pass http://127.0.0.1:4001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Passkey REST API (proxied, strip prefix)
location /passkey-api/ {
rewrite ^/passkey-api(/.*)$ $1 break;
proxy_pass http://127.0.0.1:8443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Dashboard (default)
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Security headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
}

36
deploy/start-vps.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# MCPletA2A — VPS startup script (used by systemd)
set -euo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REF_DIR="$BASE_DIR/reference_impl"
PLATFORM_DIR="$BASE_DIR/platform_impl"
export REF_IMPL_DIST="$REF_DIR/dist"
export MCPLET_CONFIG="$REF_DIR/config/reference.yaml"
export MCPLET_AGENT_MODULE="file://$REF_DIR/dist/agents/register.js"
# Start Mock Service in background
node "$REF_DIR/dist/mock-services/server.js" &
MOCK_PID=$!
# Wait until mock service is ready (max 10s)
for i in $(seq 1 20); do
if node -e "
const net = require('net');
const s = net.createConnection(5100, '127.0.0.1');
s.on('connect', () => { s.destroy(); process.exit(0); });
s.on('error', () => { s.destroy(); process.exit(1); });
" 2>/dev/null; then
echo "[vps] Mock Service ready (pid $MOCK_PID)"
break
fi
sleep 0.5
if [ "$i" -eq 20 ]; then
echo "[vps] ERROR: Mock Service did not start" >&2
exit 1
fi
done
# Start Platform Host (foreground — systemd monitors this)
exec node "$PLATFORM_DIR/dist/index.js"

View File

@@ -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;
}

View File

@@ -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,8 +437,310 @@ 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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>`;

View File

@@ -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
// 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);
}

View File

@@ -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}`);
});
}

View File

@@ -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';

View 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',
};
}
}

View File

@@ -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)

View File

@@ -95,7 +95,7 @@ directorAgent:
externalAgents: []
passkey:
mode: https
mode: remote
rpId: a2a-demo.mcplet.ai
fido2ServerUrl: https://fido2.epk.amipro.me
apiPort: 8443