instar 1.3.601 → 1.3.603
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +50 -0
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +36 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +1 -1
- package/dist/core/CoherenceJournal.d.ts.map +1 -1
- package/dist/core/CoherenceJournal.js +12 -2
- package/dist/core/CoherenceJournal.js.map +1 -1
- package/dist/core/LiveTestSlackCaller.d.ts +72 -0
- package/dist/core/LiveTestSlackCaller.d.ts.map +1 -0
- package/dist/core/LiveTestSlackCaller.js +114 -0
- package/dist/core/LiveTestSlackCaller.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +25 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ThreadlinePairingReplicatedStore.d.ts +285 -0
- package/dist/core/ThreadlinePairingReplicatedStore.d.ts.map +1 -0
- package/dist/core/ThreadlinePairingReplicatedStore.js +462 -0
- package/dist/core/ThreadlinePairingReplicatedStore.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +5 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/types.d.ts +22 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +6 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +363 -1
- package/dist/server/routes.js.map +1 -1
- package/dist/threadline/AgentTrustManager.d.ts +201 -1
- package/dist/threadline/AgentTrustManager.d.ts.map +1 -1
- package/dist/threadline/AgentTrustManager.js +395 -2
- package/dist/threadline/AgentTrustManager.js.map +1 -1
- package/dist/threadline/CredentialShareGate.d.ts +66 -0
- package/dist/threadline/CredentialShareGate.d.ts.map +1 -0
- package/dist/threadline/CredentialShareGate.js +82 -0
- package/dist/threadline/CredentialShareGate.js.map +1 -0
- package/dist/threadline/InboundMessageGate.d.ts +46 -1
- package/dist/threadline/InboundMessageGate.d.ts.map +1 -1
- package/dist/threadline/InboundMessageGate.js +105 -0
- package/dist/threadline/InboundMessageGate.js.map +1 -1
- package/dist/threadline/PairVerifyReceipt.d.ts +69 -0
- package/dist/threadline/PairVerifyReceipt.d.ts.map +1 -0
- package/dist/threadline/PairVerifyReceipt.js +151 -0
- package/dist/threadline/PairVerifyReceipt.js.map +1 -0
- package/dist/threadline/PairingPendingStore.d.ts +55 -0
- package/dist/threadline/PairingPendingStore.d.ts.map +1 -0
- package/dist/threadline/PairingPendingStore.js +110 -0
- package/dist/threadline/PairingPendingStore.js.map +1 -0
- package/dist/threadline/ThreadlineBootstrap.d.ts +11 -0
- package/dist/threadline/ThreadlineBootstrap.d.ts.map +1 -1
- package/dist/threadline/ThreadlineBootstrap.js +5 -0
- package/dist/threadline/ThreadlineBootstrap.js.map +1 -1
- package/dist/threadline/ThreadlineCrypto.d.ts +28 -0
- package/dist/threadline/ThreadlineCrypto.d.ts.map +1 -1
- package/dist/threadline/ThreadlineCrypto.js +100 -0
- package/dist/threadline/ThreadlineCrypto.js.map +1 -1
- package/dist/threadline/ThreadlineEndpoints.d.ts +8 -0
- package/dist/threadline/ThreadlineEndpoints.d.ts.map +1 -1
- package/dist/threadline/ThreadlineEndpoints.js +5 -0
- package/dist/threadline/ThreadlineEndpoints.js.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.d.ts +3 -1
- package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
- package/dist/threadline/ThreadlineMCPServer.js +107 -1
- package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
- package/dist/threadline/client/ThreadlineClient.d.ts +10 -0
- package/dist/threadline/client/ThreadlineClient.d.ts.map +1 -1
- package/dist/threadline/client/ThreadlineClient.js +13 -0
- package/dist/threadline/client/ThreadlineClient.js.map +1 -1
- package/package.json +2 -1
- package/scripts/check-e2e-pairing.cjs +4 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/data/state-coherence-registry.json +26 -0
- package/src/scaffold/templates.ts +6 -0
- package/src/threadline/data/sas-wordlist-en.json +1 -0
- package/upgrades/{1.3.601.md → 1.3.602.md} +49 -0
- package/upgrades/1.3.603.md +39 -0
- package/upgrades/side-effects/secure-a2a-verified-pairing.md +116 -0
- package/upgrades/side-effects/xoxc-slack-caller.md +99 -0
package/dist/server/routes.js
CHANGED
|
@@ -140,6 +140,7 @@ import { THREAD_ID_RE } from '../threadline/ThreadLog.js';
|
|
|
140
140
|
import { readThreadHistoryUnion } from '../threadline/canonicalHistoryRead.js';
|
|
141
141
|
import { computeSymmetryState, localThreadSync, honorPeerThreadSync } from '../threadline/threadSymmetry.js';
|
|
142
142
|
import { resolveSingleNegotiatorConfig, readNegotiatorCounts } from '../threadline/NegotiatorLease.js';
|
|
143
|
+
import { evaluateOutboundCredentialShare } from '../threadline/CredentialShareGate.js';
|
|
143
144
|
import { detectCommitmentClass, commitmentNudge } from '../threadline/ContentClassifier.js';
|
|
144
145
|
import { DEFAULT_RELAY_URL } from '../threadline/constants.js';
|
|
145
146
|
import { ThreadlineNicknames } from '../threadline/ThreadlineNicknames.js';
|
|
@@ -9178,6 +9179,257 @@ export function createRoutes(ctx) {
|
|
|
9178
9179
|
: undefined;
|
|
9179
9180
|
res.json(ctx.a2aDeliveryTracker.peerHealth(req.params.fp, { staleAfterMs }));
|
|
9180
9181
|
});
|
|
9182
|
+
// ── Secure A2A Verified Pairing surfaces (§3.6) ────────────────────────
|
|
9183
|
+
// List + detail + the PIN-gated operator verify. Ships DARK behind
|
|
9184
|
+
// threadline.verifiedPairing.enabled (resolved via the developmentAgent gate
|
|
9185
|
+
// when omitted, devAgentGate.ts) — when off, every route 503s and legacy
|
|
9186
|
+
// behavior is byte-identical. SAS WORDS are shown ONLY on the detail route to a
|
|
9187
|
+
// PIN-authed operator request while the pairing is pending-verification (§3.9);
|
|
9188
|
+
// they are never on the list route and never to a bearer-only request. The
|
|
9189
|
+
// verify route additionally REQUIRES the dashboard PIN (FD7): the agent Bearer
|
|
9190
|
+
// token is structurally insufficient to confirm a pairing — checkMandatePin is
|
|
9191
|
+
// the exact mechanism /mandate/issue uses (sha256 + timingSafeEqual + per-IP cap).
|
|
9192
|
+
const verifiedPairingRouteEnabled = () => resolveDevAgentGate(ctx.config.threadline?.verifiedPairing?.enabled, ctx.config);
|
|
9193
|
+
// Build THIS machine's local pairings list (the shape the plain + pool reads share,
|
|
9194
|
+
// so a peer's plain reply merges in identically). No SAS words ever.
|
|
9195
|
+
const buildLocalPairings = () => {
|
|
9196
|
+
const trustManager = ctx.unifiedTrust?.trustManager;
|
|
9197
|
+
if (!trustManager)
|
|
9198
|
+
return [];
|
|
9199
|
+
return trustManager
|
|
9200
|
+
.listProfiles()
|
|
9201
|
+
.filter((p) => p.pairingState !== undefined)
|
|
9202
|
+
.map((p) => ({
|
|
9203
|
+
peerFp: p.fingerprint ?? null,
|
|
9204
|
+
peerName: p.agent,
|
|
9205
|
+
state: p.pairingState,
|
|
9206
|
+
verifiedAt: p.verifiedAt,
|
|
9207
|
+
trustSource: p.source,
|
|
9208
|
+
peerAcked: p.peerAcked,
|
|
9209
|
+
}));
|
|
9210
|
+
};
|
|
9211
|
+
// Pool-scope pairing aggregation (Secure A2A Verified Pairing §3.8 — "is my channel to
|
|
9212
|
+
// <peer> verified?" gives ONE consistent answer regardless of fronting machine). Mirrors
|
|
9213
|
+
// the proven jobsPoolMerge shape: short-TTL cache, known-offline-peer skip, per-peer
|
|
9214
|
+
// timeout → failed marker (never a 500), shared pool-cache when wired. Single-machine ⇒
|
|
9215
|
+
// just the local list tagged scope:'pool'. The replicated RESULT itself flows via the
|
|
9216
|
+
// stateSync transport; THIS read is the cross-machine VIEW.
|
|
9217
|
+
const pairingPoolCache = new Map();
|
|
9218
|
+
const PAIRING_POOL_CACHE_TTL_MS = 3_000;
|
|
9219
|
+
const PAIRING_PEER_TIMEOUT_MS = 5_000;
|
|
9220
|
+
async function pairingPoolMerge(localPairings) {
|
|
9221
|
+
const cached = pairingPoolCache.get('pairing');
|
|
9222
|
+
if (cached && Date.now() - cached.at < PAIRING_POOL_CACHE_TTL_MS) {
|
|
9223
|
+
return cached.payload;
|
|
9224
|
+
}
|
|
9225
|
+
const selfMachineId = ctx.meshSelfId ?? null;
|
|
9226
|
+
const selfNickname = selfMachineId
|
|
9227
|
+
? ctx.machinePoolRegistry?.getCapacity(selfMachineId)?.nickname ?? null
|
|
9228
|
+
: null;
|
|
9229
|
+
const peers = ctx.resolvePeerUrls?.() ?? [];
|
|
9230
|
+
const failed = [];
|
|
9231
|
+
const remote = [];
|
|
9232
|
+
let poolStale = false;
|
|
9233
|
+
await Promise.all(peers.map(async (p) => {
|
|
9234
|
+
const cap = ctx.machinePoolRegistry?.getCapacity(p.machineId);
|
|
9235
|
+
if (cap && cap.online === false) {
|
|
9236
|
+
failed.push({ machineId: p.machineId, error: 'offline' });
|
|
9237
|
+
return;
|
|
9238
|
+
}
|
|
9239
|
+
try {
|
|
9240
|
+
const fetchPeer = async () => {
|
|
9241
|
+
const r = await fetch(`${p.url}/threadline/pairing`, {
|
|
9242
|
+
headers: { Authorization: `Bearer ${ctx.config.authToken}` },
|
|
9243
|
+
signal: AbortSignal.timeout(PAIRING_PEER_TIMEOUT_MS),
|
|
9244
|
+
});
|
|
9245
|
+
if (!r.ok)
|
|
9246
|
+
throw new Error(`HTTP ${r.status}`);
|
|
9247
|
+
return (await r.json());
|
|
9248
|
+
};
|
|
9249
|
+
let body;
|
|
9250
|
+
if (ctx.poolPollCache) {
|
|
9251
|
+
const res = await ctx.poolPollCache.fetchPeer(p.machineId, '/threadline/pairing', fetchPeer);
|
|
9252
|
+
body = res.body;
|
|
9253
|
+
if (res.stale)
|
|
9254
|
+
poolStale = true;
|
|
9255
|
+
}
|
|
9256
|
+
else {
|
|
9257
|
+
body = await fetchPeer();
|
|
9258
|
+
}
|
|
9259
|
+
const nickname = cap?.nickname ?? null;
|
|
9260
|
+
for (const pr of body.pairings ?? []) {
|
|
9261
|
+
remote.push({ ...pr, machineId: pr.machineId ?? p.machineId, machineNickname: pr.machineNickname ?? nickname ?? undefined, remote: true });
|
|
9262
|
+
}
|
|
9263
|
+
}
|
|
9264
|
+
catch (err) {
|
|
9265
|
+
failed.push({ machineId: p.machineId, error: err instanceof Error ? err.message : String(err) });
|
|
9266
|
+
}
|
|
9267
|
+
}));
|
|
9268
|
+
const localTagged = localPairings.map((pr) => ({
|
|
9269
|
+
...pr,
|
|
9270
|
+
machineId: pr.machineId ?? selfMachineId ?? undefined,
|
|
9271
|
+
machineNickname: pr.machineNickname ?? selfNickname ?? undefined,
|
|
9272
|
+
}));
|
|
9273
|
+
const pairings = [...localTagged, ...remote];
|
|
9274
|
+
const mutualVerifiedCount = pairings.filter((pr) => pr.state === 'mutual-verified' || pr.state === 'identity-verified').length;
|
|
9275
|
+
const payload = {
|
|
9276
|
+
pairings,
|
|
9277
|
+
mutualVerifiedCount,
|
|
9278
|
+
scope: 'pool',
|
|
9279
|
+
pool: {
|
|
9280
|
+
enabled: !!ctx.machinePoolRegistry,
|
|
9281
|
+
selfMachineId,
|
|
9282
|
+
peersQueried: peers.length,
|
|
9283
|
+
peersOk: peers.length - failed.length,
|
|
9284
|
+
failed,
|
|
9285
|
+
...(poolStale ? { stale: true } : {}),
|
|
9286
|
+
},
|
|
9287
|
+
};
|
|
9288
|
+
pairingPoolCache.set('pairing', { at: Date.now(), payload });
|
|
9289
|
+
return payload;
|
|
9290
|
+
}
|
|
9291
|
+
// List all pairings (read-only, no SAS words ever). 503 when the feature is off.
|
|
9292
|
+
// `?scope=pool` merges every online machine's pairings (dark-peer-tolerant) so the
|
|
9293
|
+
// "is this channel verified?" answer is consistent regardless of fronting machine (§3.8).
|
|
9294
|
+
router.get('/threadline/pairing', async (req, res) => {
|
|
9295
|
+
if (!verifiedPairingRouteEnabled()) {
|
|
9296
|
+
res.status(503).json({ error: 'verified pairing not enabled' });
|
|
9297
|
+
return;
|
|
9298
|
+
}
|
|
9299
|
+
const trustManager = ctx.unifiedTrust?.trustManager;
|
|
9300
|
+
if (!trustManager) {
|
|
9301
|
+
res.status(503).json({ error: 'trust manager unavailable' });
|
|
9302
|
+
return;
|
|
9303
|
+
}
|
|
9304
|
+
const pairings = buildLocalPairings();
|
|
9305
|
+
if (req.query.scope === 'pool') {
|
|
9306
|
+
res.json(await pairingPoolMerge(pairings));
|
|
9307
|
+
return;
|
|
9308
|
+
}
|
|
9309
|
+
res.json({ pairings });
|
|
9310
|
+
});
|
|
9311
|
+
// One pairing's detail. The SAS WORDS are included ONLY while the pairing is
|
|
9312
|
+
// pending-verification AND the request carries a valid dashboard-PIN operator
|
|
9313
|
+
// auth (§3.9). Without the PIN the words are omitted (the rest is returned).
|
|
9314
|
+
// The PIN arrives in the request body (mirrors checkMandatePin's body.pin); a
|
|
9315
|
+
// GET with a JSON body is accepted by the express.json() middleware. Detection
|
|
9316
|
+
// of "PIN present" is non-throwing — an absent/incorrect PIN simply omits words,
|
|
9317
|
+
// it does not 403 the read (the read itself is bearer-gated, like the list).
|
|
9318
|
+
router.get('/threadline/pairing/:peerFp', (req, res) => {
|
|
9319
|
+
if (!verifiedPairingRouteEnabled()) {
|
|
9320
|
+
res.status(503).json({ error: 'verified pairing not enabled' });
|
|
9321
|
+
return;
|
|
9322
|
+
}
|
|
9323
|
+
const trustManager = ctx.unifiedTrust?.trustManager;
|
|
9324
|
+
if (!trustManager) {
|
|
9325
|
+
res.status(503).json({ error: 'trust manager unavailable' });
|
|
9326
|
+
return;
|
|
9327
|
+
}
|
|
9328
|
+
const peerFp = req.params.peerFp;
|
|
9329
|
+
const profile = trustManager.getProfileByFingerprint(peerFp);
|
|
9330
|
+
if (!profile || profile.pairingState === undefined) {
|
|
9331
|
+
res.status(404).json({ error: `no pairing for fingerprint "${peerFp}"` });
|
|
9332
|
+
return;
|
|
9333
|
+
}
|
|
9334
|
+
const detail = {
|
|
9335
|
+
peerFp: profile.fingerprint ?? peerFp,
|
|
9336
|
+
peerName: profile.agent,
|
|
9337
|
+
state: profile.pairingState,
|
|
9338
|
+
verifiedAt: profile.verifiedAt,
|
|
9339
|
+
trustSource: profile.source,
|
|
9340
|
+
peerAcked: profile.peerAcked,
|
|
9341
|
+
pairingId: profile.pairingId,
|
|
9342
|
+
sasFingerprint: profile.sasFingerprint,
|
|
9343
|
+
};
|
|
9344
|
+
// SAS words: pending-verification ONLY, and ONLY to a valid-PIN operator.
|
|
9345
|
+
// We validate the PIN WITHOUT sending a response (the detail read succeeds
|
|
9346
|
+
// regardless) — a non-throwing constant-time compare mirroring checkMandatePin.
|
|
9347
|
+
const pin = (req.body ?? {}).pin;
|
|
9348
|
+
const pinValid = !!ctx.config.dashboardPin &&
|
|
9349
|
+
typeof pin === 'string' &&
|
|
9350
|
+
pin.length > 0 &&
|
|
9351
|
+
(() => {
|
|
9352
|
+
const ha = createHash('sha256').update(pin).digest();
|
|
9353
|
+
const hb = createHash('sha256').update(ctx.config.dashboardPin).digest();
|
|
9354
|
+
return ha.length === hb.length && timingSafeEqual(ha, hb);
|
|
9355
|
+
})();
|
|
9356
|
+
if (profile.pairingState === 'pending-verification' && pinValid) {
|
|
9357
|
+
const pending = trustManager.getPendingPairing(peerFp);
|
|
9358
|
+
if (pending)
|
|
9359
|
+
detail.sasWords = pending.sasWords;
|
|
9360
|
+
}
|
|
9361
|
+
res.json({ pairing: detail });
|
|
9362
|
+
});
|
|
9363
|
+
// Operator confirm/deny of a pairing — PIN-GATED (FD7). The local human SAS
|
|
9364
|
+
// comparison is the load-bearing security event; the agent Bearer token CANNOT
|
|
9365
|
+
// confirm. checkMandatePin is the EXACT mechanism /mandate/issue uses.
|
|
9366
|
+
router.post('/threadline/pairing/:peerFp/verify', (req, res) => {
|
|
9367
|
+
if (!verifiedPairingRouteEnabled()) {
|
|
9368
|
+
res.status(503).json({ error: 'verified pairing not enabled' });
|
|
9369
|
+
return;
|
|
9370
|
+
}
|
|
9371
|
+
if (!checkMandatePin(req, res))
|
|
9372
|
+
return; // FD7 — PIN required (response already sent)
|
|
9373
|
+
const trustManager = ctx.unifiedTrust?.trustManager;
|
|
9374
|
+
if (!trustManager) {
|
|
9375
|
+
res.status(503).json({ error: 'trust manager unavailable' });
|
|
9376
|
+
return;
|
|
9377
|
+
}
|
|
9378
|
+
const peerFp = req.params.peerFp;
|
|
9379
|
+
const body = req.body ?? {};
|
|
9380
|
+
if (typeof body.match !== 'boolean') {
|
|
9381
|
+
res.status(400).json({ error: 'match (boolean) is required' });
|
|
9382
|
+
return;
|
|
9383
|
+
}
|
|
9384
|
+
const profile = trustManager.getProfileByFingerprint(peerFp);
|
|
9385
|
+
if (!profile || profile.pairingState === undefined) {
|
|
9386
|
+
res.status(404).json({ error: `no pairing for fingerprint "${peerFp}"` });
|
|
9387
|
+
return;
|
|
9388
|
+
}
|
|
9389
|
+
if (body.match === false) {
|
|
9390
|
+
const ok = trustManager.markVerificationFailed(peerFp, 'Operator-asserted SAS mismatch (POST /threadline/pairing/:peerFp/verify)');
|
|
9391
|
+
if (!ok) {
|
|
9392
|
+
res.status(409).json({ error: 'could not mark verification-failed for this pairing' });
|
|
9393
|
+
return;
|
|
9394
|
+
}
|
|
9395
|
+
// §3.2 / §3.7 — an operator-asserted SAS mismatch is a potential MITM and must
|
|
9396
|
+
// raise ONE HIGH attention item, deduped per (peerFp) episode, NEVER coalesced
|
|
9397
|
+
// (a re-attempt LOOP — repeated handshake-then-mismatch — collapses onto the SAME
|
|
9398
|
+
// item by its stable per-peerFp id rather than spawning one topic per attempt).
|
|
9399
|
+
// HIGH priority is itself never coalesced by the AttentionTopicGuard, so the
|
|
9400
|
+
// flood-guard chokepoint enforces the burst invariant for free. Best-effort —
|
|
9401
|
+
// a notification failure never breaks the local trust write (which already landed).
|
|
9402
|
+
void ctx.telegram?.createAttentionItem?.({
|
|
9403
|
+
id: `threadline-pairing-verification-failed:${peerFp}`,
|
|
9404
|
+
title: 'Threadline pairing verification FAILED — possible MITM',
|
|
9405
|
+
summary: `The SAS for peer ${peerFp.slice(0, 12)}… was asserted NOT to match. The peer was forced to untrusted and credential-share revoked. Re-handshake before retrying.`,
|
|
9406
|
+
category: 'general',
|
|
9407
|
+
priority: 'HIGH',
|
|
9408
|
+
sourceContext: 'threadline-verified-pairing',
|
|
9409
|
+
});
|
|
9410
|
+
res.json({ ok: true, peerFp, state: 'verification-failed' });
|
|
9411
|
+
return;
|
|
9412
|
+
}
|
|
9413
|
+
// match === true → confirm. Bind to the CURRENT pairingId from the pending
|
|
9414
|
+
// record (epoch binding, FD4). markMutualVerified is the SOLE writer of the
|
|
9415
|
+
// mutual-verified source (§3.3) and re-verifies the operator-confirm precondition.
|
|
9416
|
+
const pending = trustManager.getPendingPairing(peerFp);
|
|
9417
|
+
const pairingId = pending?.pairingId ?? profile.pairingId;
|
|
9418
|
+
if (!pairingId) {
|
|
9419
|
+
res.status(409).json({ error: 'no current pairingId for this pairing — re-handshake required' });
|
|
9420
|
+
return;
|
|
9421
|
+
}
|
|
9422
|
+
const ok = trustManager.markMutualVerified(peerFp, {
|
|
9423
|
+
pairingId,
|
|
9424
|
+
operatorConfirm: true,
|
|
9425
|
+
peerAcked: profile.peerAcked,
|
|
9426
|
+
});
|
|
9427
|
+
if (!ok) {
|
|
9428
|
+
res.status(409).json({ error: 'could not confirm pairing (state/epoch mismatch or not pending) — re-handshake required' });
|
|
9429
|
+
return;
|
|
9430
|
+
}
|
|
9431
|
+
res.json({ ok: true, peerFp, state: 'mutual-verified' });
|
|
9432
|
+
});
|
|
9181
9433
|
// ── Negotiator lease state (Robustness Phase 1, FD-9) ──────────────────
|
|
9182
9434
|
// "Which session owns each conversation's voice?" — bearer-gated, paginated,
|
|
9183
9435
|
// own-data-only. Returns this agent's lease per active conversation (holder +
|
|
@@ -19989,6 +20241,19 @@ export function createRoutes(ctx) {
|
|
|
19989
20241
|
localAgent: ctx.config.projectName,
|
|
19990
20242
|
version: '1.0',
|
|
19991
20243
|
stateDir: ctx.config.stateDir,
|
|
20244
|
+
// Secure A2A Verified Pairing (§3.6): surface mutualVerifiedCount on
|
|
20245
|
+
// /threadline/health. The count is the number of profiles whose pairing
|
|
20246
|
+
// reached 'mutual-verified'. Gated on the verified-pairing flag so a dark
|
|
20247
|
+
// agent reports nothing new (legacy health shape preserved).
|
|
20248
|
+
mutualVerifiedCount: () => {
|
|
20249
|
+
const enabled = resolveDevAgentGate(ctx.config.threadline?.verifiedPairing?.enabled, ctx.config);
|
|
20250
|
+
if (!enabled)
|
|
20251
|
+
return 0;
|
|
20252
|
+
const tm = ctx.unifiedTrust?.trustManager;
|
|
20253
|
+
if (!tm)
|
|
20254
|
+
return 0;
|
|
20255
|
+
return tm.listProfiles().filter((p) => p.pairingState === 'mutual-verified').length;
|
|
20256
|
+
},
|
|
19992
20257
|
},
|
|
19993
20258
|
// Robustness Phase 1 (D-E / F4): wire the ack funnel so the verified E2E
|
|
19994
20259
|
// relay inbound path records the implicit ack (the one path that lacked it).
|
|
@@ -20175,6 +20440,46 @@ export function createRoutes(ctx) {
|
|
|
20175
20440
|
res.status(400).json({ success: false, error: 'Missing required fields: targetAgent, message' });
|
|
20176
20441
|
return;
|
|
20177
20442
|
}
|
|
20443
|
+
// ── Outbound credential-share intent (Secure A2A Verified Pairing §3.5) ──
|
|
20444
|
+
// A credential-bearing send is signalled by the caller (kind:'credential-share'
|
|
20445
|
+
// or credentialShare:true). This is a caller HINT for the OUTBOUND path only — the
|
|
20446
|
+
// SECURITY decision is keyed on WHO the resolved recipient is (mutual-verified),
|
|
20447
|
+
// never the label (FD5). When set, the funnel REFUSES the send unless the recipient
|
|
20448
|
+
// is mutual-verified AND the encrypted+signed path is available (never plaintext).
|
|
20449
|
+
// LIVE whenever verified-pairing is enabled — NOT gated by dryRun (FD10).
|
|
20450
|
+
const isCredentialShareSend = req.body?.kind === 'credential-share' || req.body?.credentialShare === true;
|
|
20451
|
+
const verifiedPairingEnabled = resolveDevAgentGate(ctx.config.threadline?.verifiedPairing?.enabled, ctx.config);
|
|
20452
|
+
/**
|
|
20453
|
+
* Outbound credential-share chokepoint (load-bearing, §3.5). Returns true if the
|
|
20454
|
+
* caller is sending a credential to a NON-eligible recipient and the response has
|
|
20455
|
+
* been written (the route must `return`). When verified-pairing is disabled, or the
|
|
20456
|
+
* send is not credential-bearing, this is a no-op (returns false). Fail-closed (FD9):
|
|
20457
|
+
* a missing trust manager / relay client → refuse a credential-bearing send.
|
|
20458
|
+
*
|
|
20459
|
+
* @param recipientFp the RESOLVED full routing fingerprint (never a name).
|
|
20460
|
+
*/
|
|
20461
|
+
const refuseCredentialShareIfBlocked = (recipientFp) => {
|
|
20462
|
+
if (!isCredentialShareSend || !verifiedPairingEnabled)
|
|
20463
|
+
return false;
|
|
20464
|
+
const trustManager = ctx.unifiedTrust?.trustManager;
|
|
20465
|
+
if (!trustManager) {
|
|
20466
|
+
// FD9 — cannot resolve pairing state ⇒ fail closed for a credential.
|
|
20467
|
+
res.status(403).json({
|
|
20468
|
+
success: false,
|
|
20469
|
+
refused: true,
|
|
20470
|
+
reason: 'peer-not-mutually-verified',
|
|
20471
|
+
error: 'Credential-share refused: pairing state cannot be resolved.',
|
|
20472
|
+
});
|
|
20473
|
+
return true;
|
|
20474
|
+
}
|
|
20475
|
+
const decision = evaluateOutboundCredentialShare(trustManager, ctx.threadlineRelayClient ?? null, recipientFp);
|
|
20476
|
+
if (!decision.allow) {
|
|
20477
|
+
console.warn(`[relay-send] credential-share REFUSED to=${recipientFp.slice(0, 12)} reason=${decision.reason}`);
|
|
20478
|
+
res.status(403).json({ success: false, refused: true, reason: decision.reason, error: `Credential-share refused: ${decision.reason}.` });
|
|
20479
|
+
return true;
|
|
20480
|
+
}
|
|
20481
|
+
return false;
|
|
20482
|
+
};
|
|
20178
20483
|
// Caller-supplied priority — accept ['critical','high','medium','low'].
|
|
20179
20484
|
// Default to 'medium' when omitted. Reject any unknown string so caller
|
|
20180
20485
|
// gets a clear 400 rather than silently downgraded delivery. The local
|
|
@@ -20552,6 +20857,12 @@ export function createRoutes(ctx) {
|
|
|
20552
20857
|
catch { /* @silent-fallback-ok — identity.json read is best-effort */ }
|
|
20553
20858
|
}
|
|
20554
20859
|
if (localTarget?.port && !isSelfTarget) {
|
|
20860
|
+
// §3.5 outbound credential-share chokepoint (local-delivery path). A credential
|
|
20861
|
+
// must go to a mutual-verified peer over the encrypted+signed path only — the
|
|
20862
|
+
// plaintext localhost relay-agent path is refused for a credential.
|
|
20863
|
+
const localFp = resolvePeerFingerprint(localTarget) ?? '';
|
|
20864
|
+
if (refuseCredentialShareIfBlocked(localFp))
|
|
20865
|
+
return;
|
|
20555
20866
|
// Check if the local agent is actually running
|
|
20556
20867
|
try {
|
|
20557
20868
|
const healthResp = await fetch(`http://localhost:${localTarget.port}/threadline/health`, {
|
|
@@ -20794,6 +21105,14 @@ export function createRoutes(ctx) {
|
|
|
20794
21105
|
});
|
|
20795
21106
|
return;
|
|
20796
21107
|
}
|
|
21108
|
+
// §3.5 outbound credential-share chokepoint (relay-delivery path). Refuse a
|
|
21109
|
+
// credential unless the resolved peer is mutual-verified AND the encrypted+signed
|
|
21110
|
+
// path is available — `sendAuto` would otherwise silently use the plaintext path
|
|
21111
|
+
// for an unknown-key peer, which a credential must never traverse.
|
|
21112
|
+
// E2E-PAIRING: EXEMPT — comment reword only, no route behavior change; the pairing
|
|
21113
|
+
// routes already have E2E coverage in tests/e2e/threadline-verified-pairing-alive.test.ts.
|
|
21114
|
+
if (refuseCredentialShareIfBlocked(resolvedId))
|
|
21115
|
+
return;
|
|
20797
21116
|
const relayMsgId = relayClient.sendAuto(resolvedId, message, threadId);
|
|
20798
21117
|
const effectiveRelayThreadId = threadId ?? relayMsgId;
|
|
20799
21118
|
// Canonical outbox write for the relay-delivery path — same shape as the
|
|
@@ -23063,6 +23382,8 @@ export function createRoutes(ctx) {
|
|
|
23063
23382
|
// agent token. We read demo creds from config.liveTest.demo (the documented keys).
|
|
23064
23383
|
const liveTestCfg = ctx.config.liveTest;
|
|
23065
23384
|
const demoSlackUserToken = liveTestCfg?.demo?.slackUserToken;
|
|
23385
|
+
const demoSlackUserCookie = liveTestCfg?.demo?.slackUserCookie;
|
|
23386
|
+
const demoSlackWorkspaceHost = liveTestCfg?.demo?.slackWorkspaceHost;
|
|
23066
23387
|
const demoTelegramBotToken = liveTestCfg?.demo?.telegramBotToken;
|
|
23067
23388
|
const senders = {};
|
|
23068
23389
|
// Telegram demo sender — posts as the DEMO bot (NOT Echo's), reads history via
|
|
@@ -23099,8 +23420,49 @@ export function createRoutes(ctx) {
|
|
|
23099
23420
|
// Slack demo sender — posts as the NON-AGENT demo user token, waits for a reply
|
|
23100
23421
|
// authored by Echo's bot user id (from the live SlackAdapter).
|
|
23101
23422
|
if (slackChannelId) {
|
|
23423
|
+
// E2E-PAIRING: EXEMPT — no new route and no liveness/503 change. This only
|
|
23424
|
+
// swaps the Slack demo SENDER's credential transport inside the existing,
|
|
23425
|
+
// already-E2E-covered /live-test/multi-machine-capstone route (Bearer
|
|
23426
|
+
// SlackApiClient → xoxc web-client LiveTestSlackCaller when the sender token is
|
|
23427
|
+
// xoxc). The transport selection is fully covered by the LiveTestSlackCaller
|
|
23428
|
+
// unit tests (tests/unit/LiveTestSlackCaller.test.ts); the route's liveness is
|
|
23429
|
+
// unchanged.
|
|
23102
23430
|
const agentBotUserId = ctx.slack?.getBotUserId() ?? null;
|
|
23103
|
-
|
|
23431
|
+
// An xoxc web-client token (member browser auth) needs a DIFFERENT transport
|
|
23432
|
+
// than a Bearer token: the member posts via xoxc-in-body + the `d` cookie, while
|
|
23433
|
+
// history reads still go over Echo's own Bearer bot token. Detect xoxc by its
|
|
23434
|
+
// `xoxc-` prefix; anything else is treated as a normal Bearer sender token.
|
|
23435
|
+
const isXoxcSender = typeof demoSlackUserToken === 'string' && demoSlackUserToken.startsWith('xoxc-');
|
|
23436
|
+
const slackBotToken = ctx.config.messaging?.find((m) => m.type === 'slack')?.config?.botToken;
|
|
23437
|
+
if (isXoxcSender && agentBotUserId) {
|
|
23438
|
+
// Member-as-real-user path: require the `d` cookie + a workspace host + a
|
|
23439
|
+
// Bearer bot token for the history reads. A missing one is a wiring error, not
|
|
23440
|
+
// a silent fallback — record it loudly as a blocked surface so the scenario is
|
|
23441
|
+
// a real driver-error FAIL rather than a fabricated reply.
|
|
23442
|
+
if (demoSlackUserCookie && demoSlackWorkspaceHost && slackBotToken) {
|
|
23443
|
+
const { LiveTestSlackCaller } = await import('../core/LiveTestSlackCaller.js');
|
|
23444
|
+
const demoApi = new LiveTestSlackCaller({
|
|
23445
|
+
workspaceHost: demoSlackWorkspaceHost,
|
|
23446
|
+
xoxcToken: demoSlackUserToken,
|
|
23447
|
+
dCookie: demoSlackUserCookie,
|
|
23448
|
+
botToken: slackBotToken,
|
|
23449
|
+
logger: (m) => console.log(` ${m}`),
|
|
23450
|
+
});
|
|
23451
|
+
senders.slack = new SlackLiveSender({
|
|
23452
|
+
api: demoApi,
|
|
23453
|
+
agentBotUserId,
|
|
23454
|
+
logger: (m) => console.log(` ${m}`),
|
|
23455
|
+
});
|
|
23456
|
+
}
|
|
23457
|
+
else {
|
|
23458
|
+
blockedSurfaces.push({
|
|
23459
|
+
surface: 'slack',
|
|
23460
|
+
reason: 'credential-unavailable (xoxc sender needs liveTest.demo.slackUserCookie + slackWorkspaceHost + a slack bot token for history reads)',
|
|
23461
|
+
});
|
|
23462
|
+
}
|
|
23463
|
+
}
|
|
23464
|
+
else if (demoSlackUserToken && agentBotUserId) {
|
|
23465
|
+
// Bearer (xoxp/xoxb) sender path — the original SlackApiClient transport.
|
|
23104
23466
|
const { SlackApiClient } = await import('../messaging/slack/SlackApiClient.js');
|
|
23105
23467
|
const demoApi = new SlackApiClient(demoSlackUserToken);
|
|
23106
23468
|
senders.slack = new SlackLiveSender({
|