@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,649 @@
|
|
|
1
|
+
import { queryArgs, optionalTarget } from "../runtime/core/hellolynk-native-addressing-tools.cjs";
|
|
2
|
+
import { captureHelloLynkModelNotices } from "../runtime/core/hellolynk-model-notices.cjs";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadCodexWorkbenchContract } from "./workbench-runtime.mjs";
|
|
7
|
+
|
|
8
|
+
export const HELLOLYNK_CODEX_NEUTRAL_ADAPTER_CONTRACT = "hellolynk-codex-neutral-adapter-v3";
|
|
9
|
+
export const HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT = "hellolynk-neutral-host-discovery-v1";
|
|
10
|
+
|
|
11
|
+
const FROZEN_WORKBENCH_ACTION_GRANT_PATH = "/host/v1/app/workbench/action-grants";
|
|
12
|
+
const FROZEN_WORKBENCH_COMMAND_PATH = "/host/v1/app/workbench/commands";
|
|
13
|
+
|
|
14
|
+
export const NEUTRAL_HOST_PATHS = Object.freeze({
|
|
15
|
+
health: "/host/v1/health",
|
|
16
|
+
attach: "/host/v1/attachments",
|
|
17
|
+
heartbeat: "/host/v1/attachments/heartbeat",
|
|
18
|
+
detach: "/host/v1/attachments/detach",
|
|
19
|
+
unread: "/host/v1/app/inbox/unread-count",
|
|
20
|
+
authorizedContacts: "/host/v1/app/authorized-contacts",
|
|
21
|
+
authorizedInboxMessages: "/host/v1/app/authorized-inbox/messages",
|
|
22
|
+
authorizedInboxRead: "/host/v1/app/authorized-inbox/read",
|
|
23
|
+
authorizedInboxActivate: "/host/v1/app/authorized-inbox/activate",
|
|
24
|
+
authorizedInboxPending: "/host/v1/app/authorized-inbox/pending",
|
|
25
|
+
authorizedInboxClaim: "/host/v1/app/authorized-inbox/claim",
|
|
26
|
+
authorizedInboxBatch: "/host/v1/app/authorized-inbox/batch/",
|
|
27
|
+
authorizedContactRequests: "/host/v1/app/authorized-contact-requests",
|
|
28
|
+
authorizedContactRequestRead: "/host/v1/app/authorized-contact-requests/read",
|
|
29
|
+
authorizedSend: "/host/v1/app/authorized-actions/send-text",
|
|
30
|
+
authorizedReply: "/host/v1/app/authorized-actions/reply-text",
|
|
31
|
+
workbenchSnapshot: "/host/v1/app/workbench/snapshot",
|
|
32
|
+
workbenchChanges: "/host/v1/app/changes/subscribe",
|
|
33
|
+
appWorkbenchActionGrant: FROZEN_WORKBENCH_ACTION_GRANT_PATH,
|
|
34
|
+
appWorkbenchCommand: FROZEN_WORKBENCH_COMMAND_PATH,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const CODEX_NEUTRAL_EXACT_SCOPES = Object.freeze([
|
|
38
|
+
"app-data:authorized-messaging",
|
|
39
|
+
"app-data:changes-read",
|
|
40
|
+
"app-data:contact-requests-read",
|
|
41
|
+
"app-data:contacts-manage",
|
|
42
|
+
"app-data:contacts-read",
|
|
43
|
+
"app-data:human-send-request",
|
|
44
|
+
"app-data:inbox-manage",
|
|
45
|
+
"app-data:inbox-read",
|
|
46
|
+
"app-data:outbox-read",
|
|
47
|
+
"app-data:outbox-retry",
|
|
48
|
+
"attachments:manage",
|
|
49
|
+
"surface:human-ui",
|
|
50
|
+
"ui:launch",
|
|
51
|
+
].sort());
|
|
52
|
+
|
|
53
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,198}$/;
|
|
54
|
+
// Another client, or a newer release, is starting the Host: wait for it instead of failing.
|
|
55
|
+
const LAUNCH_YIELD_ERRORS = new Set(["neutral_host_already_running", "neutral_host_release_superseded"]);
|
|
56
|
+
// A release handover is on hold: wait for the newer Host like already_running, report "switching" only if none appears.
|
|
57
|
+
const RELEASE_SUPERSEDED = "neutral_host_release_superseded";
|
|
58
|
+
const TERMINAL_HOST_ERRORS = new Set([
|
|
59
|
+
"neutral_host_discovery_generation_mismatch", "neutral_host_discovery_stale",
|
|
60
|
+
"neutral_host_not_found", "host_not_running", "host_request_unauthorized",
|
|
61
|
+
"host_attachment_not_found", "host_attachment_expired", "ECONNREFUSED",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export function isCodexNeutralHostUnavailable(error) {
|
|
65
|
+
return TERMINAL_HOST_ERRORS.has(error?.code) || TERMINAL_HOST_ERRORS.has(error?.cause?.code);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveHelloLynkHostMode(env = process.env) {
|
|
69
|
+
const value = String(env.HELLOLYNK_HOST_MODE || "neutral");
|
|
70
|
+
if (value === "legacy") throw codeError("hellolynk_host_mode_legacy_unsupported");
|
|
71
|
+
if (value !== "neutral") throw codeError("hellolynk_host_mode_invalid");
|
|
72
|
+
return "neutral";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function createCodexNeutralHostAdapter(options = {}) {
|
|
76
|
+
const provider = options.capabilityProvider;
|
|
77
|
+
if (typeof provider !== "function") throw codeError("neutral_capability_provider_required");
|
|
78
|
+
const supplied = await provider();
|
|
79
|
+
const binding = validateProtectedBinding(supplied);
|
|
80
|
+
const discover = options.discoverHost || discoverNeutralHost;
|
|
81
|
+
const request = options.requestHost || requestNeutralHost;
|
|
82
|
+
// The Host module ships beside this adapter and loads once a running Host has been found.
|
|
83
|
+
const requestHandover = options.requestHostHandover
|
|
84
|
+
|| (async (input) => (await import("../runtime/core/hellolynk-neutral-host-process.mjs")).requestHelloLynkNeutralHostHandover(input));
|
|
85
|
+
const discoverInput = {
|
|
86
|
+
runtimeRoot: binding.runtimeRoot,
|
|
87
|
+
region: binding.region,
|
|
88
|
+
principalId: binding.principalId,
|
|
89
|
+
hostInstallationId: binding.hostInstallationId,
|
|
90
|
+
fetchImpl: options.fetchImpl,
|
|
91
|
+
};
|
|
92
|
+
let discovery = await discoverAvailableHost(discover, discoverInput);
|
|
93
|
+
// A Host of an earlier release is asked to make room, and this client's release starts in its place.
|
|
94
|
+
if (discovery && typeof options.hostLauncher === "function"
|
|
95
|
+
&& await earlierHostLeft(requestHandover, binding, integer(options.handoverWaitMs, 5_000, 0, 30_000))) {
|
|
96
|
+
discovery = await discoverAvailableHost(discover, discoverInput);
|
|
97
|
+
}
|
|
98
|
+
if (!discovery) {
|
|
99
|
+
if (typeof options.hostLauncher !== "function") {
|
|
100
|
+
throw codeError("neutral_host_launcher_unavailable");
|
|
101
|
+
}
|
|
102
|
+
// Another client may have started the shared Host a moment ago: wait for it instead of failing.
|
|
103
|
+
let superseded = false;
|
|
104
|
+
try { await options.hostLauncher(publicBinding(binding)); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!LAUNCH_YIELD_ERRORS.has(error?.code)) throw error;
|
|
107
|
+
superseded = error.code === RELEASE_SUPERSEDED;
|
|
108
|
+
}
|
|
109
|
+
discovery = await waitForDiscovery(discover, binding, options);
|
|
110
|
+
if (!discovery && superseded) throw codeError(RELEASE_SUPERSEDED);
|
|
111
|
+
}
|
|
112
|
+
if (!discovery) throw codeError("neutral_host_not_found");
|
|
113
|
+
assertDiscoveryBinding(discovery, binding);
|
|
114
|
+
|
|
115
|
+
const adapterInstanceId = safeId(
|
|
116
|
+
options.adapterInstanceId || `codex-${process.pid}-${crypto.randomBytes(12).toString("hex")}`,
|
|
117
|
+
"neutral_adapter_instance_invalid",
|
|
118
|
+
);
|
|
119
|
+
const callRaw = async (method, pathname, body = null, requestOptions = {}) => captureHelloLynkModelNotices(await request({
|
|
120
|
+
discovery,
|
|
121
|
+
capability: binding.capability,
|
|
122
|
+
method,
|
|
123
|
+
pathname,
|
|
124
|
+
body,
|
|
125
|
+
fetchImpl: options.fetchImpl,
|
|
126
|
+
...requestOptions,
|
|
127
|
+
}));
|
|
128
|
+
const attached = requireOk(await callRaw("POST", NEUTRAL_HOST_PATHS.attach, { adapterInstanceId }));
|
|
129
|
+
|
|
130
|
+
let closed = false;
|
|
131
|
+
let deadCode = null;
|
|
132
|
+
let inboxActivation = null;
|
|
133
|
+
let leaseExpiresAt = Number(attached.leaseExpiresAt) || Date.now() + 30_000;
|
|
134
|
+
let heartbeatBusy = false;
|
|
135
|
+
const markDead = (error) => {
|
|
136
|
+
deadCode ||= String(error?.code || error?.cause?.code || "host_attachment_expired");
|
|
137
|
+
clearInterval(heartbeat);
|
|
138
|
+
};
|
|
139
|
+
const call = async (method, pathname, body = null, requestOptions = {}) => {
|
|
140
|
+
if (closed) throw codeError("neutral_adapter_closed");
|
|
141
|
+
if (deadCode) throw codeError(deadCode);
|
|
142
|
+
try {
|
|
143
|
+
const result = await callRaw(method, pathname, body, requestOptions);
|
|
144
|
+
if (isCodexNeutralHostUnavailable({ code: result?.body?.error })) {
|
|
145
|
+
throw codeError(result.body.error);
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (isCodexNeutralHostUnavailable(error)) markDead(error);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
// A Host without the native inbox routes is of an earlier release: ask it to make room, once; the error is unchanged.
|
|
154
|
+
let handoverAsked = false;
|
|
155
|
+
async function callNativeInbox(method, pathname, body) {
|
|
156
|
+
const result = await call(method, pathname, body);
|
|
157
|
+
if (isMissingNativeInboxRoute(result) && !handoverAsked) {
|
|
158
|
+
handoverAsked = true;
|
|
159
|
+
try {
|
|
160
|
+
await requestHandover({ runtimeRoot: binding.runtimeRoot, hostInstallationId: binding.hostInstallationId,
|
|
161
|
+
publishedAt: new Date().toISOString(), waitMs: 0 });
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
const heartbeatMs = integer(options.heartbeatMs, 10_000, 100, 30_000);
|
|
167
|
+
const heartbeat = setInterval(async () => {
|
|
168
|
+
if (closed || deadCode || heartbeatBusy) return;
|
|
169
|
+
heartbeatBusy = true;
|
|
170
|
+
try {
|
|
171
|
+
const result = requireOk(await call("POST", NEUTRAL_HOST_PATHS.heartbeat, { adapterInstanceId }));
|
|
172
|
+
leaseExpiresAt = Number(result.leaseExpiresAt) || Date.now() + 30_000;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (isCodexNeutralHostUnavailable(error)) markDead(error);
|
|
175
|
+
else if (Date.now() >= leaseExpiresAt) markDead(codeError("host_attachment_expired"));
|
|
176
|
+
} finally {
|
|
177
|
+
heartbeatBusy = false;
|
|
178
|
+
}
|
|
179
|
+
}, heartbeatMs);
|
|
180
|
+
heartbeat.unref?.();
|
|
181
|
+
|
|
182
|
+
async function close() {
|
|
183
|
+
if (closed) return;
|
|
184
|
+
closed = true;
|
|
185
|
+
clearInterval(heartbeat);
|
|
186
|
+
try {
|
|
187
|
+
if (!deadCode) await callRaw("POST", NEUTRAL_HOST_PATHS.detach, { adapterInstanceId }).catch(() => {});
|
|
188
|
+
} finally { binding.capability.secretBuffer.fill(0); }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
mode: "neutral",
|
|
193
|
+
get state() { return closed ? "closed" : deadCode ? "dead" : "attached"; },
|
|
194
|
+
binding: publicBinding(binding),
|
|
195
|
+
nativeTaskScope() { return Object.freeze({
|
|
196
|
+
runtimeRoot: binding.runtimeRoot, region: binding.region,
|
|
197
|
+
principalId: binding.principalId, delegateId: binding.delegateId, adapterId: binding.adapterId,
|
|
198
|
+
}); },
|
|
199
|
+
discovery: publicDiscovery(discovery),
|
|
200
|
+
adapterInstanceId,
|
|
201
|
+
async modelInbox(input) { return requireOk(await call("POST", "/host/v1/app/model-inbox", input)); },
|
|
202
|
+
async readUnreadCount() {
|
|
203
|
+
const result = requireOk(await call("GET", NEUTRAL_HOST_PATHS.unread));
|
|
204
|
+
const unreadCount = result.unreadCount;
|
|
205
|
+
if (!Number.isSafeInteger(unreadCount) || unreadCount < 0) {
|
|
206
|
+
throw codeError("neutral_unread_count_invalid");
|
|
207
|
+
}
|
|
208
|
+
return Object.freeze({ unreadCount });
|
|
209
|
+
},
|
|
210
|
+
async readWorkbenchSnapshot(input = {}) {
|
|
211
|
+
const result = await requireOk(await call("POST", NEUTRAL_HOST_PATHS.workbenchSnapshot, input));
|
|
212
|
+
return result.snapshot;
|
|
213
|
+
},
|
|
214
|
+
async readWorkbenchChanges({ afterCursor = 0 } = {}) {
|
|
215
|
+
if (!Number.isSafeInteger(afterCursor) || afterCursor < 0) throw codeError("host_app_change_cursor_invalid");
|
|
216
|
+
const result = requireOk(await call("POST", NEUTRAL_HOST_PATHS.workbenchChanges, { afterCursor }));
|
|
217
|
+
return { ...result, hostGeneration: discovery.hostGeneration };
|
|
218
|
+
},
|
|
219
|
+
async issueWorkbenchActionGrant(command) {
|
|
220
|
+
const { validateWorkbenchCommand } = await loadCodexWorkbenchContract();
|
|
221
|
+
const request = validateWorkbenchCommand(command);
|
|
222
|
+
return requireWorkbenchActionGrant(await call("POST", NEUTRAL_HOST_PATHS.appWorkbenchActionGrant, {
|
|
223
|
+
adapterInstanceId,
|
|
224
|
+
request,
|
|
225
|
+
}));
|
|
226
|
+
},
|
|
227
|
+
async executeWorkbenchCommand(command, context) {
|
|
228
|
+
const { validateWorkbenchCommand, validateWorkbenchCommandResult } = await loadCodexWorkbenchContract();
|
|
229
|
+
const request = validateWorkbenchCommand(command);
|
|
230
|
+
const statusRead = request.command === "message.send-status";
|
|
231
|
+
const actionGrant = statusRead ? noWorkbenchActionGrantContext(context) : workbenchActionGrantContext(context);
|
|
232
|
+
const result = requireWorkbenchCommandResult(await call("POST", NEUTRAL_HOST_PATHS.appWorkbenchCommand, {
|
|
233
|
+
adapterInstanceId,
|
|
234
|
+
request,
|
|
235
|
+
...(statusRead ? {} : { actionGrant }),
|
|
236
|
+
}));
|
|
237
|
+
return validateWorkbenchCommandResult(result, request.command);
|
|
238
|
+
},
|
|
239
|
+
async listAuthorizedContacts(input = {}) {
|
|
240
|
+
const query = queryArgs(input);
|
|
241
|
+
const result = await requireOk(await call(Object.keys(query).length ? "POST" : "GET", NEUTRAL_HOST_PATHS.authorizedContacts, Object.keys(query).length ? query : undefined, { timeoutMs: 25_000 }));
|
|
242
|
+
return Object.freeze({ ...(result.nativeConversations ? { nativeConversations: result.nativeConversations } : {}), contacts: Object.freeze([...(result.contacts || [])]) });
|
|
243
|
+
},
|
|
244
|
+
async listAuthorizedInboxMessages({ before } = {}) {
|
|
245
|
+
const result = await requireOk(before === undefined ? await call("GET", NEUTRAL_HOST_PATHS.authorizedInboxMessages)
|
|
246
|
+
: await call("POST", NEUTRAL_HOST_PATHS.authorizedInboxMessages, { before: safeId(before, "neutral_authorized_inbox_message_id_invalid") }));
|
|
247
|
+
return Object.freeze({ messages: Object.freeze([...(result.messages || [])]) });
|
|
248
|
+
},
|
|
249
|
+
async readAuthorizedInboxMessage(localMessageId, conversationKey) {
|
|
250
|
+
const result = await requireOk(await call("POST", NEUTRAL_HOST_PATHS.authorizedInboxRead, {
|
|
251
|
+
localMessageId: safeId(localMessageId, "neutral_authorized_inbox_message_id_invalid"),
|
|
252
|
+
conversationKey: safeId(conversationKey, "neutral_authorized_inbox_conversation_key_invalid"),
|
|
253
|
+
}));
|
|
254
|
+
return result.message;
|
|
255
|
+
},
|
|
256
|
+
async listPendingMessages(target = null) {
|
|
257
|
+
// The durable reader boundary belongs to this fixed identity/connection.
|
|
258
|
+
// Pending GETs still validate the current Host capability on every call.
|
|
259
|
+
inboxActivation ??= call("POST", NEUTRAL_HOST_PATHS.authorizedInboxActivate)
|
|
260
|
+
.then(response => {
|
|
261
|
+
if (requireOk(response).active !== true) throw codeError("neutral_inbox_activation_invalid");
|
|
262
|
+
})
|
|
263
|
+
.catch(error => { inboxActivation = null; throw error; });
|
|
264
|
+
await inboxActivation;
|
|
265
|
+
if (target?.cursor != null && (!Number.isSafeInteger(target.cursor) || target.cursor < 0)) throw codeError("neutral_pending_cursor_invalid");
|
|
266
|
+
const body = target == null ? null : target.cursor != null ? { cursor: target.cursor } : {
|
|
267
|
+
localMessageId: safeId(target.localMessageId, "neutral_authorized_inbox_message_id_invalid"),
|
|
268
|
+
conversationKey: safeId(target.conversationKey, "neutral_authorized_inbox_conversation_key_invalid"),
|
|
269
|
+
};
|
|
270
|
+
const result = requireNativeInboxOk(await callNativeInbox(body ? "POST" : "GET", NEUTRAL_HOST_PATHS.authorizedInboxPending, body));
|
|
271
|
+
if (!Array.isArray(result.messages)) throw codeError("neutral_pending_response_invalid");
|
|
272
|
+
return { messages: result.messages, ...(result.nextCursor == null ? {} : { nextCursor: result.nextCursor }) };
|
|
273
|
+
},
|
|
274
|
+
async claimMessage(localMessageId, conversationKey) {
|
|
275
|
+
return requireNativeInboxOk(await callNativeInbox("POST", NEUTRAL_HOST_PATHS.authorizedInboxClaim, {
|
|
276
|
+
localMessageId: safeId(localMessageId, "neutral_authorized_inbox_message_id_invalid"),
|
|
277
|
+
conversationKey: safeId(conversationKey, "neutral_authorized_inbox_conversation_key_invalid"),
|
|
278
|
+
}));
|
|
279
|
+
},
|
|
280
|
+
async claimBatch(batchId) {
|
|
281
|
+
return requireNativeInboxOk(await callNativeInbox("POST", NEUTRAL_HOST_PATHS.authorizedInboxClaim,
|
|
282
|
+
{ batchId: safeId(batchId, "neutral_inbox_batch_id_invalid") }));
|
|
283
|
+
},
|
|
284
|
+
async inboxBatch(operation, input = {}) {
|
|
285
|
+
if (!["reserve", "open", "attempt", "confirm", "cancel", "expire", "closed"].includes(operation))
|
|
286
|
+
throw codeError("neutral_inbox_batch_operation_invalid");
|
|
287
|
+
return requireNativeInboxOk(await callNativeInbox("POST", NEUTRAL_HOST_PATHS.authorizedInboxBatch + operation, input));
|
|
288
|
+
},
|
|
289
|
+
async listAuthorizedContactRequests() {
|
|
290
|
+
const result = await requireOk(await call("GET", NEUTRAL_HOST_PATHS.authorizedContactRequests));
|
|
291
|
+
return Object.freeze({ requests: Object.freeze([...(result.requests || [])]) });
|
|
292
|
+
},
|
|
293
|
+
async readAuthorizedContactRequest(localRecordId, conversationKey) {
|
|
294
|
+
const result = await requireOk(await call("POST", NEUTRAL_HOST_PATHS.authorizedContactRequestRead, {
|
|
295
|
+
localRecordId: safeId(localRecordId, "neutral_authorized_contact_request_id_invalid"),
|
|
296
|
+
conversationKey: safeId(conversationKey, "neutral_authorized_contact_request_conversation_key_invalid"),
|
|
297
|
+
}));
|
|
298
|
+
return result.request;
|
|
299
|
+
},
|
|
300
|
+
async sendAuthorizedMessage(input) {
|
|
301
|
+
return requireOk(await call("POST", NEUTRAL_HOST_PATHS.authorizedSend, authorizedSendInput(input)));
|
|
302
|
+
},
|
|
303
|
+
async replyAuthorizedMessage(input) {
|
|
304
|
+
if (!input || typeof input !== "object" || Array.isArray(input)
|
|
305
|
+
|| Object.keys(input).sort().join("|") !== "clientMessageId|conversationKey|localMessageId|text") {
|
|
306
|
+
throw codeError("authorized_reply_input_invalid");
|
|
307
|
+
}
|
|
308
|
+
const text = String(input.text ?? "");
|
|
309
|
+
if (!text || text.includes("\u0000") || Buffer.byteLength(text, "utf8") > 20 * 1024) throw codeError("message_text_invalid");
|
|
310
|
+
return requireOk(await call("POST", NEUTRAL_HOST_PATHS.authorizedReply, {
|
|
311
|
+
localMessageId: safeId(input.localMessageId, "neutral_authorized_inbox_message_id_invalid"),
|
|
312
|
+
conversationKey: safeId(input.conversationKey, "neutral_authorized_inbox_conversation_key_invalid"),
|
|
313
|
+
clientMessageId: safeId(input.clientMessageId, "neutral_client_message_id_invalid"), text,
|
|
314
|
+
}));
|
|
315
|
+
},
|
|
316
|
+
close,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function validateProtectedBinding(value) {
|
|
321
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
322
|
+
throw codeError("neutral_capability_binding_invalid");
|
|
323
|
+
}
|
|
324
|
+
const capability = value.capability;
|
|
325
|
+
if (!capability || typeof capability !== "object" || Array.isArray(capability)) {
|
|
326
|
+
throw codeError("neutral_capability_invalid");
|
|
327
|
+
}
|
|
328
|
+
const region = safeId(value.region, "neutral_region_invalid");
|
|
329
|
+
const principalId = safeId(value.principalId, "neutral_principal_invalid");
|
|
330
|
+
const delegateId = safeId(value.delegateId, "neutral_delegate_invalid");
|
|
331
|
+
const hostInstallationId = safeId(value.hostInstallationId, "neutral_host_installation_invalid");
|
|
332
|
+
const adapterId = safeId(value.adapterId, "neutral_adapter_invalid");
|
|
333
|
+
if (adapterId !== "codex") throw codeError("neutral_adapter_mismatch");
|
|
334
|
+
if (!value.runtimeRoot || !path.isAbsolute(String(value.runtimeRoot))) {
|
|
335
|
+
throw codeError("neutral_runtime_root_invalid");
|
|
336
|
+
}
|
|
337
|
+
if (capability.principalId !== principalId || capability.delegateId !== delegateId
|
|
338
|
+
|| capability.hostInstallationId !== hostInstallationId || capability.adapterId !== adapterId) {
|
|
339
|
+
throw codeError("neutral_capability_owner_mismatch");
|
|
340
|
+
}
|
|
341
|
+
const scopes = [...new Set((capability.scopes || []).map(String))].sort();
|
|
342
|
+
if (JSON.stringify(scopes) !== JSON.stringify(CODEX_NEUTRAL_EXACT_SCOPES)) {
|
|
343
|
+
throw codeError("neutral_capability_scopes_mismatch");
|
|
344
|
+
}
|
|
345
|
+
const secretBuffer = Buffer.isBuffer(capability.secretBuffer)
|
|
346
|
+
? Buffer.from(capability.secretBuffer)
|
|
347
|
+
: capability.secretBuffer instanceof Uint8Array ? Buffer.from(capability.secretBuffer) : null;
|
|
348
|
+
if (!secretBuffer || secretBuffer.length < 32 || secretBuffer.length > 128) {
|
|
349
|
+
throw codeError("neutral_capability_secret_invalid");
|
|
350
|
+
}
|
|
351
|
+
const version = Number(capability.version);
|
|
352
|
+
if (version !== 2) {
|
|
353
|
+
throw codeError("neutral_capability_version_mismatch");
|
|
354
|
+
}
|
|
355
|
+
if (capability.state !== "active") {
|
|
356
|
+
throw codeError("neutral_capability_inactive");
|
|
357
|
+
}
|
|
358
|
+
return Object.freeze({
|
|
359
|
+
region, principalId, delegateId, hostInstallationId, adapterId,
|
|
360
|
+
runtimeRoot: String(value.runtimeRoot || ""),
|
|
361
|
+
capability: Object.freeze({
|
|
362
|
+
contract: String(capability.contract || ""),
|
|
363
|
+
capabilityId: safeId(capability.capabilityId, "neutral_capability_id_invalid"),
|
|
364
|
+
adapterId, delegateId, principalId, hostInstallationId,
|
|
365
|
+
version, state: "active", scopes,
|
|
366
|
+
issuedAt: Number(capability.issuedAt),
|
|
367
|
+
revokedAt: capability.revokedAt ?? null,
|
|
368
|
+
region,
|
|
369
|
+
secretBuffer,
|
|
370
|
+
}),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function discoverNeutralHost(options = {}) {
|
|
375
|
+
if (typeof options.discoveryProvider === "function") {
|
|
376
|
+
return options.discoveryProvider(options);
|
|
377
|
+
}
|
|
378
|
+
const runtimeRoot = path.resolve(String(options.runtimeRoot || ""));
|
|
379
|
+
if (!options.runtimeRoot) throw codeError("neutral_runtime_root_required");
|
|
380
|
+
const key = sha256(String(options.hostInstallationId || ""));
|
|
381
|
+
const discoveryDirectory = path.join(runtimeRoot, "neutral-host");
|
|
382
|
+
const discoveryPath = path.join(discoveryDirectory, `${key}.json`);
|
|
383
|
+
let raw;
|
|
384
|
+
try {
|
|
385
|
+
await assertNoReparsePath(runtimeRoot);
|
|
386
|
+
await assertNoReparsePath(discoveryDirectory);
|
|
387
|
+
const stat = await fs.lstat(discoveryPath);
|
|
388
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) {
|
|
389
|
+
throw codeError("neutral_host_discovery_invalid");
|
|
390
|
+
}
|
|
391
|
+
raw = await fs.readFile(discoveryPath, "utf8");
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (error?.code === "ENOENT") return null;
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
let record;
|
|
397
|
+
try { record = JSON.parse(raw); } catch { throw codeError("neutral_host_discovery_invalid"); }
|
|
398
|
+
if (record.region !== options.region || record.principalId !== options.principalId
|
|
399
|
+
|| record.hostInstallationId !== options.hostInstallationId) {
|
|
400
|
+
throw codeError("neutral_host_discovery_owner_mismatch");
|
|
401
|
+
}
|
|
402
|
+
assertDiscoveryBinding(record, options);
|
|
403
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
404
|
+
const health = await fetchImpl(`http://${record.host}:${record.port}${NEUTRAL_HOST_PATHS.health}`, {
|
|
405
|
+
redirect: "error",
|
|
406
|
+
signal: AbortSignal.timeout(2_000),
|
|
407
|
+
}).catch(() => null);
|
|
408
|
+
if (!health || health.status !== 200) throw codeError("neutral_host_discovery_stale");
|
|
409
|
+
const body = await health.json().catch(() => null);
|
|
410
|
+
if (body?.hostGeneration !== record.hostGeneration || body?.status !== "running") {
|
|
411
|
+
throw codeError("neutral_host_discovery_generation_mismatch");
|
|
412
|
+
}
|
|
413
|
+
return Object.freeze(structuredClone(record));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function assertNoReparsePath(target) {
|
|
417
|
+
const resolved = path.resolve(target);
|
|
418
|
+
const parsed = path.parse(resolved);
|
|
419
|
+
const relative = path.relative(parsed.root, resolved);
|
|
420
|
+
let cursor = parsed.root;
|
|
421
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
422
|
+
cursor = path.join(cursor, segment);
|
|
423
|
+
const stat = await fs.lstat(cursor);
|
|
424
|
+
if (stat.isSymbolicLink()) throw codeError("neutral_host_discovery_unsafe_path");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function requestNeutralHost(options = {}) {
|
|
429
|
+
const fetchImpl = options.fetchImpl || fetch;
|
|
430
|
+
const discovery = options.discovery;
|
|
431
|
+
const capability = options.capability;
|
|
432
|
+
const method = String(options.method || "GET").toUpperCase();
|
|
433
|
+
const pathname = String(options.pathname || "");
|
|
434
|
+
// Workbench commands perform Host-owned validation and return the resulting
|
|
435
|
+
// snapshot. Contact writes can therefore wait behind two full Host reads.
|
|
436
|
+
// Authorized writes can also perform several durable E2EE steps before Host
|
|
437
|
+
// replies. Keep both below the MCP app's 60-second ceiling; ordinary RPC remains short.
|
|
438
|
+
const snapshotRead = method === "POST" && pathname === NEUTRAL_HOST_PATHS.workbenchSnapshot;
|
|
439
|
+
const actionGrant = method === "POST" && pathname === NEUTRAL_HOST_PATHS.appWorkbenchActionGrant;
|
|
440
|
+
const workbenchCommand = method === "POST" && pathname === NEUTRAL_HOST_PATHS.appWorkbenchCommand;
|
|
441
|
+
const authorizedSend = method === "POST" && pathname === NEUTRAL_HOST_PATHS.authorizedSend;
|
|
442
|
+
const authorizedReply = method === "POST" && pathname === NEUTRAL_HOST_PATHS.authorizedReply;
|
|
443
|
+
const authorizedWrite = authorizedSend || authorizedReply;
|
|
444
|
+
const nativeDirectoryQuery = method === "POST" && pathname === NEUTRAL_HOST_PATHS.authorizedContacts;
|
|
445
|
+
const principalSearch = workbenchCommand && options.body?.request?.command === "principal.search";
|
|
446
|
+
const rawBody = options.body == null ? Buffer.alloc(0) : Buffer.from(JSON.stringify(options.body));
|
|
447
|
+
const timestampMs = Date.now();
|
|
448
|
+
const nonce = crypto.randomBytes(24).toString("base64url");
|
|
449
|
+
const bodySha256 = sha256(rawBody);
|
|
450
|
+
const message = [
|
|
451
|
+
"hellolynk-host-request-v1", capability.capabilityId, capability.adapterId, method, pathname,
|
|
452
|
+
discovery.hostGeneration, String(timestampMs), nonce, bodySha256,
|
|
453
|
+
].join("\n");
|
|
454
|
+
const proof = crypto.createHmac("sha256", capability.secretBuffer).update(message, "utf8").digest("hex");
|
|
455
|
+
const signal = AbortSignal.timeout(workbenchCommand || authorizedWrite ? 55_000 : nativeDirectoryQuery ? 25_000 : snapshotRead || actionGrant ? 30_000 : 8_000);
|
|
456
|
+
try {
|
|
457
|
+
const response = await fetchImpl(`http://${discovery.host}:${discovery.port}${pathname}`, {
|
|
458
|
+
method,
|
|
459
|
+
headers: {
|
|
460
|
+
"content-type": "application/json",
|
|
461
|
+
"x-hellolynk-host-capability-id": capability.capabilityId,
|
|
462
|
+
"x-hellolynk-host-adapter-id": capability.adapterId,
|
|
463
|
+
"x-hellolynk-host-generation": discovery.hostGeneration,
|
|
464
|
+
"x-hellolynk-host-timestamp": String(timestampMs),
|
|
465
|
+
"x-hellolynk-host-nonce": nonce,
|
|
466
|
+
"x-hellolynk-host-proof": proof,
|
|
467
|
+
},
|
|
468
|
+
body: rawBody.length ? rawBody : undefined,
|
|
469
|
+
redirect: "error",
|
|
470
|
+
signal,
|
|
471
|
+
});
|
|
472
|
+
return { status: response.status, body: await response.json().catch((error) => {
|
|
473
|
+
if ((authorizedWrite || snapshotRead || nativeDirectoryQuery)
|
|
474
|
+
&& (error?.name === "TimeoutError" || (error?.name === "AbortError" && signal.reason?.name === "TimeoutError"))) {
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
return {};
|
|
478
|
+
}) };
|
|
479
|
+
} catch (error) {
|
|
480
|
+
const timedOut = error?.name === "TimeoutError"
|
|
481
|
+
|| (error?.name === "AbortError" && signal.reason?.name === "TimeoutError");
|
|
482
|
+
if (authorizedSend && timedOut) throw codeError("authorized_send_result_unknown");
|
|
483
|
+
if (authorizedReply && timedOut) throw codeError("authorized_reply_result_unknown");
|
|
484
|
+
if (nativeDirectoryQuery && timedOut) throw codeError("native_directory_unavailable");
|
|
485
|
+
if (snapshotRead && timedOut) throw codeError("workbench_snapshot_timeout");
|
|
486
|
+
if (principalSearch && error?.name === "TimeoutError") throw codeError("workbench_principal_search_timeout");
|
|
487
|
+
if (workbenchCommand && error?.name === "TimeoutError") throw codeError("workbench_command_timeout");
|
|
488
|
+
if (actionGrant && error?.name === "TimeoutError") throw codeError("workbench_action_grant_timeout");
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function assertDiscoveryBinding(record, binding) {
|
|
494
|
+
if (!record || record.contractId !== HELLOLYNK_NEUTRAL_HOST_DISCOVERY_CONTRACT
|
|
495
|
+
|| record.region !== binding.region || record.principalId !== binding.principalId
|
|
496
|
+
|| record.hostInstallationId !== binding.hostInstallationId || record.host !== "127.0.0.1"
|
|
497
|
+
|| !Number.isSafeInteger(record.port) || record.port < 1 || record.port > 65_535
|
|
498
|
+
|| !/^[a-f0-9]{48}$/.test(String(record.hostGeneration || ""))) {
|
|
499
|
+
throw codeError("neutral_host_discovery_owner_mismatch");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isMissingNativeInboxRoute(result) {
|
|
504
|
+
return result?.body?.error === "host_route_not_found" || (result?.status === 404 && !result?.body?.error);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function requireNativeInboxOk(result) {
|
|
508
|
+
if (isMissingNativeInboxRoute(result)) throw codeError("host_native_inbox_upgrade_required");
|
|
509
|
+
return requireOk(result);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function requireOk(result) {
|
|
513
|
+
if (!result || result.status !== 200 || result.body?.ok !== true) {
|
|
514
|
+
throw codeError(String(result?.body?.error || `neutral_host_http_${result?.status || 0}`));
|
|
515
|
+
}
|
|
516
|
+
return result.body;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function requireWorkbenchCommandResult(result) {
|
|
520
|
+
if (!result || result.status !== 200 || !result.body
|
|
521
|
+
|| typeof result.body !== "object" || Array.isArray(result.body)) {
|
|
522
|
+
throw codeError(String(result?.body?.error || `neutral_host_http_${result?.status || 0}`));
|
|
523
|
+
}
|
|
524
|
+
return result.body;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function requireWorkbenchActionGrant(result) {
|
|
528
|
+
const value = result?.body;
|
|
529
|
+
if (!result || result.status !== 200 || !value
|
|
530
|
+
|| typeof value !== "object" || Array.isArray(value)
|
|
531
|
+
|| Object.keys(value).sort().join("|") !== "actionGrant|expiresAtMs|ok"
|
|
532
|
+
|| value.ok !== true
|
|
533
|
+
|| !Number.isSafeInteger(value.expiresAtMs) || value.expiresAtMs <= Date.now()) {
|
|
534
|
+
throw codeError(String(value?.error || `neutral_host_http_${result?.status || 0}`));
|
|
535
|
+
}
|
|
536
|
+
return Object.freeze({ actionGrant: opaqueWorkbenchActionGrant(value.actionGrant) });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function workbenchActionGrantContext(value) {
|
|
540
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
541
|
+
|| Object.keys(value).sort().join("|") !== "actionGrant") {
|
|
542
|
+
throw codeError("workbench_action_grant_required");
|
|
543
|
+
}
|
|
544
|
+
return opaqueWorkbenchActionGrant(value.actionGrant);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function noWorkbenchActionGrantContext(value) {
|
|
548
|
+
if (value != null && (!value || typeof value !== "object" || Array.isArray(value)
|
|
549
|
+
|| Object.keys(value).length !== 0)) {
|
|
550
|
+
throw codeError("workbench_action_grant_forbidden");
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function opaqueWorkbenchActionGrant(value) {
|
|
556
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{32,512}$/.test(value)) {
|
|
557
|
+
throw codeError("workbench_action_grant_invalid");
|
|
558
|
+
}
|
|
559
|
+
return value;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function authorizedSendInput(value) {
|
|
563
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
564
|
+
|| Object.keys(value).filter(key => key !== "targetConversationId" && key !== "targetConversationTitle").sort().join("|") !== "clientMessageId|contactId|recipientDelegateId|text") {
|
|
565
|
+
throw codeError("authorized_send_input_invalid");
|
|
566
|
+
}
|
|
567
|
+
const text = String(value.text ?? "");
|
|
568
|
+
if (!text || text.includes("\u0000") || Buffer.byteLength(text, "utf8") > 20 * 1024) {
|
|
569
|
+
throw codeError("message_text_invalid");
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
...optionalTarget(value),
|
|
573
|
+
clientMessageId: safeId(value.clientMessageId, "neutral_client_message_id_invalid"),
|
|
574
|
+
contactId: safeId(value.contactId, "neutral_contact_id_invalid"),
|
|
575
|
+
recipientDelegateId: safeId(value.recipientDelegateId, "recipient_delegate_id_invalid"),
|
|
576
|
+
text,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function publicBinding(value) {
|
|
581
|
+
return Object.freeze({
|
|
582
|
+
region: value.region, principalId: value.principalId, delegateId: value.delegateId,
|
|
583
|
+
hostInstallationId: value.hostInstallationId, adapterId: value.adapterId,
|
|
584
|
+
capabilityVersion: value.capability.version, scopes: [...value.capability.scopes],
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function publicDiscovery(value) {
|
|
589
|
+
return Object.freeze({
|
|
590
|
+
contractId: value.contractId, region: value.region, principalId: value.principalId,
|
|
591
|
+
hostInstallationId: value.hostInstallationId, host: value.host, port: value.port,
|
|
592
|
+
hostGeneration: value.hostGeneration, startedAt: value.startedAt || null,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The same or a higher release answers "current" and nothing is written. An earlier one finishes
|
|
597
|
+
// its admitted work and leaves; while it is busy it keeps serving and this client attaches to it.
|
|
598
|
+
async function earlierHostLeft(requestHandover, binding, waitMs) {
|
|
599
|
+
try {
|
|
600
|
+
const { status } = await requestHandover({ runtimeRoot: binding.runtimeRoot,
|
|
601
|
+
hostInstallationId: binding.hostInstallationId, publishedAt: new Date().toISOString(), waitMs, untilStopped: true });
|
|
602
|
+
return ["stopped", "restarted", "no_host"].includes(status);
|
|
603
|
+
} catch { return false; }
|
|
604
|
+
}
|
|
605
|
+
async function waitForDiscovery(discover, binding, options) {
|
|
606
|
+
const timeoutMs = integer(options.launchTimeoutMs, 7_000, 100, 30_000);
|
|
607
|
+
const deadline = Date.now() + timeoutMs;
|
|
608
|
+
const wait = options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
609
|
+
let lastError = null;
|
|
610
|
+
while (Date.now() < deadline) {
|
|
611
|
+
await wait(50);
|
|
612
|
+
try {
|
|
613
|
+
const record = await discoverAvailableHost(discover, {
|
|
614
|
+
runtimeRoot: binding.runtimeRoot,
|
|
615
|
+
region: binding.region,
|
|
616
|
+
principalId: binding.principalId,
|
|
617
|
+
hostInstallationId: binding.hostInstallationId,
|
|
618
|
+
fetchImpl: options.fetchImpl,
|
|
619
|
+
});
|
|
620
|
+
if (record) return record;
|
|
621
|
+
} catch (error) { lastError = error; }
|
|
622
|
+
}
|
|
623
|
+
if (lastError) throw lastError;
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function discoverAvailableHost(discover, options) {
|
|
628
|
+
try {
|
|
629
|
+
return await discover(options);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (["neutral_host_discovery_stale", "neutral_host_discovery_generation_mismatch"].includes(error?.code)) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function sha256(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
|
|
639
|
+
function safeId(value, code) {
|
|
640
|
+
const text = String(value || "");
|
|
641
|
+
if (!SAFE_ID.test(text)) throw codeError(code);
|
|
642
|
+
return text;
|
|
643
|
+
}
|
|
644
|
+
function integer(value, fallback, minimum, maximum) {
|
|
645
|
+
const number = Number(value ?? fallback);
|
|
646
|
+
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) throw codeError("neutral_limit_invalid");
|
|
647
|
+
return number;
|
|
648
|
+
}
|
|
649
|
+
function codeError(code) { const error = new Error(code); error.code = code; return error; }
|