@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99
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/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,4374 @@
|
|
|
1
|
+
import { nativeConversationId, nativeConversationQuery, nativeDirectoryResult, queryLocalNativeConversations } from "./hellolynk-native-conversations.mjs";
|
|
2
|
+
import { DIRECT_TEXT_E2EE_MAX_TTL_MS } from "./hellolynk-direct-text-e2ee.mjs";
|
|
3
|
+
import { runModelInbox } from "./hellolynk-model-inbox.mjs";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { withLocalProcessLock, inspectProcessIdentity } from "./hellolynk-local-process-lock.mjs";
|
|
9
|
+
import {
|
|
10
|
+
createHostRequestAuth,
|
|
11
|
+
createHostNonce,
|
|
12
|
+
} from "./hellolynk-host-auth.mjs";
|
|
13
|
+
import {
|
|
14
|
+
createHelloLynkHostCore,
|
|
15
|
+
HELLOLYNK_HOST_PATHS,
|
|
16
|
+
} from "./hellolynk-host.mjs";
|
|
17
|
+
import { createNeutralHostWorkbenchReadModel } from "./hellolynk-neutral-host-workbench-read-model.mjs";
|
|
18
|
+
import {
|
|
19
|
+
deriveInstanceConversationBinding,
|
|
20
|
+
getMessageConversationBinding,
|
|
21
|
+
getOutgoingConversationIntent,
|
|
22
|
+
instanceConversationKey,
|
|
23
|
+
} from "./hellolynk-instance-conversation.mjs";
|
|
24
|
+
import {
|
|
25
|
+
HELLOLYNK_WORKBENCH_CAPABILITIES,
|
|
26
|
+
HELLOLYNK_WORKBENCH_COMMAND_TYPES,
|
|
27
|
+
HELLOLYNK_WORKBENCH_RESULT_CONTRACT,
|
|
28
|
+
HELLOLYNK_WORKBENCH_SCHEMA_VERSION,
|
|
29
|
+
validateWorkbenchSnapshot,
|
|
30
|
+
} from "../workbench/contract.mjs";
|
|
31
|
+
import { createHelloLynkWorkbenchService } from "../workbench/service.mjs";
|
|
32
|
+
|
|
33
|
+
export const HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT = "hellolynk-neutral-host-process-v1";
|
|
34
|
+
export const HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT = "hellolynk-neutral-host-discovery-v1";
|
|
35
|
+
// Raised with every release batch, together with the six client versions. A Host reports it
|
|
36
|
+
// in its health; the higher release stays and the lower one makes room.
|
|
37
|
+
export const HELLOLYNK_HOST_RELEASE_ORDINAL = 10;
|
|
38
|
+
|
|
39
|
+
const INSTALLATION_LOCK_CONTRACT = "hellolynk-neutral-host-installation-lock-v2";
|
|
40
|
+
const INSTALLATION_LOCK_MAX_BYTES = 4 * 1024;
|
|
41
|
+
const INSTALLATION_LOCK_STARTUP_GRACE_MS = 30_000;
|
|
42
|
+
const PROCESS_START_REUSE_TOLERANCE_MS = 2_000;
|
|
43
|
+
// An installer that has published new bindings asks a Host of a lower release
|
|
44
|
+
// to make room; the Host leaves through its normal drain once nothing is in flight.
|
|
45
|
+
const HANDOVER_REQUEST_CONTRACT = "hellolynk-neutral-host-handover-request-v1";
|
|
46
|
+
const HANDOVER_REQUEST_MAX_BYTES = 1024;
|
|
47
|
+
// Whoever asks for the handover also records its release and the time, and the Host that
|
|
48
|
+
// leaves stamps the time again as it frees the slot. For a short hold after that a Host of a
|
|
49
|
+
// lower release does not start, so the newer release gets the slot; then the record no
|
|
50
|
+
// longer blocks, and a client that has not updated yet still gets a Host.
|
|
51
|
+
const EXPECTED_RELEASE_CONTRACT = "hellolynk-neutral-host-expected-release-v1";
|
|
52
|
+
const EXPECTED_RELEASE_MAX_BYTES = 1024;
|
|
53
|
+
const EXPECTED_RELEASE_HOLD_MS = 60_000;
|
|
54
|
+
const HANDOVER_CHECK_EVERY_MS = 1_000;
|
|
55
|
+
// The native confirmation TTL: a bounded wait so an open window is not closed.
|
|
56
|
+
const HANDOVER_NATIVE_CONFIRMATION_QUIET_MS = 120_000;
|
|
57
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$/;
|
|
58
|
+
const APPLICATION_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,198}$/;
|
|
59
|
+
const HELLOLYNK_ID = /^hellolynk:(intl|cn):[a-z0-9][a-z0-9._-]{2,179}$/;
|
|
60
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
61
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
62
|
+
const APPLICATION_CHANGE_CAPACITY = 256;
|
|
63
|
+
// AI history browsing returns one page of the newest incoming metadata. Older
|
|
64
|
+
// pages are explicit `before` requests; the pending queue has no such window.
|
|
65
|
+
const AUTHORIZED_INBOX_HISTORY_LIMIT = 10;
|
|
66
|
+
const MANUAL_RETRYABLE_STATUSES = new Set(["recipient_offline", "failed_retryable", "failed_permanent"]);
|
|
67
|
+
const CONTACT_REQUEST_STATES = new Set(["pending", "accepted", "rejected", "cancelled", "blocked", "expired"]);
|
|
68
|
+
const WORKBENCH_CLEAR_RESOLVED_CONTACT_REQUESTS_COMMAND = "contact-request.clear-resolved";
|
|
69
|
+
const WORKBENCH_LEGACY_LIFECYCLE_ROUTES_UNAVAILABLE =
|
|
70
|
+
"neutral_host_workbench_legacy_lifecycle_routes_unavailable";
|
|
71
|
+
const WORKBENCH_LOCAL_ONLY_CAPABILITIES = new Set([
|
|
72
|
+
"contact.authorization.manage", "contact.delete", "contact.instance.delete",
|
|
73
|
+
"messages.mark-read", "messages.delete-read", "contact-request.clear-resolved",
|
|
74
|
+
"messages.mark-all-read",
|
|
75
|
+
"messages.auto-read.manage",
|
|
76
|
+
"contact.instance.rename",
|
|
77
|
+
"contact.rename",
|
|
78
|
+
"messages.delete-sent", "threads.manage-local",
|
|
79
|
+
"contacts.read", "messages.read", "outbox.read", "search.local", "security.read", "threads.read",
|
|
80
|
+
]);
|
|
81
|
+
const WORKBENCH_LIFECYCLE_DEPENDENT_CAPABILITIES = new Set([
|
|
82
|
+
"block.remove",
|
|
83
|
+
"blocks.read",
|
|
84
|
+
"contact.save",
|
|
85
|
+
"contact.instance.save",
|
|
86
|
+
"relationship.end",
|
|
87
|
+
"relationships.read",
|
|
88
|
+
]);
|
|
89
|
+
const WORKBENCH_RELATIONSHIP_METADATA_CAPABILITIES = new Set([
|
|
90
|
+
...WORKBENCH_LIFECYCLE_DEPENDENT_CAPABILITIES,
|
|
91
|
+
"contact.instances.list", "contact-request.respond",
|
|
92
|
+
"contact-request.cancel", "contact-request.send",
|
|
93
|
+
"contact-request.recover-decision", "principal.search",
|
|
94
|
+
]);
|
|
95
|
+
const WORKBENCH_RELATIONSHIP_METADATA_REFRESH_MS = 15_000;
|
|
96
|
+
const PREKEY_REFILL_RETRY_MIN_MS = 1_000;
|
|
97
|
+
const PREKEY_REFILL_RETRY_MAX_MS = 60_000;
|
|
98
|
+
const RELAY_RETRY_MIN_MS = 1_000;
|
|
99
|
+
const RELAY_RETRY_MAX_MS = 60_000;
|
|
100
|
+
const RELAY_AUTHORITY_DENIAL_CODES = new Set([
|
|
101
|
+
"host_transport_host_revoked", "host_transport_credential_revoked", "host_transport_credential_inactive",
|
|
102
|
+
"device_credential_invalid", "device_credential_revoked", "delegate_revoked", "delegate_inactive",
|
|
103
|
+
]);
|
|
104
|
+
const MESSAGE_LOCAL_CAPACITY_CODES = new Set(["local_message_store_too_large"]);
|
|
105
|
+
const CONTACT_REQUEST_LOCAL_CAPACITY_CODES = new Set([
|
|
106
|
+
"contact_request_local_capacity_exceeded",
|
|
107
|
+
"contact_request_local_store_too_large",
|
|
108
|
+
]);
|
|
109
|
+
const KNOWN_CONTACT_REQUEST_SEND_FAILURE_CODES = new Set([
|
|
110
|
+
"contact_request_recipient_prekey_unavailable",
|
|
111
|
+
"contact_request_recipient_endpoint_ambiguous",
|
|
112
|
+
]);
|
|
113
|
+
const INBOUND_LOCAL_CAPACITY_CODES = new Set([
|
|
114
|
+
...MESSAGE_LOCAL_CAPACITY_CODES,
|
|
115
|
+
...CONTACT_REQUEST_LOCAL_CAPACITY_CODES,
|
|
116
|
+
]);
|
|
117
|
+
const WORKBENCH_COMMAND_CAPABILITIES = Object.freeze({
|
|
118
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.requestSend]: "messages.request-send",
|
|
119
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.getSendStatus]: "messages.request-send",
|
|
120
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.markRead]: "messages.mark-read",
|
|
121
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.markAllRead]: "messages.mark-all-read",
|
|
122
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.setAutoRead]: "messages.auto-read.manage",
|
|
123
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteRead]: "messages.delete-read",
|
|
124
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteSent]: "messages.delete-sent",
|
|
125
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.hideThread]: "threads.manage-local",
|
|
126
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.restoreThread]: "threads.manage-local",
|
|
127
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.clearThread]: "threads.manage-local",
|
|
128
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteThread]: "threads.manage-local",
|
|
129
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteArchivedThreads]: "threads.manage-local",
|
|
130
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.configureHistory]: "threads.manage-local",
|
|
131
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.requestRetry]: "outbox.request-retry",
|
|
132
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.resendDeletedReply]: "outbox.request-retry",
|
|
133
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.saveContact]: "contact.save",
|
|
134
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.renameContact]: "contact.rename",
|
|
135
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteContact]: "contact.delete",
|
|
136
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.listContactInstances]: "contact.instances.list",
|
|
137
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.saveContactInstance]: "contact.instance.save",
|
|
138
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.renameContactInstance]: "contact.instance.rename",
|
|
139
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteContactInstance]: "contact.instance.delete",
|
|
140
|
+
[WORKBENCH_CLEAR_RESOLVED_CONTACT_REQUESTS_COMMAND]:
|
|
141
|
+
WORKBENCH_CLEAR_RESOLVED_CONTACT_REQUESTS_COMMAND,
|
|
142
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.recoverContactRequestDecision]: "contact-request.recover-decision",
|
|
143
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.respondContactRequest]: "contact-request.respond",
|
|
144
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.cancelContactRequest]: "contact-request.cancel",
|
|
145
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.searchPrincipal]: "principal.search",
|
|
146
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.sendContactRequest]: "contact-request.send",
|
|
147
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.setContactAuthorization]: "contact.authorization.manage",
|
|
148
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.endRelationship]: "relationship.end",
|
|
149
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.removeBlock]: "block.remove",
|
|
150
|
+
[HELLOLYNK_WORKBENCH_COMMAND_TYPES.openAccount]: "security.open-account",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Starts the process-owning neutral Host. This is intentionally dependency
|
|
155
|
+
* injected: P5a proves lifecycle and ordering, not a production migration of
|
|
156
|
+
* the existing local stores or the Codex/OpenClaw adapters.
|
|
157
|
+
*/
|
|
158
|
+
export async function startHelloLynkNeutralHostProcess(options = {}) {
|
|
159
|
+
const tuple = normalizeTuple(options);
|
|
160
|
+
const stableRegion = tuple.region === "international" ? "intl" : tuple.region;
|
|
161
|
+
const runtimeRoot = path.resolve(String(options.runtimeRoot || ""));
|
|
162
|
+
if (!options.runtimeRoot) throw failure("neutral_host_runtime_root_required");
|
|
163
|
+
requireInterface(options.transport, ["registerEndpoint", "openEventStream", "acknowledge"], "neutral_host_transport_invalid");
|
|
164
|
+
requireInterface(options.localStore, ["persistIncomingRecoverable"], "neutral_host_local_store_invalid");
|
|
165
|
+
requireInterface(options.contactStore, ["persistIncomingRecoverable"], "neutral_host_contact_store_invalid");
|
|
166
|
+
requireInterface(options.outbox, ["persistBeforeEncryption", "markUnknown", "markResult", "freezeUnknown"], "neutral_host_outbox_invalid");
|
|
167
|
+
if (typeof options.capabilityResolver !== "function") throw failure("neutral_host_capability_resolver_required");
|
|
168
|
+
if (options.nativeHumanConfirmationBrokerFactory != null
|
|
169
|
+
&& typeof options.nativeHumanConfirmationBrokerFactory !== "function") {
|
|
170
|
+
throw failure("neutral_host_native_broker_factory_invalid");
|
|
171
|
+
}
|
|
172
|
+
if (options.changeSink != null && typeof options.changeSink !== "function") throw failure("neutral_host_change_sink_invalid");
|
|
173
|
+
if (options.prepareLocalData != null && typeof options.prepareLocalData !== "function") {
|
|
174
|
+
throw failure("neutral_host_local_data_preparer_invalid");
|
|
175
|
+
}
|
|
176
|
+
if (options.sameHostDeliveryRegistrar != null && typeof options.sameHostDeliveryRegistrar !== "function") {
|
|
177
|
+
throw failure("neutral_host_same_host_delivery_registrar_invalid");
|
|
178
|
+
}
|
|
179
|
+
if (options.humanRelationships != null) {
|
|
180
|
+
requireInterface(
|
|
181
|
+
options.humanRelationships,
|
|
182
|
+
["search", "send", "list", "decide", "clearResolved"],
|
|
183
|
+
"neutral_host_human_relationships_invalid",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (options.accountLauncher != null && typeof options.accountLauncher !== "function") {
|
|
187
|
+
throw failure("neutral_host_account_launcher_invalid");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const endpoints = normalizeEndpoints(options.endpoints, tuple);
|
|
191
|
+
const handoverCheckEveryMs = integerInRange(options.handoverCheckEveryMs, HANDOVER_CHECK_EVERY_MS, 25, 60_000);
|
|
192
|
+
// Only a test can report a lower release than this code.
|
|
193
|
+
const releaseOrdinal = integerInRange(options.releaseOrdinal, HELLOLYNK_HOST_RELEASE_ORDINAL, 0, HELLOLYNK_HOST_RELEASE_ORDINAL);
|
|
194
|
+
const directoryPending = new Map();
|
|
195
|
+
const friendTargets = new Map();
|
|
196
|
+
const layout = discoveryLayout(runtimeRoot, tuple.hostInstallationId);
|
|
197
|
+
await ensureSafeRuntimeDirectory(runtimeRoot, layout.directory);
|
|
198
|
+
await assertHelloLynkNeutralHostReleaseCurrent({ runtimeRoot, hostInstallationId: tuple.hostInstallationId, releaseOrdinal });
|
|
199
|
+
const lockHandle = await claimInstallationLock(layout.lockPath, layout.discoveryPath, tuple).catch(async (error) => {
|
|
200
|
+
// A Host of a lower release in the slot is asked to make room, as an installer would.
|
|
201
|
+
if (error?.code === "neutral_host_already_running") {
|
|
202
|
+
await requestHelloLynkNeutralHostHandover({ runtimeRoot, hostInstallationId: tuple.hostInstallationId, releaseOrdinal,
|
|
203
|
+
publishedAt: new Date().toISOString(), waitMs: 0 }).catch(() => {});
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
});
|
|
207
|
+
try {
|
|
208
|
+
await options.prepareLocalData?.(Object.freeze({
|
|
209
|
+
...tuple,
|
|
210
|
+
lockHeld: true,
|
|
211
|
+
}));
|
|
212
|
+
} catch (error) {
|
|
213
|
+
await lockHandle.release().catch(() => {});
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
const hostGeneration = crypto.randomBytes(24).toString("hex");
|
|
217
|
+
const hostStartedAt = new Date(nowMs(options.clock)).toISOString();
|
|
218
|
+
const pendingOperations = new Set();
|
|
219
|
+
const changeSink = options.changeSink || (() => undefined);
|
|
220
|
+
const applicationInboxStore = options.inboxStore || options.localStore;
|
|
221
|
+
const applicationInbox = normalizeApplicationInbox(applicationInboxStore);
|
|
222
|
+
const applicationContacts = normalizeApplicationContacts(options.contacts || options.contactDirectory);
|
|
223
|
+
const applicationContactRequests = normalizeApplicationContactRequests(
|
|
224
|
+
options.contactRequests || options.contactRequestStore || options.contactStore,
|
|
225
|
+
);
|
|
226
|
+
const applicationOutbox = normalizeApplicationOutbox(options.applicationOutbox, options.outbox, options.messenger);
|
|
227
|
+
const sharedApplicationMessageStore = typeof applicationInboxStore?.list === "function"
|
|
228
|
+
&& applicationInboxStore === (options.applicationOutbox || options.outbox);
|
|
229
|
+
const applicationChanges = [];
|
|
230
|
+
const workbenchServices = new Map();
|
|
231
|
+
const workbenchRelationshipTargets = new Map();
|
|
232
|
+
let applicationChangeCursor = 0;
|
|
233
|
+
let applicationMutationTail = Promise.resolve();
|
|
234
|
+
const clock = options.clock || undefined;
|
|
235
|
+
let lifecycle = "starting";
|
|
236
|
+
let failureCode = null;
|
|
237
|
+
let discovery = null;
|
|
238
|
+
let inboundCapacityWarningActive = false;
|
|
239
|
+
let contactRequestNoticeRead = null;
|
|
240
|
+
let inboundPaused = false;
|
|
241
|
+
let inboundPauseCode = null;
|
|
242
|
+
let inboundResumeTask = null;
|
|
243
|
+
let server = null;
|
|
244
|
+
let stream = null;
|
|
245
|
+
let receiveTask = null;
|
|
246
|
+
let relayConnectTask = null;
|
|
247
|
+
let receiverState = null;
|
|
248
|
+
const relayAbortController = new AbortController();
|
|
249
|
+
const relayProvisionedEndpoints = new Set();
|
|
250
|
+
const relayState = { status: "connecting", failureCount: 0, nextAttemptAtMs: 0, errorCode: null };
|
|
251
|
+
const rejectedInboundTimes = new WeakSet();
|
|
252
|
+
let lastRejectedInbound = null;
|
|
253
|
+
let maintenanceTimer = null;
|
|
254
|
+
let historyMaintenanceAt = -Infinity;
|
|
255
|
+
let historyMaintenanceTask = null;
|
|
256
|
+
const preKeyRefillStates = new Map();
|
|
257
|
+
const preKeyRefillQueue = new Set();
|
|
258
|
+
const preKeyRefillAbortController = new AbortController();
|
|
259
|
+
let preKeyRefillTask = null;
|
|
260
|
+
let stopPromise = null;
|
|
261
|
+
let handoverRequested = false;
|
|
262
|
+
let handoverCheckedAt = -Infinity;
|
|
263
|
+
let nativeConfirmationAt = -Infinity;
|
|
264
|
+
let ownsDiscovery = false;
|
|
265
|
+
let modelCalls = 0;
|
|
266
|
+
let nativeBroker = null;
|
|
267
|
+
let relationshipRefreshInFlight = null;
|
|
268
|
+
// Display projection only. Authority checks always use the original controller.
|
|
269
|
+
// The enclosing Host owns this cache; no account/generation survives a restart.
|
|
270
|
+
let workbenchRelationshipMetadata = null;
|
|
271
|
+
let workbenchRelationshipMetadataState = "loading";
|
|
272
|
+
let workbenchRelationshipMetadataAttemptAt = -Infinity;
|
|
273
|
+
let workbenchRelationshipMetadataEpoch = 0;
|
|
274
|
+
let workbenchRelationshipMetadataRefresh = null;
|
|
275
|
+
let workbenchRelationshipMetadataReadContext = null;
|
|
276
|
+
for (const endpoint of endpoints.values()) {
|
|
277
|
+
const initial = endpoint.e2ee?.preKeyInventoryStatus;
|
|
278
|
+
if (initial?.status !== "retrying") continue;
|
|
279
|
+
const state = preKeyRefillState(endpoint.delegateId);
|
|
280
|
+
state.failureCount = 1;
|
|
281
|
+
state.nextAttemptAtMs = Math.max(nowMs(clock), retryAtTimestamp(initial.retryAt) || 0);
|
|
282
|
+
state.errorCode = safeCode(
|
|
283
|
+
{ code: initial.errorCode },
|
|
284
|
+
"e2ee_local_prekey_capacity_exceeded",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
nativeBroker = options.nativeHumanConfirmationBrokerFactory
|
|
289
|
+
? await options.nativeHumanConfirmationBrokerFactory({
|
|
290
|
+
hostGeneration,
|
|
291
|
+
onApproved: handleNativeApproval,
|
|
292
|
+
})
|
|
293
|
+
: null;
|
|
294
|
+
if (nativeBroker && ["request", "status", "detachAdapter", "close"].some(
|
|
295
|
+
(method) => typeof nativeBroker[method] !== "function",
|
|
296
|
+
)) throw failure("neutral_host_native_broker_invalid");
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await nativeBroker?.close?.().catch(() => {});
|
|
299
|
+
await lockHandle.release().catch(() => {});
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const workbenchCapabilities = resolveWorkbenchCapabilities({
|
|
304
|
+
transport: options.transport,
|
|
305
|
+
applicationInbox,
|
|
306
|
+
applicationContacts,
|
|
307
|
+
applicationContactRequests,
|
|
308
|
+
applicationOutbox,
|
|
309
|
+
nativeBroker,
|
|
310
|
+
humanRelationships: options.humanRelationships,
|
|
311
|
+
accountLauncher: options.accountLauncher,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const core = createHelloLynkHostCore({
|
|
315
|
+
readModelNotices: async () => {
|
|
316
|
+
// Read only on the first use or after contact-request changes, never scan messages
|
|
317
|
+
// or wait for the cloud to add a short notice to an existing local response.
|
|
318
|
+
contactRequestNoticeRead ??= (async () => {
|
|
319
|
+
const records = await applicationContactRequests?.listForHuman({ states: ["pending"] });
|
|
320
|
+
return records?.filter(record => record.state === "pending" && record.decisionOutcome == null).length ?? 0;
|
|
321
|
+
})().catch(() => null);
|
|
322
|
+
const noticeRead = contactRequestNoticeRead;
|
|
323
|
+
const pendingContactRequests = await noticeRead;
|
|
324
|
+
if (pendingContactRequests == null && contactRequestNoticeRead === noticeRead) contactRequestNoticeRead = null;
|
|
325
|
+
return { pendingContactRequests, localStorageFull: inboundPaused || inboundCapacityWarningActive };
|
|
326
|
+
},
|
|
327
|
+
hostGeneration,
|
|
328
|
+
clock,
|
|
329
|
+
capabilityResolver: async (capabilityId) => {
|
|
330
|
+
const capability = await options.capabilityResolver(capabilityId);
|
|
331
|
+
if (capability?.region !== tuple.region
|
|
332
|
+
|| capability?.principalId !== tuple.principalId
|
|
333
|
+
|| capability?.hostInstallationId !== tuple.hostInstallationId) {
|
|
334
|
+
throw failure("neutral_host_capability_owner_mismatch");
|
|
335
|
+
}
|
|
336
|
+
return capability;
|
|
337
|
+
},
|
|
338
|
+
actionHandlers: {
|
|
339
|
+
localPeerExchange: (context) => {
|
|
340
|
+
if (typeof options.localPeerExchange !== "function") throw failure("neutral_host_local_peer_unavailable");
|
|
341
|
+
return trackOperation(options.localPeerExchange(context), pendingOperations);
|
|
342
|
+
},
|
|
343
|
+
localIdentityProof: (context) => {
|
|
344
|
+
if (typeof options.localIdentityProof !== "function") throw failure("neutral_host_local_identity_unavailable");
|
|
345
|
+
return trackOperation(options.localIdentityProof(context), pendingOperations);
|
|
346
|
+
},
|
|
347
|
+
detach: (context) => nativeBroker?.detachAdapter({
|
|
348
|
+
capabilityId: context.capabilityId,
|
|
349
|
+
adapterInstanceId: context.adapterInstanceId,
|
|
350
|
+
}),
|
|
351
|
+
appInboxMessages: (context) => applicationInboxMessages(context),
|
|
352
|
+
appInboxUnreadCount: (context) => applicationInboxUnreadCount(context),
|
|
353
|
+
appInboxMarkRead: (context) => trackApplicationMutation(() => applicationInboxMarkRead(context)),
|
|
354
|
+
appInboxDeleteRead: (context) => trackApplicationMutation(() => applicationInboxDeleteRead(context)),
|
|
355
|
+
appContactsList: (context) => applicationContactsList(context),
|
|
356
|
+
appContactsUpsert: (context) => trackApplicationMutation(() => applicationContactsUpsert(context)),
|
|
357
|
+
appContactsRemove: (context) => trackApplicationMutation(() => applicationContactsRemove(context)),
|
|
358
|
+
appContactAutonomousSendAuthorization: (context) => trackApplicationMutation(
|
|
359
|
+
() => applicationContactAutonomousSendAuthorization(context),
|
|
360
|
+
),
|
|
361
|
+
appAuthorizedContactsList: (context) => applicationAuthorizedContactsList(context),
|
|
362
|
+
appModelInbox: (context) => trackOperation(applicationModelInbox(context), pendingOperations),
|
|
363
|
+
appAuthorizedInboxMessages: (context) => applicationAuthorizedInboxMessages(context),
|
|
364
|
+
appAuthorizedInboxRead: (context) => applicationAuthorizedInboxRead(context),
|
|
365
|
+
appAuthorizedInboxActivate: (context) => applicationAuthorizedInboxActivate(context),
|
|
366
|
+
appAuthorizedInboxPending: (context) => applicationAuthorizedInboxMessages(context, true),
|
|
367
|
+
appAuthorizedInboxPendingQuery: (context) => applicationAuthorizedInboxMessages(context, true, context.request),
|
|
368
|
+
appAuthorizedInboxClaim: (context) => trackOperation(context.request.batchId
|
|
369
|
+
? applicationAuthorizedInboxBatch(context, "Claim") : applicationAuthorizedInboxClaim(context), pendingOperations),
|
|
370
|
+
appAuthorizedInboxBatchReserve: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Reserve"), pendingOperations),
|
|
371
|
+
appAuthorizedInboxBatchOpen: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Open"), pendingOperations),
|
|
372
|
+
appAuthorizedInboxBatchAttempt: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Attempt"), pendingOperations),
|
|
373
|
+
appAuthorizedInboxBatchConfirm: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Confirm"), pendingOperations),
|
|
374
|
+
appAuthorizedInboxBatchCancel: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Cancel"), pendingOperations),
|
|
375
|
+
appAuthorizedInboxBatchExpire: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Expire"), pendingOperations),
|
|
376
|
+
appAuthorizedInboxBatchClosed: (context) => trackOperation(applicationAuthorizedInboxBatch(context, "Closed"), pendingOperations),
|
|
377
|
+
appAuthorizedInboxFinish: (context) => trackOperation(applicationAuthorizedInboxClaim(context, true), pendingOperations),
|
|
378
|
+
appAuthorizedContactRequestsList: (context) => applicationAuthorizedContactRequestsList(context),
|
|
379
|
+
appAuthorizedContactRequestRead: (context) => applicationAuthorizedContactRequestRead(context),
|
|
380
|
+
// Contact admission is locked by the store; keep slow delivery outside the
|
|
381
|
+
// application mutation queue so human revocations can complete meanwhile.
|
|
382
|
+
appAuthorizedSend: (context) => trackOperation(applicationAuthorizedSend(context), pendingOperations),
|
|
383
|
+
appAuthorizedReply: (context) => trackOperation(applicationAuthorizedReply(context), pendingOperations),
|
|
384
|
+
appContactRequestsList: () => applicationContactRequestsList(),
|
|
385
|
+
appOutboxMessages: (context) => applicationOutboxMessages(context),
|
|
386
|
+
appOutboxRetry: (context) => trackOperation(applicationOutboxRetry(context), pendingOperations),
|
|
387
|
+
appHumanSendRequest: (context) => nativeHumanSendRequest(context),
|
|
388
|
+
appHumanSendRequestStatus: (context) => nativeHumanSendRequestStatus(context),
|
|
389
|
+
appChangesSnapshot: (context) => applicationChangesSnapshot(context),
|
|
390
|
+
appChangesSubscribe: (context) => applicationChangesSubscribe(context),
|
|
391
|
+
appWorkbenchSnapshot: (context) => applicationWorkbenchSnapshot(context),
|
|
392
|
+
appWorkbenchActionGrant: (context) => assertWorkbenchLegacyLifecycleCapabilityAvailable(
|
|
393
|
+
WORKBENCH_COMMAND_CAPABILITIES[context.request?.command],
|
|
394
|
+
),
|
|
395
|
+
appWorkbenchCommand: (context) => trackOperation(applicationWorkbenchCommand(context), pendingOperations),
|
|
396
|
+
idleShutdown: () => beginDrain("last_attachment_lease_ended"),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
function trackApplicationMutation(operation) {
|
|
401
|
+
const current = applicationMutationTail.then(() => operation());
|
|
402
|
+
applicationMutationTail = current.catch(() => undefined);
|
|
403
|
+
return trackOperation(current, pendingOperations);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function nativeHumanSendRequest(context) {
|
|
407
|
+
if (!nativeBroker) throw hostApplicationFailure("host_app_native_broker_unavailable");
|
|
408
|
+
const nativeAuthority = context.nativeAuthority;
|
|
409
|
+
if (typeof nativeAuthority?.assertCurrent !== "function" || typeof nativeAuthority?.revalidate !== "function") {
|
|
410
|
+
throw hostApplicationFailure("human_confirmation_authority_invalidated");
|
|
411
|
+
}
|
|
412
|
+
nativeAuthority.assertCurrent();
|
|
413
|
+
const target = await resolveSavedInstance(context.request, context.delegateId);
|
|
414
|
+
await nativeAuthority.revalidate();
|
|
415
|
+
nativeAuthority.assertCurrent();
|
|
416
|
+
nativeConfirmationAt = nowMs(clock);
|
|
417
|
+
const result = await nativeBroker.request({
|
|
418
|
+
capabilityId: context.capabilityId,
|
|
419
|
+
principalId: context.principalId,
|
|
420
|
+
delegateId: context.delegateId,
|
|
421
|
+
hostInstallationId: context.hostInstallationId,
|
|
422
|
+
adapterId: context.adapterId,
|
|
423
|
+
adapterInstanceId: context.adapterInstanceId,
|
|
424
|
+
hostGeneration,
|
|
425
|
+
recipientHelloLynkId: context.request.recipientHelloLynkId,
|
|
426
|
+
recipientDelegateId: target.recipientDelegateId,
|
|
427
|
+
recipientInstanceDisplayName: target.instance.displayName,
|
|
428
|
+
clientMessageId: context.request.clientMessageId,
|
|
429
|
+
text: context.request.text,
|
|
430
|
+
}, { nativeAuthority });
|
|
431
|
+
return result.body;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function nativeHumanSendRequestStatus(context) {
|
|
435
|
+
if (!nativeBroker) throw hostApplicationFailure("host_app_native_broker_unavailable");
|
|
436
|
+
const result = await nativeBroker.status({
|
|
437
|
+
capabilityId: context.capabilityId,
|
|
438
|
+
adapterInstanceId: context.adapterInstanceId,
|
|
439
|
+
actionId: context.request.actionId,
|
|
440
|
+
});
|
|
441
|
+
return result.body;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function handleNativeApproval(approval, approvalContext = {}) {
|
|
445
|
+
if (lifecycle !== "running") throw failure("human_confirmation_broker_closed");
|
|
446
|
+
const nativeAuthority = approvalContext.nativeAuthority;
|
|
447
|
+
if (typeof nativeAuthority?.assertCurrent !== "function" || typeof nativeAuthority?.revalidate !== "function") {
|
|
448
|
+
throw failure("human_confirmation_authority_invalidated");
|
|
449
|
+
}
|
|
450
|
+
nativeAuthority.assertCurrent();
|
|
451
|
+
if (approval.principalId !== tuple.principalId
|
|
452
|
+
|| approval.hostInstallationId !== tuple.hostInstallationId
|
|
453
|
+
|| approval.hostGeneration !== hostGeneration
|
|
454
|
+
|| approval.intentSource !== "human_native_dialog") {
|
|
455
|
+
throw failure("human_confirmation_binding_mismatch");
|
|
456
|
+
}
|
|
457
|
+
const endpoint = endpoints.get(approval.delegateId);
|
|
458
|
+
if (!endpoint?.outbound || typeof endpoint.outbound.send !== "function") {
|
|
459
|
+
throw failure("human_confirmation_approval_handler_missing");
|
|
460
|
+
}
|
|
461
|
+
// Approval binds the stable target. Re-read the saved entry after the
|
|
462
|
+
// native dialog; deleting/replacing an entry cannot retarget this action.
|
|
463
|
+
const target = await resolveSavedInstance(approval, approval.delegateId);
|
|
464
|
+
await nativeAuthority.revalidate();
|
|
465
|
+
// No await between this final lifetime check and outbound admission. Once
|
|
466
|
+
// admitted, keep the actual result; detach must not make it safe to resend.
|
|
467
|
+
nativeAuthority.assertCurrent();
|
|
468
|
+
// The approval arrives outside an HTTP request; a drain must still wait for it.
|
|
469
|
+
const result = await trackOperation(sendOutboundOnce({
|
|
470
|
+
endpoint,
|
|
471
|
+
delegateId: approval.delegateId,
|
|
472
|
+
clientMessageId: approval.clientMessageId,
|
|
473
|
+
input: {
|
|
474
|
+
recipientHelloLynkId: approval.recipientHelloLynkId,
|
|
475
|
+
recipientPrincipalId: target.principalId,
|
|
476
|
+
recipientDelegateId: target.recipientDelegateId,
|
|
477
|
+
region: tuple.region,
|
|
478
|
+
route: savedInstanceRoute(target),
|
|
479
|
+
text: approval.text,
|
|
480
|
+
clientMessageId: approval.clientMessageId,
|
|
481
|
+
intentSource: approval.intentSource,
|
|
482
|
+
signal: approvalContext.signal,
|
|
483
|
+
},
|
|
484
|
+
}), pendingOperations);
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
await options.outbox.freezeUnknown();
|
|
490
|
+
server = http.createServer(handleHttp);
|
|
491
|
+
server.on("clientError", (_error, socket) => socket.destroy());
|
|
492
|
+
await listenLoopback(server, Number(options.port || 0));
|
|
493
|
+
discovery = Object.freeze({
|
|
494
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT,
|
|
495
|
+
processContractId: HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT,
|
|
496
|
+
region: tuple.region,
|
|
497
|
+
principalId: tuple.principalId,
|
|
498
|
+
hostInstallationId: tuple.hostInstallationId,
|
|
499
|
+
tupleDigest: digestTuple(tuple),
|
|
500
|
+
host: LOOPBACK_HOST,
|
|
501
|
+
port: server.address().port,
|
|
502
|
+
pid: process.pid,
|
|
503
|
+
hostGeneration,
|
|
504
|
+
startedAt: new Date(nowMs(clock)).toISOString(),
|
|
505
|
+
});
|
|
506
|
+
await atomicWriteJson(layout.discoveryPath, discovery);
|
|
507
|
+
ownsDiscovery = true;
|
|
508
|
+
lifecycle = "running";
|
|
509
|
+
// Local identity, data and authenticated IPC are ready independently of the
|
|
510
|
+
// relay. Cloud setup/recovery must never hold local discovery hostage.
|
|
511
|
+
void connectRelay();
|
|
512
|
+
const maintenanceEveryMs = integerInRange(options.maintenanceEveryMs, 250, 25, 10_000);
|
|
513
|
+
maintenanceTimer = setInterval(() => void runMaintenance().catch(() => undefined), maintenanceEveryMs);
|
|
514
|
+
maintenanceTimer.unref?.();
|
|
515
|
+
} catch (error) {
|
|
516
|
+
try { await cleanupAfterFailedStart(); }
|
|
517
|
+
catch (cleanupError) {
|
|
518
|
+
// Keep the startup failure authoritative, even if its Error is frozen.
|
|
519
|
+
try { error.cleanupErrorCode = safeCode(cleanupError, "neutral_host_startup_cleanup_failed"); }
|
|
520
|
+
catch {}
|
|
521
|
+
}
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function handleHttp(request, response) {
|
|
526
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
527
|
+
writeJson(response, 403, { ok: false, error: "neutral_host_loopback_required" });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
let requestUrl;
|
|
531
|
+
try { requestUrl = new URL(request.url || "/", "http://127.0.0.1"); }
|
|
532
|
+
catch {
|
|
533
|
+
writeJson(response, 400, { ok: false, error: "neutral_host_request_target_invalid" });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const pathname = requestUrl.pathname;
|
|
537
|
+
if (requestUrl.search !== "" || requestUrl.hash !== "" || request.url !== pathname) {
|
|
538
|
+
writeJson(response, 400, { ok: false, error: "neutral_host_request_target_invalid" });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (lifecycle !== "running") {
|
|
542
|
+
if (request.method === "GET" && pathname === HELLOLYNK_HOST_PATHS.health) {
|
|
543
|
+
writeJson(response, 200, wrapperHealth());
|
|
544
|
+
} else {
|
|
545
|
+
writeJson(response, 503, { ok: false, error: "neutral_host_draining" });
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const rawBody = await readBody(request, MAX_REQUEST_BYTES).catch((error) => {
|
|
550
|
+
writeJson(response, error?.code === "neutral_host_request_too_large" ? 413 : 400, {
|
|
551
|
+
ok: false,
|
|
552
|
+
error: safeCode(error, "neutral_host_request_invalid"),
|
|
553
|
+
});
|
|
554
|
+
return null;
|
|
555
|
+
});
|
|
556
|
+
if (rawBody == null) return;
|
|
557
|
+
if (lifecycle !== "running") {
|
|
558
|
+
writeJson(response, 503, { ok: false, error: "neutral_host_draining" });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (request.method === "GET" && pathname === HELLOLYNK_HOST_PATHS.health) {
|
|
562
|
+
const result = await trackOperation(core.dispatch({
|
|
563
|
+
method: request.method,
|
|
564
|
+
pathname,
|
|
565
|
+
rawBody,
|
|
566
|
+
headers: request.headers,
|
|
567
|
+
}), pendingOperations);
|
|
568
|
+
writeJson(response, result.status, result.status === 200 ? runningHealth(result.body) : result.body);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const result = await trackOperation(core.dispatch({
|
|
572
|
+
method: request.method,
|
|
573
|
+
pathname,
|
|
574
|
+
rawBody,
|
|
575
|
+
headers: request.headers,
|
|
576
|
+
}), pendingOperations);
|
|
577
|
+
writeJson(response, result.status, result.body);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function receiveLoop() {
|
|
581
|
+
let inboundFailed = false;
|
|
582
|
+
try {
|
|
583
|
+
for await (const item of stream.events) {
|
|
584
|
+
if (lifecycle !== "running") return;
|
|
585
|
+
if (!item || item.event === "ready" || item.event === "heartbeat") continue;
|
|
586
|
+
// A relay closure is a transport control, not an encrypted message.
|
|
587
|
+
// Temporary closures reconnect below; explicit revocation still drains.
|
|
588
|
+
if (item.event === "host_transport_closed") {
|
|
589
|
+
throw failure(safeCode({ code: item.data?.reason }, "neutral_host_transport_stream_closed"));
|
|
590
|
+
}
|
|
591
|
+
const operation = trackOperation(handleInbound(item), pendingOperations);
|
|
592
|
+
try {
|
|
593
|
+
await operation;
|
|
594
|
+
lastRejectedInbound = null;
|
|
595
|
+
} catch (error) {
|
|
596
|
+
// These errors were raised before persistence/prekey consumption.
|
|
597
|
+
// Do not ACK or retry this frame; let the relay expire it while later
|
|
598
|
+
// valid frames continue. Disk/commit/ACK failures still drain below.
|
|
599
|
+
if (rejectedInboundTimes.has(error)) {
|
|
600
|
+
lastRejectedInbound = { errorCode: error.code };
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (!isInboundLocalCapacityError(error)) {
|
|
604
|
+
inboundFailed = true;
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
await pauseInboundForLocalCapacity(error);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (lifecycle === "running") throw failure("neutral_host_transport_stream_closed");
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (lifecycle !== "running") return;
|
|
614
|
+
// Preserve fail-closed persistence/ACK and other malformed-inbound errors.
|
|
615
|
+
if (inboundFailed || RELAY_AUTHORITY_DENIAL_CODES.has(error?.code)) throw error;
|
|
616
|
+
await stream?.close?.().catch(() => {});
|
|
617
|
+
stream = null;
|
|
618
|
+
scheduleRelayRetry(error);
|
|
619
|
+
if (error?.code === "host_transport_session_rotation") void connectRelay();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function scheduleRelayRetry(error) {
|
|
624
|
+
relayState.status = "retrying";
|
|
625
|
+
relayState.errorCode = safeCode(error, "neutral_host_relay_unavailable");
|
|
626
|
+
relayState.failureCount = Math.min(relayState.failureCount + 1, 31);
|
|
627
|
+
const delay = error?.code === "host_transport_session_rotation" ? 0
|
|
628
|
+
: Math.min(RELAY_RETRY_MAX_MS, RELAY_RETRY_MIN_MS * 2 ** Math.min(relayState.failureCount - 1, 16));
|
|
629
|
+
relayState.nextAttemptAtMs = nowMs(clock) + delay;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function connectRelay() {
|
|
633
|
+
if (relayConnectTask || lifecycle !== "running" || inboundPaused || relayState.status === "ready") {
|
|
634
|
+
return relayConnectTask;
|
|
635
|
+
}
|
|
636
|
+
relayState.status = "connecting";
|
|
637
|
+
const signal = relayAbortController.signal;
|
|
638
|
+
const task = (async () => {
|
|
639
|
+
let connected = null;
|
|
640
|
+
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;
|
|
652
|
+
}
|
|
653
|
+
relayProvisionedEndpoints.add(endpoint.delegateId);
|
|
654
|
+
}
|
|
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
|
+
}));
|
|
661
|
+
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");
|
|
665
|
+
if (failed) throw failed.reason;
|
|
666
|
+
connected = await openRelayStream(signal);
|
|
667
|
+
if (!connected?.events || typeof connected.events[Symbol.asyncIterator] !== "function") {
|
|
668
|
+
throw failure("neutral_host_transport_stream_invalid");
|
|
669
|
+
}
|
|
670
|
+
if (signal.aborted || lifecycle !== "running") {
|
|
671
|
+
await connected.close?.().catch(() => {});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
stream = connected;
|
|
675
|
+
Object.assign(relayState, { status: "ready", failureCount: 0, nextAttemptAtMs: 0, errorCode: null });
|
|
676
|
+
startReceiveTask();
|
|
677
|
+
} catch (error) {
|
|
678
|
+
await connected?.close?.().catch(() => {});
|
|
679
|
+
if (signal.aborted || lifecycle !== "running") return;
|
|
680
|
+
// An explicit revocation of this Host still fences local access. A
|
|
681
|
+
// timeout/expired cloud session alone is not a local revocation.
|
|
682
|
+
if (RELAY_AUTHORITY_DENIAL_CODES.has(error?.code)) {
|
|
683
|
+
// Failure is exposed as host.status/failureCode to the release entry.
|
|
684
|
+
void beginDrain(error.code).catch(() => {});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
scheduleRelayRetry(error);
|
|
688
|
+
}
|
|
689
|
+
})();
|
|
690
|
+
// Relay setup is cancellable connection work, not a message persist/ACK
|
|
691
|
+
// unit. Its late completion is fenced above and must not hold the local
|
|
692
|
+
// shutdown lock after the caller has aborted it.
|
|
693
|
+
relayConnectTask = task;
|
|
694
|
+
relayConnectTask.finally(() => { relayConnectTask = null; });
|
|
695
|
+
return relayConnectTask;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function openRelayStream(signal) {
|
|
699
|
+
signal.throwIfAborted();
|
|
700
|
+
// The installation lock is held for this Host's entire lifetime. Persist
|
|
701
|
+
// before sending, so a crash cannot reuse an accepted attempt number.
|
|
702
|
+
// Drain tracks this local write before releasing ownership to a successor.
|
|
703
|
+
const receiverAttempt = await trackOperation((async () => {
|
|
704
|
+
if (!receiverState) {
|
|
705
|
+
await assertSafePath(layout.receiverStatePath, { mustExist: false, directory: false });
|
|
706
|
+
let saved;
|
|
707
|
+
try { saved = JSON.parse(await fs.readFile(layout.receiverStatePath, "utf8")); }
|
|
708
|
+
catch (error) { if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
|
|
709
|
+
receiverState = saved;
|
|
710
|
+
}
|
|
711
|
+
// This is disposable receive bookkeeping, not an account credential.
|
|
712
|
+
// A fresh ID cannot reuse an old attempt or displace another receiver.
|
|
713
|
+
if (!receiverState || typeof receiverState !== "object" || Array.isArray(receiverState)
|
|
714
|
+
|| receiverState.contract !== "hellolynk-host-receiver-state-v1" || receiverState.tupleDigest !== digestTuple(tuple)
|
|
715
|
+
|| typeof receiverState.receiverId !== "string" || !SAFE_ID.test(receiverState.receiverId)
|
|
716
|
+
|| !Number.isSafeInteger(receiverState.sequence) || receiverState.sequence < 0 || receiverState.sequence >= Number.MAX_SAFE_INTEGER) {
|
|
717
|
+
receiverState = { contract: "hellolynk-host-receiver-state-v1", tupleDigest: digestTuple(tuple), receiverId: crypto.randomUUID(), sequence: 0 };
|
|
718
|
+
}
|
|
719
|
+
const next = { ...receiverState, sequence: receiverState.sequence + 1 };
|
|
720
|
+
await atomicWriteJson(layout.receiverStatePath, next);
|
|
721
|
+
receiverState = next;
|
|
722
|
+
return { receiverId: next.receiverId, sequence: next.sequence };
|
|
723
|
+
})(), pendingOperations);
|
|
724
|
+
signal.throwIfAborted();
|
|
725
|
+
if (lifecycle !== "running") throw failure("neutral_host_draining");
|
|
726
|
+
return options.transport.openEventStream({ signal, receiverAttempt });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function handleInbound(item) {
|
|
730
|
+
const event = assertPlainRecord(item.data, "neutral_host_inbound_event_invalid");
|
|
731
|
+
if (event.recipientPrincipalId !== tuple.principalId || event.recipientHostId !== tuple.hostInstallationId) {
|
|
732
|
+
throw failure("neutral_host_inbound_owner_mismatch");
|
|
733
|
+
}
|
|
734
|
+
const endpoint = endpoints.get(safeId(event.recipientDelegateId, "neutral_host_inbound_delegate_invalid"));
|
|
735
|
+
if (!endpoint) throw failure("neutral_host_inbound_endpoint_unknown");
|
|
736
|
+
const isContactRequest = item.event === "contact_request";
|
|
737
|
+
if (!isContactRequest && item.event !== "message") throw failure("neutral_host_inbound_kind_invalid");
|
|
738
|
+
const decryptor = isContactRequest ? endpoint.contactE2ee : endpoint.e2ee;
|
|
739
|
+
let opened;
|
|
740
|
+
try {
|
|
741
|
+
opened = await decryptor.openIncoming({
|
|
742
|
+
event,
|
|
743
|
+
recipientPrincipalId: tuple.principalId,
|
|
744
|
+
recipientDelegateId: endpoint.delegateId,
|
|
745
|
+
});
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (["e2ee_envelope_expired", "e2ee_created_at_in_future",
|
|
748
|
+
"contact_request_envelope_expired", "contact_request_created_at_in_future"].includes(error?.code)) {
|
|
749
|
+
rejectedInboundTimes.add(error);
|
|
750
|
+
}
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
if (!opened || typeof opened.text !== "string" || typeof opened.commit !== "function") {
|
|
754
|
+
throw failure("neutral_host_inbound_decrypt_invalid");
|
|
755
|
+
}
|
|
756
|
+
if (!isContactRequest && opened.rejectedNativeContent) return consumeNativeFrame(event, opened, endpoint, item.localDelivery === true);
|
|
757
|
+
if (!isContactRequest && opened.nativeDirectory) return handleNativeDirectory(event, opened, endpoint, item.localDelivery === true);
|
|
758
|
+
if (opened.targetConversationId && (opened.replyToClientMessageId
|
|
759
|
+
|| (opened.senderPrincipalId !== tuple.principalId && !isFriendTargetFrame(opened, item.localDelivery))))
|
|
760
|
+
return consumeNativeFrame(event, opened, endpoint, item.localDelivery === true);
|
|
761
|
+
const friendTarget = opened.targetConversationId && opened.senderPrincipalId !== tuple.principalId
|
|
762
|
+
? admitFriendTarget(opened, endpoint, event.clientMessageId) : null;
|
|
763
|
+
const store = isContactRequest ? options.contactStore : options.localStore;
|
|
764
|
+
// The persistence coordinator orders business-record durability before
|
|
765
|
+
// prekey commit and journals recovery. It is not a cross-store transaction.
|
|
766
|
+
const persisted = await store.persistIncomingRecoverable({
|
|
767
|
+
event,
|
|
768
|
+
text: opened.text,
|
|
769
|
+
envelopeDigest: opened.envelopeDigest || event.envelopeDigest,
|
|
770
|
+
...(isContactRequest ? { introDigestSalt: opened.introDigestSalt } : {
|
|
771
|
+
senderPrincipalId: opened.senderPrincipalId,
|
|
772
|
+
senderDelegateId: opened.senderDelegateId,
|
|
773
|
+
authorizationScope: opened.authorizationScope,
|
|
774
|
+
intentSource: opened.intentSource,
|
|
775
|
+
...(opened.replyToClientMessageId ? { replyToClientMessageId: opened.replyToClientMessageId } : {}),
|
|
776
|
+
...(opened.targetConversationId ? { targetConversationId: opened.targetConversationId } : {}),
|
|
777
|
+
...(friendTarget ? { friendTarget } : {}),
|
|
778
|
+
conversationBinding: opened.e2eeVersion === 2 ? deriveInstanceConversationBinding(opened, {
|
|
779
|
+
direction: "incoming", region: tuple.region, principalId: tuple.principalId,
|
|
780
|
+
}) : null,
|
|
781
|
+
}),
|
|
782
|
+
commitPrekey: opened.commit,
|
|
783
|
+
humanOnly: isContactRequest,
|
|
784
|
+
modelVisibility: "none",
|
|
785
|
+
});
|
|
786
|
+
// The record and prekey commit are durable now. Refresh the application
|
|
787
|
+
// before network ACK can delay visibility.
|
|
788
|
+
await publishApplicationChange(
|
|
789
|
+
isContactRequest ? "contact_requests_changed" : "inbox_changed",
|
|
790
|
+
persisted.localRecordId || event.eventId,
|
|
791
|
+
);
|
|
792
|
+
let acknowledged = true;
|
|
793
|
+
if (item.localDelivery !== true) {
|
|
794
|
+
try {
|
|
795
|
+
await options.transport.acknowledge({ event, kind: item.event, persisted });
|
|
796
|
+
} catch (error) {
|
|
797
|
+
const deadline = typeof event.ackDeadlineAt === "string" ? Date.parse(event.ackDeadlineAt) : NaN;
|
|
798
|
+
if (isContactRequest || error?.code !== "relay_ack_delivery_attempt_not_found" || error?.httpStatus !== 409
|
|
799
|
+
|| !Number.isFinite(deadline) || new Date(deadline).toISOString() !== event.ackDeadlineAt
|
|
800
|
+
|| nowMs(clock) < deadline) throw error;
|
|
801
|
+
// The relay already removed this expired attempt. Keep the durable
|
|
802
|
+
// message and receiver, without claiming a cloud ACK or retrying a send.
|
|
803
|
+
acknowledged = false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (acknowledged && item.localDelivery !== true && !persisted.duplicate
|
|
807
|
+
&& preKeyRefillMethod(endpoint)) {
|
|
808
|
+
// A consumed one-time prekey must remain retained for replay protection.
|
|
809
|
+
// Refill only after its ACK has made the server inventory authoritative;
|
|
810
|
+
// a refill error must never roll back an acknowledged local record or stop
|
|
811
|
+
// the Host receiver.
|
|
812
|
+
requestPreKeyRefill(endpoint);
|
|
813
|
+
}
|
|
814
|
+
const change = Object.freeze({
|
|
815
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT,
|
|
816
|
+
type: isContactRequest ? "contact_requests_changed" : "inbox_changed",
|
|
817
|
+
eventId: event.eventId,
|
|
818
|
+
duplicate: persisted.duplicate === true,
|
|
819
|
+
...(acknowledged ? {} : { acknowledged: false }),
|
|
820
|
+
modelVisibility: "none",
|
|
821
|
+
});
|
|
822
|
+
assertNoSensitiveEventFields(change);
|
|
823
|
+
await changeSink(change);
|
|
824
|
+
return Object.freeze({
|
|
825
|
+
localRecordId: persisted.localRecordId,
|
|
826
|
+
duplicate: persisted.duplicate === true,
|
|
827
|
+
acknowledged,
|
|
828
|
+
...(persisted.replyTargetDeleted ? { replyTargetDeleted: true } : {}),
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
options.sameHostDeliveryRegistrar?.((item) => {
|
|
833
|
+
if (lifecycle !== "running") throw failure("neutral_host_same_host_delivery_unavailable");
|
|
834
|
+
if (item?.localDelivery !== true || item?.event !== "message") {
|
|
835
|
+
throw failure("neutral_host_same_host_delivery_invalid");
|
|
836
|
+
}
|
|
837
|
+
return trackOperation(handleInbound(item), pendingOperations);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
async function reportInboundCapacity() {
|
|
841
|
+
const change = Object.freeze({
|
|
842
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT,
|
|
843
|
+
type: "local_storage_full",
|
|
844
|
+
modelVisibility: "none",
|
|
845
|
+
});
|
|
846
|
+
assertNoSensitiveEventFields(change);
|
|
847
|
+
await publishChange(change, "local-storage-capacity");
|
|
848
|
+
inboundCapacityWarningActive = true;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function startReceiveTask() {
|
|
852
|
+
receiveTask = receiveLoop().catch((error) => {
|
|
853
|
+
if (lifecycle === "running") {
|
|
854
|
+
// Do not turn a terminal drain failure into an unhandled process abort.
|
|
855
|
+
void beginDrain(safeCode(error, "neutral_host_receive_failed")).catch(() => {});
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function requestPreKeyRefill(endpoint) {
|
|
861
|
+
const state = preKeyRefillState(endpoint.delegateId);
|
|
862
|
+
if (state.failureCount === 0) state.nextAttemptAtMs = 0;
|
|
863
|
+
preKeyRefillQueue.add(endpoint.delegateId);
|
|
864
|
+
if (preKeyRefillTask) return preKeyRefillTask;
|
|
865
|
+
const task = trackOperation(runPreKeyRefillQueue(), pendingOperations);
|
|
866
|
+
preKeyRefillTask = task;
|
|
867
|
+
task.then(
|
|
868
|
+
() => { if (preKeyRefillTask === task) preKeyRefillTask = null; },
|
|
869
|
+
() => { if (preKeyRefillTask === task) preKeyRefillTask = null; },
|
|
870
|
+
);
|
|
871
|
+
return task;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async function runPreKeyRefillQueue() {
|
|
875
|
+
while (preKeyRefillQueue.size > 0 && lifecycle === "running") {
|
|
876
|
+
const delegateId = preKeyRefillQueue.values().next().value;
|
|
877
|
+
preKeyRefillQueue.delete(delegateId);
|
|
878
|
+
const endpoint = endpoints.get(delegateId);
|
|
879
|
+
const refill = preKeyRefillMethod(endpoint);
|
|
880
|
+
if (!refill) continue;
|
|
881
|
+
const state = preKeyRefillState(delegateId);
|
|
882
|
+
const attemptedAtMs = nowMs(clock);
|
|
883
|
+
if (attemptedAtMs < state.nextAttemptAtMs) continue;
|
|
884
|
+
try {
|
|
885
|
+
await refill.call(endpoint.e2ee, { signal: preKeyRefillAbortController.signal });
|
|
886
|
+
state.failureCount = 0;
|
|
887
|
+
state.nextAttemptAtMs = 0;
|
|
888
|
+
state.errorCode = null;
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (preKeyRefillAbortController.signal.aborted || lifecycle !== "running") return;
|
|
891
|
+
state.failureCount = Math.min(state.failureCount + 1, 31);
|
|
892
|
+
const exponent = Math.min(state.failureCount - 1, 16);
|
|
893
|
+
const backoffMs = Math.min(
|
|
894
|
+
PREKEY_REFILL_RETRY_MAX_MS,
|
|
895
|
+
PREKEY_REFILL_RETRY_MIN_MS * (2 ** exponent),
|
|
896
|
+
);
|
|
897
|
+
state.nextAttemptAtMs = Math.max(
|
|
898
|
+
attemptedAtMs + backoffMs,
|
|
899
|
+
retryAtTimestamp(error?.retryAt) || 0,
|
|
900
|
+
);
|
|
901
|
+
state.errorCode = safeCode(error, "e2ee_prekey_refill_failed");
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function preKeyRefillMethod(endpoint) {
|
|
907
|
+
if (typeof endpoint?.e2ee?.ensurePreKeyInventory === "function") {
|
|
908
|
+
return endpoint.e2ee.ensurePreKeyInventory;
|
|
909
|
+
}
|
|
910
|
+
return typeof endpoint?.e2ee?.replenishPreKeyInventory === "function"
|
|
911
|
+
? endpoint.e2ee.replenishPreKeyInventory
|
|
912
|
+
: null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function preKeyRefillState(delegateId) {
|
|
916
|
+
let state = preKeyRefillStates.get(delegateId);
|
|
917
|
+
if (!state) {
|
|
918
|
+
state = { failureCount: 0, nextAttemptAtMs: 0, errorCode: null };
|
|
919
|
+
preKeyRefillStates.set(delegateId, state);
|
|
920
|
+
}
|
|
921
|
+
return state;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function preKeyInventoryHealth() {
|
|
925
|
+
const failures = [...preKeyRefillStates.values()].filter((state) => state.failureCount > 0);
|
|
926
|
+
if (failures.length === 0) return { status: "ready", failedEndpointCount: 0 };
|
|
927
|
+
const next = failures.reduce((selected, state) => (
|
|
928
|
+
!selected || state.nextAttemptAtMs < selected.nextAttemptAtMs ? state : selected
|
|
929
|
+
), null);
|
|
930
|
+
return {
|
|
931
|
+
status: "retrying",
|
|
932
|
+
failedEndpointCount: failures.length,
|
|
933
|
+
retryAt: new Date(next.nextAttemptAtMs).toISOString(),
|
|
934
|
+
errorCode: next.errorCode,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function runMaintenance() {
|
|
939
|
+
const health = await core.runMaintenance();
|
|
940
|
+
if (lifecycle !== "running") return health;
|
|
941
|
+
const now = nowMs(clock);
|
|
942
|
+
if (await handOverWhenQuiet(now)) return health;
|
|
943
|
+
if (relayState.status === "retrying" && now >= relayState.nextAttemptAtMs) void connectRelay();
|
|
944
|
+
if (typeof applicationInbox?.maintainLocalHistory === "function" && !historyMaintenanceTask
|
|
945
|
+
&& now - historyMaintenanceAt >= 60_000) {
|
|
946
|
+
historyMaintenanceAt = now;
|
|
947
|
+
// Bounded local bookkeeping only; it does not call or schedule AI work.
|
|
948
|
+
historyMaintenanceTask = trackOperation(Promise.resolve().then(async () => {
|
|
949
|
+
const result = await applicationInbox.maintainLocalHistory({ limit: 50 });
|
|
950
|
+
if (result?.archivedCount > 0 && lifecycle === "running") {
|
|
951
|
+
await publishApplicationChange("inbox_changed", "local_history_maintenance");
|
|
952
|
+
}
|
|
953
|
+
}), pendingOperations).catch(() => undefined).finally(() => { historyMaintenanceTask = null; });
|
|
954
|
+
}
|
|
955
|
+
for (const [delegateId, state] of preKeyRefillStates) {
|
|
956
|
+
if (state.failureCount > 0 && state.nextAttemptAtMs <= now) preKeyRefillQueue.add(delegateId);
|
|
957
|
+
}
|
|
958
|
+
if (preKeyRefillQueue.size > 0) {
|
|
959
|
+
const endpoint = endpoints.get(preKeyRefillQueue.values().next().value);
|
|
960
|
+
if (endpoint) await requestPreKeyRefill(endpoint);
|
|
961
|
+
}
|
|
962
|
+
return runningHealth(health);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function handOverWhenQuiet(now) {
|
|
966
|
+
if (!handoverRequested && now - handoverCheckedAt >= handoverCheckEveryMs) {
|
|
967
|
+
handoverCheckedAt = now;
|
|
968
|
+
const request = await readHandoverRequest(layout.handoverPath).catch(() => null);
|
|
969
|
+
handoverRequested = request?.hostGeneration === hostGeneration;
|
|
970
|
+
}
|
|
971
|
+
// Sends, receipts, prekey refills and verifications are all pendingOperations.
|
|
972
|
+
if (!handoverRequested || lifecycle !== "running" || pendingOperations.size > 0
|
|
973
|
+
|| nowMs(clock) - nativeConfirmationAt < HANDOVER_NATIVE_CONFIRMATION_QUIET_MS) return false;
|
|
974
|
+
// The idle-stop drain: admitted work finishes, discovery and lock are
|
|
975
|
+
// released, and adapters reconnect to the release now bound on disk.
|
|
976
|
+
void beginDrain("release_handover_requested").catch(() => undefined);
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function pauseInboundForLocalCapacity(error) {
|
|
981
|
+
if (inboundPaused) return;
|
|
982
|
+
inboundPaused = true;
|
|
983
|
+
inboundPauseCode = String(error?.code || "");
|
|
984
|
+
await stream?.close?.().catch(() => {});
|
|
985
|
+
if (!inboundCapacityWarningActive) await reportInboundCapacity().catch(() => undefined);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function resumeInboundAfterHumanCleanup(acceptedCapacityCodes) {
|
|
989
|
+
if (!inboundPaused || lifecycle !== "running") return false;
|
|
990
|
+
if (!acceptedCapacityCodes?.has(inboundPauseCode)) return false;
|
|
991
|
+
if (inboundResumeTask) return inboundResumeTask;
|
|
992
|
+
inboundResumeTask = (async () => {
|
|
993
|
+
try {
|
|
994
|
+
const resumed = await openRelayStream(relayAbortController.signal);
|
|
995
|
+
if (!resumed?.events || typeof resumed.events[Symbol.asyncIterator] !== "function") {
|
|
996
|
+
throw failure("neutral_host_transport_stream_invalid");
|
|
997
|
+
}
|
|
998
|
+
if (lifecycle !== "running") {
|
|
999
|
+
await resumed.close?.().catch(() => {});
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
stream = resumed;
|
|
1003
|
+
inboundPaused = false;
|
|
1004
|
+
inboundPauseCode = null;
|
|
1005
|
+
inboundCapacityWarningActive = false;
|
|
1006
|
+
Object.assign(relayState, { status: "ready", failureCount: 0, nextAttemptAtMs: 0, errorCode: null });
|
|
1007
|
+
startReceiveTask();
|
|
1008
|
+
return true;
|
|
1009
|
+
} catch {
|
|
1010
|
+
return false;
|
|
1011
|
+
} finally {
|
|
1012
|
+
inboundResumeTask = null;
|
|
1013
|
+
}
|
|
1014
|
+
})();
|
|
1015
|
+
return inboundResumeTask;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function sendOutboundOnce({ endpoint, delegateId, clientMessageId, input }) {
|
|
1019
|
+
try {
|
|
1020
|
+
const result = await endpoint.outbound.send(input);
|
|
1021
|
+
if (result?.message?.lastErrorCode === "reply_target_deleted" || result?.relay?.status === "reply_target_deleted") {
|
|
1022
|
+
throw hostApplicationFailure("host_app_reply_target_deleted");
|
|
1023
|
+
}
|
|
1024
|
+
const status = String(result?.status || "");
|
|
1025
|
+
const delivered = [
|
|
1026
|
+
"acknowledged_by_recipient_local_store",
|
|
1027
|
+
"delivered_to_live_connection",
|
|
1028
|
+
].includes(status);
|
|
1029
|
+
if (result?.ok === false
|
|
1030
|
+
|| (status && !delivered)
|
|
1031
|
+
|| (!status && result?.ok !== true)) {
|
|
1032
|
+
throw failure("neutral_host_outbound_delivery_failed");
|
|
1033
|
+
}
|
|
1034
|
+
await publishOutboxChange(clientMessageId, delegateId);
|
|
1035
|
+
return result;
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
// The production messenger persists the failed/unknown outbox state before
|
|
1038
|
+
// throwing. Publish that durable state and return the failure to the caller.
|
|
1039
|
+
// This path never calls retry and never schedules another send attempt.
|
|
1040
|
+
await publishOutboxChange(clientMessageId, delegateId).catch(() => undefined);
|
|
1041
|
+
throw error;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function publishOutboxChange(clientMessageId, delegateId) {
|
|
1046
|
+
const change = Object.freeze({
|
|
1047
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT,
|
|
1048
|
+
type: "outbox_changed",
|
|
1049
|
+
clientMessageId,
|
|
1050
|
+
modelVisibility: "none",
|
|
1051
|
+
});
|
|
1052
|
+
assertNoSensitiveEventFields(change);
|
|
1053
|
+
await publishChange(change, clientMessageId, delegateId);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function applicationInboxMessages(context) {
|
|
1057
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1058
|
+
const messages = await applicationInbox.list();
|
|
1059
|
+
return {
|
|
1060
|
+
messages: messages
|
|
1061
|
+
.filter((message) => message?.direction === "incoming")
|
|
1062
|
+
.map((message) => projectInboxMessage(message, tuple)),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function applicationInboxUnreadCount(context) {
|
|
1067
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1068
|
+
if (typeof applicationInbox.getMessageCounts !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1069
|
+
// The protected index updates these counts with every message mutation.
|
|
1070
|
+
// A badge needs no bodies and must not include another AI's conversations.
|
|
1071
|
+
const counts = await applicationInbox.getMessageCounts(localHistoryScope(context));
|
|
1072
|
+
if (!Number.isSafeInteger(counts?.inbox?.unread) || counts.inbox.unread < 0) {
|
|
1073
|
+
throw hostApplicationFailure("host_app_response_invalid");
|
|
1074
|
+
}
|
|
1075
|
+
return { unreadCount: counts.inbox.unread };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function applicationInboxMarkRead(context) {
|
|
1079
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1080
|
+
const localMessageId = context.request.localMessageId;
|
|
1081
|
+
const message = await applicationInbox.getIncoming(localMessageId);
|
|
1082
|
+
if (!message) throw hostApplicationFailure("host_app_record_not_found");
|
|
1083
|
+
if (message.direction !== "incoming") throw hostApplicationFailure("host_app_record_not_found");
|
|
1084
|
+
if (!isUnreadIncoming(message)) {
|
|
1085
|
+
scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1086
|
+
return { localMessageId, unread: false };
|
|
1087
|
+
}
|
|
1088
|
+
const updated = await applicationInbox.markRead(localMessageId);
|
|
1089
|
+
if (!updated || isUnreadIncoming(updated)) throw hostApplicationFailure("host_app_action_failed");
|
|
1090
|
+
await publishApplicationChange("inbox_changed", localMessageId);
|
|
1091
|
+
scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1092
|
+
return { localMessageId, unread: false };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function applicationInboxMarkAllRead(context) {
|
|
1096
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1097
|
+
if (typeof applicationInbox.markIncomingRead !== "function") throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1098
|
+
const { threadId, beforeReceiptSeq } = context.request;
|
|
1099
|
+
if (threadId !== null) await requireLocalConversation(context, threadId);
|
|
1100
|
+
const { markedCount } = await applicationInbox.markIncomingRead({ scope: localHistoryScope(context), threadId, beforeReceiptSeq });
|
|
1101
|
+
if (markedCount > 0) await publishApplicationChange("inbox_changed", threadId || context.delegateId, context.delegateId);
|
|
1102
|
+
scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1103
|
+
return { kind: "messages-marked-read", markedCount, threadId, beforeReceiptSeq };
|
|
1104
|
+
}
|
|
1105
|
+
async function applicationConversationAutoRead(context) {
|
|
1106
|
+
if (typeof applicationInbox?.configureConversationAutoRead !== "function") throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1107
|
+
const { threadId, enabled } = context.request;
|
|
1108
|
+
const conversation = await requireLocalConversation(context, threadId);
|
|
1109
|
+
if (!conversation?.binding) throw hostApplicationFailure("host_app_record_not_found");
|
|
1110
|
+
const result = await applicationInbox.configureConversationAutoRead({ scope: localHistoryScope(context),
|
|
1111
|
+
threadId, enabled, conversationBinding: conversation.binding, peerHelloLynkId: conversation.peerHelloLynkId });
|
|
1112
|
+
await publishApplicationChange("inbox_changed", threadId, context.delegateId);
|
|
1113
|
+
return { kind: "conversation-auto-read", ...result };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function applicationInboxDeleteRead(context) {
|
|
1117
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1118
|
+
const localMessageIds = context.request.localMessageIds;
|
|
1119
|
+
const selected = await Promise.all(localMessageIds.map((id) => applicationInbox.getIncoming(id)));
|
|
1120
|
+
if (selected.some((message) => !message || message.direction !== "incoming")) {
|
|
1121
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1122
|
+
}
|
|
1123
|
+
if (selected.some(isUnreadIncoming)) throw hostApplicationFailure("host_app_state_conflict");
|
|
1124
|
+
const result = await applicationInbox.deleteRead(localMessageIds);
|
|
1125
|
+
const deletedIds = result?.deletedIds;
|
|
1126
|
+
if (!Array.isArray(deletedIds) || new Set(deletedIds).size !== deletedIds.length
|
|
1127
|
+
|| deletedIds.some((id) => !localMessageIds.includes(id))) {
|
|
1128
|
+
throw hostApplicationFailure("host_app_action_failed");
|
|
1129
|
+
}
|
|
1130
|
+
// Earlier or concurrent cleanup may have left only deletion tombstones.
|
|
1131
|
+
// The store's committed result is authoritative; report the actual count
|
|
1132
|
+
// instead of turning successful cleanup into a server error.
|
|
1133
|
+
for (const localMessageId of deletedIds) await publishApplicationChange("inbox_changed", localMessageId);
|
|
1134
|
+
scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1135
|
+
return { deletedCount: deletedIds.length, deletedIds };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function localHistoryScope(context) {
|
|
1139
|
+
return { principalId: tuple.principalId, delegateId: context.delegateId, region: tuple.region };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function resumeAfterLocalHistoryCleanup(result) {
|
|
1143
|
+
if (result.deletedCount > 0) scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function scheduleInboundResumeAfterHumanCleanup(acceptedCapacityCodes) {
|
|
1147
|
+
if (!inboundPaused || lifecycle !== "running" || !acceptedCapacityCodes.has(inboundPauseCode)) return false;
|
|
1148
|
+
// The completed local cleanup never waits for reconnecting the receive
|
|
1149
|
+
// stream. Track that separate recovery so Host shutdown can drain it.
|
|
1150
|
+
void trackOperation(Promise.resolve().then(() => resumeInboundAfterHumanCleanup(acceptedCapacityCodes)),
|
|
1151
|
+
pendingOperations).catch(() => {});
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async function requireLocalConversation(context, threadId) {
|
|
1156
|
+
const scope = localHistoryScope(context);
|
|
1157
|
+
const summary = typeof applicationInbox.readWorkbenchSummary === "function"
|
|
1158
|
+
? await applicationInbox.readWorkbenchSummary(scope) : null;
|
|
1159
|
+
// The indexed summary is authenticated and restricted to this scope (or
|
|
1160
|
+
// this principal's unbound legacy history). Local housekeeping grants no
|
|
1161
|
+
// messaging or execution authority to those legacy records.
|
|
1162
|
+
const savedThread = summary?.threads.find((thread) => thread.threadId === threadId
|
|
1163
|
+
&& (thread.binding || thread.legacy === true));
|
|
1164
|
+
if (savedThread) return savedThread;
|
|
1165
|
+
const state = summary ? { messages: [], hiddenThreadIds: [] }
|
|
1166
|
+
: await applicationInbox.readWorkbenchState(scope);
|
|
1167
|
+
if (state.hiddenThreadIds.includes(threadId)) return;
|
|
1168
|
+
if (state.messages.some((message) => {
|
|
1169
|
+
try {
|
|
1170
|
+
const binding = getMessageConversationBinding(message, tuple) || getOutgoingConversationIntent(message, tuple);
|
|
1171
|
+
return binding?.localDelegateId === context.delegateId && instanceConversationKey(binding) === threadId;
|
|
1172
|
+
} catch {
|
|
1173
|
+
// Contradictory old evidence stays in read-only legacy history. It
|
|
1174
|
+
// neither authorizes this operation nor blocks a different valid route.
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
})) return;
|
|
1178
|
+
const contacts = await applicationContacts.listForDelegate(context.delegateId);
|
|
1179
|
+
for (const contact of contacts) {
|
|
1180
|
+
if (!contact.principalId || contact.hellolynkId?.split(":")[1] !== stableRegion) continue;
|
|
1181
|
+
for (const instance of contact.instances || []) {
|
|
1182
|
+
const binding = { schemaVersion: 1, region: stableRegion,
|
|
1183
|
+
localPrincipalId: tuple.principalId, localDelegateId: context.delegateId,
|
|
1184
|
+
peerPrincipalId: contact.principalId, peerDelegateId: instance.delegateId };
|
|
1185
|
+
if (instanceConversationKey(binding) === threadId) return { binding, peerHelloLynkId: contact.hellolynkId };
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function applicationLocalHistory(command, context) {
|
|
1192
|
+
const scope = localHistoryScope(context);
|
|
1193
|
+
const type = command.command;
|
|
1194
|
+
if ([HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteThread, HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteArchivedThreads].includes(type)) {
|
|
1195
|
+
const allArchived = type === HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteArchivedThreads;
|
|
1196
|
+
const conversation = allArchived ? null : await requireLocalConversation(context, command.payload.threadId);
|
|
1197
|
+
const method = allArchived ? "deleteArchivedConversations" : "deleteLocalConversation";
|
|
1198
|
+
if (typeof applicationInbox[method] !== "function") throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1199
|
+
const result = await applicationInbox[method]({ ...scope, ...command.payload,
|
|
1200
|
+
...(conversation?.binding ? { conversationBinding: conversation.binding, peerHelloLynkId: conversation.peerHelloLynkId } : {}) });
|
|
1201
|
+
resumeAfterLocalHistoryCleanup(result);
|
|
1202
|
+
await publishApplicationChange("inbox_changed", command.payload.threadId || "archived_conversations", context.delegateId);
|
|
1203
|
+
await publishApplicationChange("outbox_changed", command.payload.threadId || "archived_conversations", context.delegateId);
|
|
1204
|
+
return { kind: "local-history", ...result };
|
|
1205
|
+
}
|
|
1206
|
+
if (type === HELLOLYNK_WORKBENCH_COMMAND_TYPES.configureHistory) {
|
|
1207
|
+
if (typeof applicationInbox.configureLocalHistory !== "function") throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1208
|
+
await applicationInbox.configureLocalHistory({ scope, ...command.payload });
|
|
1209
|
+
await publishApplicationChange("inbox_changed", "local_history_preferences", context.delegateId);
|
|
1210
|
+
historyMaintenanceAt = -Infinity;
|
|
1211
|
+
return {};
|
|
1212
|
+
}
|
|
1213
|
+
if (type === HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteSent) {
|
|
1214
|
+
const result = await applicationInbox.deleteSentLocal({ ...scope, ...command.payload });
|
|
1215
|
+
resumeAfterLocalHistoryCleanup(result);
|
|
1216
|
+
for (const id of command.payload.localMessageIds) await publishApplicationChange("outbox_changed", id, context.delegateId);
|
|
1217
|
+
return { kind: "local-history", deletedCount: result.deletedCount,
|
|
1218
|
+
retainedUnreadCount: result.retainedUnreadCount, retainedOutgoingCount: result.retainedOutgoingCount,
|
|
1219
|
+
...(result.retainedTaskCount != null ? { retainedTaskCount: result.retainedTaskCount } : {}),
|
|
1220
|
+
...(result.deletedBytes != null ? { deletedBytes: result.deletedBytes } : {}) };
|
|
1221
|
+
}
|
|
1222
|
+
const { threadId } = command.payload;
|
|
1223
|
+
// The opaque key is never authority: require a saved instance, a scoped
|
|
1224
|
+
// history row, or this delegate's persisted hidden entry, all read locally.
|
|
1225
|
+
const conversation = await requireLocalConversation(context, threadId);
|
|
1226
|
+
if (type === HELLOLYNK_WORKBENCH_COMMAND_TYPES.hideThread) {
|
|
1227
|
+
await applicationInbox.hideLocalConversation({ ...scope, threadId,
|
|
1228
|
+
...(conversation?.binding ? { conversationBinding: conversation.binding, peerHelloLynkId: conversation.peerHelloLynkId } : {}) });
|
|
1229
|
+
} else if (type === HELLOLYNK_WORKBENCH_COMMAND_TYPES.restoreThread) {
|
|
1230
|
+
await applicationInbox.restoreLocalConversation({ ...scope, threadId });
|
|
1231
|
+
} else {
|
|
1232
|
+
const result = await applicationInbox.clearLocalConversation({ ...scope, threadId,
|
|
1233
|
+
...(Object.hasOwn(command.payload, "before") ? { before: command.payload.before } : {}) });
|
|
1234
|
+
resumeAfterLocalHistoryCleanup(result);
|
|
1235
|
+
await publishApplicationChange("inbox_changed", threadId, context.delegateId);
|
|
1236
|
+
await publishApplicationChange("outbox_changed", threadId, context.delegateId);
|
|
1237
|
+
return { kind: "local-history", deletedCount: result.deletedCount,
|
|
1238
|
+
retainedUnreadCount: result.retainedUnreadCount, retainedOutgoingCount: result.retainedOutgoingCount,
|
|
1239
|
+
...(result.retainedTaskCount != null ? { retainedTaskCount: result.retainedTaskCount } : {}),
|
|
1240
|
+
...(result.deletedBytes != null ? { deletedBytes: result.deletedBytes } : {}) };
|
|
1241
|
+
}
|
|
1242
|
+
await publishApplicationChange("inbox_changed", threadId, context.delegateId);
|
|
1243
|
+
return {};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async function applicationContactsList(context) {
|
|
1247
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1248
|
+
return {
|
|
1249
|
+
contacts: (await applicationContacts.listForDelegate(context.delegateId)).map(projectContact),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function readContactInstances(context) {
|
|
1254
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1255
|
+
if (typeof options.transport.listAgentInstances !== "function") {
|
|
1256
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1257
|
+
}
|
|
1258
|
+
const contact = (await applicationContacts.listForDelegate(context.delegateId))
|
|
1259
|
+
.find((item) => item.contactId === context.request.contactId);
|
|
1260
|
+
if (!contact) throw hostApplicationFailure("host_app_record_not_found");
|
|
1261
|
+
if (!contact.hellolynkId.startsWith(`hellolynk:${stableRegion}:`)) {
|
|
1262
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1263
|
+
}
|
|
1264
|
+
const directory = await options.transport.listAgentInstances({ hellolynkId: contact.hellolynkId });
|
|
1265
|
+
if (directory.hellolynkId !== contact.hellolynkId
|
|
1266
|
+
|| (contact.principalId && directory.principalId !== contact.principalId)
|
|
1267
|
+
|| (contact.hellolynkId === endpoints.get(context.delegateId)?.hellolynkId
|
|
1268
|
+
&& directory.principalId !== tuple.principalId)
|
|
1269
|
+
|| !Array.isArray(directory.instances)) {
|
|
1270
|
+
throw hostApplicationFailure("host_app_instance_directory_invalid");
|
|
1271
|
+
}
|
|
1272
|
+
const principalId = safeId(directory.principalId, "host_app_instance_directory_invalid");
|
|
1273
|
+
const seen = new Set();
|
|
1274
|
+
const instances = directory.instances.map((item) => {
|
|
1275
|
+
const delegateId = safeId(item.delegateId, "host_app_instance_directory_invalid");
|
|
1276
|
+
if (seen.has(delegateId) || item.status !== "active"
|
|
1277
|
+
|| !["codex", "openclaw", "trae-work", "workbuddy", "cursor", "dsh", "claude-code"].includes(item.clientType)) {
|
|
1278
|
+
throw hostApplicationFailure("host_app_instance_directory_invalid");
|
|
1279
|
+
}
|
|
1280
|
+
seen.add(delegateId);
|
|
1281
|
+
const createdAtMs = typeof item.createdAt === "string" && item.createdAt.length <= 64 ? Date.parse(item.createdAt) : NaN;
|
|
1282
|
+
return { delegateId, displayName: safeDisplayName(item.displayName, "host_app_instance_directory_invalid"),
|
|
1283
|
+
clientType: item.clientType, status: "active",
|
|
1284
|
+
...(Number.isFinite(createdAtMs) ? { createdAt: new Date(createdAtMs).toISOString() } : {}) };
|
|
1285
|
+
}).filter((item) => principalId !== tuple.principalId || item.delegateId !== context.delegateId);
|
|
1286
|
+
return { kind: "contact-instances", contactId: contact.contactId,
|
|
1287
|
+
principalId, hellolynkId: contact.hellolynkId, instances };
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async function saveContactInstance(context) {
|
|
1291
|
+
const directory = await readContactInstances(context);
|
|
1292
|
+
const instance = directory.instances.find((item) => item.delegateId === context.request.recipientDelegateId);
|
|
1293
|
+
if (!instance || typeof applicationContacts.upsertInstance !== "function") {
|
|
1294
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1295
|
+
}
|
|
1296
|
+
const storedContact = (await applicationContacts.listForDelegate(context.delegateId))
|
|
1297
|
+
.find((item) => item.contactId === directory.contactId);
|
|
1298
|
+
const storedInstance = storedContact?.instances?.find((item) => item.delegateId === instance.delegateId);
|
|
1299
|
+
const displayName = context.request.displayName
|
|
1300
|
+
?? storedInstance?.displayName
|
|
1301
|
+
?? instance.displayName;
|
|
1302
|
+
await applicationContacts.upsertInstance(directory.contactId, {
|
|
1303
|
+
principalId: directory.principalId, delegateId: instance.delegateId,
|
|
1304
|
+
clientType: instance.clientType,
|
|
1305
|
+
displayName: safeDisplayName(displayName, "host_app_instance_display_name_invalid"),
|
|
1306
|
+
});
|
|
1307
|
+
await publishApplicationChange("contacts_changed", directory.contactId);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function renameContact(context) {
|
|
1311
|
+
if (typeof applicationContacts?.rename !== "function") throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1312
|
+
const { contactId, displayName, expectedDisplayName, expectedHelloLynkId } = context.request;
|
|
1313
|
+
if (!expectedHelloLynkId.startsWith(`hellolynk:${stableRegion}:`)) throw hostApplicationFailure("host_app_authorization_required");
|
|
1314
|
+
try {
|
|
1315
|
+
await applicationContacts.rename(contactId, { displayName, expectedDisplayName, expectedHelloLynkId });
|
|
1316
|
+
} catch (error) {
|
|
1317
|
+
if (error?.code === "contact_name_conflict") throw hostApplicationFailure("host_app_revision_conflict");
|
|
1318
|
+
if (/^contact_(?:display_name|hellolynk_id|id)_/.test(String(error?.code || ""))) {
|
|
1319
|
+
throw hostApplicationFailure("host_app_input_invalid");
|
|
1320
|
+
}
|
|
1321
|
+
throw error;
|
|
1322
|
+
}
|
|
1323
|
+
await publishApplicationChange("contacts_changed", contactId);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
async function renameContactInstance(context) {
|
|
1327
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1328
|
+
if (typeof applicationContacts.renameInstance !== "function") {
|
|
1329
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1330
|
+
}
|
|
1331
|
+
const request = context.request;
|
|
1332
|
+
if (!request.expectedHelloLynkId.startsWith(`hellolynk:${stableRegion}:`)
|
|
1333
|
+
|| request.recipientDelegateId === context.delegateId
|
|
1334
|
+
|| (request.expectedHelloLynkId === endpoints.get(context.delegateId)?.hellolynkId
|
|
1335
|
+
&& request.expectedPrincipalId !== tuple.principalId)) {
|
|
1336
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1337
|
+
}
|
|
1338
|
+
try {
|
|
1339
|
+
await applicationContacts.renameInstance(request.contactId, {
|
|
1340
|
+
delegateId: request.recipientDelegateId,
|
|
1341
|
+
displayName: request.displayName,
|
|
1342
|
+
expectedDisplayName: request.expectedDisplayName,
|
|
1343
|
+
expectedHelloLynkId: request.expectedHelloLynkId,
|
|
1344
|
+
expectedPrincipalId: request.expectedPrincipalId,
|
|
1345
|
+
});
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (error?.code === "contact_instance_name_conflict") throw hostApplicationFailure("host_app_revision_conflict");
|
|
1348
|
+
if (/^contact_(?:display_name|hellolynk_id|delegate|id)_/.test(String(error?.code || ""))) {
|
|
1349
|
+
throw hostApplicationFailure("host_app_input_invalid");
|
|
1350
|
+
}
|
|
1351
|
+
throw error;
|
|
1352
|
+
}
|
|
1353
|
+
await publishApplicationChange("contacts_changed", request.contactId);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
async function deleteContactInstance(context) {
|
|
1357
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1358
|
+
if (typeof applicationContacts.removeInstance !== "function") {
|
|
1359
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1360
|
+
}
|
|
1361
|
+
await applicationContacts.removeInstance(context.request.contactId, context.request.recipientDelegateId);
|
|
1362
|
+
await publishApplicationChange("contacts_changed", context.request.contactId);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function resolveSavedInstance(input, delegateId) {
|
|
1366
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1367
|
+
if (!endpoints.has(delegateId) || typeof applicationContacts.resolveInstance !== "function") {
|
|
1368
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1369
|
+
}
|
|
1370
|
+
const recipientDelegateId = safeId(input.recipientDelegateId, "host_app_recipient_delegate_invalid");
|
|
1371
|
+
if (recipientDelegateId === delegateId) throw hostApplicationFailure("host_app_authorization_required");
|
|
1372
|
+
const contact = (await applicationContacts.listForDelegate(delegateId)).find((item) => (
|
|
1373
|
+
input.contactId ? item.contactId === input.contactId : item.hellolynkId === input.recipientHelloLynkId
|
|
1374
|
+
));
|
|
1375
|
+
if (!contact || (input.recipientHelloLynkId && contact.hellolynkId !== input.recipientHelloLynkId)
|
|
1376
|
+
|| !contact.hellolynkId.startsWith(`hellolynk:${stableRegion}:`)) {
|
|
1377
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1378
|
+
}
|
|
1379
|
+
try {
|
|
1380
|
+
return await applicationContacts.resolveInstance(contact.contactId, recipientDelegateId);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
if (["contact_not_found", "contact_instance_not_found"].includes(error?.code)) {
|
|
1383
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1384
|
+
}
|
|
1385
|
+
throw error;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function savedInstanceRoute(target) {
|
|
1390
|
+
if (target.principalId !== tuple.principalId || !endpoints.has(target.recipientDelegateId)) {
|
|
1391
|
+
return { routeKind: "instance_direct", recipientDelegateId: target.recipientDelegateId };
|
|
1392
|
+
}
|
|
1393
|
+
return { routeKind: "owned_delegate", recipientDelegateId: target.recipientDelegateId,
|
|
1394
|
+
recipientHostId: tuple.hostInstallationId };
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
async function applicationContactsUpsert(context) {
|
|
1399
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1400
|
+
const contactWriteKeys = Object.keys(context.request || {}).sort();
|
|
1401
|
+
const allowedContactWriteKeys = Object.hasOwn(context.request || {}, "contactId")
|
|
1402
|
+
? ["contactId", "displayName", "hellolynkId"]
|
|
1403
|
+
: ["displayName", "hellolynkId"];
|
|
1404
|
+
if (contactWriteKeys.length !== allowedContactWriteKeys.length
|
|
1405
|
+
|| contactWriteKeys.some((key, index) => key !== allowedContactWriteKeys[index])) {
|
|
1406
|
+
// Contact edits must not inject internal E2EE identity fingerprints.
|
|
1407
|
+
// Neither the direct application route nor Workbench may transport one.
|
|
1408
|
+
throw hostApplicationFailure("host_app_input_invalid");
|
|
1409
|
+
}
|
|
1410
|
+
const ownHelloLynkId = endpoints.get(context.delegateId)?.hellolynkId;
|
|
1411
|
+
if (!ownHelloLynkId || context.request.hellolynkId !== ownHelloLynkId) {
|
|
1412
|
+
if (!options.humanRelationships
|
|
1413
|
+
|| typeof options.humanRelationships.assertActiveRelationship !== "function") {
|
|
1414
|
+
throw hostApplicationFailure("host_app_relationship_status_unavailable");
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
await options.humanRelationships.assertActiveRelationship({
|
|
1418
|
+
hellolynkId: context.request.hellolynkId,
|
|
1419
|
+
});
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
if (error?.code === "human_relationship_active_relationship_required") {
|
|
1422
|
+
throw hostApplicationFailure("host_app_contact_relationship_not_active");
|
|
1423
|
+
}
|
|
1424
|
+
throw hostApplicationFailure("host_app_relationship_status_unavailable");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
let stored;
|
|
1428
|
+
try {
|
|
1429
|
+
stored = await applicationContacts.upsert(context.request);
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
if (/^contact_(?:display_name|hellolynk_id|safety_code|id|capacity)/.test(String(error?.code || ""))) {
|
|
1432
|
+
throw hostApplicationFailure("host_app_input_invalid");
|
|
1433
|
+
}
|
|
1434
|
+
throw error;
|
|
1435
|
+
}
|
|
1436
|
+
const contact = projectContactMutation(stored);
|
|
1437
|
+
await publishApplicationChange("contacts_changed", contact.contactId);
|
|
1438
|
+
return { contact };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
async function applicationContactsRemove(context) {
|
|
1442
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1443
|
+
const contactId = context.request.contactId;
|
|
1444
|
+
const removed = await applicationContacts.remove(contactId);
|
|
1445
|
+
if (removed) await publishApplicationChange("contacts_changed", contactId);
|
|
1446
|
+
return { contactId, removed: Boolean(removed) };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async function applicationContactAutonomousSendAuthorization(context) {
|
|
1450
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1451
|
+
let stored;
|
|
1452
|
+
try {
|
|
1453
|
+
stored = await applicationContacts.setAutonomousSendAuthorization(
|
|
1454
|
+
context.request.contactId,
|
|
1455
|
+
context.delegateId,
|
|
1456
|
+
context.request.enabled,
|
|
1457
|
+
context.request.recipientDelegateId,
|
|
1458
|
+
);
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
if (["contact_not_found", "contact_instance_not_found"].includes(error?.code)) throw hostApplicationFailure("host_app_record_not_found");
|
|
1461
|
+
if (/^contact_(?:authorization|delegate)/.test(String(error?.code || ""))) {
|
|
1462
|
+
throw hostApplicationFailure("host_app_input_invalid");
|
|
1463
|
+
}
|
|
1464
|
+
throw error;
|
|
1465
|
+
}
|
|
1466
|
+
const contact = projectContactAuthorizationMutation(stored);
|
|
1467
|
+
await publishApplicationChange("contacts_changed", contact.contactId, context.delegateId);
|
|
1468
|
+
return { contact };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
async function applicationContactRequestsClearResolved() {
|
|
1473
|
+
if (!options.humanRelationships
|
|
1474
|
+
|| typeof options.humanRelationships.clearResolved !== "function") {
|
|
1475
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
1476
|
+
}
|
|
1477
|
+
const result = await options.humanRelationships.clearResolved();
|
|
1478
|
+
if (!Number.isSafeInteger(result?.deletedCount) || result.deletedCount < 0
|
|
1479
|
+
|| result.localOnly !== true) {
|
|
1480
|
+
throw hostApplicationFailure("host_app_contact_request_clear_response_invalid");
|
|
1481
|
+
}
|
|
1482
|
+
if (result.deletedCount > 0) {
|
|
1483
|
+
await publishApplicationChange("contact_requests_changed", "resolved-contact-requests");
|
|
1484
|
+
}
|
|
1485
|
+
const resumeScheduled = scheduleInboundResumeAfterHumanCleanup(CONTACT_REQUEST_LOCAL_CAPACITY_CODES);
|
|
1486
|
+
return {
|
|
1487
|
+
deletedCount: result.deletedCount,
|
|
1488
|
+
localOnly: result.localOnly === true,
|
|
1489
|
+
inboundResume: resumeScheduled ? "resume_scheduled" : "not_applicable",
|
|
1490
|
+
modelCalls: 0,
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function applicationAuthorizedContactsList(context) {
|
|
1495
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1496
|
+
const contacts = (await applicationContacts.listAuthorizedForDelegate(context.delegateId)).map(projectAuthorizedContact);
|
|
1497
|
+
if (!context.request?.recipientDelegateId) return { contacts };
|
|
1498
|
+
const authorization = await resolveAuthorizedSendRequest(context);
|
|
1499
|
+
const { contactId: _contact, recipientDelegateId: _recipient, ...query } = context.request;
|
|
1500
|
+
const nativeConversations = await queryOwnNativeDirectory(context.delegateId, authorization, query);
|
|
1501
|
+
await context.revalidateCapability();
|
|
1502
|
+
await resolveAuthorizedSendRequest(context);
|
|
1503
|
+
return { contacts, nativeConversations };
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
async function localNativeDirectory(endpoint, query) {
|
|
1507
|
+
if (!endpoint?.nativeDirectoryAdapterId) return nativeDirectoryResult({ conversations: [], coverage: "unavailable",
|
|
1508
|
+
available: false, exactTargetSupported: false, reason: "native_directory_unavailable" });
|
|
1509
|
+
try {
|
|
1510
|
+
await endpoint.assertNativeDirectoryCurrent?.();
|
|
1511
|
+
const result = await queryLocalNativeConversations({ runtimeRoot, region: tuple.region, principalId: tuple.principalId,
|
|
1512
|
+
delegateId: endpoint.delegateId, adapterId: endpoint.nativeDirectoryAdapterId }, query);
|
|
1513
|
+
await endpoint.assertNativeDirectoryCurrent?.();
|
|
1514
|
+
return result;
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
if (error?.code === "native_directory_cursor_invalid") throw error;
|
|
1517
|
+
throw hostApplicationFailure("native_directory_unavailable");
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
async function queryOwnNativeDirectory(delegateId, authorization, query) {
|
|
1522
|
+
if (authorization.principalId !== tuple.principalId) throw hostApplicationFailure("native_directory_own_ai_only");
|
|
1523
|
+
query = nativeConversationQuery(query);
|
|
1524
|
+
const local = endpoints.get(authorization.recipientDelegateId);
|
|
1525
|
+
if (local) return localNativeDirectory(local, query);
|
|
1526
|
+
return queryPeerNativeDirectory(delegateId, authorization, { kind: "request", query });
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
async function queryPeerNativeDirectory(delegateId, authorization, request) {
|
|
1530
|
+
const sender = endpoints.get(delegateId);
|
|
1531
|
+
if (!sender?.outbound?.sendDirectory) throw hostApplicationFailure("native_directory_unavailable");
|
|
1532
|
+
const friend = request.kind === "friend_target_request";
|
|
1533
|
+
const unavailable = friend ? null : nativeDirectoryResult({ conversations: [], coverage: "unavailable",
|
|
1534
|
+
available: false, exactTargetSupported: false, reason: "native_directory_unavailable" });
|
|
1535
|
+
const requestId = "directory_" + crypto.randomUUID();
|
|
1536
|
+
let timer;
|
|
1537
|
+
const response = new Promise(resolve => {
|
|
1538
|
+
timer = setTimeout(() => resolve(unavailable), 12_000);
|
|
1539
|
+
directoryPending.set(requestId, { delegateId, peerDelegateId: authorization.recipientDelegateId,
|
|
1540
|
+
peerPrincipalId: authorization.principalId, peerHelloLynkId: authorization.hellolynkId, friend, resolve });
|
|
1541
|
+
});
|
|
1542
|
+
try {
|
|
1543
|
+
const sent = await sender.outbound.sendDirectory({ clientMessageId: "hellolynk_local_" + crypto.randomUUID(), recipientHelloLynkId: authorization.hellolynkId,
|
|
1544
|
+
recipientPrincipalId: authorization.principalId, route: savedInstanceRoute(authorization),
|
|
1545
|
+
nativeDirectory: { ...request, requestId } });
|
|
1546
|
+
if (["recipient_e2ee_device_offline", "recipient_offline"].includes(sent?.status))
|
|
1547
|
+
throw hostApplicationFailure("recipient_e2ee_device_offline");
|
|
1548
|
+
if (friend && (sent?.status !== "acknowledged_by_recipient_local_store"
|
|
1549
|
+
|| typeof sent.targetConnectionId !== "string" || !SAFE_ID.test(sent.targetConnectionId))) return unavailable;
|
|
1550
|
+
if (!["acknowledged_by_recipient_local_store", "delivered_to_live_connection"].includes(sent?.status)) return unavailable;
|
|
1551
|
+
const result = await response;
|
|
1552
|
+
return friend && result ? { ...result, friendTargetConnectionId: sent.targetConnectionId } : result;
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (friend && ["ordinary_message_not_permitted", "e2ee_recipient_external_contact_disabled"].includes(error?.code))
|
|
1555
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1556
|
+
if (["e2ee_peer_identity_pin_mismatch",
|
|
1557
|
+
"recipient_e2ee_device_offline", "e2ee_prekey_claim_rate_limited"].includes(error?.code)) throw hostApplicationFailure(error.code);
|
|
1558
|
+
throw hostApplicationFailure("native_directory_unavailable");
|
|
1559
|
+
} finally { clearTimeout(timer); directoryPending.delete(requestId); }
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function isFriendTargetFrame(opened, localDelivery) {
|
|
1563
|
+
// The authenticated relay checks the active friendship, blocks and exact
|
|
1564
|
+
// exposed AI on both the key claim and the write. Local delivery is for
|
|
1565
|
+
// one account only. Receiving doesn't require saving the sender's AI.
|
|
1566
|
+
return localDelivery !== true && opened.e2eeVersion === 2 && opened.routeKind === "instance_direct"
|
|
1567
|
+
&& opened.intentSource === "human_preauthorized_contact";
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function friendTargetKey(opened, endpoint, clientMessageId, targetConversationId) {
|
|
1571
|
+
return JSON.stringify([opened.senderPrincipalId, opened.senderDelegateId, opened.senderIdentityKeyId,
|
|
1572
|
+
endpoint.delegateId, clientMessageId, targetConversationId]);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function admitFriendTarget(opened, endpoint, clientMessageId) {
|
|
1576
|
+
const key = friendTargetKey(opened, endpoint, clientMessageId, opened.targetConversationId);
|
|
1577
|
+
const expires = friendTargets.get(key) ?? 0;
|
|
1578
|
+
// The store keeps the first disposition on replay, even after a restart.
|
|
1579
|
+
return expires > nowMs(clock) ? "direct" : "held";
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function rememberFriendTarget(opened, endpoint, clientMessageId, targetConversationId) {
|
|
1583
|
+
const now = nowMs(clock);
|
|
1584
|
+
for (const [key, expires] of friendTargets) {
|
|
1585
|
+
if (expires <= now) friendTargets.delete(key);
|
|
1586
|
+
}
|
|
1587
|
+
const key = friendTargetKey(opened, endpoint, clientMessageId, targetConversationId);
|
|
1588
|
+
if (!friendTargets.has(key) && friendTargets.size >= 1024) return false;
|
|
1589
|
+
// Allow time to prepare a ciphertext after the lookup. An unused grant is
|
|
1590
|
+
// tied to this message, sending key and local AI for its entire lifetime.
|
|
1591
|
+
friendTargets.set(key, now + DIRECT_TEXT_E2EE_MAX_TTL_MS + 90_000);
|
|
1592
|
+
return true;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function uniqueTitleTarget(catalog) {
|
|
1596
|
+
const [row] = catalog.conversations;
|
|
1597
|
+
return catalog.available && catalog.exactTargetSupported && catalog.conversations.length === 1
|
|
1598
|
+
&& ["idle", "active", "notLoaded"].includes(row?.state) ? row.targetConversationId : null;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function queryFriendTarget(delegateId, authorization, title, clientMessageId) {
|
|
1602
|
+
const result = await queryPeerNativeDirectory(delegateId, authorization, { kind: "friend_target_request", title, clientMessageId });
|
|
1603
|
+
if (!result) throw hostApplicationFailure("native_target_unsupported_or_unavailable");
|
|
1604
|
+
if (!result.targetConversationId) throw hostApplicationFailure("native_target_title_not_unique");
|
|
1605
|
+
return { ...result, targetConversationTitle: title };
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
async function consumeNativeFrame(event, opened, endpoint, localDelivery) {
|
|
1609
|
+
// A reserved protocol frame is never a chat message. A malformed or
|
|
1610
|
+
// unauthorized frame cannot stop the Host or be sent to a model as text.
|
|
1611
|
+
// Storage/commit errors still propagate; only validated content is dropped.
|
|
1612
|
+
await opened.commit();
|
|
1613
|
+
let acknowledged = true;
|
|
1614
|
+
if (!localDelivery) try { await options.transport.acknowledge({ event, kind: "message", persisted: {} }); }
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
const deadline = typeof event.ackDeadlineAt === "string" ? Date.parse(event.ackDeadlineAt) : NaN;
|
|
1617
|
+
if (error?.code !== "relay_ack_delivery_attempt_not_found" || error?.httpStatus !== 409
|
|
1618
|
+
|| !Number.isFinite(deadline) || new Date(deadline).toISOString() !== event.ackDeadlineAt
|
|
1619
|
+
|| nowMs(clock) < deadline) throw error;
|
|
1620
|
+
acknowledged = false;
|
|
1621
|
+
}
|
|
1622
|
+
if (acknowledged && !localDelivery && preKeyRefillMethod(endpoint)) requestPreKeyRefill(endpoint);
|
|
1623
|
+
return { acknowledged, duplicate: false };
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
async function handleNativeDirectory(event, opened, endpoint, localDelivery) {
|
|
1627
|
+
const control = opened.nativeDirectory;
|
|
1628
|
+
const friend = control.kind === "friend_target_request" || control.kind === "friend_target_response";
|
|
1629
|
+
if (opened.replyToClientMessageId || opened.targetConversationId || opened.e2eeVersion !== 2
|
|
1630
|
+
|| (friend ? opened.senderPrincipalId === tuple.principalId || !isFriendTargetFrame(opened, localDelivery)
|
|
1631
|
+
: opened.senderPrincipalId !== tuple.principalId)) return consumeNativeFrame(event, opened, endpoint, localDelivery);
|
|
1632
|
+
// Read-only control traffic has no human unread state and never reaches a
|
|
1633
|
+
// model. Consume its authenticated prekey before acknowledging transport.
|
|
1634
|
+
const consumed = await consumeNativeFrame(event, opened, endpoint, localDelivery);
|
|
1635
|
+
if (control.kind === "response" || control.kind === "friend_target_response") {
|
|
1636
|
+
const pending = directoryPending.get(control.requestId);
|
|
1637
|
+
if (pending?.delegateId === endpoint.delegateId && pending.peerDelegateId === opened.senderDelegateId
|
|
1638
|
+
&& pending.peerPrincipalId === opened.senderPrincipalId && pending.peerHelloLynkId === opened.senderHelloLynkId
|
|
1639
|
+
&& pending.friend === friend) pending.resolve(friend
|
|
1640
|
+
? { targetConversationId: control.targetConversationId } : nativeDirectoryResult(control.result));
|
|
1641
|
+
} else {
|
|
1642
|
+
// Do not await the response on the inbound event stream: two simultaneous
|
|
1643
|
+
// queries must not wait for each other's receiver to process an ACK.
|
|
1644
|
+
void (async () => {
|
|
1645
|
+
const query = friend ? { title: control.title, exactTitle: true } : control.query;
|
|
1646
|
+
let result;
|
|
1647
|
+
try { result = await localNativeDirectory(endpoint, query); }
|
|
1648
|
+
catch { result = nativeDirectoryResult({ conversations: [], coverage: "unavailable", available: false,
|
|
1649
|
+
exactTargetSupported: false, reason: "native_directory_unavailable" }); }
|
|
1650
|
+
let response = { kind: "response", requestId: control.requestId, result };
|
|
1651
|
+
if (friend) {
|
|
1652
|
+
const target = uniqueTitleTarget(result);
|
|
1653
|
+
response = { kind: "friend_target_response", requestId: control.requestId,
|
|
1654
|
+
targetConversationId: target && rememberFriendTarget(opened, endpoint, control.clientMessageId, target) ? target : null };
|
|
1655
|
+
}
|
|
1656
|
+
await endpoint.outbound.sendDirectory({ clientMessageId: "hellolynk_local_" + crypto.randomUUID(),
|
|
1657
|
+
recipientHelloLynkId: opened.senderHelloLynkId, recipientPrincipalId: opened.senderPrincipalId,
|
|
1658
|
+
route: { routeKind: "instance_direct", recipientDelegateId: opened.senderDelegateId },
|
|
1659
|
+
nativeDirectory: response });
|
|
1660
|
+
})().catch(() => {}); // The caller times out; control queries never resend user messages.
|
|
1661
|
+
}
|
|
1662
|
+
return consumed;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
async function applicationAuthorizedInboxActivate(context) {
|
|
1666
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1667
|
+
if (typeof applicationInbox.activateInboxReader !== "function") {
|
|
1668
|
+
throw hostApplicationFailure("host_app_unavailable");
|
|
1669
|
+
}
|
|
1670
|
+
await context.revalidateCapability();
|
|
1671
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1672
|
+
if (typeof applicationContacts.listReadableRoutesForDelegate !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1673
|
+
// Activation records the owner's durable inbox boundary, not a contact
|
|
1674
|
+
// grant. It needs no contact labels and must not reread them on idle polls.
|
|
1675
|
+
await applicationInbox.activateInboxReader(context.adapterId, context.delegateId);
|
|
1676
|
+
await context.revalidateCapability();
|
|
1677
|
+
return { active: true };
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
async function applicationModelInbox(context) {
|
|
1681
|
+
const identity = { adapterId: context.adapterId, delegateId: context.delegateId };
|
|
1682
|
+
const child = (query, extra = {}) => ({ ...context, request: query, ...extra });
|
|
1683
|
+
await context.revalidateCapability();
|
|
1684
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1685
|
+
if (typeof applicationInbox.getInboxNativeAssignment !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1686
|
+
return runModelInbox(context.request, {
|
|
1687
|
+
nativeScope: { runtimeRoot, region: tuple.region, principalId: tuple.principalId, ...identity },
|
|
1688
|
+
assignment: query => applicationInbox.getInboxNativeAssignment({ ...identity, ...query }),
|
|
1689
|
+
assignments: localMessageIds => applicationInbox.getInboxNativeAssignments({ ...identity, localMessageIds }),
|
|
1690
|
+
metadata: async query => {
|
|
1691
|
+
const message = await applicationInbox.getIncoming(query.localMessageId);
|
|
1692
|
+
const binding = message && getMessageConversationBinding(message, tuple);
|
|
1693
|
+
if (!message || message.friendTargetHeld || message.messageType === "local-deleted")
|
|
1694
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1695
|
+
if (!binding || binding.localDelegateId !== context.delegateId || query.conversationKey !== instanceConversationKey(binding)) {
|
|
1696
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1697
|
+
}
|
|
1698
|
+
return projectAuthorizedInboxMetadata(message, null, binding);
|
|
1699
|
+
},
|
|
1700
|
+
list: query => applicationAuthorizedInboxMessages(child(query, { modelInbox: true })),
|
|
1701
|
+
pending: query => applicationAuthorizedInboxMessages(child(query, { modelInbox: true }), true, query),
|
|
1702
|
+
read: query => applicationAuthorizedInboxRead(child(query)),
|
|
1703
|
+
claim: query => query.batchId ? applicationAuthorizedInboxBatch(child(query), "Claim") : applicationAuthorizedInboxClaim(child(query)),
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
async function inboxWakeBoundary(context) {
|
|
1708
|
+
return await applicationInbox.getInboxReaderBoundary?.(context.adapterId, context.delegateId) ?? null;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
async function applicationAuthorizedInboxMessages(context, pendingOnly = false, query = null) {
|
|
1712
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1713
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1714
|
+
if (typeof applicationContacts.listReadableRoutesForDelegate !== "function") return query ? { messages: [], nextCursor: null } : { messages: [] };
|
|
1715
|
+
await context.revalidateCapability();
|
|
1716
|
+
const indexedPending = pendingOnly && typeof applicationInbox.listPendingInboxCandidates === "function";
|
|
1717
|
+
if (pendingOnly && !indexedPending && (query || typeof applicationInbox.listInboxClaimIds !== "function")) throw hostApplicationFailure("host_app_unavailable");
|
|
1718
|
+
const afterSeq = await inboxWakeBoundary(context);
|
|
1719
|
+
if (pendingOnly && afterSeq == null) return query ? { messages: [], nextCursor: null } : { messages: [] };
|
|
1720
|
+
const target = query?.localMessageId == null ? null : query;
|
|
1721
|
+
let nextCursor = null;
|
|
1722
|
+
const consumed = pendingOnly && !indexedPending ? new Set(await applicationInbox.listInboxClaimIds(context.delegateId)) : null;
|
|
1723
|
+
let storedMessages;
|
|
1724
|
+
const before = pendingOnly ? null : context.request?.before ?? null;
|
|
1725
|
+
const indexedHistory = !pendingOnly && typeof applicationInbox.listInboxMessagesForDelegate === "function";
|
|
1726
|
+
if (before != null && !indexedHistory) throw hostApplicationFailure("host_app_unavailable");
|
|
1727
|
+
if (!pendingOnly && context.request?.unreadOnly === true) {
|
|
1728
|
+
if (typeof applicationInbox.listInboxMessagesForDelegate !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1729
|
+
storedMessages = await applicationInbox.listInboxMessagesForDelegate(context.delegateId, {
|
|
1730
|
+
unreadOnly: true, limit: context.request.limit, scope: localHistoryScope(context),
|
|
1731
|
+
});
|
|
1732
|
+
} else if (indexedPending) {
|
|
1733
|
+
// GET is always the first page. Receivers scan later pages with their
|
|
1734
|
+
// own cursor or recheck one message; the Host keeps no shared position.
|
|
1735
|
+
storedMessages = await applicationInbox.listPendingInboxCandidates({
|
|
1736
|
+
delegateId: context.delegateId, afterSeq: Math.max(afterSeq, query?.cursor ?? 0),
|
|
1737
|
+
limit: target ? 1 : 1000, modelPending: context.modelInbox === true, ...(target ? { localMessageId: target.localMessageId } : {}),
|
|
1738
|
+
});
|
|
1739
|
+
// Ownership is checked below; contact membership does not control access
|
|
1740
|
+
// to already received data. Pages follow stored rows, not visible results.
|
|
1741
|
+
if (!target && storedMessages.length === 1000) nextCursor = storedMessages.at(-1).receiptSeq;
|
|
1742
|
+
} else if (indexedHistory) {
|
|
1743
|
+
try {
|
|
1744
|
+
storedMessages = await applicationInbox.listInboxMessagesForDelegate(context.delegateId, {
|
|
1745
|
+
limit: context.modelInbox ? 1000 : AUTHORIZED_INBOX_HISTORY_LIMIT, scope: localHistoryScope(context), ...(before == null ? {} : { before }),
|
|
1746
|
+
});
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
// The page anchor must be an incoming message of this AI and account.
|
|
1749
|
+
if (error?.code === "local_history_cursor_invalid") throw hostApplicationFailure("host_app_record_not_found");
|
|
1750
|
+
throw error;
|
|
1751
|
+
}
|
|
1752
|
+
} else storedMessages = typeof applicationInbox.listInboxMessagesForDelegate === "function"
|
|
1753
|
+
? await applicationInbox.listInboxMessagesForDelegate(context.delegateId, {})
|
|
1754
|
+
: await applicationInbox.list();
|
|
1755
|
+
// Contacts only label records in this response. Empty indexed pages are
|
|
1756
|
+
// common while waiting for mail; don't read/parse every contact for them.
|
|
1757
|
+
// Nonempty pages still get fresh labels, without a cross-request cache.
|
|
1758
|
+
const authorized = new Map((storedMessages.length === 0 ? []
|
|
1759
|
+
: await applicationContacts.listReadableRoutesForDelegate(context.delegateId))
|
|
1760
|
+
.map((contact) => [JSON.stringify([contact.hellolynkId, contact.recipientDelegateId]), contact]));
|
|
1761
|
+
const messages = [];
|
|
1762
|
+
if (pendingOnly) storedMessages.sort((left, right) => (left.receiptSeq ?? 0) - (right.receiptSeq ?? 0));
|
|
1763
|
+
for (const message of storedMessages) {
|
|
1764
|
+
if (message?.direction !== "incoming" || message.friendTargetHeld) continue;
|
|
1765
|
+
const binding = getMessageConversationBinding(message, tuple);
|
|
1766
|
+
if (!binding || binding.localDelegateId !== context.delegateId) continue;
|
|
1767
|
+
const contact = authorized.get(JSON.stringify([
|
|
1768
|
+
message.peerHelloLynkId || message.senderHelloLynkId, binding.peerDelegateId,
|
|
1769
|
+
]));
|
|
1770
|
+
const label = contact?.principalId === binding.peerPrincipalId ? contact : null;
|
|
1771
|
+
const metadata = projectAuthorizedInboxMetadata(message, label, binding, afterSeq);
|
|
1772
|
+
if (pendingOnly && (!metadata.wakeEligible
|
|
1773
|
+
|| consumed?.has(metadata.localMessageId) || (target && metadata.conversationKey !== target.conversationKey))) continue;
|
|
1774
|
+
messages.push(context.modelInbox && pendingOnly ? { ...metadata, receiptSeq: message.receiptSeq } : metadata);
|
|
1775
|
+
if (pendingOnly && messages.length === 1000) break;
|
|
1776
|
+
}
|
|
1777
|
+
await context.revalidateCapability();
|
|
1778
|
+
if (query) return { messages, nextCursor };
|
|
1779
|
+
return {
|
|
1780
|
+
// History browsing is a recent context window, separate from the durable
|
|
1781
|
+
// automatic wake window. Older unhandled messages remain manually claimable.
|
|
1782
|
+
// The index already orders newest first; keeping that order lets the next
|
|
1783
|
+
// explicit page start exactly after this page's last message.
|
|
1784
|
+
messages: pendingOnly ? messages : (indexedHistory ? messages : messages.sort((left, right) =>
|
|
1785
|
+
String(right.createdAt).localeCompare(String(left.createdAt)))).slice(0, context.modelInbox ? 1000 : AUTHORIZED_INBOX_HISTORY_LIMIT),
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async function applicationAuthorizedInboxRead(context) {
|
|
1790
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1791
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1792
|
+
const afterSeq = await inboxWakeBoundary(context);
|
|
1793
|
+
if (typeof applicationInbox.readIncomingForModel !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1794
|
+
const message = await applicationInbox.readIncomingForModel(context.request.localMessageId,
|
|
1795
|
+
(value, highWater, automatic) => authorizeInboxMessage(context, value, afterSeq, highWater, false, false, automatic));
|
|
1796
|
+
if (message.humanReadAutomatically === true) {
|
|
1797
|
+
await publishApplicationChange("inbox_changed", context.request.localMessageId, context.delegateId).catch(() => undefined);
|
|
1798
|
+
scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1799
|
+
}
|
|
1800
|
+
return { message };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
async function authorizeInboxMessage(context, message, afterSeq, highWater, requireWake = false, finish = false, automatic = false) {
|
|
1804
|
+
await context.revalidateCapability();
|
|
1805
|
+
if (!message || message.direction !== "incoming" || message.friendTargetHeld || (message.messageType === "local-deleted" && !finish)) {
|
|
1806
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1807
|
+
}
|
|
1808
|
+
const binding = getMessageConversationBinding(message, tuple);
|
|
1809
|
+
if (!binding || binding.localDelegateId !== context.delegateId
|
|
1810
|
+
|| context.request.conversationKey !== instanceConversationKey(binding)) {
|
|
1811
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1812
|
+
}
|
|
1813
|
+
const contacts = await applicationContacts.listReadableRoutesForDelegate(context.delegateId);
|
|
1814
|
+
const contact = contacts.find((candidate) => candidate.principalId === binding.peerPrincipalId
|
|
1815
|
+
&& candidate.recipientDelegateId === binding.peerDelegateId
|
|
1816
|
+
&& candidate.hellolynkId === (message.peerHelloLynkId || message.senderHelloLynkId)) || null;
|
|
1817
|
+
if (finish && message.messageType === "local-deleted") {
|
|
1818
|
+
await context.revalidateCapability();
|
|
1819
|
+
return {}; // Completing the existing claim exposes no erased body.
|
|
1820
|
+
}
|
|
1821
|
+
const projected = projectAuthorizedInboxMessage(message, contact, binding, afterSeq, automatic);
|
|
1822
|
+
if (requireWake && !projected.wakeEligible) throw hostApplicationFailure("host_app_authorization_required");
|
|
1823
|
+
await context.revalidateCapability();
|
|
1824
|
+
return projected;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
async function applicationAuthorizedInboxBatch(context, operation) {
|
|
1828
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1829
|
+
await context.revalidateCapability();
|
|
1830
|
+
const method = ({ Reserve: "reserveInboxBatch", Open: "listInboxBatches", Attempt: "attemptInboxBatch",
|
|
1831
|
+
Confirm: "confirmInboxBatch", Cancel: "cancelInboxBatch", Expire: "expireInboxBatch",
|
|
1832
|
+
Closed: "closeInboxBatch", Claim: "claimInboxBatch" })[operation];
|
|
1833
|
+
if (typeof applicationInbox[method] !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1834
|
+
// Labels are read once for the whole batch. Ownership is checked per record;
|
|
1835
|
+
// a removed contact never revokes access to this AI's already received mail.
|
|
1836
|
+
const contacts = operation === "Claim"
|
|
1837
|
+
? await applicationContacts.listReadableRoutesForDelegate(context.delegateId) : [];
|
|
1838
|
+
const labels = new Map(contacts.map(contact => [JSON.stringify([contact.hellolynkId, contact.recipientDelegateId]), contact]));
|
|
1839
|
+
const authorize = (message, afterSeq, highWater, automatic = false) => {
|
|
1840
|
+
if (message.friendTargetHeld) throw hostApplicationFailure("host_app_record_not_found");
|
|
1841
|
+
const binding = getMessageConversationBinding(message, tuple);
|
|
1842
|
+
if (!binding || binding.localDelegateId !== context.delegateId || message.direction !== "incoming") {
|
|
1843
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1844
|
+
}
|
|
1845
|
+
if (operation !== "Claim") return null;
|
|
1846
|
+
const label = labels.get(JSON.stringify([message.peerHelloLynkId || message.senderHelloLynkId, binding.peerDelegateId]));
|
|
1847
|
+
return projectAuthorizedInboxMessage(message, label?.principalId === binding.peerPrincipalId ? label : null, binding, afterSeq, automatic);
|
|
1848
|
+
};
|
|
1849
|
+
const result = await applicationInbox[method]({ ...context.request, adapterId: context.adapterId, delegateId: context.delegateId }, authorize);
|
|
1850
|
+
await context.revalidateCapability();
|
|
1851
|
+
if (["Reserve", "Expire", "Claim", "Closed"].includes(operation)) {
|
|
1852
|
+
await publishApplicationChange("inbox_changed", context.delegateId, context.delegateId).catch(() => undefined);
|
|
1853
|
+
}
|
|
1854
|
+
if (operation === "Closed" || result.messages?.some(message => message.humanReadAutomatically === true)) {
|
|
1855
|
+
scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1856
|
+
}
|
|
1857
|
+
return result;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
async function applicationAuthorizedInboxClaim(context, finish = false) {
|
|
1861
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1862
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1863
|
+
const method = finish ? "finishInboxClaim" : "claimInboxMessage";
|
|
1864
|
+
if (typeof applicationInbox[method] !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
1865
|
+
try {
|
|
1866
|
+
const result = await applicationInbox[method]({ ...context.request, adapterId: context.adapterId, delegateId: context.delegateId },
|
|
1867
|
+
(message, afterSeq, highWater, automatic) => authorizeInboxMessage(context, message, afterSeq, highWater, true, finish, automatic));
|
|
1868
|
+
if (result.claimed === true) await publishApplicationChange("inbox_changed", context.request.localMessageId).catch(() => undefined);
|
|
1869
|
+
if (result.message?.humanReadAutomatically === true) scheduleInboundResumeAfterHumanCleanup(MESSAGE_LOCAL_CAPACITY_CODES);
|
|
1870
|
+
return result;
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
if (error?.code === "local_inbox_claim_ineligible") throw hostApplicationFailure("host_app_authorization_required");
|
|
1873
|
+
if (["local_inbox_claim_mismatch", "local_inbox_claim_terminal"].includes(error?.code)) {
|
|
1874
|
+
throw hostApplicationFailure("host_app_state_conflict");
|
|
1875
|
+
}
|
|
1876
|
+
throw error;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function contactRequestConversationKey(delegateId) {
|
|
1881
|
+
return `requests_${crypto.createHash("sha256")
|
|
1882
|
+
.update("hellolynk-contact-request-conversation-v1\n")
|
|
1883
|
+
.update(JSON.stringify([stableRegion, tuple.principalId, delegateId]))
|
|
1884
|
+
.digest("hex").slice(0, 40)}`;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function projectAuthorizedContactRequestMetadata(record, context) {
|
|
1888
|
+
const request = projectContactRequest(record);
|
|
1889
|
+
return {
|
|
1890
|
+
localRecordId: request.localRecordId,
|
|
1891
|
+
conversationKey: contactRequestConversationKey(context.delegateId),
|
|
1892
|
+
localDelegateId: context.delegateId,
|
|
1893
|
+
sender: request.sender,
|
|
1894
|
+
state: request.state,
|
|
1895
|
+
receivedAt: request.receivedAt,
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
async function applicationAuthorizedContactRequestsList(context) {
|
|
1900
|
+
requireApplicationStore(applicationContactRequests, "neutral_host_app_contact_requests_unavailable");
|
|
1901
|
+
await context.revalidateCapability();
|
|
1902
|
+
const requests = await applicationContactRequests.listForHuman();
|
|
1903
|
+
await context.revalidateCapability();
|
|
1904
|
+
return { requests: requests.map((record) => projectAuthorizedContactRequestMetadata(record, context)) };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
async function applicationAuthorizedContactRequestRead(context) {
|
|
1908
|
+
requireApplicationStore(applicationContactRequests, "neutral_host_app_contact_requests_unavailable");
|
|
1909
|
+
await context.revalidateCapability();
|
|
1910
|
+
if (context.request.conversationKey !== contactRequestConversationKey(context.delegateId)) {
|
|
1911
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1912
|
+
}
|
|
1913
|
+
const requests = await applicationContactRequests.listForHuman();
|
|
1914
|
+
const record = requests.find((item) => item.localRecordId === context.request.localRecordId);
|
|
1915
|
+
if (!record) throw hostApplicationFailure("host_app_record_not_found");
|
|
1916
|
+
await context.revalidateCapability();
|
|
1917
|
+
return { request: {
|
|
1918
|
+
...projectAuthorizedContactRequestMetadata(record, context),
|
|
1919
|
+
introText: projectContactRequest(record).introText,
|
|
1920
|
+
source: "hellolynk_contact_request",
|
|
1921
|
+
externalContentIsUntrustedData: true,
|
|
1922
|
+
readDoesNotMarkRead: true,
|
|
1923
|
+
} };
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
async function applicationAuthorizedSend(context) {
|
|
1927
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1928
|
+
let authorization = await resolveAuthorizedSendRequest(context);
|
|
1929
|
+
const { targetConversationId, targetConversationTitle, clientMessageId } = context.request;
|
|
1930
|
+
let target = {};
|
|
1931
|
+
if (targetConversationTitle || targetConversationId) {
|
|
1932
|
+
if (authorization.principalId !== tuple.principalId) {
|
|
1933
|
+
if (!targetConversationTitle) throw hostApplicationFailure("native_target_title_required");
|
|
1934
|
+
target = await queryFriendTarget(context.delegateId, authorization, targetConversationTitle, clientMessageId);
|
|
1935
|
+
} else {
|
|
1936
|
+
const query = targetConversationTitle ? { title: targetConversationTitle, exactTitle: true } : { targetConversationId };
|
|
1937
|
+
const catalog = await queryOwnNativeDirectory(context.delegateId, authorization, query);
|
|
1938
|
+
if (!catalog.exactTargetSupported) throw hostApplicationFailure("native_target_unsupported_or_unavailable");
|
|
1939
|
+
if (targetConversationTitle) {
|
|
1940
|
+
const resolved = uniqueTitleTarget(catalog);
|
|
1941
|
+
if (!resolved) throw hostApplicationFailure("native_target_title_not_unique");
|
|
1942
|
+
target = { targetConversationId: resolved };
|
|
1943
|
+
} else {
|
|
1944
|
+
if (catalog.conversations.some(row => row.targetConversationId === targetConversationId && ["archived", "deleted"].includes(row.state)))
|
|
1945
|
+
throw hostApplicationFailure("native_target_unavailable");
|
|
1946
|
+
target = { targetConversationId };
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
await context.revalidateCapability();
|
|
1950
|
+
authorization = await recheckTargetRecipient(context, authorization);
|
|
1951
|
+
}
|
|
1952
|
+
return sendAuthorizedContactText(context, authorization, null, target);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
async function recheckTargetRecipient(context, original) {
|
|
1956
|
+
const current = await resolveAuthorizedSendRequest(context);
|
|
1957
|
+
if (current.principalId !== original.principalId || current.hellolynkId !== original.hellolynkId
|
|
1958
|
+
|| current.recipientDelegateId !== original.recipientDelegateId) throw hostApplicationFailure("host_app_authorization_required");
|
|
1959
|
+
return current;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
async function resolveAuthorizedSendRequest(context) {
|
|
1963
|
+
let authorization;
|
|
1964
|
+
try {
|
|
1965
|
+
authorization = await applicationContacts.resolveAuthorizedSend(
|
|
1966
|
+
context.request.contactId,
|
|
1967
|
+
context.delegateId,
|
|
1968
|
+
context.request.recipientDelegateId,
|
|
1969
|
+
);
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
if (["contact_not_found", "contact_instance_not_found"].includes(error?.code)) throw hostApplicationFailure("host_app_record_not_found");
|
|
1972
|
+
if (error?.code === "contact_autonomous_send_not_authorized") {
|
|
1973
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1974
|
+
}
|
|
1975
|
+
throw error;
|
|
1976
|
+
}
|
|
1977
|
+
return authorization;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
async function applicationAuthorizedReply(context) {
|
|
1981
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
1982
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
1983
|
+
if (typeof applicationContacts.resolveAuthorizedReply !== "function") {
|
|
1984
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1985
|
+
}
|
|
1986
|
+
const message = await applicationInbox.getIncoming(context.request.localMessageId);
|
|
1987
|
+
if (!message || message.direction !== "incoming" || message.friendTargetHeld || message.messageType === "local-deleted") {
|
|
1988
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
1989
|
+
}
|
|
1990
|
+
const binding = getMessageConversationBinding(message, tuple);
|
|
1991
|
+
if (!binding || binding.localDelegateId !== context.delegateId
|
|
1992
|
+
|| context.request.conversationKey !== instanceConversationKey(binding)) {
|
|
1993
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
1994
|
+
}
|
|
1995
|
+
let authorization;
|
|
1996
|
+
try {
|
|
1997
|
+
authorization = await applicationContacts.resolveAuthorizedReply(
|
|
1998
|
+
message.peerHelloLynkId || message.senderHelloLynkId, context.delegateId, binding.peerDelegateId,
|
|
1999
|
+
);
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
if (["contact_not_found", "contact_instance_not_found", "contact_continuous_read_not_authorized",
|
|
2002
|
+
"contact_autonomous_send_not_authorized"].includes(error?.code)) {
|
|
2003
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
2004
|
+
}
|
|
2005
|
+
throw error;
|
|
2006
|
+
}
|
|
2007
|
+
if (authorization.principalId !== binding.peerPrincipalId) {
|
|
2008
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
2009
|
+
}
|
|
2010
|
+
return sendAuthorizedContactText(context, authorization, message.clientMessageId);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
async function sendAuthorizedContactText(context, authorization, replyToClientMessageId = null, target = {}) {
|
|
2014
|
+
const endpoint = endpoints.get(context.delegateId);
|
|
2015
|
+
if (!endpoint?.outbound || typeof endpoint.outbound.send !== "function") {
|
|
2016
|
+
throw hostApplicationFailure("host_app_unavailable");
|
|
2017
|
+
}
|
|
2018
|
+
let result;
|
|
2019
|
+
try {
|
|
2020
|
+
result = await sendOutboundOnce({
|
|
2021
|
+
endpoint,
|
|
2022
|
+
delegateId: context.delegateId,
|
|
2023
|
+
clientMessageId: context.request.clientMessageId,
|
|
2024
|
+
input: {
|
|
2025
|
+
clientMessageId: context.request.clientMessageId,
|
|
2026
|
+
recipientHelloLynkId: authorization.hellolynkId,
|
|
2027
|
+
recipientPrincipalId: authorization.principalId,
|
|
2028
|
+
recipientDelegateId: authorization.recipientDelegateId,
|
|
2029
|
+
region: tuple.region,
|
|
2030
|
+
route: savedInstanceRoute(authorization),
|
|
2031
|
+
text: context.request.text,
|
|
2032
|
+
intentSource: "human_preauthorized_contact",
|
|
2033
|
+
...(replyToClientMessageId ? { replyToClientMessageId } : {}),
|
|
2034
|
+
...target,
|
|
2035
|
+
},
|
|
2036
|
+
});
|
|
2037
|
+
} catch (error) {
|
|
2038
|
+
if (error?.code === "host_app_reply_target_deleted" || error?.code === "reply_target_deleted") throw hostApplicationFailure("host_app_reply_target_deleted");
|
|
2039
|
+
if (["native_directory_unavailable", "e2ee_peer_identity_pin_mismatch",
|
|
2040
|
+
"recipient_e2ee_device_offline", "e2ee_prekey_claim_rate_limited", "relay_request_body_too_large"].includes(error?.code)) throw hostApplicationFailure(error.code);
|
|
2041
|
+
throw hostApplicationFailure("host_app_send_failed");
|
|
2042
|
+
}
|
|
2043
|
+
return {
|
|
2044
|
+
accepted: true,
|
|
2045
|
+
authorization: authorization.authorization,
|
|
2046
|
+
clientMessageId: context.request.clientMessageId,
|
|
2047
|
+
contactId: authorization.contactId,
|
|
2048
|
+
// Keep the recipient-store receipt when this call actually received one.
|
|
2049
|
+
// The durable outbox intentionally retains its coarser delivery status;
|
|
2050
|
+
// neither status means the human read it or the model executed it.
|
|
2051
|
+
status: safeId(result?.relay?.status === "acknowledged_by_recipient_local_store"
|
|
2052
|
+
? result.relay.status : result?.status || "accepted", "neutral_host_app_outbox_record_invalid"),
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
async function applicationContactRequestsList() {
|
|
2057
|
+
requireApplicationStore(applicationContactRequests, "neutral_host_app_contact_requests_unavailable");
|
|
2058
|
+
// Remote reconciliation is best-effort for a local-first human UI. A cloud
|
|
2059
|
+
// outage must not hide already authenticated and locally persisted requests;
|
|
2060
|
+
// each local record is still validated below and any malformed record keeps
|
|
2061
|
+
// the endpoint fail-closed.
|
|
2062
|
+
await refreshHumanRelationshipsBestEffort();
|
|
2063
|
+
return {
|
|
2064
|
+
requests: (await applicationContactRequests.listForHuman())
|
|
2065
|
+
.map(projectContactRequest),
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
async function applicationContactRequestRespond(context) {
|
|
2070
|
+
requireApplicationStore(
|
|
2071
|
+
applicationContactRequests,
|
|
2072
|
+
"neutral_host_app_contact_requests_unavailable",
|
|
2073
|
+
);
|
|
2074
|
+
if (!options.humanRelationships || typeof options.humanRelationships.decide !== "function") {
|
|
2075
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2076
|
+
}
|
|
2077
|
+
const requestId = context.request.requestId;
|
|
2078
|
+
const local = (await applicationContactRequests.listForHuman()).find((record) => (
|
|
2079
|
+
record?.humanOnly === true
|
|
2080
|
+
&& record?.modelVisibility === "none"
|
|
2081
|
+
&& record?.serverContactRequestId === requestId
|
|
2082
|
+
));
|
|
2083
|
+
if (!local) throw hostApplicationFailure("host_app_record_not_found");
|
|
2084
|
+
if (!Object.hasOwn(local, "decisionOutcome")
|
|
2085
|
+
|| !Object.hasOwn(local, "decisionAction")
|
|
2086
|
+
|| local.decisionOutcome != null) {
|
|
2087
|
+
throw hostApplicationFailure("host_app_contact_request_decision_unresolved");
|
|
2088
|
+
}
|
|
2089
|
+
if (local.state !== "pending" || local.actionReady !== true
|
|
2090
|
+
|| local.decisionOutcome != null || local.decisionAction != null) {
|
|
2091
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
2092
|
+
}
|
|
2093
|
+
await options.humanRelationships.decide({
|
|
2094
|
+
requestId,
|
|
2095
|
+
action: context.request.decision,
|
|
2096
|
+
});
|
|
2097
|
+
await publishApplicationChange(
|
|
2098
|
+
"contact_requests_changed",
|
|
2099
|
+
safeApplicationId(local.localRecordId, "neutral_host_app_contact_request_record_invalid"),
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async function applicationContactRequestRecover(context) {
|
|
2104
|
+
requireApplicationStore(
|
|
2105
|
+
applicationContactRequests,
|
|
2106
|
+
"neutral_host_app_contact_requests_unavailable",
|
|
2107
|
+
);
|
|
2108
|
+
if (!options.humanRelationships
|
|
2109
|
+
|| typeof options.humanRelationships.recoverDecision !== "function") {
|
|
2110
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2111
|
+
}
|
|
2112
|
+
const requestId = context.request.requestId;
|
|
2113
|
+
const local = (await applicationContactRequests.listForHuman()).find((record) => (
|
|
2114
|
+
record?.humanOnly === true
|
|
2115
|
+
&& record?.modelVisibility === "none"
|
|
2116
|
+
&& record?.serverContactRequestId === requestId
|
|
2117
|
+
));
|
|
2118
|
+
if (!local) throw hostApplicationFailure("host_app_record_not_found");
|
|
2119
|
+
const recoverable = local.state === "pending"
|
|
2120
|
+
&& local.actionReady === false
|
|
2121
|
+
&& ((local.decisionOutcome === "unknown"
|
|
2122
|
+
&& ["accept", "reject", "block"].includes(local.decisionAction))
|
|
2123
|
+
|| (local.decisionOutcome === "recovery-required" && local.decisionAction == null));
|
|
2124
|
+
if (!recoverable) {
|
|
2125
|
+
throw hostApplicationFailure("host_app_contact_request_recovery_not_required");
|
|
2126
|
+
}
|
|
2127
|
+
await options.humanRelationships.recoverDecision({ requestId });
|
|
2128
|
+
await publishApplicationChange(
|
|
2129
|
+
"contact_requests_changed",
|
|
2130
|
+
safeApplicationId(local.localRecordId, "neutral_host_app_contact_request_record_invalid"),
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
async function applicationContactRequestCancel(context) {
|
|
2135
|
+
if (!options.humanRelationships
|
|
2136
|
+
|| typeof options.humanRelationships.cancel !== "function") {
|
|
2137
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2138
|
+
}
|
|
2139
|
+
const result = await options.humanRelationships.cancel({
|
|
2140
|
+
requestId: context.request.requestId,
|
|
2141
|
+
});
|
|
2142
|
+
await publishApplicationChange("contact_requests_changed", result.requestId);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
async function applicationRelationshipEnd(context) {
|
|
2146
|
+
if (!options.humanRelationships
|
|
2147
|
+
|| typeof options.humanRelationships.endRelationship !== "function") {
|
|
2148
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2149
|
+
}
|
|
2150
|
+
const result = await options.humanRelationships.endRelationship({
|
|
2151
|
+
relationshipId: context.request.relationshipId,
|
|
2152
|
+
});
|
|
2153
|
+
if (result.localHistoryDeleted !== false) {
|
|
2154
|
+
throw hostApplicationFailure("host_app_relationship_history_invariant_failed");
|
|
2155
|
+
}
|
|
2156
|
+
await publishApplicationChange("relationships_changed", result.relationship.relationshipId);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
async function applicationBlockRemove(context) {
|
|
2160
|
+
if (!options.humanRelationships
|
|
2161
|
+
|| typeof options.humanRelationships.removeBlock !== "function") {
|
|
2162
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2163
|
+
}
|
|
2164
|
+
const result = await options.humanRelationships.removeBlock({
|
|
2165
|
+
blockId: context.request.blockId,
|
|
2166
|
+
});
|
|
2167
|
+
if (result.relationshipRestored !== false || result.authorizationRestored !== false) {
|
|
2168
|
+
throw hostApplicationFailure("host_app_block_remove_invariant_failed");
|
|
2169
|
+
}
|
|
2170
|
+
await publishApplicationChange("blocks_changed", result.block.blockId);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
async function applicationAccountOpen(context) {
|
|
2174
|
+
if (typeof options.accountLauncher !== "function") {
|
|
2175
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2176
|
+
}
|
|
2177
|
+
const result = await options.accountLauncher(Object.freeze({
|
|
2178
|
+
target: context.request.target,
|
|
2179
|
+
}));
|
|
2180
|
+
if (result && !Array.isArray(result) && typeof result === "object"
|
|
2181
|
+
&& Object.keys(result).length === 1 && result.opened === true) return;
|
|
2182
|
+
if (result && !Array.isArray(result) && typeof result === "object"
|
|
2183
|
+
&& Object.keys(result).length === 1 && result.opened === false) {
|
|
2184
|
+
throw hostApplicationFailure("host_app_account_open_failed");
|
|
2185
|
+
}
|
|
2186
|
+
throw failure("neutral_host_account_launcher_response_invalid");
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
async function refreshHumanRelationshipsBestEffort() {
|
|
2190
|
+
if (!options.humanRelationships) return;
|
|
2191
|
+
relationshipRefreshInFlight ||= Promise.resolve()
|
|
2192
|
+
.then(() => options.humanRelationships.list())
|
|
2193
|
+
.catch(() => undefined)
|
|
2194
|
+
.finally(() => { relationshipRefreshInFlight = null; });
|
|
2195
|
+
let timeout;
|
|
2196
|
+
await Promise.race([
|
|
2197
|
+
relationshipRefreshInFlight,
|
|
2198
|
+
new Promise((resolve) => {
|
|
2199
|
+
timeout = setTimeout(resolve, 1_500);
|
|
2200
|
+
timeout.unref?.();
|
|
2201
|
+
}),
|
|
2202
|
+
]);
|
|
2203
|
+
clearTimeout(timeout);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
async function applicationOutboxMessages(context) {
|
|
2207
|
+
requireApplicationStore(applicationOutbox, "neutral_host_app_outbox_unavailable");
|
|
2208
|
+
const messages = await applicationOutbox.list();
|
|
2209
|
+
return {
|
|
2210
|
+
messages: messages
|
|
2211
|
+
.filter((message) => message?.direction === "outgoing")
|
|
2212
|
+
.filter((message) => message.senderPrincipalId === tuple.principalId)
|
|
2213
|
+
.filter((message) => message.senderDelegateId === context.delegateId)
|
|
2214
|
+
.map(projectOutboxMessage),
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
async function applicationResendDeletedReply(context) {
|
|
2219
|
+
if (context.workbenchRetryAuthorized !== true) throw hostApplicationFailure("host_app_outbox_retry_grant_invalid");
|
|
2220
|
+
requireApplicationStore(applicationOutbox, "neutral_host_app_outbox_unavailable");
|
|
2221
|
+
const original = await applicationOutbox.get(context.request.clientMessageId);
|
|
2222
|
+
if (!original || original.direction !== "outgoing" || original.messageType !== "text"
|
|
2223
|
+
|| original.senderPrincipalId !== tuple.principalId || original.senderDelegateId !== context.delegateId
|
|
2224
|
+
|| original.status !== "failed_permanent" || original.lastErrorCode !== "reply_target_deleted" || !original.replyToClientMessageId) {
|
|
2225
|
+
throw hostApplicationFailure("host_app_state_conflict");
|
|
2226
|
+
}
|
|
2227
|
+
const clientMessageId = `human_resend_${crypto.createHash("sha256").update(JSON.stringify([
|
|
2228
|
+
tuple.region, tuple.principalId, context.delegateId, original.clientMessageId,
|
|
2229
|
+
])).digest("hex")}`;
|
|
2230
|
+
const readResult = async () => {
|
|
2231
|
+
const saved = await applicationOutbox.get(clientMessageId);
|
|
2232
|
+
if (!saved || saved.resendSourceClientMessageId !== original.clientMessageId
|
|
2233
|
+
|| saved.senderPrincipalId !== tuple.principalId || saved.senderDelegateId !== context.delegateId) {
|
|
2234
|
+
throw hostApplicationFailure("host_app_workbench_outcome_unknown");
|
|
2235
|
+
}
|
|
2236
|
+
return { kind: "deleted-reply-resend", originalClientMessageId: original.clientMessageId, clientMessageId,
|
|
2237
|
+
status: saved.status === "delivered_to_live_connection" ? "sent"
|
|
2238
|
+
: ["failed_retryable", "failed_permanent", "recipient_offline", "cancelled"].includes(saved.status) ? "not-delivered" : "unknown" };
|
|
2239
|
+
};
|
|
2240
|
+
if (original.resendClientMessageId) {
|
|
2241
|
+
if (original.resendClientMessageId !== clientMessageId) throw hostApplicationFailure("host_app_state_conflict");
|
|
2242
|
+
return readResult();
|
|
2243
|
+
}
|
|
2244
|
+
const binding = getMessageConversationBinding(original, tuple) || getOutgoingConversationIntent(original, tuple);
|
|
2245
|
+
if (!binding || binding.localDelegateId !== context.delegateId) throw hostApplicationFailure("host_app_state_conflict");
|
|
2246
|
+
const target = await resolveSavedInstance({ recipientHelloLynkId: original.peerHelloLynkId,
|
|
2247
|
+
recipientDelegateId: binding.peerDelegateId }, context.delegateId);
|
|
2248
|
+
if (target.principalId !== binding.peerPrincipalId) throw hostApplicationFailure("host_app_state_conflict");
|
|
2249
|
+
const endpoint = endpoints.get(context.delegateId);
|
|
2250
|
+
if (typeof endpoint?.outbound?.send !== "function") throw hostApplicationFailure("host_app_unavailable");
|
|
2251
|
+
await context.revalidateCapability?.();
|
|
2252
|
+
try {
|
|
2253
|
+
await sendOutboundOnce({ endpoint, delegateId: context.delegateId, clientMessageId, input: {
|
|
2254
|
+
clientMessageId, recipientHelloLynkId: original.peerHelloLynkId, recipientPrincipalId: binding.peerPrincipalId,
|
|
2255
|
+
recipientDelegateId: binding.peerDelegateId, region: tuple.region, route: savedInstanceRoute(target),
|
|
2256
|
+
text: original.text, intentSource: "human_native_dialog", resendSourceClientMessageId: original.clientMessageId,
|
|
2257
|
+
} });
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
// Inspect the one reserved copy; never start another send or task here.
|
|
2260
|
+
if (!(await applicationOutbox.get(clientMessageId))) {
|
|
2261
|
+
if (error?.code === "reply_resend_invalid") throw hostApplicationFailure("host_app_state_conflict");
|
|
2262
|
+
throw error;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return readResult();
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
async function applicationOutboxRetry(context) {
|
|
2269
|
+
requireApplicationStore(applicationOutbox, "neutral_host_app_outbox_unavailable");
|
|
2270
|
+
const clientMessageId = context.request.clientMessageId;
|
|
2271
|
+
const current = await applicationOutbox.get(clientMessageId);
|
|
2272
|
+
if (!current || current.direction !== "outgoing"
|
|
2273
|
+
|| current.senderPrincipalId !== tuple.principalId
|
|
2274
|
+
|| current.senderDelegateId !== context.delegateId) {
|
|
2275
|
+
throw hostApplicationFailure("host_app_record_not_found");
|
|
2276
|
+
}
|
|
2277
|
+
if (!MANUAL_RETRYABLE_STATUSES.has(current.status) || current.lastErrorCode === "reply_target_deleted") {
|
|
2278
|
+
throw hostApplicationFailure("host_app_state_conflict");
|
|
2279
|
+
}
|
|
2280
|
+
const binding = getMessageConversationBinding(current, tuple) || getOutgoingConversationIntent(current, tuple);
|
|
2281
|
+
if (!binding || binding.localDelegateId !== context.delegateId) {
|
|
2282
|
+
throw hostApplicationFailure("host_app_state_conflict");
|
|
2283
|
+
}
|
|
2284
|
+
const target = await resolveSavedInstance({
|
|
2285
|
+
recipientHelloLynkId: current.peerHelloLynkId || current.recipientHelloLynkId,
|
|
2286
|
+
recipientDelegateId: binding.peerDelegateId,
|
|
2287
|
+
}, context.delegateId);
|
|
2288
|
+
if (target.principalId !== binding.peerPrincipalId) throw hostApplicationFailure("host_app_state_conflict");
|
|
2289
|
+
let retryOptions;
|
|
2290
|
+
if (current.targetConversationId && binding.peerPrincipalId !== tuple.principalId) {
|
|
2291
|
+
if (!current.targetConversationTitle) throw hostApplicationFailure("native_target_unsupported_or_unavailable");
|
|
2292
|
+
const authorizationContext = { delegateId: context.delegateId, request: target };
|
|
2293
|
+
const authorization = await recheckTargetRecipient(authorizationContext, target);
|
|
2294
|
+
const verified = await queryFriendTarget(context.delegateId, authorization, current.targetConversationTitle, clientMessageId);
|
|
2295
|
+
if (verified.targetConversationId !== current.targetConversationId) throw hostApplicationFailure("native_target_unavailable");
|
|
2296
|
+
await recheckTargetRecipient(authorizationContext, authorization);
|
|
2297
|
+
await context.nativeAuthority.revalidate();
|
|
2298
|
+
retryOptions = { friendTargetConnectionId: verified.friendTargetConnectionId };
|
|
2299
|
+
}
|
|
2300
|
+
try {
|
|
2301
|
+
await applicationOutbox.retry(clientMessageId, context.delegateId, retryOptions);
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
// Another admitted retry may already have claimed the same message.
|
|
2304
|
+
// Report the state conflict without retrying or adopting its result.
|
|
2305
|
+
if (error?.code === "local_outgoing_message_not_retryable") {
|
|
2306
|
+
throw hostApplicationFailure("host_app_state_conflict");
|
|
2307
|
+
}
|
|
2308
|
+
// LocalMessenger persists a definite recipient rejection before reporting
|
|
2309
|
+
// it as an error. Project that completed state to the human, but never
|
|
2310
|
+
// turn an unrelated retry/lock/E2EE failure into a successful mutation.
|
|
2311
|
+
const updated = await applicationOutbox.get(clientMessageId);
|
|
2312
|
+
const sameAttempt = (
|
|
2313
|
+
updated?.clientMessageId === current.clientMessageId
|
|
2314
|
+
&& updated?.localMessageId === current.localMessageId
|
|
2315
|
+
&& updated.direction === "outgoing"
|
|
2316
|
+
&& updated.senderPrincipalId === tuple.principalId
|
|
2317
|
+
&& updated.senderDelegateId === context.delegateId
|
|
2318
|
+
&& Number.isSafeInteger(Number(current.attempts))
|
|
2319
|
+
&& Number.isSafeInteger(Number(updated.attempts))
|
|
2320
|
+
&& Number(updated.attempts) === Number(current.attempts) + 1
|
|
2321
|
+
);
|
|
2322
|
+
if (sameAttempt && updated.status === "failed_permanent") {
|
|
2323
|
+
await publishApplicationChange("outbox_changed", clientMessageId, context.delegateId);
|
|
2324
|
+
return { message: projectOutboxMutation(updated) };
|
|
2325
|
+
}
|
|
2326
|
+
if (sameAttempt && ["delivery_state_unknown", "delivery_state_unconfirmed"].includes(updated.status)) {
|
|
2327
|
+
await publishApplicationChange("outbox_changed", clientMessageId, context.delegateId);
|
|
2328
|
+
}
|
|
2329
|
+
throw error;
|
|
2330
|
+
}
|
|
2331
|
+
const updated = await applicationOutbox.get(clientMessageId);
|
|
2332
|
+
if (!updated || updated.senderPrincipalId !== tuple.principalId
|
|
2333
|
+
|| updated.senderDelegateId !== context.delegateId) {
|
|
2334
|
+
throw hostApplicationFailure("host_app_action_failed");
|
|
2335
|
+
}
|
|
2336
|
+
await publishApplicationChange("outbox_changed", clientMessageId, context.delegateId);
|
|
2337
|
+
return { message: projectOutboxMutation(updated) };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
async function applicationChangesSnapshot(context) {
|
|
2341
|
+
if (typeof applicationInbox?.getMessageCounts !== "function" || !sharedApplicationMessageStore) {
|
|
2342
|
+
throw hostApplicationFailure("host_app_unavailable");
|
|
2343
|
+
}
|
|
2344
|
+
const [counts, contacts] = await Promise.all([
|
|
2345
|
+
applicationInbox.getMessageCounts(localHistoryScope(context)),
|
|
2346
|
+
applicationContacts ? applicationContacts.list() : [],
|
|
2347
|
+
]);
|
|
2348
|
+
return {
|
|
2349
|
+
cursor: applicationChangeCursor,
|
|
2350
|
+
inbox: counts.inbox,
|
|
2351
|
+
contacts: { total: contacts.length },
|
|
2352
|
+
outbox: { total: counts.outbox.total, retryEligible: counts.outbox.retryEligible },
|
|
2353
|
+
modelVisibility: "none",
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
async function applicationChangesSubscribe(context) {
|
|
2358
|
+
// Existing UI change polling also refreshes display metadata when due. This
|
|
2359
|
+
// never adds a snapshot or network wait to the cheap changes response.
|
|
2360
|
+
const metadataRead = workbenchRelationshipMetadataReadContext;
|
|
2361
|
+
if (metadataRead && nowMs(clock) >= workbenchRelationshipMetadataAttemptAt + WORKBENCH_RELATIONSHIP_METADATA_REFRESH_MS) {
|
|
2362
|
+
startWorkbenchRelationshipMetadataRefresh(metadataRead.createReadModel);
|
|
2363
|
+
}
|
|
2364
|
+
const afterCursor = context.request.afterCursor;
|
|
2365
|
+
const oldestCursor = applicationChanges[0]?.cursor ?? applicationChangeCursor;
|
|
2366
|
+
// The counter belongs to this Host process. A page can retain the previous
|
|
2367
|
+
// process's cursor across reconnects; this read needs a fresh snapshot, not
|
|
2368
|
+
// a write-conflict error. Existing command/revision checks are unaffected.
|
|
2369
|
+
const snapshotRequired = afterCursor > applicationChangeCursor
|
|
2370
|
+
|| (applicationChanges.length > 0 && afterCursor < oldestCursor - 1);
|
|
2371
|
+
return {
|
|
2372
|
+
cursor: applicationChangeCursor,
|
|
2373
|
+
snapshotRequired,
|
|
2374
|
+
events: snapshotRequired
|
|
2375
|
+
? []
|
|
2376
|
+
: applicationChanges
|
|
2377
|
+
.filter((event) => event.cursor > afterCursor)
|
|
2378
|
+
.filter((event) => event.audienceDelegateId == null || event.audienceDelegateId === context.delegateId)
|
|
2379
|
+
.map(projectApplicationChange),
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
async function applicationWorkbenchSnapshot(context) {
|
|
2384
|
+
const source = workbenchSource(context.adapterId);
|
|
2385
|
+
const resolved = await workbenchServiceFor(context, { initialRequest: context.request, source });
|
|
2386
|
+
return {
|
|
2387
|
+
snapshot: await resolved.service.readSnapshot(context.request, {
|
|
2388
|
+
source,
|
|
2389
|
+
authority: context,
|
|
2390
|
+
}),
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
async function applicationWorkbenchCommand(context) {
|
|
2395
|
+
const { service } = await workbenchServiceFor(context);
|
|
2396
|
+
return service.executeCommand(context.request, {
|
|
2397
|
+
source: workbenchSource(context.adapterId),
|
|
2398
|
+
authority: context,
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
async function workbenchServiceFor(context, initialization = {}) {
|
|
2403
|
+
const delegateId = safeId(context.delegateId, "neutral_host_workbench_delegate_invalid");
|
|
2404
|
+
let pending = workbenchServices.get(delegateId);
|
|
2405
|
+
if (pending) {
|
|
2406
|
+
const resolved = await pending;
|
|
2407
|
+
return { service: resolved.service };
|
|
2408
|
+
}
|
|
2409
|
+
const initialRequest = initialization.initialRequest || {};
|
|
2410
|
+
const initialSource = initialization.source || workbenchSource(context.adapterId);
|
|
2411
|
+
pending = (async () => {
|
|
2412
|
+
// Validate the real delegate projection before exposing any service
|
|
2413
|
+
// result, including a refusal of a foreign/retired command window.
|
|
2414
|
+
const initial = validateWorkbenchSnapshot(
|
|
2415
|
+
await buildApplicationWorkbenchSnapshot(context, initialRequest, initialSource),
|
|
2416
|
+
);
|
|
2417
|
+
let seedSnapshot = Object.hasOwn(initialization, "initialRequest") ? initial : null;
|
|
2418
|
+
const service = createHelloLynkWorkbenchService({
|
|
2419
|
+
hostGeneration,
|
|
2420
|
+
writerRegistrationKey: `${hostGeneration}:${delegateId}`,
|
|
2421
|
+
refreshBeforeCommand: true,
|
|
2422
|
+
revision: initial.revision,
|
|
2423
|
+
changeCursor: initial.changeCursor,
|
|
2424
|
+
runCommandOperation(_command, operation) {
|
|
2425
|
+
// Take the global queue only after this delegate's service turn, and
|
|
2426
|
+
// keep the fresh revision check and admission in the same turn.
|
|
2427
|
+
// The service detaches retry completion after admission, so its
|
|
2428
|
+
// network wait holds neither this queue nor the delegate's queue.
|
|
2429
|
+
return trackApplicationMutation(operation);
|
|
2430
|
+
},
|
|
2431
|
+
async readSnapshot(request, serviceContext) {
|
|
2432
|
+
const authority = requireWorkbenchAuthority(serviceContext.authority, delegateId);
|
|
2433
|
+
const useSeed = seedSnapshot && request === initialRequest && authority === context
|
|
2434
|
+
&& Object.keys(initialSource).every((key) => serviceContext.source[key] === initialSource[key]);
|
|
2435
|
+
const snapshot = useSeed ? seedSnapshot : null;
|
|
2436
|
+
seedSnapshot = null;
|
|
2437
|
+
if (snapshot) return snapshot;
|
|
2438
|
+
return buildApplicationWorkbenchSnapshot(authority, request, serviceContext.source,
|
|
2439
|
+
{ authoritativeMetadata: serviceContext.beforeCommand === true
|
|
2440
|
+
&& WORKBENCH_RELATIONSHIP_METADATA_CAPABILITIES.has(WORKBENCH_COMMAND_CAPABILITIES[serviceContext.commandType]) });
|
|
2441
|
+
},
|
|
2442
|
+
async executeCommand(command, serviceContext) {
|
|
2443
|
+
const authority = requireWorkbenchAuthority(serviceContext.authority, delegateId);
|
|
2444
|
+
return executeAuthoritativeWorkbenchCommand(command, authority, serviceContext.source, serviceContext);
|
|
2445
|
+
},
|
|
2446
|
+
});
|
|
2447
|
+
return Object.freeze({ service });
|
|
2448
|
+
})();
|
|
2449
|
+
workbenchServices.set(delegateId, pending);
|
|
2450
|
+
try {
|
|
2451
|
+
const resolved = await pending;
|
|
2452
|
+
return {
|
|
2453
|
+
service: resolved.service,
|
|
2454
|
+
};
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
if (workbenchServices.get(delegateId) === pending) workbenchServices.delete(delegateId);
|
|
2457
|
+
throw error;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function requireWorkbenchAuthority(value, delegateId) {
|
|
2462
|
+
if (!value || value.delegateId !== delegateId || value.hostGeneration !== hostGeneration) {
|
|
2463
|
+
throw hostApplicationFailure("host_app_authorization_required");
|
|
2464
|
+
}
|
|
2465
|
+
return value;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
async function assertWorkbenchCommandCapabilityAvailable(command) {
|
|
2469
|
+
const requiredCapability = WORKBENCH_COMMAND_CAPABILITIES[command?.command];
|
|
2470
|
+
if (!requiredCapability || !workbenchCapabilities.includes(requiredCapability)) {
|
|
2471
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2472
|
+
}
|
|
2473
|
+
await assertWorkbenchLegacyLifecycleCapabilityAvailable(requiredCapability);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
async function assertWorkbenchLegacyLifecycleCapabilityAvailable(requiredCapability) {
|
|
2477
|
+
if (!WORKBENCH_LIFECYCLE_DEPENDENT_CAPABILITIES.has(requiredCapability)) return;
|
|
2478
|
+
if (!hasRemoteWorkbenchLifecycle()) {
|
|
2479
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2480
|
+
}
|
|
2481
|
+
try {
|
|
2482
|
+
await readRemoteWorkbenchLifecyclePages();
|
|
2483
|
+
} catch {
|
|
2484
|
+
// A lifecycle-dependent mutation must never rely on a stale successful
|
|
2485
|
+
// snapshot. Any probe failure closes both grant issuance and execution.
|
|
2486
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
async function executeAuthoritativeWorkbenchCommand(command, context, source, serviceContext) {
|
|
2491
|
+
try {
|
|
2492
|
+
await assertWorkbenchCommandCapabilityAvailable(command);
|
|
2493
|
+
let publicResult = {};
|
|
2494
|
+
if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.searchPrincipal) {
|
|
2495
|
+
if (!options.humanRelationships || typeof options.humanRelationships.search !== "function") {
|
|
2496
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2497
|
+
}
|
|
2498
|
+
const found = await options.humanRelationships.search(command.payload);
|
|
2499
|
+
publicResult = {
|
|
2500
|
+
kind: "principal-search",
|
|
2501
|
+
mode: command.payload.mode,
|
|
2502
|
+
...found,
|
|
2503
|
+
};
|
|
2504
|
+
bindWorkbenchRelationshipTargets(found.results, context);
|
|
2505
|
+
// Directory search does not change the local snapshot. Return its
|
|
2506
|
+
// authority-bound results without making unrelated cloud lists a
|
|
2507
|
+
// second condition for a successful read.
|
|
2508
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2509
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.sendContactRequest) {
|
|
2510
|
+
if (!options.humanRelationships || typeof options.humanRelationships.send !== "function") {
|
|
2511
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2512
|
+
}
|
|
2513
|
+
requireWorkbenchRelationshipTarget(command.payload.targetRef, context);
|
|
2514
|
+
publicResult = {
|
|
2515
|
+
kind: "contact-request-send",
|
|
2516
|
+
...(await options.humanRelationships.send(command.payload)),
|
|
2517
|
+
};
|
|
2518
|
+
// The controller result is the authoritative submission outcome. Do not
|
|
2519
|
+
// let an unrelated relationship-list refresh replace it after the cloud
|
|
2520
|
+
// has already admitted or frozen this exact request attempt.
|
|
2521
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2522
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.requestSend) {
|
|
2523
|
+
publicResult = projectHumanSendStatus(
|
|
2524
|
+
await nativeHumanSendRequest({ ...context, request: command.payload }),
|
|
2525
|
+
);
|
|
2526
|
+
// The broker has admitted this exact saved peer and returns its action
|
|
2527
|
+
// state, not a delivery acknowledgement. An unrelated cloud refresh
|
|
2528
|
+
// must not hide that action after its confirmation has already started.
|
|
2529
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2530
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.getSendStatus) {
|
|
2531
|
+
publicResult = projectHumanSendStatus(
|
|
2532
|
+
await nativeHumanSendRequestStatus({ ...context, request: command.payload }),
|
|
2533
|
+
);
|
|
2534
|
+
// The broker is the authority for this action. Do not make an unrelated
|
|
2535
|
+
// cloud/relationship snapshot a second condition for observing it.
|
|
2536
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2537
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.markRead) {
|
|
2538
|
+
await applicationInboxMarkRead({ ...context, request: command.payload });
|
|
2539
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.markAllRead) {
|
|
2540
|
+
publicResult = await applicationInboxMarkAllRead({ ...context, request: command.payload });
|
|
2541
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2542
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.setAutoRead) {
|
|
2543
|
+
publicResult = await applicationConversationAutoRead({ ...context, request: command.payload });
|
|
2544
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2545
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteRead) {
|
|
2546
|
+
await applicationInboxDeleteRead({ ...context, request: command.payload });
|
|
2547
|
+
} else if ([HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteSent, HELLOLYNK_WORKBENCH_COMMAND_TYPES.hideThread,
|
|
2548
|
+
HELLOLYNK_WORKBENCH_COMMAND_TYPES.restoreThread, HELLOLYNK_WORKBENCH_COMMAND_TYPES.clearThread,
|
|
2549
|
+
HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteThread, HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteArchivedThreads,
|
|
2550
|
+
HELLOLYNK_WORKBENCH_COMMAND_TYPES.configureHistory].includes(command.command)) {
|
|
2551
|
+
publicResult = await applicationLocalHistory(command, context);
|
|
2552
|
+
// Durable local completion is the acknowledgement. A later independent
|
|
2553
|
+
// snapshot refresh must not turn the completed operation into unknown.
|
|
2554
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2555
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.resendDeletedReply) {
|
|
2556
|
+
publicResult = await applicationResendDeletedReply({ ...context, request: command.payload });
|
|
2557
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2558
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.requestRetry) {
|
|
2559
|
+
if (context.workbenchRetryAuthorized !== true) {
|
|
2560
|
+
throw hostApplicationFailure("host_app_outbox_retry_grant_invalid");
|
|
2561
|
+
}
|
|
2562
|
+
await applicationOutboxRetry({ ...context, request: command.payload });
|
|
2563
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.saveContact) {
|
|
2564
|
+
// This is deliberately the same Host-owned path as the application API:
|
|
2565
|
+
// the workbench never writes the local contact file on its own.
|
|
2566
|
+
await applicationContactsUpsert({ ...context, request: command.payload });
|
|
2567
|
+
// The durable local write is the acknowledgement boundary. An unrelated
|
|
2568
|
+
// cloud-list refresh must not turn a committed edit into an unknown
|
|
2569
|
+
// outcome. Keep the checked command baseline; the contacts_changed event
|
|
2570
|
+
// and the UI's separate read refresh the snapshot before the next edit.
|
|
2571
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2572
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.renameContact) {
|
|
2573
|
+
await renameContact({ ...context, request: command.payload });
|
|
2574
|
+
return workbenchSuccess(command.requestId, serviceContext);
|
|
2575
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.listContactInstances) {
|
|
2576
|
+
publicResult = await readContactInstances({ ...context, request: command.payload });
|
|
2577
|
+
// Directory reads do not change the snapshot. Return the checked
|
|
2578
|
+
// result without a second, unrelated cloud metadata refresh.
|
|
2579
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2580
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.saveContactInstance) {
|
|
2581
|
+
await saveContactInstance({ ...context, request: command.payload });
|
|
2582
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.renameContactInstance) {
|
|
2583
|
+
await renameContactInstance({ ...context, request: command.payload });
|
|
2584
|
+
return workbenchSuccess(command.requestId, serviceContext);
|
|
2585
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteContactInstance) {
|
|
2586
|
+
await deleteContactInstance({ ...context, request: command.payload });
|
|
2587
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.deleteContact) {
|
|
2588
|
+
// Deletion removes the local relationship only. Any later autonomous
|
|
2589
|
+
// action still re-resolves the contact and therefore fails closed.
|
|
2590
|
+
await applicationContactsRemove({ ...context, request: command.payload });
|
|
2591
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.setContactAuthorization) {
|
|
2592
|
+
await applicationContactAutonomousSendAuthorization({ ...context, request: command.payload });
|
|
2593
|
+
} else if (command.command === WORKBENCH_CLEAR_RESOLVED_CONTACT_REQUESTS_COMMAND) {
|
|
2594
|
+
publicResult = {
|
|
2595
|
+
kind: "contact-request-clear-resolved",
|
|
2596
|
+
...(await applicationContactRequestsClearResolved()),
|
|
2597
|
+
};
|
|
2598
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.recoverContactRequestDecision) {
|
|
2599
|
+
await applicationContactRequestRecover({ ...context, request: command.payload });
|
|
2600
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2601
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.respondContactRequest) {
|
|
2602
|
+
await applicationContactRequestRespond({ ...context, request: command.payload });
|
|
2603
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2604
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.cancelContactRequest) {
|
|
2605
|
+
await applicationContactRequestCancel({ ...context, request: command.payload });
|
|
2606
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2607
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.endRelationship) {
|
|
2608
|
+
await applicationRelationshipEnd({ ...context, request: command.payload });
|
|
2609
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2610
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.removeBlock) {
|
|
2611
|
+
await applicationBlockRemove({ ...context, request: command.payload });
|
|
2612
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2613
|
+
} else if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.openAccount) {
|
|
2614
|
+
await applicationAccountOpen({ ...context, request: command.payload });
|
|
2615
|
+
} else {
|
|
2616
|
+
throw hostApplicationFailure("host_app_workbench_command_not_enabled");
|
|
2617
|
+
}
|
|
2618
|
+
if (WORKBENCH_LOCAL_ONLY_CAPABILITIES.has(WORKBENCH_COMMAND_CAPABILITIES[command.command])) {
|
|
2619
|
+
return workbenchSuccess(command.requestId, serviceContext, publicResult);
|
|
2620
|
+
}
|
|
2621
|
+
const snapshot = await buildApplicationWorkbenchSnapshot(context, {}, source, {
|
|
2622
|
+
authoritativeMetadata: WORKBENCH_RELATIONSHIP_METADATA_CAPABILITIES.has(WORKBENCH_COMMAND_CAPABILITIES[command.command]),
|
|
2623
|
+
});
|
|
2624
|
+
return workbenchSuccess(command.requestId, snapshot, publicResult);
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.searchPrincipal) {
|
|
2627
|
+
const code = String(error?.code || "");
|
|
2628
|
+
if (code === "directory_search_rate_limited" && error.httpStatus === 429) {
|
|
2629
|
+
return workbenchFailure(command.requestId, code, serviceContext, "known-failed");
|
|
2630
|
+
}
|
|
2631
|
+
const safeCode = /^(?:host_app|host_transport|human_relationship)_[a-z0-9_]{1,100}$/.test(code)
|
|
2632
|
+
? code : "host_app_principal_search_failed";
|
|
2633
|
+
// A timed-out search may already have consumed its cloud quota. Keep
|
|
2634
|
+
// that uncertainty and never replay it, but retain the useful code.
|
|
2635
|
+
const refusedBeforeSearch = safeCode.startsWith("host_app_") && safeCode !== "host_app_principal_search_failed";
|
|
2636
|
+
return workbenchFailure(command.requestId, safeCode, refusedBeforeSearch ? serviceContext : null,
|
|
2637
|
+
refusedBeforeSearch ? "known-failed" : "unknown");
|
|
2638
|
+
}
|
|
2639
|
+
// A contact-request refusal is already authoritative. Refreshing unrelated
|
|
2640
|
+
// lists here can hide that known failure behind the browser's timeout.
|
|
2641
|
+
if (command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.sendContactRequest
|
|
2642
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.requestSend
|
|
2643
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.resendDeletedReply
|
|
2644
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.getSendStatus
|
|
2645
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.recoverContactRequestDecision
|
|
2646
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.respondContactRequest
|
|
2647
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.cancelContactRequest
|
|
2648
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.endRelationship
|
|
2649
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.removeBlock
|
|
2650
|
+
|| command.command === HELLOLYNK_WORKBENCH_COMMAND_TYPES.listContactInstances) {
|
|
2651
|
+
const knownCode = knownWorkbenchCommandFailure(error);
|
|
2652
|
+
return workbenchFailure(
|
|
2653
|
+
command.requestId,
|
|
2654
|
+
knownCode || "host_app_workbench_outcome_unknown",
|
|
2655
|
+
knownCode ? serviceContext : null,
|
|
2656
|
+
knownCode ? "known-failed" : "unknown",
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
const knownCode = knownWorkbenchCommandFailure(error);
|
|
2660
|
+
if (WORKBENCH_LOCAL_ONLY_CAPABILITIES.has(WORKBENCH_COMMAND_CAPABILITIES[command.command])) {
|
|
2661
|
+
// The local CAS already determined whether this edit was rejected.
|
|
2662
|
+
// Cloud metadata cannot improve that answer or acknowledge a failed write.
|
|
2663
|
+
return workbenchFailure(command.requestId, knownCode || "host_app_workbench_outcome_unknown",
|
|
2664
|
+
knownCode ? serviceContext : null, knownCode ? "known-failed" : "unknown");
|
|
2665
|
+
}
|
|
2666
|
+
if (knownCode) {
|
|
2667
|
+
const snapshot = await buildApplicationWorkbenchSnapshot(context, {}, source, {
|
|
2668
|
+
authoritativeMetadata: WORKBENCH_RELATIONSHIP_METADATA_CAPABILITIES.has(WORKBENCH_COMMAND_CAPABILITIES[command.command]),
|
|
2669
|
+
}).catch(() => null);
|
|
2670
|
+
return workbenchFailure(command.requestId, knownCode, snapshot, "known-failed");
|
|
2671
|
+
}
|
|
2672
|
+
return workbenchFailure(command.requestId, "host_app_workbench_outcome_unknown", null, "unknown");
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function bindWorkbenchRelationshipTargets(results, context) {
|
|
2677
|
+
const now = nowMs(clock);
|
|
2678
|
+
for (const [targetRef, binding] of workbenchRelationshipTargets) {
|
|
2679
|
+
if (binding.expiresAtMs <= now) workbenchRelationshipTargets.delete(targetRef);
|
|
2680
|
+
}
|
|
2681
|
+
for (const result of results) {
|
|
2682
|
+
const expiresAtMs = Date.parse(result.expiresAt);
|
|
2683
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) {
|
|
2684
|
+
throw hostApplicationFailure("host_app_contact_target_expired");
|
|
2685
|
+
}
|
|
2686
|
+
workbenchRelationshipTargets.set(result.targetRef, Object.freeze({
|
|
2687
|
+
delegateId: context.delegateId,
|
|
2688
|
+
capabilityId: context.capabilityId,
|
|
2689
|
+
adapterInstanceId: context.adapterInstanceId,
|
|
2690
|
+
hostGeneration,
|
|
2691
|
+
expiresAtMs,
|
|
2692
|
+
}));
|
|
2693
|
+
}
|
|
2694
|
+
while (workbenchRelationshipTargets.size > 64) {
|
|
2695
|
+
workbenchRelationshipTargets.delete(workbenchRelationshipTargets.keys().next().value);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function requireWorkbenchRelationshipTarget(targetRef, context) {
|
|
2700
|
+
const binding = workbenchRelationshipTargets.get(targetRef);
|
|
2701
|
+
if (binding?.expiresAtMs <= nowMs(clock)) workbenchRelationshipTargets.delete(targetRef);
|
|
2702
|
+
if (!binding || binding.expiresAtMs <= nowMs(clock)
|
|
2703
|
+
|| binding.delegateId !== context.delegateId
|
|
2704
|
+
|| binding.capabilityId !== context.capabilityId
|
|
2705
|
+
|| binding.adapterInstanceId !== context.adapterInstanceId
|
|
2706
|
+
|| binding.hostGeneration !== hostGeneration) {
|
|
2707
|
+
throw hostApplicationFailure("host_app_contact_target_expired");
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
async function buildApplicationWorkbenchSnapshot(context, request, source, behavior = {}) {
|
|
2712
|
+
requireApplicationStore(applicationInbox, "neutral_host_app_inbox_unavailable");
|
|
2713
|
+
requireApplicationStore(applicationContacts, "neutral_host_app_contacts_unavailable");
|
|
2714
|
+
requireApplicationStore(applicationContactRequests, "neutral_host_app_contact_requests_unavailable");
|
|
2715
|
+
requireApplicationStore(applicationOutbox, "neutral_host_app_outbox_unavailable");
|
|
2716
|
+
const endpoint = endpoints.get(context.delegateId);
|
|
2717
|
+
if (!endpoint?.hellolynkId || !Number.isSafeInteger(endpoint.authorizationVersion)) {
|
|
2718
|
+
throw hostApplicationFailure("host_app_workbench_subject_unavailable");
|
|
2719
|
+
}
|
|
2720
|
+
const remoteMetadata = hasRemoteWorkbenchLifecycle();
|
|
2721
|
+
let observedMetadata = null;
|
|
2722
|
+
const createReadModel = ({ capabilities, legacyLifecycleFallback = false, localOnly = false,
|
|
2723
|
+
metadata, metadataState }) => (
|
|
2724
|
+
createNeutralHostWorkbenchReadModel({
|
|
2725
|
+
subject: {
|
|
2726
|
+
region: tuple.region,
|
|
2727
|
+
principalId: tuple.principalId,
|
|
2728
|
+
principalLabel: endpoint.principalLabel ?? null,
|
|
2729
|
+
hellolynkId: endpoint.hellolynkId,
|
|
2730
|
+
delegateId: endpoint.delegateId,
|
|
2731
|
+
delegateLabel: endpoint.delegateLabel ?? null,
|
|
2732
|
+
hostInstallationId: tuple.hostInstallationId,
|
|
2733
|
+
authorizationVersion: endpoint.authorizationVersion,
|
|
2734
|
+
},
|
|
2735
|
+
hostGeneration,
|
|
2736
|
+
applicationChangeCursor: String(applicationChangeCursor),
|
|
2737
|
+
messageStore: applicationInbox,
|
|
2738
|
+
contactStore: applicationContacts,
|
|
2739
|
+
contactRequestStore: {
|
|
2740
|
+
async listForHuman(input) {
|
|
2741
|
+
if (metadata) return projectLocalWorkbenchRelationshipMetadata(metadata);
|
|
2742
|
+
if (localOnly) return { requests: [], nextCursor: null, relationships: [], blocks: [] };
|
|
2743
|
+
observedMetadata = await applicationWorkbenchRelationshipState(input, { legacyLifecycleFallback });
|
|
2744
|
+
return observedMetadata;
|
|
2745
|
+
},
|
|
2746
|
+
},
|
|
2747
|
+
localDelegateLabels: [...endpoints.values()].map((item) => ({
|
|
2748
|
+
principalId: item.principalId,
|
|
2749
|
+
delegateId: item.delegateId,
|
|
2750
|
+
delegateLabel: item.delegateLabel ?? null,
|
|
2751
|
+
})),
|
|
2752
|
+
capabilities,
|
|
2753
|
+
...(metadataState ? { relationshipMetadataState: metadataState } : {}),
|
|
2754
|
+
hostStatus: !localOnly && lifecycle === "running" ? "online" : "offline",
|
|
2755
|
+
connectedClients: core.connectedClients(),
|
|
2756
|
+
changeEventTypes: [...new Set(applicationChanges.map((item) => item.type))].sort(),
|
|
2757
|
+
snapshotRequired: false,
|
|
2758
|
+
now: () => new Date(hostStartedAt),
|
|
2759
|
+
})
|
|
2760
|
+
);
|
|
2761
|
+
if (remoteMetadata) workbenchRelationshipMetadataReadContext = { createReadModel };
|
|
2762
|
+
if (remoteMetadata && behavior.authoritativeMetadata !== true && !Object.hasOwn(request, "contactRequestCursor")) {
|
|
2763
|
+
const now = nowMs(clock);
|
|
2764
|
+
// Never put the network task on the service's serialized read/write tail.
|
|
2765
|
+
// Capture this projection before starting it so a cold read is coherent.
|
|
2766
|
+
const metadataState = workbenchRelationshipMetadataState;
|
|
2767
|
+
const metadata = workbenchRelationshipMetadata || { requests: [], nextCursor: null, relationships: [], blocks: [] };
|
|
2768
|
+
if (now >= workbenchRelationshipMetadataAttemptAt + WORKBENCH_RELATIONSHIP_METADATA_REFRESH_MS) {
|
|
2769
|
+
startWorkbenchRelationshipMetadataRefresh(createReadModel);
|
|
2770
|
+
}
|
|
2771
|
+
return createReadModel({ metadata, metadataState,
|
|
2772
|
+
capabilities: metadataState === "fresh" ? workbenchCapabilities
|
|
2773
|
+
: workbenchCapabilities.filter((capability) => !WORKBENCH_RELATIONSHIP_METADATA_CAPABILITIES.has(capability)
|
|
2774
|
+
// Starting a new relationship does not depend on the cached list
|
|
2775
|
+
// of existing relationships. Keep the discovery form available;
|
|
2776
|
+
// request submission still performs its authoritative preflight.
|
|
2777
|
+
|| capability === "principal.search" || capability === "contact-request.send"),
|
|
2778
|
+
}).readSnapshot(request, source);
|
|
2779
|
+
}
|
|
2780
|
+
// A newer authoritative read supersedes any older background projection,
|
|
2781
|
+
// including when only the server observed a relationship change.
|
|
2782
|
+
const metadataEpoch = remoteMetadata ? ++workbenchRelationshipMetadataEpoch : workbenchRelationshipMetadataEpoch;
|
|
2783
|
+
try {
|
|
2784
|
+
const snapshot = await createReadModel({ capabilities: workbenchCapabilities,
|
|
2785
|
+
...(remoteMetadata ? { metadataState: "fresh" } : {}) })
|
|
2786
|
+
.readSnapshot(request, source);
|
|
2787
|
+
if (remoteMetadata && request.contactRequestCursor == null && observedMetadata) {
|
|
2788
|
+
await commitWorkbenchRelationshipMetadata(observedMetadata, metadataEpoch, false);
|
|
2789
|
+
}
|
|
2790
|
+
return { ...snapshot };
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
if (remoteMetadata && metadataEpoch === workbenchRelationshipMetadataEpoch) {
|
|
2793
|
+
workbenchRelationshipMetadataState = "stale";
|
|
2794
|
+
}
|
|
2795
|
+
if (isTransientWorkbenchCloudFailure(error)
|
|
2796
|
+
|| error?.code === "host_transport_account_authorization_required") {
|
|
2797
|
+
// Local history does not need a successful cloud metadata read. Do not
|
|
2798
|
+
// present empty remote lists as authoritative. Local records and local
|
|
2799
|
+
// send settings remain usable without a successful remote metadata read.
|
|
2800
|
+
return { ...(await createReadModel({
|
|
2801
|
+
capabilities: workbenchCapabilities.filter((capability) => WORKBENCH_LOCAL_ONLY_CAPABILITIES.has(capability)),
|
|
2802
|
+
localOnly: true,
|
|
2803
|
+
}).readSnapshot(request, source)) };
|
|
2804
|
+
}
|
|
2805
|
+
if (error?.code !== WORKBENCH_LEGACY_LIFECYCLE_ROUTES_UNAVAILABLE) throw error;
|
|
2806
|
+
const degradedCapabilities = workbenchCapabilities.filter(
|
|
2807
|
+
(capability) => !WORKBENCH_LIFECYCLE_DEPENDENT_CAPABILITIES.has(capability),
|
|
2808
|
+
);
|
|
2809
|
+
return {
|
|
2810
|
+
...(await createReadModel({
|
|
2811
|
+
capabilities: degradedCapabilities,
|
|
2812
|
+
legacyLifecycleFallback: true,
|
|
2813
|
+
}).readSnapshot(request, source)),
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
async function commitWorkbenchRelationshipMetadata(metadata, epoch, notify = true) {
|
|
2819
|
+
if (lifecycle !== "running" || epoch !== workbenchRelationshipMetadataEpoch) return;
|
|
2820
|
+
// Keep only validated relationship display metadata. Local intro text,
|
|
2821
|
+
// receipts and decision state are reread from the protected store below.
|
|
2822
|
+
metadata = workbenchRelationshipMetadataProjection(metadata);
|
|
2823
|
+
const changed = workbenchRelationshipMetadataState !== "fresh"
|
|
2824
|
+
|| JSON.stringify(metadata) !== JSON.stringify(workbenchRelationshipMetadata);
|
|
2825
|
+
workbenchRelationshipMetadata = structuredClone(metadata);
|
|
2826
|
+
workbenchRelationshipMetadataState = "fresh";
|
|
2827
|
+
workbenchRelationshipMetadataAttemptAt = nowMs(clock);
|
|
2828
|
+
if (changed && notify) await publishApplicationChange("contacts_changed", "relationship-metadata");
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function workbenchRelationshipMetadataProjection(metadata) {
|
|
2832
|
+
return { ...metadata, requests: metadata.requests.map((request) => ({
|
|
2833
|
+
requestId: request.serverContactRequestId ?? request.requestId ?? request.localRecordId,
|
|
2834
|
+
direction: request.direction ?? "incoming", status: request.state ?? request.status,
|
|
2835
|
+
otherPrincipal: request.otherPrincipal ?? {
|
|
2836
|
+
displayName: request.sender?.verifiedDisplayName ?? null, hellolynkId: request.sender?.hellolynkId,
|
|
2837
|
+
},
|
|
2838
|
+
createdAt: request.receivedAt ?? request.createdAt,
|
|
2839
|
+
humanOnly: true, modelVisibility: "none",
|
|
2840
|
+
})) };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
async function projectLocalWorkbenchRelationshipMetadata(metadata) {
|
|
2844
|
+
const local = await applicationContactRequests.listForHuman();
|
|
2845
|
+
const byId = new Map(local.map((request) => [request.serverContactRequestId ?? request.localRecordId, request]));
|
|
2846
|
+
const requests = metadata.requests.map((remote) => {
|
|
2847
|
+
const current = remote.direction === "incoming" ? byId.get(remote.requestId) : null;
|
|
2848
|
+
byId.delete(remote.requestId);
|
|
2849
|
+
if (!current) return { ...remote, actionReady: false, decisionOutcome: null, decisionAction: null };
|
|
2850
|
+
const state = current.state === "pending" && current.decisionOutcome != null
|
|
2851
|
+
? "pending" : remote.status === "pending" ? current.state : remote.status;
|
|
2852
|
+
const consistent = state === current.state;
|
|
2853
|
+
return { ...remote, ...current, state,
|
|
2854
|
+
actionReady: consistent && current.actionReady === true,
|
|
2855
|
+
decisionOutcome: consistent ? current.decisionOutcome : null,
|
|
2856
|
+
decisionAction: consistent ? current.decisionAction : null,
|
|
2857
|
+
};
|
|
2858
|
+
});
|
|
2859
|
+
return { ...structuredClone(metadata), requests: [...requests, ...byId.values()] };
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
function startWorkbenchRelationshipMetadataRefresh(createReadModel) {
|
|
2863
|
+
if (lifecycle !== "running" || workbenchRelationshipMetadataRefresh) return;
|
|
2864
|
+
const epoch = workbenchRelationshipMetadataEpoch;
|
|
2865
|
+
workbenchRelationshipMetadataAttemptAt = nowMs(clock);
|
|
2866
|
+
const task = Promise.resolve().then(async () => {
|
|
2867
|
+
try {
|
|
2868
|
+
// Refreshing unchanged display data is not a business change. Keep the
|
|
2869
|
+
// last successful projection while this read is pending; real failures
|
|
2870
|
+
// and relationship events still mark it stale. Commands keep their
|
|
2871
|
+
// authoritative preflight and never rely on this display cache.
|
|
2872
|
+
const metadata = await applicationWorkbenchRelationshipState();
|
|
2873
|
+
if (lifecycle !== "running" || epoch !== workbenchRelationshipMetadataEpoch) return;
|
|
2874
|
+
// Validate the same relationship projection used by snapshots. Building
|
|
2875
|
+
// a complete local snapshot here would reread unrelated chat history.
|
|
2876
|
+
createReadModel({ capabilities: workbenchCapabilities, metadataState: "fresh" })
|
|
2877
|
+
.validateRelationshipMetadata(metadata);
|
|
2878
|
+
await commitWorkbenchRelationshipMetadata(metadata, epoch);
|
|
2879
|
+
} catch {
|
|
2880
|
+
if (lifecycle !== "running" || epoch !== workbenchRelationshipMetadataEpoch) return;
|
|
2881
|
+
const changed = workbenchRelationshipMetadataState !== "stale";
|
|
2882
|
+
workbenchRelationshipMetadataState = "stale";
|
|
2883
|
+
if (changed) await publishApplicationChange("contacts_changed", "relationship-metadata");
|
|
2884
|
+
}
|
|
2885
|
+
}).finally(() => {
|
|
2886
|
+
if (workbenchRelationshipMetadataRefresh === task) workbenchRelationshipMetadataRefresh = null;
|
|
2887
|
+
});
|
|
2888
|
+
workbenchRelationshipMetadataRefresh = task;
|
|
2889
|
+
trackOperation(task, pendingOperations);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
async function applicationWorkbenchRelationshipState(input = {}, behavior = {}) {
|
|
2893
|
+
const cursor = input?.cursor ?? null;
|
|
2894
|
+
const hasRemoteLifecycle = hasRemoteWorkbenchLifecycle();
|
|
2895
|
+
if (hasRemoteLifecycle) {
|
|
2896
|
+
if (behavior.legacyLifecycleFallback === true) {
|
|
2897
|
+
const contactRequestPage = await options.humanRelationships.list(
|
|
2898
|
+
cursor == null ? {} : { cursor },
|
|
2899
|
+
);
|
|
2900
|
+
return {
|
|
2901
|
+
requests: contactRequestPage.requests,
|
|
2902
|
+
nextCursor: contactRequestPage.nextCursor,
|
|
2903
|
+
relationships: [],
|
|
2904
|
+
blocks: [],
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
// The lifecycle lists are authoritative as one read surface. Never
|
|
2908
|
+
// turn a remote failure into an incomplete page or empty arrays while
|
|
2909
|
+
// advertising the corresponding read and mutation capabilities.
|
|
2910
|
+
// These snapshot reads are independent. Starting all three together
|
|
2911
|
+
// also shares the transport's in-flight protected session resolution.
|
|
2912
|
+
// Settle every read before choosing an error; a failed page must never
|
|
2913
|
+
// be exposed as an authoritative empty list.
|
|
2914
|
+
const [lifecycleResult, contactRequestResult] = await Promise.allSettled([
|
|
2915
|
+
readRemoteWorkbenchLifecyclePages(),
|
|
2916
|
+
Promise.resolve().then(() => options.humanRelationships.list(cursor == null ? {} : { cursor })),
|
|
2917
|
+
]);
|
|
2918
|
+
if (contactRequestResult.status === "rejected"
|
|
2919
|
+
&& !isTransientWorkbenchCloudFailure(contactRequestResult.reason)
|
|
2920
|
+
&& contactRequestResult.reason?.code !== "host_transport_account_authorization_required") {
|
|
2921
|
+
throw contactRequestResult.reason;
|
|
2922
|
+
}
|
|
2923
|
+
if (lifecycleResult.status === "rejected") throw lifecycleResult.reason;
|
|
2924
|
+
if (contactRequestResult.status === "rejected") throw contactRequestResult.reason;
|
|
2925
|
+
const { relationshipPage, blockPage } = lifecycleResult.value;
|
|
2926
|
+
const contactRequestPage = contactRequestResult.value;
|
|
2927
|
+
return {
|
|
2928
|
+
requests: contactRequestPage.requests,
|
|
2929
|
+
nextCursor: contactRequestPage.nextCursor,
|
|
2930
|
+
relationships: relationshipPage.relationships,
|
|
2931
|
+
blocks: blockPage.blocks,
|
|
2932
|
+
};
|
|
2933
|
+
} else if (cursor != null) {
|
|
2934
|
+
throw hostApplicationFailure("host_app_capability_unavailable");
|
|
2935
|
+
}
|
|
2936
|
+
return {
|
|
2937
|
+
requests: await applicationContactRequests.listForHuman(),
|
|
2938
|
+
nextCursor: null,
|
|
2939
|
+
relationships: [],
|
|
2940
|
+
blocks: [],
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function hasRemoteWorkbenchLifecycle() {
|
|
2945
|
+
return Boolean(options.humanRelationships
|
|
2946
|
+
&& typeof options.humanRelationships.list === "function"
|
|
2947
|
+
&& typeof options.humanRelationships.listRelationships === "function"
|
|
2948
|
+
&& typeof options.humanRelationships.listBlocks === "function");
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
async function readRemoteWorkbenchLifecyclePages() {
|
|
2952
|
+
const [relationshipResult, blockResult] = await Promise.allSettled([
|
|
2953
|
+
options.humanRelationships.listRelationships(),
|
|
2954
|
+
options.humanRelationships.listBlocks(),
|
|
2955
|
+
]);
|
|
2956
|
+
if (relationshipResult.status === "rejected" || blockResult.status === "rejected") {
|
|
2957
|
+
if (relationshipResult.status === "rejected"
|
|
2958
|
+
&& blockResult.status === "rejected"
|
|
2959
|
+
&& isMissingLegacyLifecycleRoute(relationshipResult.reason)
|
|
2960
|
+
&& isMissingLegacyLifecycleRoute(blockResult.reason)) {
|
|
2961
|
+
throw failure(WORKBENCH_LEGACY_LIFECYCLE_ROUTES_UNAVAILABLE);
|
|
2962
|
+
}
|
|
2963
|
+
const permanentFailure = [relationshipResult, blockResult].find(
|
|
2964
|
+
(result) => result.status === "rejected"
|
|
2965
|
+
&& !isMissingLegacyLifecycleRoute(result.reason)
|
|
2966
|
+
&& !isTransientWorkbenchCloudFailure(result.reason)
|
|
2967
|
+
&& result.reason?.code !== "host_transport_account_authorization_required",
|
|
2968
|
+
);
|
|
2969
|
+
const nonMissingRouteFailure = [relationshipResult, blockResult].find(
|
|
2970
|
+
(result) => result.status === "rejected" && !isMissingLegacyLifecycleRoute(result.reason),
|
|
2971
|
+
);
|
|
2972
|
+
const firstFailure = permanentFailure || nonMissingRouteFailure
|
|
2973
|
+
|| [relationshipResult, blockResult].find((result) => result.status === "rejected");
|
|
2974
|
+
throw firstFailure.reason;
|
|
2975
|
+
}
|
|
2976
|
+
return {
|
|
2977
|
+
relationshipPage: relationshipResult.value,
|
|
2978
|
+
blockPage: blockResult.value,
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
async function publishChange(change, entityId, audienceDelegateId = null) {
|
|
2983
|
+
assertNoSensitiveEventFields(change);
|
|
2984
|
+
await changeSink(change);
|
|
2985
|
+
await publishApplicationChange(change.type, entityId, audienceDelegateId);
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
async function publishApplicationChange(type, entityId, audienceDelegateId = null) {
|
|
2989
|
+
if (type === "contacts_changed" && entityId === "relationship-metadata") contactRequestNoticeRead = null;
|
|
2990
|
+
if (["contact_requests_changed", "relationships_changed", "blocks_changed"].includes(type)) {
|
|
2991
|
+
contactRequestNoticeRead = null;
|
|
2992
|
+
workbenchRelationshipMetadataEpoch += 1;
|
|
2993
|
+
workbenchRelationshipMetadataAttemptAt = -Infinity;
|
|
2994
|
+
workbenchRelationshipMetadataState = workbenchRelationshipMetadata ? "stale" : "loading";
|
|
2995
|
+
}
|
|
2996
|
+
const event = Object.freeze({
|
|
2997
|
+
cursor: ++applicationChangeCursor,
|
|
2998
|
+
type: safeId(type, "neutral_host_change_type_invalid"),
|
|
2999
|
+
entityId: safeApplicationId(entityId, "neutral_host_change_entity_invalid"),
|
|
3000
|
+
changedAt: new Date(nowMs(clock)).toISOString(),
|
|
3001
|
+
modelVisibility: "none",
|
|
3002
|
+
audienceDelegateId,
|
|
3003
|
+
});
|
|
3004
|
+
assertNoSensitiveEventFields(event);
|
|
3005
|
+
applicationChanges.push(event);
|
|
3006
|
+
if (applicationChanges.length > APPLICATION_CHANGE_CAPACITY) applicationChanges.shift();
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
async function beginDrain(reason = "stop_requested") {
|
|
3010
|
+
if (stopPromise) return stopPromise;
|
|
3011
|
+
lifecycle = "draining";
|
|
3012
|
+
workbenchRelationshipMetadataEpoch += 1;
|
|
3013
|
+
workbenchRelationshipMetadata = null;
|
|
3014
|
+
workbenchRelationshipMetadataReadContext = null;
|
|
3015
|
+
workbenchRelationshipMetadataState = "loading";
|
|
3016
|
+
stopPromise = (async () => {
|
|
3017
|
+
if (maintenanceTimer) clearInterval(maintenanceTimer);
|
|
3018
|
+
maintenanceTimer = null;
|
|
3019
|
+
preKeyRefillAbortController.abort();
|
|
3020
|
+
relayAbortController.abort();
|
|
3021
|
+
// A confirmation-window cleanup failure must not retain the Host's
|
|
3022
|
+
// transport, listener or installation lock after a requested stop.
|
|
3023
|
+
try { await nativeBroker?.close(); } catch {}
|
|
3024
|
+
// Fence new callbacks before waiting. Anything already in pendingOperations
|
|
3025
|
+
// crossed the running gate and is allowed to finish its persist/ACK unit.
|
|
3026
|
+
await stream?.close?.().catch(() => {});
|
|
3027
|
+
const drainTimeoutMs = integerInRange(options.drainTimeoutMs, 5_000, 100, 60_000);
|
|
3028
|
+
// Give admitted persist/ACK units a bounded grace period. After that,
|
|
3029
|
+
// closing the transport cancels slow cloud work; the second wait still
|
|
3030
|
+
// protects all admitted local writes before releasing ownership.
|
|
3031
|
+
// Close belongs here, not only in assembly.stop: idle/revocation also use
|
|
3032
|
+
// this path, and the installation lock is still owned at this point.
|
|
3033
|
+
try { await waitForOperations(pendingOperations, drainTimeoutMs); }
|
|
3034
|
+
catch (error) {
|
|
3035
|
+
if (error?.code !== "neutral_host_drain_timeout") throw error;
|
|
3036
|
+
}
|
|
3037
|
+
finally {
|
|
3038
|
+
const transportDrain = options.transport.close?.();
|
|
3039
|
+
if (transportDrain) trackOperation(transportDrain, pendingOperations);
|
|
3040
|
+
}
|
|
3041
|
+
await waitForOperations(pendingOperations, drainTimeoutMs);
|
|
3042
|
+
if (server) await closeServer(server);
|
|
3043
|
+
await receiveTask?.catch(() => {});
|
|
3044
|
+
if (ownsDiscovery) await removeOwnedDiscovery(layout.discoveryPath, hostGeneration);
|
|
3045
|
+
// The same generation check clears a handover request addressed to this Host.
|
|
3046
|
+
await removeOwnedDiscovery(layout.handoverPath, hostGeneration);
|
|
3047
|
+
if (handoverRequested) await restampExpectedRelease(layout, releaseOrdinal).catch(() => {});
|
|
3048
|
+
await lockHandle.release();
|
|
3049
|
+
lifecycle = "stopped";
|
|
3050
|
+
return { ok: true, reason, status: lifecycle };
|
|
3051
|
+
})().catch(async (error) => {
|
|
3052
|
+
failureCode = safeCode(error, "neutral_host_stop_failed");
|
|
3053
|
+
// A failed drain is terminal. Keep the lock while this process is alive,
|
|
3054
|
+
// close its listener, and let the owning release entry exit with failure.
|
|
3055
|
+
// Never leave a live, unusable Host indefinitely advertising "draining".
|
|
3056
|
+
try { options.transport.close?.()?.catch(() => {}); } catch {}
|
|
3057
|
+
if (server) {
|
|
3058
|
+
const closing = closeServer(server);
|
|
3059
|
+
server.closeAllConnections?.();
|
|
3060
|
+
await closing.catch(() => {});
|
|
3061
|
+
}
|
|
3062
|
+
lifecycle = "failed";
|
|
3063
|
+
throw error;
|
|
3064
|
+
});
|
|
3065
|
+
return stopPromise;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
async function cleanupAfterFailedStart() {
|
|
3069
|
+
if (maintenanceTimer) clearInterval(maintenanceTimer);
|
|
3070
|
+
preKeyRefillAbortController.abort();
|
|
3071
|
+
relayAbortController.abort();
|
|
3072
|
+
await nativeBroker?.close().catch(() => {});
|
|
3073
|
+
await stream?.close?.().catch(() => {});
|
|
3074
|
+
try {
|
|
3075
|
+
if (options.transport.close) {
|
|
3076
|
+
trackOperation(options.transport.close(), pendingOperations);
|
|
3077
|
+
await waitForOperations(pendingOperations, integerInRange(options.drainTimeoutMs, 5_000, 100, 60_000));
|
|
3078
|
+
}
|
|
3079
|
+
} finally {
|
|
3080
|
+
// A failed local drain must retain ownership, but not an unusable listener.
|
|
3081
|
+
if (server) await closeServer(server).catch(() => {});
|
|
3082
|
+
}
|
|
3083
|
+
await lockHandle.release().catch(() => {});
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
return Object.freeze({
|
|
3087
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT,
|
|
3088
|
+
tuple,
|
|
3089
|
+
discovery,
|
|
3090
|
+
core,
|
|
3091
|
+
stop: beginDrain,
|
|
3092
|
+
runMaintenance,
|
|
3093
|
+
get status() { return lifecycle; },
|
|
3094
|
+
get failureCode() { return failureCode; },
|
|
3095
|
+
get modelCalls() { return modelCalls; },
|
|
3096
|
+
get pendingOperationCount() { return pendingOperations.size; },
|
|
3097
|
+
});
|
|
3098
|
+
|
|
3099
|
+
function wrapperHealth() {
|
|
3100
|
+
return {
|
|
3101
|
+
ok: lifecycle === "running",
|
|
3102
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_PROCESS_CONTRACT,
|
|
3103
|
+
hostGeneration,
|
|
3104
|
+
status: lifecycle,
|
|
3105
|
+
releaseOrdinal,
|
|
3106
|
+
receive: receiveHealth(),
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function runningHealth(health = core.publicHealth()) {
|
|
3111
|
+
return {
|
|
3112
|
+
...health,
|
|
3113
|
+
releaseOrdinal,
|
|
3114
|
+
receive: receiveHealth(),
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
function receiveHealth() {
|
|
3119
|
+
const preKeyInventory = preKeyInventoryHealth();
|
|
3120
|
+
return {
|
|
3121
|
+
status: inboundPaused
|
|
3122
|
+
? "paused"
|
|
3123
|
+
: preKeyInventory.status === "ready" && relayState.status === "ready" && !lastRejectedInbound ? "ready" : "degraded",
|
|
3124
|
+
...(lastRejectedInbound ? { lastRejectedInbound } : {}),
|
|
3125
|
+
preKeyInventory,
|
|
3126
|
+
relay: {
|
|
3127
|
+
status: relayState.status,
|
|
3128
|
+
...(relayState.errorCode ? { errorCode: relayState.errorCode } : {}),
|
|
3129
|
+
...(relayState.status === "retrying" ? { retryAt: new Date(relayState.nextAttemptAtMs).toISOString() } : {}),
|
|
3130
|
+
},
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
export async function discoverHelloLynkNeutralHost(options = {}) {
|
|
3136
|
+
const tuple = normalizeTuple(options);
|
|
3137
|
+
const runtimeRoot = path.resolve(String(options.runtimeRoot || ""));
|
|
3138
|
+
if (!options.runtimeRoot) throw failure("neutral_host_runtime_root_required");
|
|
3139
|
+
const layout = discoveryLayout(runtimeRoot, tuple.hostInstallationId);
|
|
3140
|
+
await assertNoReparseAncestors(runtimeRoot);
|
|
3141
|
+
await assertSafePath(runtimeRoot, { mustExist: true, directory: true });
|
|
3142
|
+
await assertSafePath(layout.directory, { mustExist: true, directory: true });
|
|
3143
|
+
await assertSafePath(layout.discoveryPath, { mustExist: true, directory: false });
|
|
3144
|
+
let record;
|
|
3145
|
+
try {
|
|
3146
|
+
record = JSON.parse(await fs.readFile(layout.discoveryPath, "utf8"));
|
|
3147
|
+
} catch (error) {
|
|
3148
|
+
if (error?.code === "ENOENT") return null;
|
|
3149
|
+
throw failure("neutral_host_discovery_invalid", error);
|
|
3150
|
+
}
|
|
3151
|
+
validateDiscovery(record);
|
|
3152
|
+
if (record.tupleDigest !== digestTuple(tuple)
|
|
3153
|
+
|| record.region !== tuple.region
|
|
3154
|
+
|| record.principalId !== tuple.principalId
|
|
3155
|
+
|| record.hostInstallationId !== tuple.hostInstallationId) {
|
|
3156
|
+
throw failure("neutral_host_discovery_owner_mismatch");
|
|
3157
|
+
}
|
|
3158
|
+
const health = await fetch(`http://${record.host}:${record.port}${HELLOLYNK_HOST_PATHS.health}`, {
|
|
3159
|
+
redirect: "error",
|
|
3160
|
+
signal: AbortSignal.timeout(2_000),
|
|
3161
|
+
}).catch(() => null);
|
|
3162
|
+
if (!health || health.status !== 200) throw failure("neutral_host_discovery_stale");
|
|
3163
|
+
const body = await health.json().catch(() => null);
|
|
3164
|
+
if (body?.hostGeneration !== record.hostGeneration || body?.status !== "running") {
|
|
3165
|
+
throw failure("neutral_host_discovery_generation_mismatch");
|
|
3166
|
+
}
|
|
3167
|
+
return Object.freeze(structuredClone(record));
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
export async function attachHelloLynkNeutralHost(options = {}) {
|
|
3171
|
+
const record = options.discovery || await discoverHelloLynkNeutralHost(options);
|
|
3172
|
+
if (!record) throw failure("neutral_host_not_found");
|
|
3173
|
+
return requestHelloLynkNeutralHost({
|
|
3174
|
+
...options,
|
|
3175
|
+
discovery: record,
|
|
3176
|
+
method: "POST",
|
|
3177
|
+
pathname: HELLOLYNK_HOST_PATHS.attachmentAttach,
|
|
3178
|
+
body: { adapterInstanceId: safeId(options.adapterInstanceId, "neutral_host_adapter_instance_invalid") },
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
// For installers, after new bindings are committed, for adapters that find a running Host
|
|
3183
|
+
// or one without a route they need, and for a Host start that finds the slot taken. A Host
|
|
3184
|
+
// of a lower release is asked to hand over; one of the same or a higher release is
|
|
3185
|
+
// "current" and left alone. Only a different, healthy generation is "restarted", which
|
|
3186
|
+
// still says nothing about its release; a removed discovery is "stopped"; anything
|
|
3187
|
+
// unobservable stays pending/unconfirmed. Never an install failure.
|
|
3188
|
+
export async function requestHelloLynkNeutralHostHandover(options = {}) {
|
|
3189
|
+
if (!options.runtimeRoot) throw failure("neutral_host_runtime_root_required");
|
|
3190
|
+
const runtimeRoot = path.resolve(String(options.runtimeRoot));
|
|
3191
|
+
const hostInstallationId = safeId(options.hostInstallationId, "neutral_host_installation_invalid");
|
|
3192
|
+
const publishedAtMs = Date.parse(String(options.publishedAt || ""));
|
|
3193
|
+
if (!Number.isFinite(publishedAtMs)) throw failure("neutral_host_handover_published_at_invalid");
|
|
3194
|
+
const waitMs = integerInRange(options.waitMs, 5_000, 0, 30_000);
|
|
3195
|
+
// Only a test can ask for a lower release than this code.
|
|
3196
|
+
const releaseOrdinal = integerInRange(options.releaseOrdinal, HELLOLYNK_HOST_RELEASE_ORDINAL, 0, HELLOLYNK_HOST_RELEASE_ORDINAL);
|
|
3197
|
+
// An adapter starts its own release's Host next, so it stops waiting once the slot is free.
|
|
3198
|
+
const untilStopped = options.untilStopped === true;
|
|
3199
|
+
const layout = discoveryLayout(runtimeRoot, hostInstallationId);
|
|
3200
|
+
try {
|
|
3201
|
+
const record = await readDiscoveryRecord(layout.discoveryPath);
|
|
3202
|
+
if (record === null) return Object.freeze({ status: "no_host" });
|
|
3203
|
+
const health = await discoveryHealth(record);
|
|
3204
|
+
if (!health) return Object.freeze({ status: "unconfirmed" });
|
|
3205
|
+
if (releaseOrdinalOf(health) >= releaseOrdinal) {
|
|
3206
|
+
return Object.freeze({ status: "current", hostGeneration: record.hostGeneration });
|
|
3207
|
+
}
|
|
3208
|
+
// A failed write only loses the hold; the request still goes out.
|
|
3209
|
+
await recordExpectedRelease(runtimeRoot, layout, releaseOrdinal).catch(() => {});
|
|
3210
|
+
await atomicWriteJson(layout.handoverPath, {
|
|
3211
|
+
contract: HANDOVER_REQUEST_CONTRACT,
|
|
3212
|
+
hostGeneration: record.hostGeneration,
|
|
3213
|
+
publishedAt: new Date(publishedAtMs).toISOString(),
|
|
3214
|
+
});
|
|
3215
|
+
let stopped = false;
|
|
3216
|
+
for (const deadline = Date.now() + waitMs; Date.now() < deadline;) {
|
|
3217
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3218
|
+
const current = await readDiscoveryRecord(layout.discoveryPath).catch(() => undefined);
|
|
3219
|
+
if (current === null) { stopped = true; if (untilStopped) break; continue; }
|
|
3220
|
+
if (current && current.hostGeneration !== record.hostGeneration && await discoveryRunning(current)) {
|
|
3221
|
+
return Object.freeze({ status: "restarted", previousHostGeneration: record.hostGeneration,
|
|
3222
|
+
hostGeneration: current.hostGeneration });
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return Object.freeze(stopped
|
|
3226
|
+
? { status: "stopped", previousHostGeneration: record.hostGeneration }
|
|
3227
|
+
: { status: "pending", requestWritten: true, hostGeneration: record.hostGeneration });
|
|
3228
|
+
} catch {
|
|
3229
|
+
return Object.freeze({ status: "unconfirmed" });
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// While a newer release is taking over (a fresh record of a higher release), a Host of a lower
|
|
3234
|
+
// release leaves the start to it. A stale or unreadable record never blocks a start. Read-only
|
|
3235
|
+
// and cheap, so the assembly calls it before it reads profiles, credentials and keys.
|
|
3236
|
+
export async function assertHelloLynkNeutralHostReleaseCurrent(options = {}) {
|
|
3237
|
+
if (!options.runtimeRoot) throw failure("neutral_host_runtime_root_required");
|
|
3238
|
+
const runtimeRoot = path.resolve(String(options.runtimeRoot));
|
|
3239
|
+
const hostInstallationId = safeId(options.hostInstallationId, "neutral_host_installation_invalid");
|
|
3240
|
+
// Only a test can report a lower release than this code.
|
|
3241
|
+
const releaseOrdinal = integerInRange(options.releaseOrdinal, HELLOLYNK_HOST_RELEASE_ORDINAL, 0, HELLOLYNK_HOST_RELEASE_ORDINAL);
|
|
3242
|
+
const recorded = await readExpectedRelease(discoveryLayout(runtimeRoot, hostInstallationId).expectedReleasePath)
|
|
3243
|
+
.catch(() => null);
|
|
3244
|
+
if (recorded && recorded.releaseOrdinal > releaseOrdinal && expectedReleaseHolds(recorded)) {
|
|
3245
|
+
throw failure("neutral_host_release_superseded");
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
// For an explicit restore to an earlier release: a record this release wrote would hold the
|
|
3250
|
+
// restored release back. A higher release's record or another format stays; none is fine.
|
|
3251
|
+
export async function removeHelloLynkNeutralHostExpectedRelease(options = {}) {
|
|
3252
|
+
if (!options.runtimeRoot) throw failure("neutral_host_runtime_root_required");
|
|
3253
|
+
const runtimeRoot = path.resolve(String(options.runtimeRoot));
|
|
3254
|
+
const hostInstallationId = safeId(options.hostInstallationId, "neutral_host_installation_invalid");
|
|
3255
|
+
const layout = discoveryLayout(runtimeRoot, hostInstallationId);
|
|
3256
|
+
await assertNoReparseAncestors(layout.expectedReleasePath);
|
|
3257
|
+
await assertSafePath(layout.expectedReleasePath, { mustExist: false, directory: false });
|
|
3258
|
+
let text;
|
|
3259
|
+
try {
|
|
3260
|
+
const metadata = await fs.lstat(layout.expectedReleasePath);
|
|
3261
|
+
text = metadata.size > EXPECTED_RELEASE_MAX_BYTES ? "" : await fs.readFile(layout.expectedReleasePath, "utf8");
|
|
3262
|
+
} catch (error) {
|
|
3263
|
+
if (error?.code === "ENOENT") return Object.freeze({ removed: false });
|
|
3264
|
+
throw error;
|
|
3265
|
+
}
|
|
3266
|
+
let recorded = null;
|
|
3267
|
+
try { recorded = JSON.parse(text); } catch {}
|
|
3268
|
+
if (recorded && typeof recorded === "object" && recorded.contract !== EXPECTED_RELEASE_CONTRACT) {
|
|
3269
|
+
return Object.freeze({ removed: false });
|
|
3270
|
+
}
|
|
3271
|
+
if (Number.isSafeInteger(recorded?.releaseOrdinal) && recorded.releaseOrdinal > HELLOLYNK_HOST_RELEASE_ORDINAL) {
|
|
3272
|
+
return Object.freeze({ removed: false, releaseOrdinal: recorded.releaseOrdinal });
|
|
3273
|
+
}
|
|
3274
|
+
await unlinkWithWindowsRetry(layout.expectedReleasePath);
|
|
3275
|
+
return Object.freeze({ removed: true });
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// Records the asking release before a handover request. A fresh record of a higher release stays.
|
|
3279
|
+
async function recordExpectedRelease(runtimeRoot, layout, releaseOrdinal) {
|
|
3280
|
+
await ensureSafeRuntimeDirectory(runtimeRoot, layout.directory);
|
|
3281
|
+
const recorded = await readExpectedRelease(layout.expectedReleasePath).catch(() => null);
|
|
3282
|
+
if (recorded && recorded.releaseOrdinal > releaseOrdinal && expectedReleaseHolds(recorded)) return;
|
|
3283
|
+
await atomicWriteJson(layout.expectedReleasePath, {
|
|
3284
|
+
contract: EXPECTED_RELEASE_CONTRACT,
|
|
3285
|
+
releaseOrdinal,
|
|
3286
|
+
publishedAt: new Date().toISOString(),
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
// The Host that was asked to leave stamps the time again as it frees the slot: admitted work
|
|
3291
|
+
// and an open native confirmation can keep it running past a hold counted from the request.
|
|
3292
|
+
async function restampExpectedRelease(layout, releaseOrdinal) {
|
|
3293
|
+
const recorded = await readExpectedRelease(layout.expectedReleasePath);
|
|
3294
|
+
if (!recorded || recorded.releaseOrdinal <= releaseOrdinal) return;
|
|
3295
|
+
await atomicWriteJson(layout.expectedReleasePath, {
|
|
3296
|
+
contract: EXPECTED_RELEASE_CONTRACT,
|
|
3297
|
+
releaseOrdinal: recorded.releaseOrdinal,
|
|
3298
|
+
publishedAt: new Date().toISOString(),
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
function expectedReleaseHolds(record) {
|
|
3303
|
+
return Math.abs(Date.now() - Date.parse(String(record.publishedAt || ""))) < EXPECTED_RELEASE_HOLD_MS;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
export async function requestHelloLynkNeutralHost(options = {}) {
|
|
3307
|
+
const record = options.discovery;
|
|
3308
|
+
validateDiscovery(record);
|
|
3309
|
+
const capability = options.capability;
|
|
3310
|
+
if (!capability || typeof capability !== "object") throw failure("neutral_host_capability_required");
|
|
3311
|
+
const method = String(options.method || "GET").toUpperCase();
|
|
3312
|
+
const pathname = String(options.pathname || "");
|
|
3313
|
+
const rawBody = options.body == null ? Buffer.alloc(0) : Buffer.from(JSON.stringify(options.body));
|
|
3314
|
+
const auth = createHostRequestAuth({
|
|
3315
|
+
capabilityId: capability.capabilityId,
|
|
3316
|
+
adapterId: capability.adapterId,
|
|
3317
|
+
hostGeneration: record.hostGeneration,
|
|
3318
|
+
method,
|
|
3319
|
+
pathname,
|
|
3320
|
+
timestampMs: Number(options.timestampMs ?? Date.now()),
|
|
3321
|
+
nonce: options.nonce || createHostNonce(),
|
|
3322
|
+
rawBody,
|
|
3323
|
+
secretBuffer: capability.secretBuffer,
|
|
3324
|
+
});
|
|
3325
|
+
const timeoutMs = options.timeoutMs == null ? null : Number(options.timeoutMs);
|
|
3326
|
+
if (timeoutMs != null && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100
|
|
3327
|
+
|| timeoutMs > 30_000)) {
|
|
3328
|
+
throw failure("neutral_host_request_timeout_invalid");
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
const response = await fetch(`http://${record.host}:${record.port}${pathname}`, {
|
|
3332
|
+
method,
|
|
3333
|
+
headers: { "content-type": "application/json", ...auth.headers, ...(options.extraHeaders || {}) },
|
|
3334
|
+
body: rawBody.length ? rawBody : undefined,
|
|
3335
|
+
redirect: "error",
|
|
3336
|
+
...((timeoutMs == null && !options.signal) ? {} : { signal: AbortSignal.any([
|
|
3337
|
+
...(timeoutMs == null ? [] : [AbortSignal.timeout(timeoutMs)]), ...(options.signal ? [options.signal] : []),
|
|
3338
|
+
]) }),
|
|
3339
|
+
});
|
|
3340
|
+
const body = await response.json().catch(() => ({}));
|
|
3341
|
+
return Object.freeze({ status: response.status, body });
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
export async function probeLegacyStoresReadOnly(paths = []) {
|
|
3345
|
+
const results = [];
|
|
3346
|
+
for (const candidate of paths) {
|
|
3347
|
+
const resolved = path.resolve(String(candidate || ""));
|
|
3348
|
+
if (!candidate) continue;
|
|
3349
|
+
try {
|
|
3350
|
+
const stat = await fs.stat(resolved);
|
|
3351
|
+
results.push(Object.freeze({ path: resolved, exists: true, kind: stat.isFile() ? "file" : "other" }));
|
|
3352
|
+
} catch (error) {
|
|
3353
|
+
if (error?.code === "ENOENT") results.push(Object.freeze({ path: resolved, exists: false, kind: "missing" }));
|
|
3354
|
+
else throw failure("neutral_host_legacy_probe_failed", error);
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
return Object.freeze(results);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
function normalizeApplicationInbox(value) {
|
|
3361
|
+
if (!value) return null;
|
|
3362
|
+
const localHistory = Object.fromEntries([
|
|
3363
|
+
"readWorkbenchState", "deleteSentLocal", "hideLocalConversation", "restoreLocalConversation", "clearLocalConversation", "deleteLocalConversation", "deleteArchivedConversations",
|
|
3364
|
+
"getReceiptHighWater", "getInboxReaderBoundary", "activateInboxReader",
|
|
3365
|
+
"listInboxClaimIds", "claimInboxMessage", "finishInboxClaim", "readIncomingForModel",
|
|
3366
|
+
"reserveInboxBatch", "listInboxBatches", "getInboxNativeAssignment", "getInboxNativeAssignments", "attemptInboxBatch", "confirmInboxBatch",
|
|
3367
|
+
"cancelInboxBatch", "expireInboxBatch", "closeInboxBatch", "claimInboxBatch",
|
|
3368
|
+
"readWorkbenchSummary", "readWorkbenchRevision", "queryConversationPage", "readLocalHistoryStats", "queryOutbox", "searchWorkbenchThreads", "markIncomingRead", "configureConversationAutoRead",
|
|
3369
|
+
"configureLocalHistory", "maintainLocalHistory", "listPendingInboxCandidates", "listInboxMessagesForDelegate", "getMessageCounts",
|
|
3370
|
+
].filter((name) => typeof value[name] === "function").map((name) => [name, (...args) => value[name](...args)]));
|
|
3371
|
+
if (typeof value.list === "function" && typeof value.getIncoming === "function"
|
|
3372
|
+
&& typeof value.updateIncoming === "function" && typeof value.deleteIncoming === "function") {
|
|
3373
|
+
return Object.freeze({
|
|
3374
|
+
...localHistory,
|
|
3375
|
+
list: () => value.list(),
|
|
3376
|
+
getIncoming: (id) => value.getIncoming(id),
|
|
3377
|
+
markRead: (id) => value.updateIncoming(id, { status: "fetched" }),
|
|
3378
|
+
deleteRead: (ids) => value.deleteIncoming(ids),
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
return null;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
function normalizeApplicationContacts(value) {
|
|
3385
|
+
if (!value) return null;
|
|
3386
|
+
const list = typeof value.list === "function" ? () => value.list() : null;
|
|
3387
|
+
const upsert = typeof value.save === "function" ? (input) => value.save(input)
|
|
3388
|
+
: typeof value.upsert === "function" ? (input) => value.upsert(input) : null;
|
|
3389
|
+
const remove = typeof value.delete === "function" ? (id) => value.delete(id)
|
|
3390
|
+
: typeof value.remove === "function" ? (id) => value.remove(id) : null;
|
|
3391
|
+
const listForDelegate = typeof value.listForDelegate === "function"
|
|
3392
|
+
? (delegateId) => value.listForDelegate(delegateId) : null;
|
|
3393
|
+
const listAuthorizedForDelegate = typeof value.listAuthorizedForDelegate === "function"
|
|
3394
|
+
? (delegateId) => value.listAuthorizedForDelegate(delegateId) : null;
|
|
3395
|
+
const resolveAuthorizedSend = typeof value.resolveAuthorizedSend === "function"
|
|
3396
|
+
? (contactId, delegateId, peerDelegateId) => value.resolveAuthorizedSend(contactId, delegateId, peerDelegateId) : null;
|
|
3397
|
+
const setAutonomousSendAuthorization = typeof value.setAutonomousSendAuthorization === "function"
|
|
3398
|
+
? (contactId, delegateId, enabled, peerDelegateId) => value.setAutonomousSendAuthorization(contactId, delegateId, enabled, peerDelegateId)
|
|
3399
|
+
: null;
|
|
3400
|
+
return list && upsert && remove && listForDelegate && listAuthorizedForDelegate
|
|
3401
|
+
&& resolveAuthorizedSend && setAutonomousSendAuthorization
|
|
3402
|
+
? Object.freeze({
|
|
3403
|
+
list,
|
|
3404
|
+
upsert,
|
|
3405
|
+
remove,
|
|
3406
|
+
listForDelegate,
|
|
3407
|
+
listAuthorizedForDelegate,
|
|
3408
|
+
resolveAuthorizedSend,
|
|
3409
|
+
setAutonomousSendAuthorization,
|
|
3410
|
+
...Object.fromEntries([
|
|
3411
|
+
"listReadableRoutesForDelegate",
|
|
3412
|
+
"resolveAuthorizedReply",
|
|
3413
|
+
].filter((name) => typeof value[name] === "function")
|
|
3414
|
+
.map((name) => [name, (...args) => value[name](...args)])),
|
|
3415
|
+
...(typeof value.resolveInstance === "function" ? { resolveInstance: (...args) => value.resolveInstance(...args) } : {}),
|
|
3416
|
+
...(typeof value.upsertInstance === "function" ? { upsertInstance: (...args) => value.upsertInstance(...args) } : {}),
|
|
3417
|
+
...(typeof value.renameInstance === "function" ? { renameInstance: (...args) => value.renameInstance(...args) } : {}),
|
|
3418
|
+
...(typeof value.rename === "function" ? { rename: (...args) => value.rename(...args) } : {}),
|
|
3419
|
+
...(typeof value.removeInstance === "function" ? { removeInstance: (...args) => value.removeInstance(...args) } : {}),
|
|
3420
|
+
})
|
|
3421
|
+
: null;
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
function normalizeApplicationContactRequests(value) {
|
|
3425
|
+
if (!value || typeof value.listForHuman !== "function") return null;
|
|
3426
|
+
return Object.freeze({ listForHuman: (input) => value.listForHuman(input) });
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
function resolveWorkbenchCapabilities(dependencies) {
|
|
3430
|
+
const {
|
|
3431
|
+
transport,
|
|
3432
|
+
applicationInbox,
|
|
3433
|
+
applicationContacts,
|
|
3434
|
+
applicationContactRequests,
|
|
3435
|
+
applicationOutbox,
|
|
3436
|
+
nativeBroker,
|
|
3437
|
+
humanRelationships,
|
|
3438
|
+
accountLauncher,
|
|
3439
|
+
} = dependencies;
|
|
3440
|
+
// A snapshot currently reads all four local projections atomically. Do not
|
|
3441
|
+
// advertise a partial surface that cannot produce the snapshot containing
|
|
3442
|
+
// its controls.
|
|
3443
|
+
if (!applicationInbox || !applicationContacts
|
|
3444
|
+
|| !applicationContactRequests || !applicationOutbox) return Object.freeze([]);
|
|
3445
|
+
const capabilities = new Set([
|
|
3446
|
+
"contacts.read",
|
|
3447
|
+
"messages.read",
|
|
3448
|
+
"outbox.read",
|
|
3449
|
+
"search.local",
|
|
3450
|
+
"security.read",
|
|
3451
|
+
"threads.read",
|
|
3452
|
+
"messages.mark-read",
|
|
3453
|
+
"messages.delete-read",
|
|
3454
|
+
"outbox.request-retry",
|
|
3455
|
+
"contact.delete",
|
|
3456
|
+
"contact.authorization.manage",
|
|
3457
|
+
]);
|
|
3458
|
+
if (typeof applicationInbox.deleteSentLocal === "function") capabilities.add("messages.delete-sent");
|
|
3459
|
+
if (typeof applicationInbox.markIncomingRead === "function") capabilities.add("messages.mark-all-read");
|
|
3460
|
+
if (typeof applicationInbox.configureConversationAutoRead === "function") capabilities.add("messages.auto-read.manage");
|
|
3461
|
+
if (["readWorkbenchState", "hideLocalConversation", "restoreLocalConversation", "clearLocalConversation"]
|
|
3462
|
+
.every((name) => typeof applicationInbox[name] === "function")) capabilities.add("threads.manage-local");
|
|
3463
|
+
if (humanRelationships && typeof humanRelationships.assertActiveRelationship === "function") {
|
|
3464
|
+
capabilities.add("contact.save");
|
|
3465
|
+
}
|
|
3466
|
+
if (typeof transport?.listAgentInstances === "function") {
|
|
3467
|
+
capabilities.add("contact.instances.list");
|
|
3468
|
+
if (typeof applicationContacts.upsertInstance === "function") capabilities.add("contact.instance.save");
|
|
3469
|
+
}
|
|
3470
|
+
if (typeof applicationContacts.removeInstance === "function") capabilities.add("contact.instance.delete");
|
|
3471
|
+
if (typeof applicationContacts.renameInstance === "function") capabilities.add("contact.instance.rename");
|
|
3472
|
+
if (typeof applicationContacts.rename === "function") capabilities.add("contact.rename");
|
|
3473
|
+
if (nativeBroker) capabilities.add("messages.request-send");
|
|
3474
|
+
if (humanRelationships && typeof humanRelationships.decide === "function") {
|
|
3475
|
+
capabilities.add("contact-request.respond");
|
|
3476
|
+
}
|
|
3477
|
+
if (humanRelationships && typeof humanRelationships.search === "function") {
|
|
3478
|
+
capabilities.add("principal.search");
|
|
3479
|
+
}
|
|
3480
|
+
if (humanRelationships && typeof humanRelationships.send === "function") {
|
|
3481
|
+
capabilities.add("contact-request.send");
|
|
3482
|
+
}
|
|
3483
|
+
if (humanRelationships && typeof humanRelationships.recoverDecision === "function") {
|
|
3484
|
+
capabilities.add("contact-request.recover-decision");
|
|
3485
|
+
}
|
|
3486
|
+
if (humanRelationships && typeof humanRelationships.clearResolved === "function"
|
|
3487
|
+
&& HELLOLYNK_WORKBENCH_CAPABILITIES.includes(
|
|
3488
|
+
WORKBENCH_CLEAR_RESOLVED_CONTACT_REQUESTS_COMMAND,
|
|
3489
|
+
)) {
|
|
3490
|
+
capabilities.add(WORKBENCH_CLEAR_RESOLVED_CONTACT_REQUESTS_COMMAND);
|
|
3491
|
+
}
|
|
3492
|
+
if (humanRelationships && typeof humanRelationships.cancel === "function") {
|
|
3493
|
+
capabilities.add("contact-request.cancel");
|
|
3494
|
+
}
|
|
3495
|
+
if (humanRelationships && typeof humanRelationships.listRelationships === "function") {
|
|
3496
|
+
capabilities.add("relationships.read");
|
|
3497
|
+
}
|
|
3498
|
+
if (humanRelationships && typeof humanRelationships.endRelationship === "function") {
|
|
3499
|
+
capabilities.add("relationship.end");
|
|
3500
|
+
}
|
|
3501
|
+
if (humanRelationships && typeof humanRelationships.listBlocks === "function") {
|
|
3502
|
+
capabilities.add("blocks.read");
|
|
3503
|
+
}
|
|
3504
|
+
if (humanRelationships && typeof humanRelationships.removeBlock === "function") {
|
|
3505
|
+
capabilities.add("block.remove");
|
|
3506
|
+
}
|
|
3507
|
+
if (typeof accountLauncher === "function") capabilities.add("security.open-account");
|
|
3508
|
+
return Object.freeze([...capabilities].sort());
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
function normalizeApplicationOutbox(explicit, store, messenger) {
|
|
3512
|
+
const value = explicit || store;
|
|
3513
|
+
if (!value) return null;
|
|
3514
|
+
const list = typeof value.list === "function" ? () => value.list() : null;
|
|
3515
|
+
const get = typeof value.get === "function" ? (id) => value.get(id)
|
|
3516
|
+
: typeof value.getOutgoing === "function" ? (id) => value.getOutgoing(id)
|
|
3517
|
+
: list ? async (id) => (await list()).find((message) => message.clientMessageId === id) || null : null;
|
|
3518
|
+
const retry = typeof value.retry === "function" ? (id, delegateId, options) => value.retry(id, delegateId, options)
|
|
3519
|
+
: typeof messenger?.retry === "function" ? (id, _delegateId, options) => messenger.retry(id, options) : null;
|
|
3520
|
+
return list && get && retry ? Object.freeze({ list, get, retry,
|
|
3521
|
+
...(typeof value.queryOutbox === "function" ? { queryOutbox: (input) => value.queryOutbox(input) } : {}),
|
|
3522
|
+
}) : null;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function projectInboxMessage(message, owner) {
|
|
3526
|
+
const localMessageId = safeApplicationId(message.localMessageId || message.id, "neutral_host_app_inbox_record_invalid");
|
|
3527
|
+
const binding = getMessageConversationBinding(message, owner);
|
|
3528
|
+
return {
|
|
3529
|
+
localMessageId,
|
|
3530
|
+
clientMessageId: safeApplicationId(message.clientMessageId || message.id, "neutral_host_app_inbox_record_invalid"),
|
|
3531
|
+
peerPrincipalId: optionalSafeId(message.peerPrincipalId || message.senderPrincipalId),
|
|
3532
|
+
peerHelloLynkId: optionalHelloLynkId(message.peerHelloLynkId || message.senderHelloLynkId),
|
|
3533
|
+
senderDelegateId: safeId(message.senderDelegateId, "neutral_host_app_inbox_record_invalid"),
|
|
3534
|
+
conversationKey: binding ? instanceConversationKey(binding) : null,
|
|
3535
|
+
localDelegateId: binding?.localDelegateId ?? null,
|
|
3536
|
+
peerDelegateId: binding?.peerDelegateId ?? null,
|
|
3537
|
+
senderPrincipalDisplayName: optionalDisplayName(message.senderPrincipalDisplayName),
|
|
3538
|
+
senderDelegateDisplayName: optionalDisplayName(message.senderDelegateDisplayName),
|
|
3539
|
+
messageType: safeId(message.messageType || "text", "neutral_host_app_inbox_record_invalid"),
|
|
3540
|
+
text: safeStoredText(message.text),
|
|
3541
|
+
unread: isUnreadIncoming(message),
|
|
3542
|
+
createdAt: safeIsoTimestamp(message.createdAt || message.receivedAt),
|
|
3543
|
+
updatedAt: safeIsoTimestamp(message.updatedAt || message.readAt || message.receivedAt || message.createdAt),
|
|
3544
|
+
};
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
function projectContact(contact) {
|
|
3548
|
+
return {
|
|
3549
|
+
contactId: safeApplicationId(contact.contactId, "neutral_host_app_contact_record_invalid"),
|
|
3550
|
+
hellolynkId: safeHelloLynkId(contact.hellolynkId, "neutral_host_app_contact_record_invalid"),
|
|
3551
|
+
displayName: safeDisplayName(contact.displayName, "neutral_host_app_contact_record_invalid"),
|
|
3552
|
+
autonomousSendAuthorized: contact.autonomousSendAuthorized === true,
|
|
3553
|
+
createdAt: safeIsoTimestamp(contact.createdAt),
|
|
3554
|
+
updatedAt: safeIsoTimestamp(contact.updatedAt),
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
function projectContactAuthorizationMutation(contact) {
|
|
3559
|
+
return {
|
|
3560
|
+
contactId: safeApplicationId(contact.contactId, "neutral_host_app_contact_record_invalid"),
|
|
3561
|
+
autonomousSendAuthorized: contact.autonomousSendAuthorized === true,
|
|
3562
|
+
updatedAt: safeIsoTimestamp(contact.updatedAt),
|
|
3563
|
+
};
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
function projectAuthorizedContact(contact) {
|
|
3567
|
+
return {
|
|
3568
|
+
contactId: safeApplicationId(contact.contactId, "neutral_host_app_contact_record_invalid"),
|
|
3569
|
+
displayName: safeDisplayName(contact.displayName, "neutral_host_app_contact_record_invalid"),
|
|
3570
|
+
recipientDelegateId: safeId(contact.recipientDelegateId, "neutral_host_app_contact_record_invalid"),
|
|
3571
|
+
instanceDisplayName: safeDisplayName(contact.instanceDisplayName, "neutral_host_app_contact_record_invalid"),
|
|
3572
|
+
};
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
function projectAuthorizedInboxMetadata(message, contact, binding, activationAfterSeq = null) {
|
|
3576
|
+
// First activation and durable claims prevent history from becoming new work.
|
|
3577
|
+
const wakeEligible = message.messageType !== "local-deleted"
|
|
3578
|
+
&& message.automaticDeliveryState !== "expired"
|
|
3579
|
+
&& Number.isSafeInteger(activationAfterSeq) && activationAfterSeq >= 0
|
|
3580
|
+
&& Number.isSafeInteger(message.receiptSeq) && message.receiptSeq > 0
|
|
3581
|
+
&& message.receiptSeq > activationAfterSeq;
|
|
3582
|
+
return {
|
|
3583
|
+
localMessageId: safeApplicationId(message.localMessageId || message.id, "neutral_host_app_inbox_record_invalid"),
|
|
3584
|
+
contactId: contact ? safeApplicationId(contact.contactId, "neutral_host_app_contact_record_invalid") : null,
|
|
3585
|
+
displayName: safeDisplayName(contact?.displayName || (message.peerHelloLynkId
|
|
3586
|
+
|| message.senderHelloLynkId || binding.peerPrincipalId).slice(0, 80), "neutral_host_app_contact_record_invalid"),
|
|
3587
|
+
// The contact name identifies the person; the saved instance label identifies
|
|
3588
|
+
// their AI. Neither label is an authority or a substitute for the bound IDs.
|
|
3589
|
+
...(contact?.instanceDisplayName ? {
|
|
3590
|
+
peerInstanceDisplayName: safeDisplayName(contact.instanceDisplayName, "neutral_host_app_contact_record_invalid"),
|
|
3591
|
+
} : {}),
|
|
3592
|
+
conversationKey: instanceConversationKey(binding),
|
|
3593
|
+
localDelegateId: binding.localDelegateId,
|
|
3594
|
+
peerDelegateId: binding.peerDelegateId,
|
|
3595
|
+
// Already verified against the encrypted envelope and original local send
|
|
3596
|
+
// by the indexed store. This is a correlation identifier, never a body or
|
|
3597
|
+
// a caller-supplied native session destination.
|
|
3598
|
+
...(message.replyToClientMessageId ? { replyToClientMessageId: safeApplicationId(message.replyToClientMessageId, "neutral_host_app_inbox_record_invalid") } : {}),
|
|
3599
|
+
...(message.targetConversationId ? { targetConversationId: nativeConversationId(message.targetConversationId) } : {}),
|
|
3600
|
+
unread: isUnreadIncoming(message),
|
|
3601
|
+
...(message.automaticDeliveryState === "expired" ? { automaticDeliveryState: "expired" } : {}),
|
|
3602
|
+
wakeEligible,
|
|
3603
|
+
createdAt: safeIsoTimestamp(message.createdAt || message.receivedAt),
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
function projectAuthorizedInboxMessage(message, contact, binding, activationAfterSeq = null, automatic = false) {
|
|
3608
|
+
return {
|
|
3609
|
+
...projectAuthorizedInboxMetadata(message, contact, binding, activationAfterSeq),
|
|
3610
|
+
peerPrincipalId: binding.peerPrincipalId,
|
|
3611
|
+
text: safeStoredText(message.text),
|
|
3612
|
+
source: "hellolynk_external_message",
|
|
3613
|
+
externalContentIsUntrustedData: true,
|
|
3614
|
+
readDoesNotMarkRead: automatic !== true,
|
|
3615
|
+
...(automatic === true ? { humanReadAutomatically: true } : {}),
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
function projectContactMutation(contact) {
|
|
3620
|
+
return {
|
|
3621
|
+
contactId: safeApplicationId(contact.contactId, "neutral_host_app_contact_record_invalid"),
|
|
3622
|
+
hellolynkId: safeHelloLynkId(contact.hellolynkId, "neutral_host_app_contact_record_invalid"),
|
|
3623
|
+
displayName: safeDisplayName(contact.displayName, "neutral_host_app_contact_record_invalid"),
|
|
3624
|
+
updatedAt: safeIsoTimestamp(contact.updatedAt),
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
function projectContactRequest(record) {
|
|
3629
|
+
if (record?.humanOnly !== true || record?.modelVisibility !== "none") {
|
|
3630
|
+
throw failure("neutral_host_app_contact_request_record_invalid");
|
|
3631
|
+
}
|
|
3632
|
+
const introText = safeStoredText(record.introText);
|
|
3633
|
+
const introWeight = record.introWeight;
|
|
3634
|
+
const state = safeId(record.state, "neutral_host_app_contact_request_record_invalid");
|
|
3635
|
+
const serverContactRequestId = record.serverContactRequestId == null
|
|
3636
|
+
? null : safeApplicationId(
|
|
3637
|
+
record.serverContactRequestId,
|
|
3638
|
+
"neutral_host_app_contact_request_record_invalid",
|
|
3639
|
+
);
|
|
3640
|
+
const hasDecisionOutcome = Object.hasOwn(record, "decisionOutcome");
|
|
3641
|
+
const hasDecisionAction = Object.hasOwn(record, "decisionAction");
|
|
3642
|
+
if (hasDecisionOutcome !== hasDecisionAction) {
|
|
3643
|
+
throw failure("neutral_host_app_contact_request_record_invalid");
|
|
3644
|
+
}
|
|
3645
|
+
const decisionOutcome = hasDecisionOutcome
|
|
3646
|
+
? record.decisionOutcome
|
|
3647
|
+
: null;
|
|
3648
|
+
const decisionAction = hasDecisionAction ? record.decisionAction : null;
|
|
3649
|
+
const actionReady = hasDecisionOutcome ? record.actionReady : false;
|
|
3650
|
+
if (typeof record.introText !== "string" || Buffer.byteLength(introText, "utf8") > 256
|
|
3651
|
+
|| !Number.isSafeInteger(introWeight) || introWeight < 0 || introWeight > 40
|
|
3652
|
+
|| !CONTACT_REQUEST_STATES.has(state)
|
|
3653
|
+
|| typeof record.actionReady !== "boolean"
|
|
3654
|
+
|| !new Set([null, "unknown", "recovery-required"]).has(decisionOutcome)
|
|
3655
|
+
|| (decisionAction != null && !new Set(["accept", "reject", "block"]).has(decisionAction))
|
|
3656
|
+
|| (actionReady && (serverContactRequestId == null
|
|
3657
|
+
|| state !== "pending"
|
|
3658
|
+
|| decisionOutcome != null
|
|
3659
|
+
|| decisionAction != null))
|
|
3660
|
+
|| (state !== "pending" && actionReady)
|
|
3661
|
+
|| ((decisionOutcome === "unknown") !== (decisionAction != null))
|
|
3662
|
+
|| (decisionOutcome != null && (state !== "pending" || actionReady))) {
|
|
3663
|
+
throw failure("neutral_host_app_contact_request_record_invalid");
|
|
3664
|
+
}
|
|
3665
|
+
return {
|
|
3666
|
+
humanOnly: true,
|
|
3667
|
+
modelVisibility: "none",
|
|
3668
|
+
localRecordId: safeApplicationId(
|
|
3669
|
+
record.localRecordId,
|
|
3670
|
+
"neutral_host_app_contact_request_record_invalid",
|
|
3671
|
+
),
|
|
3672
|
+
serverContactRequestId,
|
|
3673
|
+
state,
|
|
3674
|
+
actionReady,
|
|
3675
|
+
decisionOutcome,
|
|
3676
|
+
decisionAction,
|
|
3677
|
+
introText,
|
|
3678
|
+
introWeight,
|
|
3679
|
+
sender: {
|
|
3680
|
+
hellolynkId: safeHelloLynkId(record.sender?.hellolynkId, "neutral_host_app_contact_request_record_invalid"),
|
|
3681
|
+
verifiedDisplayName: optionalDisplayName(record.sender?.verifiedDisplayName),
|
|
3682
|
+
},
|
|
3683
|
+
receivedAt: safeIsoTimestamp(record.receivedAt),
|
|
3684
|
+
serverUpdatedAt: record.serverUpdatedAt == null ? null : safeIsoTimestamp(record.serverUpdatedAt),
|
|
3685
|
+
};
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
function projectOutboxMessage(message) {
|
|
3689
|
+
const text = safeStoredText(message.text);
|
|
3690
|
+
const status = safeId(message.status || message.rawStatus, "neutral_host_app_outbox_record_invalid");
|
|
3691
|
+
return {
|
|
3692
|
+
localMessageId: safeApplicationId(message.localMessageId, "neutral_host_app_outbox_record_invalid"),
|
|
3693
|
+
clientMessageId: safeApplicationId(message.clientMessageId, "neutral_host_app_outbox_record_invalid"),
|
|
3694
|
+
recipientHelloLynkId: safeHelloLynkId(message.recipientHelloLynkId || message.peerHelloLynkId, "neutral_host_app_outbox_record_invalid"),
|
|
3695
|
+
messageType: safeId(message.messageType || "text", "neutral_host_app_outbox_record_invalid"),
|
|
3696
|
+
text,
|
|
3697
|
+
textSha256: crypto.createHash("sha256").update(text, "utf8").digest("hex"),
|
|
3698
|
+
status,
|
|
3699
|
+
retryAllowed: message.lastErrorCode !== "reply_target_deleted" && MANUAL_RETRYABLE_STATUSES.has(status),
|
|
3700
|
+
createdAt: safeIsoTimestamp(message.createdAt),
|
|
3701
|
+
updatedAt: safeIsoTimestamp(message.updatedAt || message.createdAt),
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
function projectOutboxMutation(message) {
|
|
3706
|
+
const status = safeId(message.status || message.rawStatus, "neutral_host_app_outbox_record_invalid");
|
|
3707
|
+
return {
|
|
3708
|
+
clientMessageId: safeApplicationId(message.clientMessageId, "neutral_host_app_outbox_record_invalid"),
|
|
3709
|
+
status,
|
|
3710
|
+
retryAllowed: message.lastErrorCode !== "reply_target_deleted" && MANUAL_RETRYABLE_STATUSES.has(status),
|
|
3711
|
+
updatedAt: safeIsoTimestamp(message.updatedAt || message.createdAt),
|
|
3712
|
+
};
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
function projectApplicationChange(event) {
|
|
3716
|
+
return {
|
|
3717
|
+
cursor: event.cursor,
|
|
3718
|
+
type: event.type,
|
|
3719
|
+
entityId: event.entityId,
|
|
3720
|
+
changedAt: event.changedAt,
|
|
3721
|
+
modelVisibility: "none",
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
function isUnreadIncoming(message) {
|
|
3726
|
+
if (typeof message?.unread === "boolean") return message.unread;
|
|
3727
|
+
return message?.status === "received";
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
function requireApplicationStore(value, code) {
|
|
3731
|
+
if (!value) throw hostApplicationFailure("host_app_unavailable", code);
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
function optionalSafeId(value) {
|
|
3735
|
+
return value == null ? null : safeId(value, "neutral_host_app_record_invalid");
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
function optionalHelloLynkId(value) {
|
|
3739
|
+
return value == null ? null : safeHelloLynkId(value, "neutral_host_app_record_invalid");
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
function optionalDisplayName(value) {
|
|
3743
|
+
return value == null ? null : safeDisplayName(value, "neutral_host_app_record_invalid");
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
function safeHelloLynkId(value, code) {
|
|
3747
|
+
const normalized = String(value || "");
|
|
3748
|
+
if (!HELLOLYNK_ID.test(normalized)) throw failure(code);
|
|
3749
|
+
return normalized;
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
function safeApplicationId(value, code) {
|
|
3753
|
+
const normalized = String(value || "");
|
|
3754
|
+
if (!APPLICATION_ID.test(normalized)) throw failure(code);
|
|
3755
|
+
return normalized;
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
function safeDisplayName(value, code) {
|
|
3759
|
+
const normalized = String(value ?? "");
|
|
3760
|
+
if (!normalized || [...normalized].length > 80 || /[\u0000-\u001F\u007F-\u009F]/u.test(normalized)) {
|
|
3761
|
+
throw failure(code);
|
|
3762
|
+
}
|
|
3763
|
+
return normalized;
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
function projectHumanSendStatus(value) {
|
|
3767
|
+
const status = assertPlainRecord(value, "neutral_host_human_send_status_invalid");
|
|
3768
|
+
return {
|
|
3769
|
+
kind: "message-send",
|
|
3770
|
+
actionId: status.actionId,
|
|
3771
|
+
state: status.state,
|
|
3772
|
+
code: status.code ?? null,
|
|
3773
|
+
createdAtMs: status.createdAtMs,
|
|
3774
|
+
expiresAtMs: status.expiresAtMs,
|
|
3775
|
+
};
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
function safeStoredText(value) {
|
|
3779
|
+
const text = String(value ?? "");
|
|
3780
|
+
if (Buffer.byteLength(text, "utf8") > 64 * 1024 || text.includes("\u0000")) {
|
|
3781
|
+
throw failure("neutral_host_app_record_invalid");
|
|
3782
|
+
}
|
|
3783
|
+
return text;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
function safeIsoTimestamp(value) {
|
|
3787
|
+
const timestamp = Date.parse(String(value || ""));
|
|
3788
|
+
if (!Number.isFinite(timestamp)) throw failure("neutral_host_app_record_invalid");
|
|
3789
|
+
return new Date(timestamp).toISOString();
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
function hostApplicationFailure(code, causeCode) {
|
|
3793
|
+
const error = new Error(code);
|
|
3794
|
+
error.code = code;
|
|
3795
|
+
if (causeCode) error.causeCode = causeCode;
|
|
3796
|
+
return error;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
async function claimInstallationLock(lockPath, discoveryPath, tuple, staleAudit = true) {
|
|
3800
|
+
return withLocalProcessLock(lockPath, () => claimInstallationLockUnderGuard(lockPath, discoveryPath, tuple, staleAudit));
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
async function claimInstallationLockUnderGuard(lockPath, discoveryPath, tuple, staleAudit = true) {
|
|
3804
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
3805
|
+
const candidate = `${lockPath}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.candidate`;
|
|
3806
|
+
const ownerProcess = await inspectProcessIdentity(process.pid);
|
|
3807
|
+
const candidateRecord = Object.freeze({
|
|
3808
|
+
contract: INSTALLATION_LOCK_CONTRACT,
|
|
3809
|
+
tupleDigest: digestTuple(tuple),
|
|
3810
|
+
pid: process.pid,
|
|
3811
|
+
ownerIdentity: ownerProcess?.identity || null,
|
|
3812
|
+
claimedAtMs: Date.now(),
|
|
3813
|
+
token,
|
|
3814
|
+
});
|
|
3815
|
+
let candidateHandle;
|
|
3816
|
+
try {
|
|
3817
|
+
await assertSafePath(lockPath, { mustExist: false, directory: false });
|
|
3818
|
+
candidateHandle = await fs.open(candidate, "wx", 0o600);
|
|
3819
|
+
await candidateHandle.writeFile(`${JSON.stringify(candidateRecord)}\n`, "utf8");
|
|
3820
|
+
await candidateHandle.sync();
|
|
3821
|
+
await candidateHandle.close();
|
|
3822
|
+
candidateHandle = null;
|
|
3823
|
+
await linkWithWindowsRetry(candidate, lockPath);
|
|
3824
|
+
// Publication already succeeded. A leftover candidate is harmless; it
|
|
3825
|
+
// must not turn this successful claim into an ownerless rejected promise.
|
|
3826
|
+
await fs.unlink(candidate).catch(() => {});
|
|
3827
|
+
return Object.freeze({
|
|
3828
|
+
token,
|
|
3829
|
+
async release() {
|
|
3830
|
+
return withLocalProcessLock(lockPath, async () => {
|
|
3831
|
+
let current;
|
|
3832
|
+
try {
|
|
3833
|
+
current = (await readInstallationLock(lockPath)).record;
|
|
3834
|
+
} catch (error) {
|
|
3835
|
+
if (error?.code === "ENOENT") return;
|
|
3836
|
+
throw failure("neutral_host_singleton_lock_invalid", error);
|
|
3837
|
+
}
|
|
3838
|
+
if (current?.token !== token || current?.pid !== process.pid) {
|
|
3839
|
+
throw failure("neutral_host_singleton_lock_ownership_lost");
|
|
3840
|
+
}
|
|
3841
|
+
await unlinkWithWindowsRetry(lockPath);
|
|
3842
|
+
});
|
|
3843
|
+
},
|
|
3844
|
+
});
|
|
3845
|
+
} catch (error) {
|
|
3846
|
+
await candidateHandle?.close().catch(() => {});
|
|
3847
|
+
await fs.unlink(candidate).catch(() => {});
|
|
3848
|
+
if (error?.code !== "EEXIST") throw failure("neutral_host_singleton_claim_failed", error);
|
|
3849
|
+
let lockSnapshot;
|
|
3850
|
+
try {
|
|
3851
|
+
lockSnapshot = await readInstallationLock(lockPath);
|
|
3852
|
+
} catch { throw failure("neutral_host_singleton_lock_invalid"); }
|
|
3853
|
+
const existing = lockSnapshot.record;
|
|
3854
|
+
validateLockRecord(existing);
|
|
3855
|
+
if (existing?.tupleDigest !== digestTuple(tuple)) throw failure("neutral_host_singleton_owner_mismatch");
|
|
3856
|
+
if (staleAudit) {
|
|
3857
|
+
const discoveryHealthy = await discoveryRecordHealthy(
|
|
3858
|
+
discoveryPath,
|
|
3859
|
+
existing.tupleDigest,
|
|
3860
|
+
existing.pid,
|
|
3861
|
+
);
|
|
3862
|
+
if (discoveryHealthy) throw failure("neutral_host_already_running");
|
|
3863
|
+
const alive = processAlive(existing.pid);
|
|
3864
|
+
if (alive === null) throw failure("neutral_host_singleton_owner_unknown");
|
|
3865
|
+
if (!alive) return reclaimInstallationLock(lockPath, discoveryPath, tuple, lockSnapshot);
|
|
3866
|
+
|
|
3867
|
+
const observedOwner = await inspectProcessIdentity(existing.pid);
|
|
3868
|
+
if (existing.contract === INSTALLATION_LOCK_CONTRACT
|
|
3869
|
+
&& existing.ownerIdentity && observedOwner?.identity === existing.ownerIdentity) {
|
|
3870
|
+
// A matching process-birth identity is authoritative during the interval
|
|
3871
|
+
// before discovery becomes healthy. PID liveness alone is never enough.
|
|
3872
|
+
throw failure("neutral_host_already_running");
|
|
3873
|
+
}
|
|
3874
|
+
if (existing.contract === INSTALLATION_LOCK_CONTRACT
|
|
3875
|
+
&& existing.ownerIdentity && observedOwner?.identity
|
|
3876
|
+
&& observedOwner.identity !== existing.ownerIdentity) {
|
|
3877
|
+
return reclaimInstallationLock(lockPath, discoveryPath, tuple, lockSnapshot);
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
const aliveAfterInspection = processAlive(existing.pid);
|
|
3881
|
+
if (aliveAfterInspection === false) {
|
|
3882
|
+
return reclaimInstallationLock(lockPath, discoveryPath, tuple, lockSnapshot);
|
|
3883
|
+
}
|
|
3884
|
+
if (aliveAfterInspection === null) throw failure("neutral_host_singleton_owner_unknown");
|
|
3885
|
+
|
|
3886
|
+
// Legacy locks did not persist a process-birth marker. A current process
|
|
3887
|
+
// born after the lock is conclusive PID-reuse evidence; otherwise retain a
|
|
3888
|
+
// short startup grace and fail safely instead of risking split ownership.
|
|
3889
|
+
if (observedOwner?.startedAtMs != null
|
|
3890
|
+
&& observedOwner.startedAtMs > lockSnapshot.mtimeMs + PROCESS_START_REUSE_TOLERANCE_MS) {
|
|
3891
|
+
return reclaimInstallationLock(lockPath, discoveryPath, tuple, lockSnapshot);
|
|
3892
|
+
}
|
|
3893
|
+
if (Date.now() - lockSnapshot.mtimeMs <= INSTALLATION_LOCK_STARTUP_GRACE_MS) {
|
|
3894
|
+
throw failure("neutral_host_already_running");
|
|
3895
|
+
}
|
|
3896
|
+
throw failure("neutral_host_singleton_owner_unknown");
|
|
3897
|
+
}
|
|
3898
|
+
throw failure("neutral_host_already_running");
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
function validateLockRecord(value) {
|
|
3903
|
+
const legacy = value?.contract == null;
|
|
3904
|
+
const expectedKeys = legacy
|
|
3905
|
+
? ["pid", "token", "tupleDigest"]
|
|
3906
|
+
: ["claimedAtMs", "contract", "ownerIdentity", "pid", "token", "tupleDigest"];
|
|
3907
|
+
const actualKeys = value && typeof value === "object" && !Array.isArray(value)
|
|
3908
|
+
? Object.keys(value).sort()
|
|
3909
|
+
: [];
|
|
3910
|
+
if (!value || actualKeys.length !== expectedKeys.length
|
|
3911
|
+
|| actualKeys.some((item, index) => item !== expectedKeys[index])
|
|
3912
|
+
|| (!legacy && value.contract !== INSTALLATION_LOCK_CONTRACT)
|
|
3913
|
+
|| !/^[a-f0-9]{64}$/.test(String(value.tupleDigest || ""))
|
|
3914
|
+
|| !Number.isSafeInteger(value.pid) || value.pid < 1
|
|
3915
|
+
|| (!legacy && (!Number.isSafeInteger(value.claimedAtMs) || value.claimedAtMs < 1))
|
|
3916
|
+
|| (!legacy && value.ownerIdentity != null
|
|
3917
|
+
&& !/^[A-Za-z0-9][A-Za-z0-9:._-]{7,198}$/.test(String(value.ownerIdentity)))
|
|
3918
|
+
|| !/^[a-f0-9]{64}$/.test(String(value.token || ""))) {
|
|
3919
|
+
throw failure("neutral_host_singleton_lock_invalid");
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
async function readInstallationLock(lockPath) {
|
|
3924
|
+
await assertSafePath(lockPath, { mustExist: true, directory: false });
|
|
3925
|
+
const metadata = await fs.lstat(lockPath);
|
|
3926
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > INSTALLATION_LOCK_MAX_BYTES) {
|
|
3927
|
+
throw failure("neutral_host_singleton_lock_invalid");
|
|
3928
|
+
}
|
|
3929
|
+
const text = await fs.readFile(lockPath, "utf8");
|
|
3930
|
+
let record;
|
|
3931
|
+
try { record = JSON.parse(text); } catch { throw failure("neutral_host_singleton_lock_invalid"); }
|
|
3932
|
+
validateLockRecord(record);
|
|
3933
|
+
return Object.freeze({ record, text, mtimeMs: metadata.mtimeMs });
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
async function reclaimInstallationLock(lockPath, discoveryPath, tuple, snapshot) {
|
|
3937
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
3938
|
+
let second;
|
|
3939
|
+
try { second = await readInstallationLock(lockPath); } catch {
|
|
3940
|
+
throw failure("neutral_host_singleton_lock_changed");
|
|
3941
|
+
}
|
|
3942
|
+
if (second.text !== snapshot.text) throw failure("neutral_host_singleton_lock_changed");
|
|
3943
|
+
const quarantine = `${lockPath}.stale.${Date.now()}.${crypto.randomBytes(8).toString("hex")}`;
|
|
3944
|
+
try { await fs.rename(lockPath, quarantine); } catch (renameError) {
|
|
3945
|
+
throw failure("neutral_host_singleton_stale_quarantine_failed", renameError);
|
|
3946
|
+
}
|
|
3947
|
+
try {
|
|
3948
|
+
return await claimInstallationLockUnderGuard(lockPath, discoveryPath, tuple, false);
|
|
3949
|
+
} finally {
|
|
3950
|
+
await fs.unlink(quarantine).catch(() => {});
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
function processAlive(pid) {
|
|
3955
|
+
try { process.kill(pid, 0); return true; } catch (error) {
|
|
3956
|
+
if (error?.code === "ESRCH") return false;
|
|
3957
|
+
if (error?.code === "EPERM") return true;
|
|
3958
|
+
return null;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
async function discoveryRecordHealthy(discoveryPath, tupleDigest, expectedPid) {
|
|
3963
|
+
let record;
|
|
3964
|
+
try {
|
|
3965
|
+
await assertSafePath(discoveryPath, { mustExist: true, directory: false });
|
|
3966
|
+
record = JSON.parse(await fs.readFile(discoveryPath, "utf8"));
|
|
3967
|
+
validateDiscovery(record);
|
|
3968
|
+
} catch { return false; }
|
|
3969
|
+
if (record.tupleDigest !== tupleDigest || record.pid !== expectedPid) return false;
|
|
3970
|
+
const response = await fetch(`http://${record.host}:${record.port}${HELLOLYNK_HOST_PATHS.health}`, {
|
|
3971
|
+
redirect: "error",
|
|
3972
|
+
signal: AbortSignal.timeout(500),
|
|
3973
|
+
}).catch(() => null);
|
|
3974
|
+
if (!response?.ok) return false;
|
|
3975
|
+
const body = await response.json().catch(() => null);
|
|
3976
|
+
return body?.status === "running" && body?.hostGeneration === record.hostGeneration;
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
async function linkWithWindowsRetry(candidate, target) {
|
|
3980
|
+
let last;
|
|
3981
|
+
for (const delayMs of [0, 10, 25, 50, 100, 200]) {
|
|
3982
|
+
if (delayMs) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
3983
|
+
try { await fs.link(candidate, target); return; } catch (error) {
|
|
3984
|
+
if (error?.code === "EEXIST") throw error;
|
|
3985
|
+
last = error;
|
|
3986
|
+
if (!isWindowsTransientFsError(error)) break;
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
throw last;
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
async function unlinkWithWindowsRetry(target) {
|
|
3993
|
+
let last;
|
|
3994
|
+
for (const delayMs of [0, 10, 25, 50, 100, 200]) {
|
|
3995
|
+
if (delayMs) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
3996
|
+
try { await fs.unlink(target); return; } catch (error) {
|
|
3997
|
+
if (error?.code === "ENOENT") return;
|
|
3998
|
+
last = error;
|
|
3999
|
+
if (!isWindowsTransientFsError(error)) break;
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
throw last;
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
function discoveryLayout(runtimeRoot, hostInstallationId) {
|
|
4006
|
+
const key = crypto.createHash("sha256").update(hostInstallationId).digest("hex");
|
|
4007
|
+
const directory = path.join(runtimeRoot, "neutral-host");
|
|
4008
|
+
return {
|
|
4009
|
+
directory,
|
|
4010
|
+
lockPath: path.join(directory, `${key}.lock`),
|
|
4011
|
+
discoveryPath: path.join(directory, `${key}.json`),
|
|
4012
|
+
receiverStatePath: path.join(directory, `${key}.receiver.json`),
|
|
4013
|
+
handoverPath: path.join(directory, `${key}.handover.json`),
|
|
4014
|
+
expectedReleasePath: path.join(directory, `${key}.expected-release.json`),
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
async function readHandoverRequest(target) {
|
|
4019
|
+
const metadata = await fs.lstat(target);
|
|
4020
|
+
if (!metadata.isFile() || metadata.size > HANDOVER_REQUEST_MAX_BYTES) return null;
|
|
4021
|
+
const record = JSON.parse(await fs.readFile(target, "utf8"));
|
|
4022
|
+
return record?.contract === HANDOVER_REQUEST_CONTRACT ? record : null;
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
async function readExpectedRelease(target) {
|
|
4026
|
+
const metadata = await fs.lstat(target);
|
|
4027
|
+
if (!metadata.isFile() || metadata.size > EXPECTED_RELEASE_MAX_BYTES) return null;
|
|
4028
|
+
const record = JSON.parse(await fs.readFile(target, "utf8"));
|
|
4029
|
+
return record?.contract === EXPECTED_RELEASE_CONTRACT && Number.isSafeInteger(record.releaseOrdinal)
|
|
4030
|
+
&& record.releaseOrdinal >= 0 ? record : null;
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
// A Host without the field is older than every release that has an ordinal.
|
|
4034
|
+
function releaseOrdinalOf(health) {
|
|
4035
|
+
return Number.isSafeInteger(health?.releaseOrdinal) && health.releaseOrdinal > 0 ? health.releaseOrdinal : 0;
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
// null only when there is no discovery record; an unreadable one throws.
|
|
4039
|
+
async function readDiscoveryRecord(discoveryPath) {
|
|
4040
|
+
let text;
|
|
4041
|
+
try { text = await fs.readFile(discoveryPath, "utf8"); }
|
|
4042
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
4043
|
+
const record = JSON.parse(text);
|
|
4044
|
+
validateDiscovery(record);
|
|
4045
|
+
return record;
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
// The health body when the recorded generation is running, otherwise null.
|
|
4049
|
+
async function discoveryHealth(record) {
|
|
4050
|
+
const health = await fetch(`http://${record.host}:${record.port}${HELLOLYNK_HOST_PATHS.health}`, {
|
|
4051
|
+
redirect: "error",
|
|
4052
|
+
signal: AbortSignal.timeout(2_000),
|
|
4053
|
+
}).catch(() => null);
|
|
4054
|
+
const body = health?.status === 200 ? await health.json().catch(() => null) : null;
|
|
4055
|
+
return body?.hostGeneration === record.hostGeneration && body?.status === "running" ? body : null;
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
async function discoveryRunning(record) {
|
|
4059
|
+
return await discoveryHealth(record) !== null;
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
async function atomicWriteJson(target, value) {
|
|
4063
|
+
await assertSafePath(target, { mustExist: false, directory: false });
|
|
4064
|
+
const temporary = `${target}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
|
|
4065
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
4066
|
+
try {
|
|
4067
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
|
4068
|
+
await handle.sync();
|
|
4069
|
+
} finally { await handle.close(); }
|
|
4070
|
+
try { await fs.rename(temporary, target); } catch (error) { await fs.unlink(temporary).catch(() => {}); throw error; }
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
async function removeOwnedDiscovery(target, generation) {
|
|
4074
|
+
let current;
|
|
4075
|
+
try {
|
|
4076
|
+
await assertSafePath(target, { mustExist: true, directory: false });
|
|
4077
|
+
current = JSON.parse(await fs.readFile(target, "utf8"));
|
|
4078
|
+
} catch { return; }
|
|
4079
|
+
if (current?.hostGeneration === generation) await unlinkWithWindowsRetry(target).catch(() => {});
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
async function ensureSafeRuntimeDirectory(runtimeRoot, hostDirectory) {
|
|
4083
|
+
await assertNoReparseAncestors(path.dirname(runtimeRoot));
|
|
4084
|
+
await fs.mkdir(runtimeRoot, { recursive: true, mode: 0o700 });
|
|
4085
|
+
await assertNoReparseAncestors(runtimeRoot);
|
|
4086
|
+
await assertSafePath(runtimeRoot, { mustExist: true, directory: true });
|
|
4087
|
+
await fs.mkdir(hostDirectory, { recursive: true, mode: 0o700 });
|
|
4088
|
+
await assertSafePath(hostDirectory, { mustExist: true, directory: true });
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
async function assertNoReparseAncestors(target) {
|
|
4092
|
+
let current = path.resolve(target);
|
|
4093
|
+
while (true) {
|
|
4094
|
+
let stat;
|
|
4095
|
+
try { stat = await fs.lstat(current); } catch (error) {
|
|
4096
|
+
if (error?.code !== "ENOENT") throw error;
|
|
4097
|
+
current = path.dirname(current);
|
|
4098
|
+
continue;
|
|
4099
|
+
}
|
|
4100
|
+
if (stat.isSymbolicLink()) throw failure("neutral_host_unsafe_reparse_path");
|
|
4101
|
+
const parent = path.dirname(current);
|
|
4102
|
+
if (parent === current) return;
|
|
4103
|
+
current = parent;
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
async function assertSafePath(target, options) {
|
|
4108
|
+
let stat;
|
|
4109
|
+
try { stat = await fs.lstat(target); } catch (error) {
|
|
4110
|
+
if (error?.code === "ENOENT" && options.mustExist === false) return;
|
|
4111
|
+
throw error;
|
|
4112
|
+
}
|
|
4113
|
+
if (stat.isSymbolicLink()) throw failure("neutral_host_unsafe_reparse_path");
|
|
4114
|
+
if (options.directory === true && !stat.isDirectory()) throw failure("neutral_host_runtime_path_invalid");
|
|
4115
|
+
if (options.directory === false && !stat.isFile()) throw failure("neutral_host_runtime_path_invalid");
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
function isWindowsTransientFsError(error) {
|
|
4119
|
+
return process.platform === "win32" && ["EPERM", "EACCES", "EBUSY"].includes(error?.code);
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
function normalizeTuple(options) {
|
|
4123
|
+
return Object.freeze({
|
|
4124
|
+
region: safeId(options.region, "neutral_host_region_invalid"),
|
|
4125
|
+
principalId: safeId(options.principalId, "neutral_host_principal_invalid"),
|
|
4126
|
+
hostInstallationId: safeId(options.hostInstallationId, "neutral_host_installation_invalid"),
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
function normalizeEndpoints(value, tuple) {
|
|
4131
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 64) throw failure("neutral_host_endpoints_invalid");
|
|
4132
|
+
const map = new Map();
|
|
4133
|
+
for (const item of value) {
|
|
4134
|
+
const delegateId = safeId(item?.delegateId, "neutral_host_delegate_invalid");
|
|
4135
|
+
if (item?.principalId !== tuple.principalId || item?.hostInstallationId !== tuple.hostInstallationId) {
|
|
4136
|
+
throw failure("neutral_host_endpoint_owner_mismatch");
|
|
4137
|
+
}
|
|
4138
|
+
if (!item.e2ee || typeof item.e2ee.openIncoming !== "function") throw failure("neutral_host_endpoint_e2ee_invalid");
|
|
4139
|
+
if (!item.contactE2ee || typeof item.contactE2ee.openIncoming !== "function") {
|
|
4140
|
+
throw failure("neutral_host_endpoint_contact_e2ee_invalid");
|
|
4141
|
+
}
|
|
4142
|
+
if (map.has(delegateId)) throw failure("neutral_host_endpoint_duplicate");
|
|
4143
|
+
map.set(delegateId, Object.freeze({ ...item, delegateId }));
|
|
4144
|
+
}
|
|
4145
|
+
return map;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
function workbenchSource(adapterId) {
|
|
4149
|
+
const id = safeId(adapterId, "neutral_host_workbench_adapter_invalid");
|
|
4150
|
+
const clients = {
|
|
4151
|
+
codex: ["mcp-app-rpc", "Codex"],
|
|
4152
|
+
cursor: ["cursor-webview-rpc", "Cursor"],
|
|
4153
|
+
openclaw: ["loopback-http", "OpenClaw"],
|
|
4154
|
+
"trae-work": ["loopback-http", "TRAE Work"],
|
|
4155
|
+
workbuddy: ["loopback-http", "WorkBuddy"],
|
|
4156
|
+
};
|
|
4157
|
+
const [transport, clientLabel] = clients[id] || ["loopback-http", id];
|
|
4158
|
+
return Object.freeze({
|
|
4159
|
+
mode: "neutral-host",
|
|
4160
|
+
transport,
|
|
4161
|
+
clientId: id,
|
|
4162
|
+
clientLabel,
|
|
4163
|
+
});
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
function workbenchSuccess(requestId, snapshot, result = {}) {
|
|
4167
|
+
return {
|
|
4168
|
+
contractId: HELLOLYNK_WORKBENCH_RESULT_CONTRACT,
|
|
4169
|
+
schemaVersion: HELLOLYNK_WORKBENCH_SCHEMA_VERSION,
|
|
4170
|
+
ok: true,
|
|
4171
|
+
requestId,
|
|
4172
|
+
revision: snapshot.revision,
|
|
4173
|
+
changeCursor: snapshot.changeCursor,
|
|
4174
|
+
result,
|
|
4175
|
+
};
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
function workbenchFailure(requestId, code, snapshot, outcome) {
|
|
4179
|
+
const known = outcome === "known-failed";
|
|
4180
|
+
return {
|
|
4181
|
+
contractId: HELLOLYNK_WORKBENCH_RESULT_CONTRACT,
|
|
4182
|
+
schemaVersion: HELLOLYNK_WORKBENCH_SCHEMA_VERSION,
|
|
4183
|
+
ok: false,
|
|
4184
|
+
requestId,
|
|
4185
|
+
revision: known ? snapshot?.revision ?? null : null,
|
|
4186
|
+
changeCursor: known ? snapshot?.changeCursor ?? null : null,
|
|
4187
|
+
error: {
|
|
4188
|
+
code,
|
|
4189
|
+
retryable: false,
|
|
4190
|
+
outcome,
|
|
4191
|
+
retryAfterMs: null,
|
|
4192
|
+
currentRevision: known ? snapshot?.revision ?? null : null,
|
|
4193
|
+
},
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
function knownWorkbenchCommandFailure(error) {
|
|
4198
|
+
const code = String(error?.code || "");
|
|
4199
|
+
if (new Set([
|
|
4200
|
+
"human_relationship_decision_outcome_unknown",
|
|
4201
|
+
"human_relationship_cancel_outcome_unknown",
|
|
4202
|
+
"human_relationship_cancel_response_invalid",
|
|
4203
|
+
"human_relationship_relationship_end_outcome_unknown",
|
|
4204
|
+
"human_relationship_relationship_end_response_invalid",
|
|
4205
|
+
"human_relationship_block_remove_outcome_unknown",
|
|
4206
|
+
"human_relationship_block_remove_response_invalid",
|
|
4207
|
+
// The cloud write has already succeeded before these local/invariant
|
|
4208
|
+
// checks fail. They cannot prove rejection or permit another submission.
|
|
4209
|
+
"human_relationship_local_authorization_revoke_failed",
|
|
4210
|
+
"host_app_relationship_history_invariant_failed",
|
|
4211
|
+
"host_app_block_remove_invariant_failed",
|
|
4212
|
+
]).has(code)) return null;
|
|
4213
|
+
if (code === "human_relationship_cancel_known_failed") {
|
|
4214
|
+
return "host_app_contact_request_cancel_failed";
|
|
4215
|
+
}
|
|
4216
|
+
if (code === "human_relationship_relationship_end_known_failed") {
|
|
4217
|
+
return "host_app_relationship_end_failed";
|
|
4218
|
+
}
|
|
4219
|
+
if (code === "human_relationship_block_remove_known_failed") {
|
|
4220
|
+
return "host_app_block_remove_failed";
|
|
4221
|
+
}
|
|
4222
|
+
if (code === "human_relationship_decision_known_failed") {
|
|
4223
|
+
return "host_app_contact_request_decision_failed";
|
|
4224
|
+
}
|
|
4225
|
+
if (code === "human_relationship_decision_not_applied") {
|
|
4226
|
+
return "host_app_contact_request_decision_not_applied";
|
|
4227
|
+
}
|
|
4228
|
+
if (code === "human_relationship_target_expired") return "host_app_contact_target_expired";
|
|
4229
|
+
if (code === "human_relationship_retry_input_conflict") return "host_app_contact_request_input_conflict";
|
|
4230
|
+
if (code === "contact_identity_mismatch") return "host_app_authorization_required";
|
|
4231
|
+
if (["local_history_unread_protected", "local_history_outgoing_protected", "local_history_task_protected"].includes(code)) return `host_app_${code}`;
|
|
4232
|
+
if (code === "local_history_record_not_found") return "host_app_record_not_found";
|
|
4233
|
+
if (["local_history_scope_invalid", "local_history_thread_invalid", "local_history_message_invalid"].includes(code)) {
|
|
4234
|
+
return "host_app_input_invalid";
|
|
4235
|
+
}
|
|
4236
|
+
if (["contact_not_found", "contact_instance_not_found"].includes(code)) return "host_app_record_not_found";
|
|
4237
|
+
if (/^contact_(?:authorization|delegate)/.test(code)) return "host_app_input_invalid";
|
|
4238
|
+
if (/^contact_request_intro_[a-z0-9_]{1,80}$/.test(code)) return code;
|
|
4239
|
+
if (error?.httpStatus === 409 && KNOWN_CONTACT_REQUEST_SEND_FAILURE_CODES.has(code)) return code;
|
|
4240
|
+
if (/^human_relationship_[a-z0-9_]{1,100}$/.test(code)) return "host_app_contact_request_failed";
|
|
4241
|
+
if (/^host_app_[a-z0-9_]{1,100}$/.test(code)) return code;
|
|
4242
|
+
if (/^human_confirmation_[a-z0-9_]{1,100}$/.test(code)) return code;
|
|
4243
|
+
return null;
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
function validateDiscovery(record) {
|
|
4247
|
+
if (!record || record.contractId !== HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT
|
|
4248
|
+
|| record.host !== LOOPBACK_HOST || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535
|
|
4249
|
+
|| !/^[a-f0-9]{48}$/.test(String(record.hostGeneration || ""))
|
|
4250
|
+
|| !/^[a-f0-9]{64}$/.test(String(record.tupleDigest || ""))) {
|
|
4251
|
+
throw failure("neutral_host_discovery_invalid");
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
function digestTuple(tuple) {
|
|
4256
|
+
return crypto.createHash("sha256")
|
|
4257
|
+
.update(`${tuple.region}\0${tuple.principalId}\0${tuple.hostInstallationId}`)
|
|
4258
|
+
.digest("hex");
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
function requireInterface(value, methods, code) {
|
|
4262
|
+
if (!value || methods.some((method) => typeof value[method] !== "function")) throw failure(code);
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
function trackOperation(promise, set) {
|
|
4266
|
+
const tracked = Promise.resolve(promise);
|
|
4267
|
+
set.add(tracked);
|
|
4268
|
+
tracked.finally(() => set.delete(tracked)).catch(() => {});
|
|
4269
|
+
return tracked;
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
async function waitForOperations(set, timeoutMs) {
|
|
4273
|
+
const deadline = Date.now() + timeoutMs;
|
|
4274
|
+
while (set.size > 0) {
|
|
4275
|
+
const remaining = deadline - Date.now();
|
|
4276
|
+
if (remaining <= 0) throw failure("neutral_host_drain_timeout");
|
|
4277
|
+
await Promise.race([Promise.allSettled([...set]), new Promise((resolve) => setTimeout(resolve, Math.min(remaining, 25)))]);
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4281
|
+
function listenLoopback(server, port) {
|
|
4282
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw failure("neutral_host_port_invalid");
|
|
4283
|
+
return new Promise((resolve, reject) => {
|
|
4284
|
+
server.once("error", reject);
|
|
4285
|
+
server.listen(port, LOOPBACK_HOST, () => { server.off("error", reject); resolve(); });
|
|
4286
|
+
});
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
function closeServer(server) {
|
|
4290
|
+
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
function readBody(request, maximum) {
|
|
4294
|
+
return new Promise((resolve, reject) => {
|
|
4295
|
+
const chunks = [];
|
|
4296
|
+
let size = 0;
|
|
4297
|
+
request.on("data", (chunk) => {
|
|
4298
|
+
size += chunk.length;
|
|
4299
|
+
if (size > maximum) { reject(failure("neutral_host_request_too_large")); request.destroy(); return; }
|
|
4300
|
+
chunks.push(chunk);
|
|
4301
|
+
});
|
|
4302
|
+
request.on("end", () => resolve(Buffer.concat(chunks)));
|
|
4303
|
+
request.on("error", reject);
|
|
4304
|
+
});
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
function writeJson(response, status, body) {
|
|
4308
|
+
const raw = Buffer.from(JSON.stringify(body));
|
|
4309
|
+
response.writeHead(status, { "content-type": "application/json", "content-length": raw.length, "cache-control": "no-store" });
|
|
4310
|
+
response.end(raw);
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
function isLoopbackAddress(value) {
|
|
4314
|
+
return value === "127.0.0.1" || value === "::1" || value === "::ffff:127.0.0.1";
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
function assertNoSensitiveEventFields(value) {
|
|
4318
|
+
for (const key of Object.keys(value)) {
|
|
4319
|
+
if (/text|body|content|secret|token|credential|envelope|prekey/i.test(key)) throw failure("neutral_host_change_event_sensitive");
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
|
|
4323
|
+
function isInboundLocalCapacityError(error) {
|
|
4324
|
+
return INBOUND_LOCAL_CAPACITY_CODES.has(String(error?.code || ""));
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4327
|
+
function safeId(value, code) {
|
|
4328
|
+
const normalized = String(value || "");
|
|
4329
|
+
if (!SAFE_ID.test(normalized)) throw failure(code);
|
|
4330
|
+
return normalized;
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
function assertPlainRecord(value, code) {
|
|
4334
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw failure(code);
|
|
4335
|
+
const prototype = Object.getPrototypeOf(value);
|
|
4336
|
+
if (prototype !== Object.prototype && prototype !== null) throw failure(code);
|
|
4337
|
+
return value;
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4340
|
+
function integerInRange(value, fallback, minimum, maximum) {
|
|
4341
|
+
const normalized = Number(value ?? fallback);
|
|
4342
|
+
if (!Number.isSafeInteger(normalized) || normalized < minimum || normalized > maximum) throw failure("neutral_host_limit_invalid");
|
|
4343
|
+
return normalized;
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
function nowMs(clock) {
|
|
4347
|
+
return typeof clock === "function" ? clock() : typeof clock?.now === "function" ? clock.now() : Date.now();
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
function retryAtTimestamp(value) {
|
|
4351
|
+
if (value == null || value === "") return null;
|
|
4352
|
+
const parsed = Date.parse(String(value));
|
|
4353
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
function safeCode(error, fallback) {
|
|
4357
|
+
const code = String(error?.code || fallback);
|
|
4358
|
+
return /^[a-z][a-z0-9_]{1,127}$/.test(code) ? code : fallback;
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
function isTransientWorkbenchCloudFailure(error) {
|
|
4362
|
+
return new Set(["host_transport_request_timeout", "host_transport_request_failed"]).has(error?.code)
|
|
4363
|
+
|| (Number.isInteger(error?.httpStatus) && error.httpStatus >= 500 && error.httpStatus <= 599);
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4366
|
+
function isMissingLegacyLifecycleRoute(error) {
|
|
4367
|
+
return error?.code === "host_transport_http_error" && error?.httpStatus === 404;
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
function failure(code, cause) {
|
|
4371
|
+
const error = new Error(code, cause ? { cause } : undefined);
|
|
4372
|
+
error.code = code;
|
|
4373
|
+
return error;
|
|
4374
|
+
}
|