@hellolynk-ai/workbuddy-mcp 0.3.101 → 0.3.103

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.
@@ -1,9 +1,11 @@
1
+ import { createHash } from "node:crypto";
1
2
  import runtimeSafety from "./hellolynk-runtime-safety.cjs";
2
3
  const DEFAULT_TIMEOUT_MS = 10_000;
3
4
  const MAX_JSON_RESPONSE_BYTES = 256 * 1024;
4
5
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
5
6
  const DEVICE_RUNTIME_TTL_MS = 20 * 60 * 1000;
6
7
  const DEVICE_RUNTIME_RECOVERY_WINDOW_MS = 120_000;
8
+ const DEVICE_CREDENTIAL_RECHECK_MS = 60 * 60_000;
7
9
  const DEVICE_RUNTIME_SCOPES = Object.freeze([
8
10
  "relay.text.receive",
9
11
  "relay.text.send",
@@ -567,6 +569,7 @@ export function createDeviceRuntimeSessionManager(options = {}) {
567
569
  const issue = options.issue || issueDeviceRuntimeSession;
568
570
  const now = options.now || (() => Date.now());
569
571
  const sessionStore = options.sessionStore || null;
572
+ const credentialRejections = sessionStore?.credentialRejections;
570
573
  const sessionKey = options.sessionKey == null ? null : String(options.sessionKey);
571
574
  if (sessionStore && (
572
575
  !sessionKey ||
@@ -580,6 +583,8 @@ export function createDeviceRuntimeSessionManager(options = {}) {
580
583
  5 * 60 * 1000,
581
584
  );
582
585
  let current = null;
586
+ let rejectedCredential = null;
587
+ let localIssueTail = Promise.resolve();
583
588
 
584
589
  const routeMatches = (session, input) => {
585
590
  const expectedValues = [
@@ -652,22 +657,67 @@ export function createDeviceRuntimeSessionManager(options = {}) {
652
657
  return proof;
653
658
  };
654
659
 
660
+ const issueSession = async (input, previousSession) => {
661
+ const request = { ...input, previousSession };
662
+ if (options.loadDeviceCredential) request.deviceCredential = await options.loadDeviceCredential();
663
+ const fingerprint = request.deviceCredential && request.baseUrl
664
+ ? createHash("sha256").update(JSON.stringify([
665
+ normalizeHelloLynkBaseUrl(request.baseUrl, { allowLoopbackHttp: request.allowLoopbackHttp === true }),
666
+ request.deviceCredential,
667
+ ])).digest("hex")
668
+ : null;
669
+ // The production caller holds the existing session lock, including across
670
+ // Host processes. An authority denial may drain the current Host.
671
+ if (fingerprint) {
672
+ const isCoolingDown = (rejection) => {
673
+ if (rejection?.fingerprint !== fingerprint) return false;
674
+ const age = now() - rejection.rejectedAtMs;
675
+ return age >= 0 && age < DEVICE_CREDENTIAL_RECHECK_MS;
676
+ };
677
+ // A generic denial may clear after a server-side repair, without a new
678
+ // credential. The lock permits one recheck once the cooldown ends.
679
+ if (isCoolingDown(rejectedCredential)
680
+ || isCoolingDown(await credentialRejections?.load(sessionKey).catch(() => null))) {
681
+ throw new HelloLynkClientError("device_credential_invalid", { httpStatus: 401 });
682
+ }
683
+ }
684
+ let session;
685
+ try {
686
+ session = reusable(await issue(request), input);
687
+ } catch (error) {
688
+ if (fingerprint && error?.httpStatus === 401 && error.code === "device_credential_invalid") {
689
+ rejectedCredential = { fingerprint, rejectedAtMs: now() };
690
+ // Preserve the authority denial even if local storage is unavailable.
691
+ await credentialRejections?.save(sessionKey, rejectedCredential).catch(() => {});
692
+ }
693
+ throw error;
694
+ }
695
+ if (session && fingerprint) {
696
+ rejectedCredential = null;
697
+ await credentialRejections?.remove(sessionKey).catch(() => {});
698
+ }
699
+ return session;
700
+ };
701
+
655
702
  return {
656
703
  async get(input) {
657
704
  if (!sessionStore) {
658
- if (
659
- current && current.expiresAtMs - now() > minimumRemainingMs
660
- && routeMatches(current, input)
661
- && scopesMatch(current, input)
662
- && (input?.expectedAgentId == null || input.expectedAgentId === current.agentId)
663
- && (input?.expectedPrincipalId == null || input.expectedPrincipalId === current.principalId)
664
- ) return current;
665
- const reusableCurrent = reusable(current, input);
666
- if (reusableCurrent) return reusableCurrent;
667
- const previousSession = recoveryProof(current, input);
668
- current = reusable(await issue({ ...input, previousSession }), input);
669
- if (!current) throw new HelloLynkClientError("invalid_runtime_session_handle");
670
- return current;
705
+ const result = localIssueTail.then(async () => {
706
+ if (
707
+ current && current.expiresAtMs - now() > minimumRemainingMs
708
+ && routeMatches(current, input)
709
+ && scopesMatch(current, input)
710
+ && (input?.expectedAgentId == null || input.expectedAgentId === current.agentId)
711
+ && (input?.expectedPrincipalId == null || input.expectedPrincipalId === current.principalId)
712
+ ) return current;
713
+ const reusableCurrent = reusable(current, input);
714
+ if (reusableCurrent) return reusableCurrent;
715
+ current = await issueSession(input, recoveryProof(current, input));
716
+ if (!current) throw new HelloLynkClientError("invalid_runtime_session_handle");
717
+ return current;
718
+ });
719
+ localIssueTail = result.catch(() => {});
720
+ return result;
671
721
  }
672
722
  const inMemory = reusable(current, input);
673
723
  if (inMemory) return inMemory;
@@ -678,7 +728,7 @@ export function createDeviceRuntimeSessionManager(options = {}) {
678
728
  current = persisted;
679
729
  return current;
680
730
  }
681
- const issued = reusable(await issue({ ...input, previousSession: persisted }), input);
731
+ const issued = await issueSession(input, persisted);
682
732
  if (!issued) throw new HelloLynkClientError("invalid_runtime_session_handle");
683
733
  await sessionStore.save(sessionKey, issued);
684
734
  current = issued;
@@ -7,7 +7,6 @@ import path from "node:path";
7
7
 
8
8
  import {
9
9
  createDeviceRuntimeSessionManager,
10
- issueDeviceRuntimeSession,
11
10
  normalizeHelloLynkBaseUrl,
12
11
  } from "./hellolynk-client-core.mjs";
13
12
  import { readClientProfile } from "./hellolynk-client-profile.mjs";
@@ -243,31 +242,19 @@ export async function startHelloLynkNeutralHostAssembly(options = {}, dependenci
243
242
  const manager = (dependencies.createRuntimeSessionManager || createDeviceRuntimeSessionManager)({
244
243
  sessionStore: credentialStore.runtimeSessions,
245
244
  sessionKey,
246
- issue: async ({ previousSession } = {}) => {
247
- let deviceCredential = await credentialStore.load(profile.profileId);
248
- try {
249
- return await (dependencies.issueRuntimeSession || issueDeviceRuntimeSession)({
250
- baseUrl: profile.baseUrl,
251
- allowLoopbackHttp: profile.allowLoopbackHttp,
252
- deviceCredential,
253
- expectedPrincipalId: profile.principalId,
254
- expectedAgentId: profile.agentId,
255
- expectedDelegateId: profile.delegateId,
256
- expectedHostId: profile.hostId,
257
- expectedAuthorizationVersion: profile.authorizationVersion,
258
- previousSession,
259
- fetchImpl: dependencies.fetchImpl,
260
- });
261
- } finally { deviceCredential = null; }
262
- },
245
+ loadDeviceCredential: () => credentialStore.load(profile.profileId),
246
+ issue: dependencies.issueRuntimeSession,
263
247
  });
264
248
  const sessionProvider = Object.freeze({
265
249
  get: () => manager.get({
250
+ baseUrl: profile.baseUrl,
251
+ allowLoopbackHttp: profile.allowLoopbackHttp,
266
252
  expectedPrincipalId: profile.principalId,
267
253
  expectedAgentId: profile.agentId,
268
254
  expectedDelegateId: profile.delegateId,
269
255
  expectedHostId: profile.hostId,
270
256
  expectedAuthorizationVersion: profile.authorizationVersion,
257
+ fetchImpl: dependencies.fetchImpl,
271
258
  }),
272
259
  clear: (error) => manager.clear(error),
273
260
  status: () => manager.status(),
@@ -347,6 +334,9 @@ export async function startHelloLynkNeutralHostAssembly(options = {}, dependenci
347
334
  authorizationVersion: profile.authorizationVersion,
348
335
  identityKeyId: e2ee.identityKeyId,
349
336
  runtimeHeaders: () => runtimeHeaders(sessionProvider),
337
+ // Drops only this instance's cached runtime session, and only for a stale
338
+ // session, so the Host can verify it once more with the device credential.
339
+ clearRuntimeSession: (error) => sessionProvider.clear(error),
350
340
  e2ee,
351
341
  contactE2ee,
352
342
  }));
@@ -1,6 +1,7 @@
1
1
  import { nativeConversationId, nativeConversationQuery, nativeDirectoryResult, queryLocalNativeConversations } from "./hellolynk-native-conversations.mjs";
2
2
  import { DIRECT_TEXT_E2EE_MAX_TTL_MS } from "./hellolynk-direct-text-e2ee.mjs";
3
3
  import { runModelInbox } from "./hellolynk-model-inbox.mjs";
4
+ import { shouldInvalidateDeviceRuntimeSession } from "./hellolynk-client-core.mjs";
4
5
  import crypto from "node:crypto";
5
6
  import fs from "node:fs/promises";
6
7
  import http from "node:http";
@@ -34,7 +35,7 @@ export const HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT = "hellolynk-neutral-host-p
34
35
  export const HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT = "hellolynk-neutral-host-discovery-v1";
35
36
  // Raised with every release batch, together with the six client versions. A Host reports it
36
37
  // in its health; the higher release stays and the lower one makes room.
37
- export const HELLOLYNK_HOST_RELEASE_ORDINAL = 16;
38
+ export const HELLOLYNK_HOST_RELEASE_ORDINAL = 18;
38
39
 
39
40
  const INSTALLATION_LOCK_CONTRACT = "hellolynk-neutral-host-installation-lock-v2";
40
41
  const INSTALLATION_LOCK_MAX_BYTES = 4 * 1024;
@@ -97,9 +98,16 @@ const PREKEY_REFILL_RETRY_MIN_MS = 1_000;
97
98
  const PREKEY_REFILL_RETRY_MAX_MS = 60_000;
98
99
  const RELAY_RETRY_MIN_MS = 1_000;
99
100
  const RELAY_RETRY_MAX_MS = 60_000;
100
- const RELAY_AUTHORITY_DENIAL_CODES = new Set([
101
+ // Revocation of this Host or its transport credential fences the whole Host.
102
+ const HOST_AUTHORITY_DENIAL_CODES = new Set([
101
103
  "host_transport_host_revoked", "host_transport_credential_revoked", "host_transport_credential_inactive",
104
+ ]);
105
+ // A refusal of one instance's own device credential or delegate stops only that
106
+ // instance. The Host and its other instances keep receiving and sending. The
107
+ // server reports these when issuing or using that instance's runtime session.
108
+ const ENDPOINT_AUTHORITY_DENIAL_CODES = new Set([
102
109
  "device_credential_invalid", "device_credential_revoked", "delegate_revoked", "delegate_inactive",
110
+ "device_credential_identity_inactive", "device_credential_inactive", "runtime_identity_inactive",
103
111
  ]);
104
112
  const MESSAGE_LOCAL_CAPACITY_CODES = new Set(["local_message_store_too_large"]);
105
113
  const CONTACT_REQUEST_LOCAL_CAPACITY_CODES = new Set([
@@ -247,6 +255,9 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
247
255
  let receiverState = null;
248
256
  const relayAbortController = new AbortController();
249
257
  const relayProvisionedEndpoints = new Set();
258
+ // Refused instances and their refusal code. Only a later successful registration
259
+ // (the next relay connection or session rotation) readmits one.
260
+ const rejectedEndpoints = new Map();
250
261
  const relayState = { status: "connecting", failureCount: 0, nextAttemptAtMs: 0, errorCode: null };
251
262
  const rejectedInboundTimes = new WeakSet();
252
263
  let lastRejectedInbound = null;
@@ -600,6 +611,20 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
600
611
  lastRejectedInbound = { errorCode: error.code };
601
612
  continue;
602
613
  }
614
+ // The frame's own instance was refused (for example at ACK). Stop only that
615
+ // instance and skip the frame; the relay expires or redelivers it.
616
+ const refused = ENDPOINT_AUTHORITY_DENIAL_CODES.has(error?.code)
617
+ && endpoints.get(String(item.data?.recipientDelegateId || ""));
618
+ if (refused) {
619
+ rejectedEndpoints.set(refused.delegateId, error.code);
620
+ relayState.errorCode = error.code;
621
+ lastRejectedInbound = { errorCode: error.code };
622
+ continue;
623
+ }
624
+ // A stale runtime session (such as a revocation landing between persist and
625
+ // ACK) is not a local failure. Reconnect: registration verifies that instance
626
+ // again and isolates it if refused. The durable record stays; the relay redelivers.
627
+ if (shouldInvalidateDeviceRuntimeSession(error)) throw error;
603
628
  if (!isInboundLocalCapacityError(error)) {
604
629
  inboundFailed = true;
605
630
  throw error;
@@ -612,7 +637,7 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
612
637
  } catch (error) {
613
638
  if (lifecycle !== "running") return;
614
639
  // Preserve fail-closed persistence/ACK and other malformed-inbound errors.
615
- if (inboundFailed || RELAY_AUTHORITY_DENIAL_CODES.has(error?.code)) throw error;
640
+ if (inboundFailed || HOST_AUTHORITY_DENIAL_CODES.has(error?.code)) throw error;
616
641
  await stream?.close?.().catch(() => {});
617
642
  stream = null;
618
643
  scheduleRelayRetry(error);
@@ -638,31 +663,63 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
638
663
  const task = (async () => {
639
664
  let connected = null;
640
665
  try {
641
- const registrations = await Promise.allSettled([...endpoints.values()].map(async (endpoint) => {
642
- if (endpoint.e2ee?.preKeyInventoryStatus?.status === "deferred"
643
- && !relayProvisionedEndpoints.has(endpoint.delegateId)) {
644
- try {
645
- await endpoint.e2ee.ensurePreKeyInventory({ signal });
646
- } catch (error) {
647
- if (error?.code !== "e2ee_local_prekey_capacity_exceeded") throw error;
648
- const state = preKeyRefillState(endpoint.delegateId);
649
- state.failureCount = 1;
650
- state.nextAttemptAtMs = Math.max(nowMs(clock) + PREKEY_REFILL_RETRY_MIN_MS, retryAtTimestamp(error.retryAt) || 0);
651
- state.errorCode = error.code;
666
+ const attempted = [...endpoints.values()];
667
+ const registrations = await Promise.allSettled(attempted.map(async (endpoint) => {
668
+ const register = async () => {
669
+ if (endpoint.e2ee?.preKeyInventoryStatus?.status === "deferred"
670
+ && !relayProvisionedEndpoints.has(endpoint.delegateId)) {
671
+ try {
672
+ await endpoint.e2ee.ensurePreKeyInventory({ signal });
673
+ } catch (error) {
674
+ if (error?.code !== "e2ee_local_prekey_capacity_exceeded") throw error;
675
+ const state = preKeyRefillState(endpoint.delegateId);
676
+ state.failureCount = 1;
677
+ state.nextAttemptAtMs = Math.max(nowMs(clock) + PREKEY_REFILL_RETRY_MIN_MS, retryAtTimestamp(error.retryAt) || 0);
678
+ state.errorCode = error.code;
679
+ }
680
+ relayProvisionedEndpoints.add(endpoint.delegateId);
652
681
  }
653
- relayProvisionedEndpoints.add(endpoint.delegateId);
682
+ if (signal.aborted || lifecycle !== "running") return;
683
+ await options.transport.registerEndpoint({
684
+ delegateId: endpoint.delegateId, identityKeyId: endpoint.identityKeyId,
685
+ runtimeHeaders: endpoint.runtimeHeaders, signal,
686
+ });
687
+ };
688
+ try {
689
+ await register();
690
+ } catch (error) {
691
+ // Revoking an instance also drops its runtime session, so its cached session
692
+ // fails first. Clear only that stale cache and verify once more: a device
693
+ // refusal then isolates the instance; any other failure keeps the usual path.
694
+ const cleared = !shouldInvalidateDeviceRuntimeSession(error) || signal.aborted || lifecycle !== "running"
695
+ || typeof endpoint.clearRuntimeSession !== "function" ? false
696
+ : await Promise.resolve().then(() => endpoint.clearRuntimeSession(error)).catch(() => false);
697
+ if (cleared !== true) throw error;
698
+ await register();
654
699
  }
655
- if (signal.aborted || lifecycle !== "running") return;
656
- await options.transport.registerEndpoint({
657
- delegateId: endpoint.delegateId, identityKeyId: endpoint.identityKeyId,
658
- runtimeHeaders: endpoint.runtimeHeaders, signal,
659
- });
660
700
  }));
661
701
  if (signal.aborted || lifecycle !== "running") return;
662
- const failed = registrations.find((result) => result.status === "rejected"
663
- && RELAY_AUTHORITY_DENIAL_CODES.has(result.reason?.code))
664
- || registrations.find((result) => result.status === "rejected");
702
+ const hostDenial = registrations.find((result) => result.status === "rejected"
703
+ && HOST_AUTHORITY_DENIAL_CODES.has(result.reason?.code));
704
+ if (hostDenial) throw hostDenial.reason;
705
+ // A refused instance stays out of the relay while the others connect. Each
706
+ // later connection, including session rotation, registers it again.
707
+ registrations.forEach((result, index) => {
708
+ if (result.status === "fulfilled") rejectedEndpoints.delete(attempted[index].delegateId);
709
+ else if (ENDPOINT_AUTHORITY_DENIAL_CODES.has(result.reason?.code)) {
710
+ rejectedEndpoints.set(attempted[index].delegateId, result.reason.code);
711
+ }
712
+ });
713
+ // A refused instance whose re-verification fails for another reason (such as
714
+ // its dropped runtime session) stays refused and does not hold back the others.
715
+ const failed = registrations.find((result, index) => result.status === "rejected"
716
+ && !rejectedEndpoints.has(attempted[index].delegateId));
665
717
  if (failed) throw failed.reason;
718
+ // With every instance refused there is nothing to receive: keep the Host and
719
+ // retry with the usual backoff. The session manager bounds cloud rechecks.
720
+ if (rejectedEndpoints.size === endpoints.size) {
721
+ throw registrations.find((result) => result.status === "rejected").reason;
722
+ }
666
723
  connected = await openRelayStream(signal);
667
724
  if (!connected?.events || typeof connected.events[Symbol.asyncIterator] !== "function") {
668
725
  throw failure("neutral_host_transport_stream_invalid");
@@ -672,14 +729,15 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
672
729
  return;
673
730
  }
674
731
  stream = connected;
675
- Object.assign(relayState, { status: "ready", failureCount: 0, nextAttemptAtMs: 0, errorCode: null });
732
+ Object.assign(relayState, { status: "ready", failureCount: 0, nextAttemptAtMs: 0,
733
+ errorCode: rejectedEndpoints.values().next().value ?? null });
676
734
  startReceiveTask();
677
735
  } catch (error) {
678
736
  await connected?.close?.().catch(() => {});
679
737
  if (signal.aborted || lifecycle !== "running") return;
680
738
  // An explicit revocation of this Host still fences local access. A
681
739
  // timeout/expired cloud session alone is not a local revocation.
682
- if (RELAY_AUTHORITY_DENIAL_CODES.has(error?.code)) {
740
+ if (HOST_AUTHORITY_DENIAL_CODES.has(error?.code)) {
683
741
  // Failure is exposed as host.status/failureCode to the release entry.
684
742
  void beginDrain(error.code).catch(() => {});
685
743
  return;
@@ -733,6 +791,14 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
733
791
  }
734
792
  const endpoint = endpoints.get(safeId(event.recipientDelegateId, "neutral_host_inbound_delegate_invalid"));
735
793
  if (!endpoint) throw failure("neutral_host_inbound_endpoint_unknown");
794
+ if (rejectedEndpoints.has(endpoint.delegateId)) {
795
+ // A refused instance receives nothing, also not from its Host's siblings.
796
+ // Relay frames are skipped unacknowledged; a local sender sees the failure.
797
+ const refused = failure(item.localDelivery === true
798
+ ? "neutral_host_same_host_recipient_unavailable" : rejectedEndpoints.get(endpoint.delegateId));
799
+ rejectedInboundTimes.add(refused);
800
+ throw refused;
801
+ }
736
802
  const isContactRequest = item.event === "contact_request";
737
803
  if (!isContactRequest && item.event !== "message") throw failure("neutral_host_inbound_kind_invalid");
738
804
  const decryptor = isContactRequest ? endpoint.contactE2ee : endpoint.e2ee;
@@ -1015,7 +1081,14 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
1015
1081
  return inboundResumeTask;
1016
1082
  }
1017
1083
 
1084
+ // A refused instance does not send, including over local routes that skip the relay.
1085
+ function assertEndpointAccepted(delegateId) {
1086
+ const code = rejectedEndpoints.get(delegateId);
1087
+ if (code) throw hostApplicationFailure(code);
1088
+ }
1089
+
1018
1090
  async function sendOutboundOnce({ endpoint, delegateId, clientMessageId, input }) {
1091
+ assertEndpointAccepted(endpoint.delegateId);
1019
1092
  try {
1020
1093
  const result = await endpoint.outbound.send(input);
1021
1094
  if (result?.message?.lastErrorCode === "reply_target_deleted" || result?.relay?.status === "reply_target_deleted") {
@@ -1529,6 +1602,7 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
1529
1602
  async function queryPeerNativeDirectory(delegateId, authorization, request) {
1530
1603
  const sender = endpoints.get(delegateId);
1531
1604
  if (!sender?.outbound?.sendDirectory) throw hostApplicationFailure("native_directory_unavailable");
1605
+ assertEndpointAccepted(delegateId);
1532
1606
  const friend = request.kind === "friend_target_request";
1533
1607
  const unavailable = friend ? null : nativeDirectoryResult({ conversations: [], coverage: "unavailable",
1534
1608
  available: false, exactTargetSupported: false, reason: "native_directory_unavailable" });
@@ -2274,6 +2348,7 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
2274
2348
  || current.senderDelegateId !== context.delegateId) {
2275
2349
  throw hostApplicationFailure("host_app_record_not_found");
2276
2350
  }
2351
+ assertEndpointAccepted(context.delegateId);
2277
2352
  if (!MANUAL_RETRYABLE_STATUSES.has(current.status) || current.lastErrorCode === "reply_target_deleted") {
2278
2353
  throw hostApplicationFailure("host_app_state_conflict");
2279
2354
  }
@@ -3120,7 +3195,8 @@ export async function startHelloLynkNeutralHostProcess(options = {}) {
3120
3195
  return {
3121
3196
  status: inboundPaused
3122
3197
  ? "paused"
3123
- : preKeyInventory.status === "ready" && relayState.status === "ready" && !lastRejectedInbound ? "ready" : "degraded",
3198
+ : preKeyInventory.status === "ready" && relayState.status === "ready" && !lastRejectedInbound
3199
+ && rejectedEndpoints.size === 0 ? "ready" : "degraded",
3124
3200
  ...(lastRejectedInbound ? { lastRejectedInbound } : {}),
3125
3201
  preKeyInventory,
3126
3202
  relay: {
@@ -297,6 +297,36 @@ export async function secureWindowsClientReleaseTree(clientRoot, releaseId, opti
297
297
  });
298
298
  }
299
299
 
300
+ // Accessed under the same cross-process lock as the corresponding session.
301
+ function createCredentialRejectionStore(root) {
302
+ const fileFor = (profile) => join(root, `runtime-session-rejection-${assertCredentialProfileId(profile)}.json`);
303
+ const isRejection = (value) => typeof value?.fingerprint === "string" && /^[a-f0-9]{64}$/.test(value.fingerprint)
304
+ && Number.isSafeInteger(value.rejectedAtMs) && value.rejectedAtMs >= 0;
305
+ return {
306
+ async load(profile) {
307
+ let value;
308
+ try { value = JSON.parse(await readFile(fileFor(profile), "utf8")); }
309
+ catch (error) { if (error?.code === "ENOENT" || error instanceof SyntaxError) return null; throw error; }
310
+ return isRejection(value) ? value : null;
311
+ },
312
+ async save(profile, rejection) {
313
+ if (!isRejection(rejection)) throw new HelloLynkCredentialStoreError("invalid_credential_rejection");
314
+ const { fingerprint, rejectedAtMs } = rejection;
315
+ const file = fileFor(profile);
316
+ const temporary = `${file}.${randomBytes(12).toString("hex")}.tmp`;
317
+ try {
318
+ await writeFile(temporary, `${JSON.stringify({ fingerprint, rejectedAtMs })}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
319
+ await rename(temporary, file);
320
+ } finally {
321
+ await rm(temporary, { force: true }).catch(() => {});
322
+ }
323
+ },
324
+ async remove(profile) {
325
+ await rm(fileFor(profile), { force: true });
326
+ },
327
+ };
328
+ }
329
+
300
330
  export function createPlatformCredentialStore(options = {}) {
301
331
  const platform = options.platform || process.platform;
302
332
  const run = options.runHelper || runCredentialHelper;
@@ -370,6 +400,7 @@ export function createPlatformCredentialStore(options = {}) {
370
400
  await invoke("Remove", assertCredentialProfileId(profile), "");
371
401
  },
372
402
  runtimeSessions: {
403
+ credentialRejections: createCredentialRejectionStore(runtimeStoreRoot),
373
404
  async save(profile, session) {
374
405
  const body = JSON.stringify({ schemaVersion: 1, ...assertRuntimeSession(session) });
375
406
  await invoke("StoreRuntimeSession", assertCredentialProfileId(profile), body);
@@ -504,6 +535,7 @@ export function createPlatformCredentialStore(options = {}) {
504
535
  },
505
536
  async remove(profile) { await entry(root, "device", profile, "remove"); },
506
537
  runtimeSessions: {
538
+ credentialRejections: createCredentialRejectionStore(root),
507
539
  async save(profile, session) { await entry(root, "session", profile, "save", assertRuntimeSession(session)); },
508
540
  async load(profile) {
509
541
  const value = await entry(root, "session", profile, "load");
@@ -2,7 +2,7 @@
2
2
  "contract": "hellolynk-client-npm-pack-manifest-v1",
3
3
  "client": "workbuddy",
4
4
  "packageName": "@hellolynk-ai/workbuddy-mcp",
5
- "version": "0.3.101",
5
+ "version": "0.3.103",
6
6
  "workbench": {
7
7
  "path": "runtime/workbench/hellolynk-workbench-build.json",
8
8
  "contract": "hellolynk-shared-workbench-build-v1",
@@ -27,8 +27,8 @@
27
27
  },
28
28
  {
29
29
  "path": "clients/core/hellolynk-client-core.mjs",
30
- "bytes": 34320,
31
- "sha256": "af7c9cb7a996fdb194c51dbcc745c48b616ff1686d79a1451104690d882df1b8"
30
+ "bytes": 36628,
31
+ "sha256": "5d2190c2354ede169b2053864674c7ae5b80985970dc13609d6818c5a6d16940"
32
32
  },
33
33
  {
34
34
  "path": "clients/core/hellolynk-client-profile.mjs",
@@ -217,8 +217,8 @@
217
217
  },
218
218
  {
219
219
  "path": "clients/core/hellolynk-neutral-host-assembly.mjs",
220
- "bytes": 47797,
221
- "sha256": "98b2b669183cda15272b182d5c04d3ae77511b8b657cd3179cbf2fa5d5f27920"
220
+ "bytes": 47470,
221
+ "sha256": "5a09b332a869c3863ac92c798a1a5df5901607987a2427a20b06154e41e17cb8"
222
222
  },
223
223
  {
224
224
  "path": "clients/core/hellolynk-neutral-host-installation-store.mjs",
@@ -232,8 +232,8 @@
232
232
  },
233
233
  {
234
234
  "path": "clients/core/hellolynk-neutral-host-process.mjs",
235
- "bytes": 229535,
236
- "sha256": "634279c96b8b4e83bcbcc92c5184f4ec7c31713a097f45af3f5650f8151fb78b"
235
+ "bytes": 234159,
236
+ "sha256": "0a66fca2b862fde9269722f026fc67e9d1c79435f0d410809aee669be9f9f497"
237
237
  },
238
238
  {
239
239
  "path": "clients/core/hellolynk-neutral-host-store-adapters.mjs",
@@ -247,8 +247,8 @@
247
247
  },
248
248
  {
249
249
  "path": "clients/core/hellolynk-platform-credential-store.mjs",
250
- "bytes": 39386,
251
- "sha256": "0ab136d36e890309b4cb5ff6f1cef6f3d82caf65f5b1d08c7b3992442ffe38a7"
250
+ "bytes": 40982,
251
+ "sha256": "4a4f073c3a5398d0d51be5a28c1b86b06e6f3b0b613e833928d8437c030cf65f"
252
252
  },
253
253
  {
254
254
  "path": "clients/core/hellolynk-relay-client-cli.mjs",
@@ -313,7 +313,7 @@
313
313
  {
314
314
  "path": "integrations/workbuddy/package.json",
315
315
  "bytes": 1156,
316
- "sha256": "6840c8972c83975bbac2e995b89008f24b47b0557c6cdb394f652844a1ed7111"
316
+ "sha256": "ecccf7f9ddc65b046cf1516281b6cf33a1f2c5b53bf32a61a1ca1df3aa8e937a"
317
317
  },
318
318
  {
319
319
  "path": "integrations/workbuddy/server/host-lifecycle.mjs",
@@ -423,7 +423,7 @@
423
423
  {
424
424
  "path": "package.json",
425
425
  "bytes": 2019,
426
- "sha256": "4b15cb8b2b4300e0ddb82037ff8d3b498da78c2b3525c41dda5f82d4028f71e7"
426
+ "sha256": "ff4be183cc660df51d5587c1902a9ae41a914ed27283137b3a7246c8a18193e6"
427
427
  },
428
428
  {
429
429
  "path": "README.md",
@@ -447,8 +447,8 @@
447
447
  },
448
448
  {
449
449
  "path": "runtime/core/hellolynk-client-core.mjs",
450
- "bytes": 33550,
451
- "sha256": "94bbad1d0ccee969bbffed176449e427188f1b892512a64167ab31f63d258e2b"
450
+ "bytes": 35808,
451
+ "sha256": "dbab0f067c1a7f02c43b2696e1f5972288acd4f93a18684bf3cc120edff5cf94"
452
452
  },
453
453
  {
454
454
  "path": "runtime/core/hellolynk-client-profile.mjs",
@@ -632,8 +632,8 @@
632
632
  },
633
633
  {
634
634
  "path": "runtime/core/hellolynk-neutral-host-assembly.mjs",
635
- "bytes": 46842,
636
- "sha256": "d0ac9c697bf8a85263d99a0739da557c62f52c72f4afb105eb97b3aac2ae1f3b"
635
+ "bytes": 46525,
636
+ "sha256": "b63c3371dfc08d8b15a4fd5ce84c61b40d9e7011424ccb3ff11466adb4f862cc"
637
637
  },
638
638
  {
639
639
  "path": "runtime/core/hellolynk-neutral-host-installation-store.mjs",
@@ -647,8 +647,8 @@
647
647
  },
648
648
  {
649
649
  "path": "runtime/core/hellolynk-neutral-host-process.mjs",
650
- "bytes": 225161,
651
- "sha256": "7b237a1400c604da5decbb29ab3ce556fd1b744d0cfd7d28b5560b217da120b1"
650
+ "bytes": 229709,
651
+ "sha256": "34b985f75bc693954eb66bb788fc2814482bb8bf402f14b72ea2faf5f70cf4ea"
652
652
  },
653
653
  {
654
654
  "path": "runtime/core/hellolynk-neutral-host-store-adapters.mjs",
@@ -662,8 +662,8 @@
662
662
  },
663
663
  {
664
664
  "path": "runtime/core/hellolynk-platform-credential-store.mjs",
665
- "bytes": 38559,
666
- "sha256": "3969708bafefc3b267bb34a9dcc115804c874ad14f4a036426d727c02ff70d42"
665
+ "bytes": 40123,
666
+ "sha256": "4b4b27cd86f4816e5e6ec4914816a335cdea5c4b4ffb2091775c061a9676a69c"
667
667
  },
668
668
  {
669
669
  "path": "runtime/core/hellolynk-relay-client-cli.mjs",
@@ -708,12 +708,12 @@
708
708
  {
709
709
  "path": "runtime/neutral-host-release-manifest.json",
710
710
  "bytes": 11517,
711
- "sha256": "938801bbdc7516676014bf044b7e5e992a446777bbc74016b31d6e7a6560b36d"
711
+ "sha256": "4cefc24593d0de46133fc6d4d302979ecb9c85454af7a7c9d35e197e7b54ff86"
712
712
  },
713
713
  {
714
714
  "path": "runtime/neutral-host-release-pin.json",
715
715
  "bytes": 146,
716
- "sha256": "85d3f3c173ad0daec8ab8f5b980612ef8db08afa3bff48ec41d5f146e5a3aee5"
716
+ "sha256": "4ee879b7cb941112afbc33f44c75fe309b51b7db257f63bc6a10aa0f759283c0"
717
717
  },
718
718
  {
719
719
  "path": "runtime/platform/windows/configure-agent.ps1",
@@ -893,12 +893,12 @@
893
893
  {
894
894
  "path": "workbuddy-package-manifest.json",
895
895
  "bytes": 22640,
896
- "sha256": "7f0af3682b670fd4da7539690aaecf2beb5d076978de78c84d3b676c80d8147a"
896
+ "sha256": "cd6dc4893f52b99bf2012b2f0834ef41fb3f10ec0b483b3fa3deeb4fa1d59437"
897
897
  },
898
898
  {
899
899
  "path": "workbuddy-package-pin.json",
900
900
  "bytes": 143,
901
- "sha256": "457d050c42a9425eea929d4cefb79ced77dfa2e9aae25ac9a9589265f8c1238d"
901
+ "sha256": "e562fabd2b124a20eaa83a914c4c7e751793451940ac390eccffb8e5889d2015"
902
902
  }
903
903
  ]
904
904
  }
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "contract": "hellolynk-client-npm-pack-pin-v1",
3
- "manifestSha256": "c48ed0d7b96819164fa687f1421a93d55e8725657853a505ee949f0e41b07f86"
3
+ "manifestSha256": "b159fccc127851ccd8068920b1bbcaf687e966f90d2f774a8a6cf04b5c966001"
4
4
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellolynk-ai/workbuddy-mcp",
3
- "version": "0.3.101",
3
+ "version": "0.3.103",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "WorkBuddy one-click bridge for local HelloLynk Messaging.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellolynk-ai/workbuddy-mcp",
3
- "version": "0.3.101",
3
+ "version": "0.3.103",
4
4
  "description": "HelloLynk Messaging for WorkBuddy: open conversations and let your AI message other people's AI assistants.",
5
5
  "homepage": "https://hellolynk.com",
6
6
  "repository": {