Deployed version
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -137,3 +137,6 @@ dist
|
|||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Deploy secrets
|
||||||
|
deploy/.env
|
||||||
|
|||||||
87
README.md
87
README.md
@@ -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
|
## Spec Compliance
|
||||||
|
|
||||||
| Requirement | Implementation |
|
| Requirement | Implementation |
|
||||||
|
|||||||
6
deploy/.env.example
Normal file
6
deploy/.env.example
Normal 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
19
deploy/mcplet-a2a.service
Normal 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
|
||||||
58
deploy/nginx-a2a-demo.conf
Normal file
58
deploy/nginx-a2a-demo.conf
Normal 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
36
deploy/start-vps.sh
Normal 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"
|
||||||
@@ -5,18 +5,25 @@ import type {
|
|||||||
MCPletTool,
|
MCPletTool,
|
||||||
MCPletToolResult,
|
MCPletToolResult,
|
||||||
MCPletErrorCode,
|
MCPletErrorCode,
|
||||||
|
MCPletAuthPayload,
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import type { PoolRegistry } from '../pools/pool-registry.js';
|
import type { PoolRegistry } from '../pools/pool-registry.js';
|
||||||
import type { LLMAdapter, LLMMessage, LLMToolDef, LLMToolCall } from '../llm/llm-adapter.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 type { MCPletRouter } from '../host/mcplet-router.js';
|
||||||
import { AuditLog } from '../host/audit-log.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 {
|
export interface AgentDeps {
|
||||||
poolRegistry: PoolRegistry;
|
poolRegistry: PoolRegistry;
|
||||||
mcpRouter: MCPletRouter;
|
mcpRouter: MCPletRouter;
|
||||||
llm: LLMAdapter;
|
llm: LLMAdapter;
|
||||||
passkeyServer?: PasskeyServer;
|
passkeyServer?: IPasskeyCeremonyServer;
|
||||||
passkeyPlatformService?: PasskeyPlatformService;
|
passkeyPlatformService?: PasskeyPlatformService;
|
||||||
auditLog: AuditLog;
|
auditLog: AuditLog;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { PoolRegistry } from '../pools/pool-registry.js';
|
|||||||
import type { AuditLog } from '../host/audit-log.js';
|
import type { AuditLog } from '../host/audit-log.js';
|
||||||
import type { A2ALocalBus } from '../a2a/local-bus.js';
|
import type { A2ALocalBus } from '../a2a/local-bus.js';
|
||||||
import type { DirectorAgent } from '../agents/director-agent.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));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -17,11 +19,12 @@ export class DashboardServer {
|
|||||||
private readonly auditLog: AuditLog,
|
private readonly auditLog: AuditLog,
|
||||||
private readonly localBus: A2ALocalBus,
|
private readonly localBus: A2ALocalBus,
|
||||||
private readonly directorAgent?: DirectorAgent,
|
private readonly directorAgent?: DirectorAgent,
|
||||||
|
private readonly remotePasskeyServer?: RemotePasskeyServer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
start(port: number): void {
|
start(port: number): void {
|
||||||
this.server = http.createServer((req, res) => {
|
this.server = http.createServer((req, res) => {
|
||||||
this.handle(req, res);
|
void this.handle(req, res);
|
||||||
});
|
});
|
||||||
this.server.listen(port, () => {
|
this.server.listen(port, () => {
|
||||||
console.log(`[dashboard] Listening on http://localhost:${port}`);
|
console.log(`[dashboard] Listening on http://localhost:${port}`);
|
||||||
@@ -32,7 +35,7 @@ export class DashboardServer {
|
|||||||
this.server?.close();
|
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`);
|
const url = new URL(req.url ?? '/', `http://localhost`);
|
||||||
|
|
||||||
if (url.pathname === '/api/tools') {
|
if (url.pathname === '/api/tools') {
|
||||||
@@ -58,12 +61,52 @@ export class DashboardServer {
|
|||||||
sendJson(res, 202, { message: 'Director cycle triggered. Watch /api/audit for progress.' });
|
sendJson(res, 202, { message: 'Director cycle triggered. Watch /api/audit for progress.' });
|
||||||
return;
|
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') {
|
if (url.pathname === '/favicon.ico') {
|
||||||
serveFavicon(res);
|
serveFavicon(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||||
serveDashboardPage(res);
|
serveDashboardPage(res, !!this.remotePasskeyServer);
|
||||||
return;
|
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');
|
const staticPath = path.resolve(__dirname, '../../public/dashboard/index.html');
|
||||||
if (fs.existsSync(staticPath)) {
|
if (fs.existsSync(staticPath)) {
|
||||||
const html = fs.readFileSync(staticPath, 'utf-8');
|
const html = fs.readFileSync(staticPath, 'utf-8');
|
||||||
@@ -101,11 +144,23 @@ function serveDashboardPage(res: http.ServerResponse): void {
|
|||||||
res.end(html);
|
res.end(html);
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -238,6 +293,29 @@ function inlineDashboard(): string {
|
|||||||
.audit-scroll::-webkit-scrollbar { width: 6px; }
|
.audit-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
.audit-scroll::-webkit-scrollbar-thumb { background: rgba(99,102,241,.3); border-radius: 3px; }
|
.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 ── */
|
||||||
.footer { text-align: center; padding: 20px; font-size: 12px; color: #475569; }
|
.footer { text-align: center; padding: 20px; font-size: 12px; color: #475569; }
|
||||||
</style>
|
</style>
|
||||||
@@ -265,6 +343,11 @@ function inlineDashboard(): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="passkeyBanner" class="passkey-banner">
|
||||||
|
<h3>Passkey Approval Required</h3>
|
||||||
|
<div id="passkeyList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Registered MCPlet Tools</h2>
|
<h2>Registered MCPlet Tools</h2>
|
||||||
<table>
|
<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();
|
load();
|
||||||
|
loadPasskey();
|
||||||
setInterval(load, 10000);
|
setInterval(load, 10000);
|
||||||
|
setInterval(loadPasskey, 3000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</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 type { LLMAdapter } from '../llm/llm-adapter.js';
|
||||||
import { A2ALocalBus, type IAgent } from '../a2a/local-bus.js';
|
import { A2ALocalBus, type IAgent } from '../a2a/local-bus.js';
|
||||||
import { A2AExternalEndpoint } from '../a2a/external-endpoint.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 { DashboardServer } from '../dashboard/dashboard-server.js';
|
||||||
import { DirectorAgent } from '../agents/director-agent.js';
|
import { DirectorAgent } from '../agents/director-agent.js';
|
||||||
import { AuditLog } from './audit-log.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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type AgentConstructor = new (agentId: string, accessiblePools: string[], deps: any) => IAgent;
|
export type AgentConstructor = new (agentId: string, accessiblePools: string[], deps: any) => IAgent;
|
||||||
@@ -29,9 +29,10 @@ export class MCPletHost {
|
|||||||
private llm!: LLMAdapter;
|
private llm!: LLMAdapter;
|
||||||
private localBus!: A2ALocalBus;
|
private localBus!: A2ALocalBus;
|
||||||
private auditLog!: AuditLog;
|
private auditLog!: AuditLog;
|
||||||
private passkeyServer?: PasskeyServer;
|
private passkeyServer?: IPasskeyCeremonyServer;
|
||||||
private passkeyPlatformService?: PasskeyPlatformService;
|
private passkeyPlatformService?: PasskeyPlatformService;
|
||||||
private passkeyAPIServer?: PasskeyAPIServer;
|
private passkeyAPIServer?: PasskeyAPIServer;
|
||||||
|
private remotePasskeyServer?: RemotePasskeyServer;
|
||||||
private dashboardServer?: DashboardServer;
|
private dashboardServer?: DashboardServer;
|
||||||
private externalEndpoint?: A2AExternalEndpoint;
|
private externalEndpoint?: A2AExternalEndpoint;
|
||||||
private directorAgent?: DirectorAgent;
|
private directorAgent?: DirectorAgent;
|
||||||
@@ -49,13 +50,14 @@ export class MCPletHost {
|
|||||||
if (config.passkey) {
|
if (config.passkey) {
|
||||||
const rpId = config.passkey.rpId || 'localhost';
|
const rpId = config.passkey.rpId || 'localhost';
|
||||||
const fido2ServerUrl = config.passkey.fido2ServerUrl || 'https://fido2.epk.amipro.me';
|
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}`
|
? `https://${rpId}`
|
||||||
: 'http://127.0.0.1';
|
: 'http://127.0.0.1';
|
||||||
|
|
||||||
// Create Passkey Platform Service (delegates to external FIDO2 server)
|
// Create Passkey Platform Service (delegates to external FIDO2 server)
|
||||||
this.passkeyPlatformService = new PasskeyPlatformService(rpId, fido2ServerUrl);
|
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
|
// Create and start Passkey API Server
|
||||||
this.passkeyAPIServer = new PasskeyAPIServer(
|
this.passkeyAPIServer = new PasskeyAPIServer(
|
||||||
@@ -64,10 +66,22 @@ export class MCPletHost {
|
|||||||
origin,
|
origin,
|
||||||
);
|
);
|
||||||
const apiPort = config.passkey.apiPort || 8443;
|
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
|
||||||
this.passkeyServer = new PasskeyServer(rpId, fido2ServerUrl);
|
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)
|
// Connect MCPlet servers declared in config (each is a stdio child process)
|
||||||
@@ -100,6 +114,7 @@ export class MCPletHost {
|
|||||||
if (config.dashboard) {
|
if (config.dashboard) {
|
||||||
this.dashboardServer = new DashboardServer(
|
this.dashboardServer = new DashboardServer(
|
||||||
this.poolRegistry, this.auditLog, this.localBus, this.directorAgent,
|
this.poolRegistry, this.auditLog, this.localBus, this.directorAgent,
|
||||||
|
this.remotePasskeyServer,
|
||||||
);
|
);
|
||||||
this.dashboardServer.start(config.dashboard.port);
|
this.dashboardServer.start(config.dashboard.port);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ export class PasskeyAPIServer {
|
|||||||
private readonly origin: string,
|
private readonly origin: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
start(port: number): void {
|
start(port: number, host: string = '127.0.0.1'): void {
|
||||||
this.server = http.createServer((req, res) => {
|
this.server = http.createServer((req, res) => {
|
||||||
void this.handleRequest(req, res);
|
void this.handleRequest(req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.listen(port, '127.0.0.1', () => {
|
this.server.listen(port, host, () => {
|
||||||
console.log(`[passkey-api] Server listening on http://127.0.0.1:${port}`);
|
console.log(`[passkey-api] Server listening on http://${host}:${port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export { PasskeyPlatformService } from './platform-service.js';
|
|||||||
// Servers
|
// Servers
|
||||||
export { PasskeyServer } from './passkey-server.js';
|
export { PasskeyServer } from './passkey-server.js';
|
||||||
export { PasskeyAPIServer } from './api-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)
|
// Storage (for local credential tracking if needed)
|
||||||
export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js';
|
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 {
|
export interface PasskeyConfig {
|
||||||
mode: 'localhost' | 'https';
|
mode: 'localhost' | 'https' | 'remote' | 'demo';
|
||||||
rpId: string;
|
rpId: string;
|
||||||
fido2ServerUrl?: string;
|
fido2ServerUrl?: string;
|
||||||
apiPort?: number; // Port for REST API (default: 8443)
|
apiPort?: number; // Port for REST API (default: 8443)
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ directorAgent:
|
|||||||
externalAgents: []
|
externalAgents: []
|
||||||
|
|
||||||
passkey:
|
passkey:
|
||||||
mode: https
|
mode: remote
|
||||||
rpId: a2a-demo.mcplet.ai
|
rpId: a2a-demo.mcplet.ai
|
||||||
fido2ServerUrl: https://fido2.epk.amipro.me
|
fido2ServerUrl: https://fido2.epk.amipro.me
|
||||||
apiPort: 8443
|
apiPort: 8443
|
||||||
|
|||||||
Reference in New Issue
Block a user