Skip to content

Commit 0cc91be

Browse files
JiaDeclaude
andcommitted
feat: two-step IM pairing with YES confirmation + DynamoDB MAPPING# write
Binding flow (H2 Proxy + main.py): - /start TOKEN → pair-pending (validate, return employee name) → inject "reply YES to confirm binding to [Name · Position]" → store pending in H2 Proxy memory Map (10 min TTL) - YES → pair-complete → write DynamoDB MAPPING# + SSM → inject success - NO → cancel → inject cancel message - already_bound_other → block with explanation Security fixes: - pair-complete now writes DynamoDB MAPPING#{channel}__{userId} (was SSM-only) - duplicate binding check: same IM userId cannot bind to two different employees - same employee rebinding: allowed, shows "重新绑定" phrasing - token consumed only on YES, not on /start (prevents replay if user says NO) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 793b5e5 commit 0cc91be

File tree

2 files changed

+157
-46
lines changed

2 files changed

+157
-46
lines changed

enterprise/admin-console/server/main.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,20 +1815,70 @@ def pair_status(token: str, authorization: str = Header(default="")):
18151815
return {"status": item.get("status", "pending")}
18161816

18171817

1818+
class PairPendingRequest(BaseModel):
1819+
token: str
1820+
channelUserId: str
1821+
channel: str
1822+
1823+
@app.post("/api/v1/bindings/pair-pending")
1824+
def pair_pending(body: PairPendingRequest):
1825+
"""Called by H2 Proxy on /start TOKEN — validates token and returns employee info
1826+
for the YES/NO confirmation message. Does NOT consume the token.
1827+
H2 Proxy caches the result and calls pair-complete only after YES."""
1828+
import time as _t
1829+
item = db.get_pair_token(body.token)
1830+
if not item:
1831+
return {"valid": False, "reason": "not_found"}
1832+
if item.get("ttl", 0) < int(_t.time()):
1833+
return {"valid": False, "reason": "expired"}
1834+
if item.get("status") not in ("pending",):
1835+
return {"valid": False, "reason": "already_used"}
1836+
1837+
emp_id = item["employeeId"]
1838+
1839+
# Check: is this IM userId already bound to a DIFFERENT employee?
1840+
existing = db.get_user_mapping(body.channel, body.channelUserId)
1841+
if existing and existing.get("employeeId") != emp_id:
1842+
other_emps = db.get_employees()
1843+
other_emp = next((e for e in other_emps if e["id"] == existing["employeeId"]), None)
1844+
return {
1845+
"valid": False,
1846+
"reason": "already_bound_other",
1847+
"boundTo": other_emp.get("name", existing["employeeId"]) if other_emp else existing["employeeId"],
1848+
}
1849+
1850+
emps = db.get_employees()
1851+
emp = next((e for e in emps if e["id"] == emp_id), {})
1852+
is_rebind = existing is not None and existing.get("employeeId") == emp_id
1853+
1854+
return {
1855+
"valid": True,
1856+
"employeeId": emp_id,
1857+
"employeeName": emp.get("name", emp_id),
1858+
"positionName": emp.get("positionName", ""),
1859+
"isRebind": is_rebind,
1860+
}
1861+
1862+
18181863
@app.post("/api/v1/bindings/pair-complete")
18191864
def pair_complete(body: PairCompleteRequest):
1820-
"""Called by H2 Proxy when employee sends /start TOKEN to the bot.
1865+
"""Called by H2 Proxy after employee confirms YES.
18211866
No auth — called from internal network only (H2 Proxy on same EC2).
1822-
Validates token, writes SSM user mapping, logs audit entry."""
1867+
Consumes token, writes DynamoDB MAPPING# + SSM, logs audit entry."""
18231868
item = db.consume_pair_token(body.token)
18241869
if not item:
18251870
raise HTTPException(400, "Token invalid, already used, or expired")
18261871

18271872
emp_id = item["employeeId"]
18281873
channel = item.get("channel", body.channel)
18291874

1830-
# Write SSM mapping — must use us-east-1 (where agent container reads from)
1831-
# pair_complete is the only endpoint called by H2 Proxy, so we need explicit region
1875+
# Write DynamoDB MAPPING# (primary, used by tenant_router and workspace_assembler)
1876+
try:
1877+
db.create_user_mapping(channel, body.channelUserId, emp_id)
1878+
except Exception as e:
1879+
print(f"[pair-complete] DynamoDB MAPPING# write failed: {e}")
1880+
1881+
# Write SSM (dual-write for backward compat during transition)
18321882
import boto3 as _b3_pair
18331883
_ssm_pair = _b3_pair.client("ssm", region_name=_GATEWAY_REGION)
18341884
_prefix = _mapping_prefix()

enterprise/gateway/bedrock_proxy_h2.js

Lines changed: 103 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ function log(msg) {
4949
// States: 'cold' -> 'warming' -> 'warm' -> (TTL expires) -> 'cold'
5050
const tenantState = new Map();
5151

52+
// Pending IM pairing confirmations (two-step: /start TOKEN → YES/NO)
53+
// key: `${channel}:${userId}` → { token, empName, expiresAt }
54+
// Stored in-memory only; a proxy restart loses pending pairings (user re-scans QR to retry)
55+
const pendingPairings = new Map();
56+
5257
function getTenantKey(channel, userId) {
5358
return `${channel}__${userId}`;
5459
}
@@ -596,58 +601,114 @@ server.on('stream', (stream, headers) => {
596601
}
597602

598603
// =====================================================================
599-
// PATH C: IM Self-Service Pairing — intercept /start TOKEN commands
600-
// When an employee scans the Portal QR code, Telegram sends "/start TOKEN".
601-
// We validate the token against Admin Console and write the SSM mapping
602-
// WITHOUT invoking AgentCore — fast, free, no microVM spin-up.
604+
// PATH C: IM Self-Service Pairing — two-step confirmation flow
605+
//
606+
// Step 1: /start TOKEN → pair-pending (validate) → inject "reply YES to confirm"
607+
// Pending state stored in memory (pendingPairings Map, 10 min TTL)
608+
// Step 2: YES → pair-complete → inject success
609+
// NO → cancel → inject cancel
603610
//
604-
// Safety: only intercepts exact pattern /start [A-Z0-9]{10,16}
605-
// On any error (invalid token, network, etc.) → falls through to normal routing
611+
// Safety: errors fall through to normal routing (employee gets agent response)
606612
// =====================================================================
607-
// Search anywhere in userText — the /start TOKEN appears after OpenClaw's metadata blocks
613+
614+
// Helper: call Admin Console API (internal only, no auth needed)
615+
const callAdminAPI = (path, payload) => new Promise((resolve, reject) => {
616+
const http = require('node:http');
617+
const body = JSON.stringify(payload);
618+
const req = http.request({
619+
hostname: '127.0.0.1', port: 8099, path,
620+
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
621+
}, (res) => {
622+
let data = '';
623+
res.on('data', c => data += c);
624+
res.on('end', () => {
625+
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
626+
catch { resolve({ status: res.statusCode, body: {} }); }
627+
});
628+
});
629+
req.on('error', reject);
630+
req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); });
631+
req.write(body);
632+
req.end();
633+
});
634+
635+
// Helper: inject a fake Bedrock response without calling AgentCore
636+
const injectResponse = (text) => {
637+
if (isStream) {
638+
stream.respond({ ':status': 200, 'content-type': 'application/vnd.amazon.eventstream' });
639+
for (const e of buildEventStream(text)) stream.write(e);
640+
stream.end();
641+
} else {
642+
stream.respond({ ':status': 200, 'content-type': 'application/json' });
643+
stream.end(JSON.stringify(buildConverseResponse(text)));
644+
}
645+
};
646+
647+
const pendingKey = `${channel}:${userId}`;
648+
const msgTrim = userText.trim();
649+
650+
// ── Step 2a: YES confirmation ──────────────────────────────────────
651+
if (/^(yes|YES|Yes|Y|y||)$/.test(msgTrim) && pendingPairings.has(pendingKey)) {
652+
const pending = pendingPairings.get(pendingKey);
653+
if (Date.now() > pending.expiresAt) {
654+
pendingPairings.delete(pendingKey);
655+
injectResponse('⏱ 绑定超时,请回到 Portal 重新生成二维码。');
656+
return;
657+
}
658+
try {
659+
const result = await callAdminAPI('/api/v1/bindings/pair-complete', {
660+
token: pending.token, channel, channelUserId: userId,
661+
});
662+
pendingPairings.delete(pendingKey);
663+
if (result.status === 200 && result.body.success) {
664+
log(`PATH C: Pairing confirmed ${channel} ${userId}${result.body.employeeId}`);
665+
injectResponse(`✅ 绑定成功!你现在可以在这里直接与 AI Agent 对话了。`);
666+
} else {
667+
injectResponse(`绑定失败:${result.body.detail || '请重试'}。`);
668+
}
669+
} catch (e) {
670+
log(`PATH C: pair-complete error: ${e.message}`);
671+
injectResponse('绑定时出错,请稍后重试。');
672+
}
673+
return;
674+
}
675+
676+
// ── Step 2b: NO / cancel ───────────────────────────────────────────
677+
if (/^(no|NO|No|N|n||cancel|CANCEL)$/.test(msgTrim) && pendingPairings.has(pendingKey)) {
678+
pendingPairings.delete(pendingKey);
679+
injectResponse('已取消。如需重新绑定请回到 Portal 生成二维码。');
680+
return;
681+
}
682+
683+
// ── Step 1: /start TOKEN ───────────────────────────────────────────
608684
const pairMatch = userText.match(/\/start\s+([A-Za-z0-9]{10,16})/);
609685
if (pairMatch && userId !== 'unknown' && channel !== 'unknown') {
610686
const token = pairMatch[1].toUpperCase();
611687
try {
612-
const http = require('node:http');
613-
const pairResult = await new Promise((resolve, reject) => {
614-
const payload = JSON.stringify({ channel, channelUserId: userId, token });
615-
const req = http.request({
616-
hostname: '127.0.0.1', port: 8099, path: '/api/v1/bindings/pair-complete',
617-
method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
618-
}, (res) => {
619-
let data = '';
620-
res.on('data', c => data += c);
621-
res.on('end', () => {
622-
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
623-
catch { resolve({ status: res.statusCode, body: {} }); }
624-
});
625-
});
626-
req.on('error', reject);
627-
req.setTimeout(5000, () => { req.destroy(); reject(new Error('pair-complete timeout')); });
628-
req.write(payload);
629-
req.end();
688+
const pending = await callAdminAPI('/api/v1/bindings/pair-pending', {
689+
token, channel, channelUserId: userId,
630690
});
631-
632-
if (pairResult.status === 200 && pairResult.body.success) {
633-
const { employeeName, positionName } = pairResult.body;
634-
const confirmMsg = `✅ Connected! Hi ${employeeName} — your ${positionName || 'AI'} Agent is ready. Just send me a message to get started!`;
635-
log(`PATH C: Pairing complete ${channel} ${userId}${pairResult.body.employeeId}`);
636-
if (isStream) {
637-
stream.respond({ ':status': 200, 'content-type': 'application/vnd.amazon.eventstream' });
638-
for (const e of buildEventStream(confirmMsg)) stream.write(e);
639-
stream.end();
640-
} else {
641-
stream.respond({ ':status': 200, 'content-type': 'application/json' });
642-
stream.end(JSON.stringify(buildConverseResponse(confirmMsg)));
643-
}
644-
return; // ← pairing handled, do NOT route to AgentCore
691+
if (pending.status === 200 && pending.body.valid) {
692+
const { employeeName, positionName, isRebind } = pending.body;
693+
pendingPairings.set(pendingKey, {
694+
token,
695+
empName: employeeName,
696+
expiresAt: Date.now() + 10 * 60 * 1000,
697+
});
698+
const action = isRebind ? '重新绑定' : '绑定';
699+
const msg = `你正在将此账号${action}到 [${employeeName}${positionName ? ' · ' + positionName : ''}]。\n\n回复 YES 确认,回复 NO 取消(10 分钟内有效)。`;
700+
log(`PATH C: Pending pairing ${channel} ${userId}${employeeName}`);
701+
injectResponse(msg);
702+
return;
703+
}
704+
if (pending.status === 200 && pending.body.reason === 'already_bound_other') {
705+
injectResponse(`此账号已绑定到 ${pending.body.boundTo},请联系 IT 管理员解绑后再试。`);
706+
return;
645707
}
646-
// Token invalid/expired — fall through to normal routing with a hint
647-
log(`PATH C: Pairing token invalid/expired for ${userId}, routing normally`);
708+
// Token invalid/expired — fall through to normal routing
709+
log(`PATH C: pair-pending invalid (${pending.body?.reason}), routing normally`);
648710
} catch (pairErr) {
649711
log(`PATH C: Pairing error (falling through): ${pairErr.message}`);
650-
// Fall through to normal routing — employee gets regular agent response
651712
}
652713
}
653714

0 commit comments

Comments
 (0)