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.
Files changed (83) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +50 -0
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +36 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CoherenceJournal.d.ts +1 -1
  8. package/dist/core/CoherenceJournal.d.ts.map +1 -1
  9. package/dist/core/CoherenceJournal.js +12 -2
  10. package/dist/core/CoherenceJournal.js.map +1 -1
  11. package/dist/core/LiveTestSlackCaller.d.ts +72 -0
  12. package/dist/core/LiveTestSlackCaller.d.ts.map +1 -0
  13. package/dist/core/LiveTestSlackCaller.js +114 -0
  14. package/dist/core/LiveTestSlackCaller.js.map +1 -0
  15. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  16. package/dist/core/PostUpdateMigrator.js +25 -0
  17. package/dist/core/PostUpdateMigrator.js.map +1 -1
  18. package/dist/core/ThreadlinePairingReplicatedStore.d.ts +285 -0
  19. package/dist/core/ThreadlinePairingReplicatedStore.d.ts.map +1 -0
  20. package/dist/core/ThreadlinePairingReplicatedStore.js +462 -0
  21. package/dist/core/ThreadlinePairingReplicatedStore.js.map +1 -0
  22. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  23. package/dist/core/devGatedFeatures.js +5 -0
  24. package/dist/core/devGatedFeatures.js.map +1 -1
  25. package/dist/core/types.d.ts +22 -0
  26. package/dist/core/types.d.ts.map +1 -1
  27. package/dist/core/types.js.map +1 -1
  28. package/dist/scaffold/templates.d.ts.map +1 -1
  29. package/dist/scaffold/templates.js +6 -0
  30. package/dist/scaffold/templates.js.map +1 -1
  31. package/dist/server/routes.d.ts.map +1 -1
  32. package/dist/server/routes.js +363 -1
  33. package/dist/server/routes.js.map +1 -1
  34. package/dist/threadline/AgentTrustManager.d.ts +201 -1
  35. package/dist/threadline/AgentTrustManager.d.ts.map +1 -1
  36. package/dist/threadline/AgentTrustManager.js +395 -2
  37. package/dist/threadline/AgentTrustManager.js.map +1 -1
  38. package/dist/threadline/CredentialShareGate.d.ts +66 -0
  39. package/dist/threadline/CredentialShareGate.d.ts.map +1 -0
  40. package/dist/threadline/CredentialShareGate.js +82 -0
  41. package/dist/threadline/CredentialShareGate.js.map +1 -0
  42. package/dist/threadline/InboundMessageGate.d.ts +46 -1
  43. package/dist/threadline/InboundMessageGate.d.ts.map +1 -1
  44. package/dist/threadline/InboundMessageGate.js +105 -0
  45. package/dist/threadline/InboundMessageGate.js.map +1 -1
  46. package/dist/threadline/PairVerifyReceipt.d.ts +69 -0
  47. package/dist/threadline/PairVerifyReceipt.d.ts.map +1 -0
  48. package/dist/threadline/PairVerifyReceipt.js +151 -0
  49. package/dist/threadline/PairVerifyReceipt.js.map +1 -0
  50. package/dist/threadline/PairingPendingStore.d.ts +55 -0
  51. package/dist/threadline/PairingPendingStore.d.ts.map +1 -0
  52. package/dist/threadline/PairingPendingStore.js +110 -0
  53. package/dist/threadline/PairingPendingStore.js.map +1 -0
  54. package/dist/threadline/ThreadlineBootstrap.d.ts +11 -0
  55. package/dist/threadline/ThreadlineBootstrap.d.ts.map +1 -1
  56. package/dist/threadline/ThreadlineBootstrap.js +5 -0
  57. package/dist/threadline/ThreadlineBootstrap.js.map +1 -1
  58. package/dist/threadline/ThreadlineCrypto.d.ts +28 -0
  59. package/dist/threadline/ThreadlineCrypto.d.ts.map +1 -1
  60. package/dist/threadline/ThreadlineCrypto.js +100 -0
  61. package/dist/threadline/ThreadlineCrypto.js.map +1 -1
  62. package/dist/threadline/ThreadlineEndpoints.d.ts +8 -0
  63. package/dist/threadline/ThreadlineEndpoints.d.ts.map +1 -1
  64. package/dist/threadline/ThreadlineEndpoints.js +5 -0
  65. package/dist/threadline/ThreadlineEndpoints.js.map +1 -1
  66. package/dist/threadline/ThreadlineMCPServer.d.ts +3 -1
  67. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  68. package/dist/threadline/ThreadlineMCPServer.js +107 -1
  69. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  70. package/dist/threadline/client/ThreadlineClient.d.ts +10 -0
  71. package/dist/threadline/client/ThreadlineClient.d.ts.map +1 -1
  72. package/dist/threadline/client/ThreadlineClient.js +13 -0
  73. package/dist/threadline/client/ThreadlineClient.js.map +1 -1
  74. package/package.json +2 -1
  75. package/scripts/check-e2e-pairing.cjs +4 -1
  76. package/src/data/builtin-manifest.json +63 -63
  77. package/src/data/state-coherence-registry.json +26 -0
  78. package/src/scaffold/templates.ts +6 -0
  79. package/src/threadline/data/sas-wordlist-en.json +1 -0
  80. package/upgrades/{1.3.601.md → 1.3.602.md} +49 -0
  81. package/upgrades/1.3.603.md +39 -0
  82. package/upgrades/side-effects/secure-a2a-verified-pairing.md +116 -0
  83. package/upgrades/side-effects/xoxc-slack-caller.md +99 -0
@@ -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
- if (demoSlackUserToken && agentBotUserId) {
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({