@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,765 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
link,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { performance } from "node:perf_hooks";
|
|
17
|
+
import { withLocalProcessLock } from "./hellolynk-local-process-lock.mjs";
|
|
18
|
+
import { readClientProfile } from "./hellolynk-client-profile.mjs";
|
|
19
|
+
import { assertIdentifier } from "./hellolynk-host-auth.mjs";
|
|
20
|
+
import runtimeSafety from "./hellolynk-runtime-safety.cjs";
|
|
21
|
+
|
|
22
|
+
const { sanitizeHelloLynkChildEnvironment } = runtimeSafety;
|
|
23
|
+
|
|
24
|
+
export const HELLOLYNK_NEUTRAL_HOST_INSTALLATIONS_CONTRACT = "hellolynk-neutral-host-installations-v1";
|
|
25
|
+
export const HELLOLYNK_NEUTRAL_HOST_REGISTRY_FILE = "installations-v1.json";
|
|
26
|
+
export const HELLOLYNK_NEUTRAL_HOST_PROFILE_MANIFEST_CONTRACT = "hellolynk-neutral-host-profile-manifest-v1";
|
|
27
|
+
export const HELLOLYNK_NEUTRAL_HOST_PROFILE_MANIFEST_FILE = "profile-manifest-v1.json";
|
|
28
|
+
|
|
29
|
+
const HOST_ID_PATTERN = /^host_[A-Za-z0-9_-]{32}$/;
|
|
30
|
+
const MAX_REGISTRY_BYTES = 256 * 1024;
|
|
31
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
32
|
+
const REGISTRY_LOCK_CONTRACT = "hellolynk-neutral-host-registry-lock-v2";
|
|
33
|
+
const MAX_REGISTRY_LOCK_BYTES = 16 * 1024;
|
|
34
|
+
const REGISTRY_LOCK_STALE_MS = 5_000;
|
|
35
|
+
const REGISTRY_LOCK_STABILITY_WAIT_MS = 20;
|
|
36
|
+
const WINDOWS_SYSTEM_DIR = join(process.env.SystemRoot || process.env.WINDIR || "C:\\Windows", "System32");
|
|
37
|
+
|
|
38
|
+
export function defaultNeutralHostStoreRoot(platform = process.platform, env = process.env) {
|
|
39
|
+
const configured = String(env.HELLOLYNK_NEUTRAL_STORE_ROOT || "").trim();
|
|
40
|
+
if (configured) {
|
|
41
|
+
if (!isAbsolute(configured)) throw codeError("neutral_host_store_root_invalid");
|
|
42
|
+
const root = resolve(configured);
|
|
43
|
+
const parent = dirname(root);
|
|
44
|
+
if (basename(root).toLowerCase() !== "host" || parent === parse(parent).root) {
|
|
45
|
+
throw codeError("neutral_host_store_root_invalid");
|
|
46
|
+
}
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
const home = resolve(String(env.HOME || env.USERPROFILE || homedir()));
|
|
50
|
+
if (platform === "win32") return resolve(String(env.LOCALAPPDATA || join(home, "AppData", "Local")), "HelloLynk", "host");
|
|
51
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "HelloLynk", "host");
|
|
52
|
+
return resolve(String(env.XDG_DATA_HOME || join(home, ".local", "share")), "hellolynk", "host");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createNeutralHostInstallationStore(options = {}) {
|
|
56
|
+
const root = assertAbsoluteRoot(options.root || defaultNeutralHostStoreRoot());
|
|
57
|
+
const readOnly = options.readOnly === true;
|
|
58
|
+
const verifyPath = typeof options.verifyPath === "function" ? options.verifyPath : null;
|
|
59
|
+
if (readOnly && process.platform === "win32" && !verifyPath) {
|
|
60
|
+
throw codeError("host_store_read_only_verifier_required");
|
|
61
|
+
}
|
|
62
|
+
const clock = normalizeClock(options.clock);
|
|
63
|
+
const random = typeof options.randomBytes === "function" ? options.randomBytes : randomBytes;
|
|
64
|
+
const registryPath = join(root, HELLOLYNK_NEUTRAL_HOST_REGISTRY_FILE);
|
|
65
|
+
const lockPath = join(root, ".installations-v1.lock");
|
|
66
|
+
|
|
67
|
+
async function createId() {
|
|
68
|
+
if (readOnly) throw codeError("host_store_read_only");
|
|
69
|
+
return withRegistryLock(root, lockPath, async () => {
|
|
70
|
+
const registry = await readRegistry(registryPath);
|
|
71
|
+
if (registry.installations.length > 1) throw codeError("host_installation_ambiguous");
|
|
72
|
+
const existing = registry.installations[0];
|
|
73
|
+
if (existing) return publicInstallation(existing);
|
|
74
|
+
const now = safeTimestamp(clock());
|
|
75
|
+
const hostInstallationId = createHostInstallationId(random);
|
|
76
|
+
const installation = validateInstallation({
|
|
77
|
+
region: null,
|
|
78
|
+
principalId: null,
|
|
79
|
+
hostInstallationId,
|
|
80
|
+
canonicalProfileId: null,
|
|
81
|
+
state: "unclaimed",
|
|
82
|
+
delegateBindings: [],
|
|
83
|
+
createdAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
});
|
|
86
|
+
await writeRegistry(registryPath, {
|
|
87
|
+
contract: HELLOLYNK_NEUTRAL_HOST_INSTALLATIONS_CONTRACT,
|
|
88
|
+
installations: [installation],
|
|
89
|
+
});
|
|
90
|
+
return publicInstallation(installation);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function adoptProfileFile(input = {}) {
|
|
95
|
+
if (readOnly) throw codeError("host_store_read_only");
|
|
96
|
+
const profileFile = assertExplicitProfileFile(input.profileFile);
|
|
97
|
+
const adapterId = assertIdentifier(input.adapterId, "host_adapter_id_invalid");
|
|
98
|
+
const profile = await readSyntheticProfile(profileFile);
|
|
99
|
+
return withRegistryLock(root, lockPath, async () => {
|
|
100
|
+
const registry = await readRegistry(registryPath);
|
|
101
|
+
if (registry.installations.length > 1) throw codeError("host_installation_ambiguous");
|
|
102
|
+
const current = registry.installations[0] || null;
|
|
103
|
+
const now = safeTimestamp(clock());
|
|
104
|
+
let installation;
|
|
105
|
+
if (!current) {
|
|
106
|
+
installation = validateInstallation({
|
|
107
|
+
region: profile.region,
|
|
108
|
+
principalId: profile.principalId,
|
|
109
|
+
hostInstallationId: profile.hostId,
|
|
110
|
+
canonicalProfileId: profile.profileId,
|
|
111
|
+
state: "active",
|
|
112
|
+
delegateBindings: [bindingFromProfile(profile, adapterId)],
|
|
113
|
+
createdAt: now,
|
|
114
|
+
updatedAt: now,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
if (current.hostInstallationId !== profile.hostId) {
|
|
118
|
+
throw codeError("host_installation_id_conflict");
|
|
119
|
+
}
|
|
120
|
+
if (current.state === "active" && (
|
|
121
|
+
current.region !== profile.region || current.principalId !== profile.principalId
|
|
122
|
+
)) {
|
|
123
|
+
throw codeError("host_installation_owner_conflict");
|
|
124
|
+
}
|
|
125
|
+
if (!['active', 'unclaimed'].includes(current.state)) {
|
|
126
|
+
throw codeError("host_installation_inactive");
|
|
127
|
+
}
|
|
128
|
+
const bindings = current.delegateBindings.map(cloneBinding);
|
|
129
|
+
const sameProfile = bindings.find((item) => item.profileId === profile.profileId);
|
|
130
|
+
const sameDelegate = bindings.find((item) => item.delegateId === profile.delegateId);
|
|
131
|
+
if (sameProfile && sameProfile.delegateId !== profile.delegateId) {
|
|
132
|
+
throw codeError("host_profile_delegate_conflict");
|
|
133
|
+
}
|
|
134
|
+
if (sameDelegate && sameDelegate.profileId !== profile.profileId) {
|
|
135
|
+
throw codeError("host_delegate_profile_conflict");
|
|
136
|
+
}
|
|
137
|
+
const existing = sameProfile || sameDelegate;
|
|
138
|
+
if (existing) {
|
|
139
|
+
if (existing.authorizationVersion !== profile.authorizationVersion) {
|
|
140
|
+
throw codeError("host_authorization_version_conflict");
|
|
141
|
+
}
|
|
142
|
+
if (!existing.adapters.includes(adapterId)) {
|
|
143
|
+
existing.adapters = [...existing.adapters, adapterId].sort();
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
bindings.push(bindingFromProfile(profile, adapterId));
|
|
147
|
+
}
|
|
148
|
+
bindings.sort((left, right) => left.profileId.localeCompare(right.profileId));
|
|
149
|
+
installation = validateInstallation({
|
|
150
|
+
...current,
|
|
151
|
+
region: profile.region,
|
|
152
|
+
principalId: profile.principalId,
|
|
153
|
+
canonicalProfileId: current.canonicalProfileId || profile.profileId,
|
|
154
|
+
state: "active",
|
|
155
|
+
delegateBindings: bindings,
|
|
156
|
+
updatedAt: now,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
await writeRegistry(registryPath, {
|
|
160
|
+
contract: HELLOLYNK_NEUTRAL_HOST_INSTALLATIONS_CONTRACT,
|
|
161
|
+
installations: [installation],
|
|
162
|
+
});
|
|
163
|
+
return publicInstallation(installation);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function status() {
|
|
168
|
+
// The read-only registry verifier checks root ACL, all path ancestors and
|
|
169
|
+
// the file ACL together. Do not launch a second root-only verifier.
|
|
170
|
+
if (readOnly && process.platform !== "win32") await assertReadOnlyPath(root, true, verifyPath);
|
|
171
|
+
if (!readOnly) await assertSecureDirectory(root, false, false);
|
|
172
|
+
const registry = await readRegistry(registryPath, { readOnly, verifyPath });
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
contract: registry.contract,
|
|
175
|
+
installations: Object.freeze(registry.installations.map(publicInstallation)),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function resolveBinding(input = {}) {
|
|
180
|
+
if (readOnly && process.platform !== "win32") await assertReadOnlyPath(root, true, verifyPath);
|
|
181
|
+
if (!readOnly) await assertSecureDirectory(root, false, false);
|
|
182
|
+
const registry = await readRegistry(registryPath, { readOnly, verifyPath });
|
|
183
|
+
const active = registry.installations.filter((item) => item.state === "active");
|
|
184
|
+
if (active.length !== 1) throw codeError(active.length ? "host_installation_ambiguous" : "host_installation_missing");
|
|
185
|
+
const installation = active[0];
|
|
186
|
+
const profileId = input.profileId == null ? null : assertIdentifier(input.profileId, "host_profile_id_invalid");
|
|
187
|
+
const delegateId = input.delegateId == null ? null : assertIdentifier(input.delegateId, "host_delegate_id_invalid");
|
|
188
|
+
if (!profileId && !delegateId && installation.delegateBindings.length !== 1) {
|
|
189
|
+
throw codeError("host_delegate_binding_ambiguous");
|
|
190
|
+
}
|
|
191
|
+
const matches = installation.delegateBindings.filter((item) => (
|
|
192
|
+
(!profileId || item.profileId === profileId) && (!delegateId || item.delegateId === delegateId)
|
|
193
|
+
));
|
|
194
|
+
if (matches.length !== 1) throw codeError(matches.length ? "host_delegate_binding_ambiguous" : "host_delegate_binding_missing");
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
region: installation.region,
|
|
197
|
+
principalId: installation.principalId,
|
|
198
|
+
hostInstallationId: installation.hostInstallationId,
|
|
199
|
+
canonicalProfileId: installation.canonicalProfileId,
|
|
200
|
+
...cloneBinding(matches[0]),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return Object.freeze({ root, registryPath, createId, adoptProfileFile, status, resolveBinding });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function writeNeutralHostProfileManifest(options = {}) {
|
|
208
|
+
const root = assertAbsoluteRoot(options.root || defaultNeutralHostStoreRoot());
|
|
209
|
+
if (!Array.isArray(options.profiles) || options.profiles.length < 1 || options.profiles.length > 32) {
|
|
210
|
+
throw codeError("host_profile_manifest_profiles_invalid");
|
|
211
|
+
}
|
|
212
|
+
let profiles = options.profiles.map(assertExplicitProfileFile);
|
|
213
|
+
if (new Set(profiles.map((value) => value.toLowerCase())).size !== profiles.length) {
|
|
214
|
+
throw codeError("host_profile_manifest_profiles_invalid");
|
|
215
|
+
}
|
|
216
|
+
const manifestPath = join(root, HELLOLYNK_NEUTRAL_HOST_PROFILE_MANIFEST_FILE);
|
|
217
|
+
// Keep registry membership and manifest publication in one critical section.
|
|
218
|
+
// Lock order is registry -> manifest; adoption only takes the registry lock.
|
|
219
|
+
return withRegistryLock(root, join(root, ".installations-v1.lock"), () =>
|
|
220
|
+
withRegistryLock(root, join(root, ".profile-manifest-v1.lock"), async () => {
|
|
221
|
+
const registry = await readRegistry(join(root, HELLOLYNK_NEUTRAL_HOST_REGISTRY_FILE));
|
|
222
|
+
const active = registry.installations.filter((item) => item.state === "active");
|
|
223
|
+
if (active.length !== 1) {
|
|
224
|
+
throw codeError(active.length ? "host_installation_ambiguous" : "host_installation_missing");
|
|
225
|
+
}
|
|
226
|
+
const installation = active[0];
|
|
227
|
+
if (options.mergeRegisteredProfiles === true) {
|
|
228
|
+
const paths = new Map();
|
|
229
|
+
const addPath = (file) => {
|
|
230
|
+
const path = assertExplicitProfileFile(file);
|
|
231
|
+
const id = basename(path, ".json");
|
|
232
|
+
const previous = paths.get(id);
|
|
233
|
+
if (previous && previous.toLowerCase() !== path.toLowerCase()) throw codeError("host_profile_manifest_profiles_invalid");
|
|
234
|
+
paths.set(id, path);
|
|
235
|
+
};
|
|
236
|
+
try {
|
|
237
|
+
const metadata = await lstat(manifestPath);
|
|
238
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_REGISTRY_BYTES) {
|
|
239
|
+
throw codeError("host_profile_manifest_profiles_invalid");
|
|
240
|
+
}
|
|
241
|
+
const body = await readFile(manifestPath, "utf8");
|
|
242
|
+
if (Buffer.byteLength(body, "utf8") > MAX_REGISTRY_BYTES) throw codeError("host_profile_manifest_profiles_invalid");
|
|
243
|
+
const existing = JSON.parse(body);
|
|
244
|
+
if (existing?.contract !== HELLOLYNK_NEUTRAL_HOST_PROFILE_MANIFEST_CONTRACT
|
|
245
|
+
|| !Array.isArray(existing.profiles) || existing.profiles.length > 32) throw codeError("host_profile_manifest_profiles_invalid");
|
|
246
|
+
existing.profiles.forEach(addPath);
|
|
247
|
+
} catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
248
|
+
profiles.forEach(addPath);
|
|
249
|
+
const profileRoot = assertAbsoluteRoot(options.clientRoot);
|
|
250
|
+
const ids = [installation.canonicalProfileId, ...installation.delegateBindings.map((item) => item.profileId)
|
|
251
|
+
.filter((id) => id !== installation.canonicalProfileId).sort()];
|
|
252
|
+
profiles = ids.map((id) => paths.get(id) || join(profileRoot, "profiles", `${id}.json`));
|
|
253
|
+
if (profiles.length > 32 || new Set(profiles.map((path) => path.toLowerCase())).size !== profiles.length) {
|
|
254
|
+
throw codeError("host_profile_manifest_profiles_invalid");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const loaded = await Promise.all(profiles.map(readSyntheticProfile));
|
|
258
|
+
if (loaded[0].profileId !== installation.canonicalProfileId) {
|
|
259
|
+
throw codeError("host_profile_manifest_canonical_profile_invalid");
|
|
260
|
+
}
|
|
261
|
+
for (const profile of loaded) {
|
|
262
|
+
const binding = installation.delegateBindings.find((item) => item.profileId === profile.profileId);
|
|
263
|
+
if (!binding || binding.delegateId !== profile.delegateId
|
|
264
|
+
|| binding.authorizationVersion !== profile.authorizationVersion
|
|
265
|
+
|| profile.region !== installation.region || profile.principalId !== installation.principalId
|
|
266
|
+
|| profile.hostId !== installation.hostInstallationId) {
|
|
267
|
+
throw codeError("host_profile_manifest_owner_mismatch");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const manifest = Object.freeze({
|
|
271
|
+
contract: HELLOLYNK_NEUTRAL_HOST_PROFILE_MANIFEST_CONTRACT,
|
|
272
|
+
profiles: Object.freeze([...profiles]),
|
|
273
|
+
});
|
|
274
|
+
await writeRegistry(manifestPath, manifest);
|
|
275
|
+
return Object.freeze({ manifestPath, contract: manifest.contract, profiles: manifest.profiles });
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createHostInstallationId(random = randomBytes) {
|
|
280
|
+
for (let attempt = 0; attempt < 64; attempt += 1) {
|
|
281
|
+
const bytes = Buffer.from(random(24));
|
|
282
|
+
if (bytes.length !== 24) throw codeError("host_installation_random_invalid");
|
|
283
|
+
const candidate = `host_${bytes.toString("base64url")}`;
|
|
284
|
+
if (HOST_ID_PATTERN.test(candidate) && /[A-Za-z0-9]$/.test(candidate)) return candidate;
|
|
285
|
+
}
|
|
286
|
+
throw codeError("host_installation_random_invalid");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readSyntheticProfile(profileFile) {
|
|
290
|
+
const metadata = await lstat(profileFile).catch((error) => {
|
|
291
|
+
if (error?.code === "ENOENT") throw codeError("host_profile_file_missing");
|
|
292
|
+
throw error;
|
|
293
|
+
});
|
|
294
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 128 * 1024) {
|
|
295
|
+
throw codeError("host_profile_file_invalid");
|
|
296
|
+
}
|
|
297
|
+
const profile = await readClientProfile(profileFile);
|
|
298
|
+
if (profile.schemaVersion !== 2) throw codeError("host_profile_schema_2_required");
|
|
299
|
+
return profile;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function bindingFromProfile(profile, adapterId) {
|
|
303
|
+
return {
|
|
304
|
+
profileId: profile.profileId,
|
|
305
|
+
delegateId: profile.delegateId,
|
|
306
|
+
authorizationVersion: profile.authorizationVersion,
|
|
307
|
+
adapters: [adapterId],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function readRegistry(path, options = {}) {
|
|
312
|
+
let raw;
|
|
313
|
+
try {
|
|
314
|
+
const metadata = await lstat(path);
|
|
315
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_REGISTRY_BYTES) {
|
|
316
|
+
throw codeError("host_registry_path_invalid");
|
|
317
|
+
}
|
|
318
|
+
if (options.readOnly) await assertReadOnlyPath(path, false, options.verifyPath);
|
|
319
|
+
// Registry permissions are established on creation/atomic publication.
|
|
320
|
+
// Reading through a writable store must not change ctime and invalidate
|
|
321
|
+
// another Host's successful path verification.
|
|
322
|
+
raw = await readFile(path, "utf8");
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error?.code === "ENOENT" && !options.readOnly) {
|
|
325
|
+
return Object.freeze({ contract: HELLOLYNK_NEUTRAL_HOST_INSTALLATIONS_CONTRACT, installations: Object.freeze([]) });
|
|
326
|
+
}
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
let parsed;
|
|
330
|
+
try { parsed = JSON.parse(raw); } catch { throw codeError("host_registry_invalid"); }
|
|
331
|
+
assertExactKeys(parsed, ["contract", "installations"], "host_registry_fields_invalid");
|
|
332
|
+
if (parsed.contract !== HELLOLYNK_NEUTRAL_HOST_INSTALLATIONS_CONTRACT || !Array.isArray(parsed.installations)) {
|
|
333
|
+
throw codeError("host_registry_invalid");
|
|
334
|
+
}
|
|
335
|
+
if (parsed.installations.length > 64) throw codeError("host_registry_too_large");
|
|
336
|
+
const installations = parsed.installations.map(validateInstallation);
|
|
337
|
+
const ids = new Set(installations.map((item) => item.hostInstallationId));
|
|
338
|
+
if (ids.size !== installations.length) throw codeError("host_installation_duplicate");
|
|
339
|
+
return Object.freeze({ contract: parsed.contract, installations: Object.freeze(installations) });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function assertReadOnlyPath(path, directory, verifyPath) {
|
|
343
|
+
const metadata = await lstat(path);
|
|
344
|
+
if (metadata.isSymbolicLink() || (directory ? !metadata.isDirectory() : !metadata.isFile())) {
|
|
345
|
+
throw codeError(directory ? "host_store_root_invalid" : "host_registry_path_invalid");
|
|
346
|
+
}
|
|
347
|
+
if (process.platform === "win32") {
|
|
348
|
+
await verifyPath({ path, directory });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validateInstallation(value) {
|
|
353
|
+
assertExactKeys(value, [
|
|
354
|
+
"canonicalProfileId", "createdAt", "delegateBindings", "hostInstallationId",
|
|
355
|
+
"principalId", "region", "state", "updatedAt",
|
|
356
|
+
], "host_installation_fields_invalid");
|
|
357
|
+
if (!HOST_ID_PATTERN.test(String(value.hostInstallationId || ""))) throw codeError("host_installation_id_invalid");
|
|
358
|
+
if (!['active', 'unclaimed'].includes(value.state)) throw codeError("host_installation_state_invalid");
|
|
359
|
+
const unclaimed = value.state === "unclaimed";
|
|
360
|
+
const region = unclaimed && value.region === null ? null : assertIdentifier(value.region, "host_region_invalid");
|
|
361
|
+
const principalId = unclaimed && value.principalId === null
|
|
362
|
+
? null : assertIdentifier(value.principalId, "host_principal_id_invalid");
|
|
363
|
+
const canonicalProfileId = unclaimed && value.canonicalProfileId === null
|
|
364
|
+
? null : assertIdentifier(value.canonicalProfileId, "host_profile_id_invalid");
|
|
365
|
+
if (!Array.isArray(value.delegateBindings) || value.delegateBindings.length > 64) {
|
|
366
|
+
throw codeError("host_delegate_bindings_invalid");
|
|
367
|
+
}
|
|
368
|
+
if (unclaimed && value.delegateBindings.length) throw codeError("host_unclaimed_bindings_invalid");
|
|
369
|
+
const delegateBindings = value.delegateBindings.map(validateBinding);
|
|
370
|
+
const profiles = new Set(delegateBindings.map((item) => item.profileId));
|
|
371
|
+
const delegates = new Set(delegateBindings.map((item) => item.delegateId));
|
|
372
|
+
if (profiles.size !== delegateBindings.length || delegates.size !== delegateBindings.length) {
|
|
373
|
+
throw codeError("host_delegate_binding_duplicate");
|
|
374
|
+
}
|
|
375
|
+
if (!unclaimed && (!delegateBindings.length || !profiles.has(canonicalProfileId))) {
|
|
376
|
+
throw codeError("host_canonical_profile_invalid");
|
|
377
|
+
}
|
|
378
|
+
return Object.freeze({
|
|
379
|
+
region,
|
|
380
|
+
principalId,
|
|
381
|
+
hostInstallationId: value.hostInstallationId,
|
|
382
|
+
canonicalProfileId,
|
|
383
|
+
state: value.state,
|
|
384
|
+
delegateBindings: Object.freeze(delegateBindings),
|
|
385
|
+
createdAt: safeTimestamp(value.createdAt),
|
|
386
|
+
updatedAt: safeTimestamp(value.updatedAt),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function validateBinding(value) {
|
|
391
|
+
assertExactKeys(value, ["adapters", "authorizationVersion", "delegateId", "profileId"], "host_delegate_binding_fields_invalid");
|
|
392
|
+
if (!Array.isArray(value.adapters) || !value.adapters.length || value.adapters.length > 32) {
|
|
393
|
+
throw codeError("host_delegate_adapters_invalid");
|
|
394
|
+
}
|
|
395
|
+
const adapters = value.adapters.map((item) => assertIdentifier(item, "host_adapter_id_invalid"));
|
|
396
|
+
const canonical = [...new Set(adapters)].sort();
|
|
397
|
+
if (canonical.length !== adapters.length || canonical.some((item, index) => item !== adapters[index])) {
|
|
398
|
+
throw codeError("host_delegate_adapters_noncanonical");
|
|
399
|
+
}
|
|
400
|
+
const authorizationVersion = Number(value.authorizationVersion);
|
|
401
|
+
if (!Number.isSafeInteger(authorizationVersion) || authorizationVersion < 1) {
|
|
402
|
+
throw codeError("host_authorization_version_invalid");
|
|
403
|
+
}
|
|
404
|
+
return Object.freeze({
|
|
405
|
+
profileId: assertIdentifier(value.profileId, "host_profile_id_invalid"),
|
|
406
|
+
delegateId: assertIdentifier(value.delegateId, "host_delegate_id_invalid"),
|
|
407
|
+
authorizationVersion,
|
|
408
|
+
adapters: Object.freeze(adapters),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function writeRegistry(path, registry) {
|
|
413
|
+
const root = dirname(path);
|
|
414
|
+
await assertSecureDirectory(root, true);
|
|
415
|
+
const output = `${JSON.stringify(registry, null, 2)}\n`;
|
|
416
|
+
if (Buffer.byteLength(output) > MAX_REGISTRY_BYTES) throw codeError("host_registry_too_large");
|
|
417
|
+
const temp = join(root, `.${basename(path)}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`);
|
|
418
|
+
let handle;
|
|
419
|
+
try {
|
|
420
|
+
handle = await open(temp, "wx", 0o600);
|
|
421
|
+
await handle.writeFile(output, "utf8");
|
|
422
|
+
await handle.sync();
|
|
423
|
+
await handle.close();
|
|
424
|
+
handle = null;
|
|
425
|
+
await chmod(temp, 0o600);
|
|
426
|
+
await applyCurrentUserAcl(temp, false);
|
|
427
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
428
|
+
try {
|
|
429
|
+
await rename(temp, path);
|
|
430
|
+
break;
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const transient = ["EACCES", "EBUSY", "EPERM"].includes(error?.code);
|
|
433
|
+
if (!transient || attempt >= 20) throw error;
|
|
434
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 5 * (attempt + 1)));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} finally {
|
|
438
|
+
await handle?.close().catch(() => {});
|
|
439
|
+
await rm(temp, { force: true }).catch(() => {});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function withNeutralHostStoreLock(root, name, operation) {
|
|
444
|
+
if (!/^[a-z][a-z0-9-]{1,70}$/.test(name)) throw codeError("host_registry_lock_name_invalid");
|
|
445
|
+
return withRegistryLock(root, join(root, `.${name}.lock`), operation);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function withRegistryLock(root, lockPath, operation) {
|
|
449
|
+
await assertSecureDirectory(root, true, false);
|
|
450
|
+
// Serialize the whole ownership lifetime. Snapshot checks and a rename alone
|
|
451
|
+
// cannot stop another stale-lock reclaimer from moving a new owner's file.
|
|
452
|
+
try {
|
|
453
|
+
return await withLocalProcessLock(lockPath, () => withRegistryLockUnderGuard(lockPath, operation), { timeoutMs: LOCK_TIMEOUT_MS });
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error?.code === "hellolynk_local_process_lock_busy") throw codeError("host_registry_lock_timeout");
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function withRegistryLockUnderGuard(lockPath, operation) {
|
|
461
|
+
const deadline = performance.now() + LOCK_TIMEOUT_MS;
|
|
462
|
+
let ownershipToken = null;
|
|
463
|
+
while (true) {
|
|
464
|
+
if (performance.now() >= deadline) throw codeError("host_registry_lock_timeout");
|
|
465
|
+
try {
|
|
466
|
+
ownershipToken = await publishRegistryLock(lockPath);
|
|
467
|
+
break;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error?.code !== "EEXIST") throw error;
|
|
470
|
+
if (await recoverStaleRegistryLock(lockPath)) continue;
|
|
471
|
+
if (performance.now() >= deadline) throw codeError("host_registry_lock_timeout");
|
|
472
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 15));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
try { return await operation(); } finally { await releaseRegistryLock(lockPath, ownershipToken); }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function publishRegistryLock(lockPath) {
|
|
479
|
+
const ownershipToken = randomBytes(24).toString("hex");
|
|
480
|
+
const candidatePath = `${lockPath}.candidate.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
481
|
+
let handle = null;
|
|
482
|
+
try {
|
|
483
|
+
handle = await open(candidatePath, "wx", 0o600);
|
|
484
|
+
await handle.writeFile(`${JSON.stringify({
|
|
485
|
+
contract: REGISTRY_LOCK_CONTRACT,
|
|
486
|
+
pid: process.pid,
|
|
487
|
+
ownershipToken,
|
|
488
|
+
createdAt: Date.now(),
|
|
489
|
+
})}\n`, "utf8");
|
|
490
|
+
await handle.sync();
|
|
491
|
+
await handle.close();
|
|
492
|
+
handle = null;
|
|
493
|
+
await chmod(candidatePath, 0o600);
|
|
494
|
+
await applyCurrentUserAcl(candidatePath, false);
|
|
495
|
+
await link(candidatePath, lockPath);
|
|
496
|
+
return ownershipToken;
|
|
497
|
+
} finally {
|
|
498
|
+
await handle?.close().catch(() => {});
|
|
499
|
+
await rm(candidatePath, { force: true }).catch(() => {});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function releaseRegistryLock(lockPath, ownershipToken) {
|
|
504
|
+
try {
|
|
505
|
+
const snapshot = await readRegistryLockSnapshot(lockPath);
|
|
506
|
+
if (snapshot?.record.ownershipToken === ownershipToken) await rm(lockPath);
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function recoverStaleRegistryLock(lockPath) {
|
|
511
|
+
const first = await readRegistryLockSnapshotForRecovery(lockPath);
|
|
512
|
+
if (first === undefined) return false;
|
|
513
|
+
if (!first) return true;
|
|
514
|
+
if (!registryLockEligibleForRecovery(first)) return false;
|
|
515
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, REGISTRY_LOCK_STABILITY_WAIT_MS));
|
|
516
|
+
const second = await readRegistryLockSnapshotForRecovery(lockPath);
|
|
517
|
+
if (second === undefined) return false;
|
|
518
|
+
if (!second) return true;
|
|
519
|
+
if (!registryLockEligibleForRecovery(second) || !sameRegistryLockSnapshot(first, second)) return false;
|
|
520
|
+
const quarantinedPath = `${lockPath}.stale.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
521
|
+
try {
|
|
522
|
+
await rename(lockPath, quarantinedPath);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (["ENOENT", "EACCES", "EPERM", "EBUSY"].includes(error?.code)) return false;
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
const quarantined = await readRegistryLockSnapshot(quarantinedPath);
|
|
528
|
+
if (!quarantined || !sameRegistryLockSnapshot(second, quarantined)) {
|
|
529
|
+
try {
|
|
530
|
+
await link(quarantinedPath, lockPath);
|
|
531
|
+
await rm(quarantinedPath);
|
|
532
|
+
} catch {}
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
await rm(quarantinedPath);
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function readRegistryLockSnapshotForRecovery(lockPath) {
|
|
540
|
+
try {
|
|
541
|
+
return await readRegistryLockSnapshot(lockPath);
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (["EACCES", "EBUSY", "EPERM"].includes(error?.code)) return undefined;
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function readRegistryLockSnapshot(lockPath) {
|
|
549
|
+
let before;
|
|
550
|
+
try { before = await lstat(lockPath); }
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error?.code === "ENOENT") return null;
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
if (before.isDirectory() && !before.isSymbolicLink()) {
|
|
556
|
+
throw codeError("host_registry_legacy_lock_requires_manual_recovery");
|
|
557
|
+
}
|
|
558
|
+
if (before.isSymbolicLink() || !before.isFile() || before.size > MAX_REGISTRY_LOCK_BYTES) {
|
|
559
|
+
throw codeError("host_registry_lock_path_invalid");
|
|
560
|
+
}
|
|
561
|
+
let handle;
|
|
562
|
+
let body;
|
|
563
|
+
let metadata;
|
|
564
|
+
try {
|
|
565
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
566
|
+
handle = await open(lockPath, fsConstants.O_RDONLY | noFollow);
|
|
567
|
+
metadata = await handle.stat();
|
|
568
|
+
if (!metadata.isFile() || metadata.size > MAX_REGISTRY_LOCK_BYTES
|
|
569
|
+
|| !sameRegistryLockFileIdentity(before, metadata)) {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
body = await handle.readFile();
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error?.code === "ENOENT") return null;
|
|
575
|
+
throw error;
|
|
576
|
+
} finally {
|
|
577
|
+
await handle?.close().catch(() => {});
|
|
578
|
+
}
|
|
579
|
+
if (body.length !== metadata.size) throw codeError("host_registry_lock_path_invalid");
|
|
580
|
+
let after;
|
|
581
|
+
try { after = await lstat(lockPath); }
|
|
582
|
+
catch (error) {
|
|
583
|
+
if (error?.code === "ENOENT") return null;
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
if (!after.isFile() || after.isSymbolicLink()
|
|
587
|
+
|| !sameRegistryLockFileIdentity(metadata, after)
|
|
588
|
+
|| after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
let record;
|
|
592
|
+
try { record = JSON.parse(body.toString("utf8")); }
|
|
593
|
+
catch { throw codeError("host_registry_lock_path_invalid"); }
|
|
594
|
+
assertExactKeys(record, ["contract", "createdAt", "ownershipToken", "pid"], "host_registry_lock_path_invalid");
|
|
595
|
+
if (record.contract !== REGISTRY_LOCK_CONTRACT
|
|
596
|
+
|| !Number.isSafeInteger(Number(record.pid)) || Number(record.pid) <= 0
|
|
597
|
+
|| typeof record.ownershipToken !== "string" || record.ownershipToken.length < 16
|
|
598
|
+
|| !Number.isFinite(Number(record.createdAt))) {
|
|
599
|
+
throw codeError("host_registry_lock_path_invalid");
|
|
600
|
+
}
|
|
601
|
+
return Object.freeze({ metadata, body, record: Object.freeze({ ...record, pid: Number(record.pid) }) });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function registryLockEligibleForRecovery(snapshot) {
|
|
605
|
+
return Date.now() - snapshot.metadata.mtimeMs >= REGISTRY_LOCK_STALE_MS
|
|
606
|
+
&& processDefinitelyDead(snapshot.record.pid);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function processDefinitelyDead(pid) {
|
|
610
|
+
try {
|
|
611
|
+
process.kill(pid, 0);
|
|
612
|
+
return false;
|
|
613
|
+
} catch (error) {
|
|
614
|
+
return error?.code === "ESRCH";
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function sameRegistryLockSnapshot(left, right) {
|
|
619
|
+
return (
|
|
620
|
+
sameRegistryLockFileIdentity(left.metadata, right.metadata)
|
|
621
|
+
&& left.metadata.size === right.metadata.size
|
|
622
|
+
&& left.metadata.mtimeMs === right.metadata.mtimeMs
|
|
623
|
+
&& left.body.length === right.body.length
|
|
624
|
+
&& timingSafeEqual(left.body, right.body)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function sameRegistryLockFileIdentity(left, right) {
|
|
629
|
+
if (process.platform === "win32" && (left.dev === 0 || right.dev === 0)
|
|
630
|
+
&& left.ino !== right.ino) return false;
|
|
631
|
+
if (Number.isSafeInteger(left.dev) && Number.isSafeInteger(left.ino)
|
|
632
|
+
&& Number.isSafeInteger(right.dev) && Number.isSafeInteger(right.ino)) {
|
|
633
|
+
return left.ino === right.ino
|
|
634
|
+
&& (left.dev === right.dev
|
|
635
|
+
|| (process.platform === "win32" && (left.dev === 0 || right.dev === 0)));
|
|
636
|
+
}
|
|
637
|
+
return left.size === right.size && left.mtimeMs === right.mtimeMs;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function assertSecureDirectory(path, create, secureAcl = true) {
|
|
641
|
+
if (!isAbsolute(path)) throw codeError("host_store_root_invalid");
|
|
642
|
+
const created = create ? await mkdir(path, { recursive: true, mode: 0o700 }) : undefined;
|
|
643
|
+
const metadata = await lstat(path);
|
|
644
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw codeError("host_store_root_invalid");
|
|
645
|
+
if (secureAcl || created !== undefined) {
|
|
646
|
+
await chmod(path, 0o700);
|
|
647
|
+
await applyCurrentUserAcl(path, true);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
let currentUserSidPromise;
|
|
652
|
+
function currentWindowsUserSid() {
|
|
653
|
+
// The process token does not change during ordinary Host operations. Retry
|
|
654
|
+
// lookup failures, but do not launch whoami for every newly written file.
|
|
655
|
+
if (!currentUserSidPromise) currentUserSidPromise = spawnText(join(WINDOWS_SYSTEM_DIR, "whoami.exe"), ["/user", "/fo", "csv", "/nh"])
|
|
656
|
+
.then(output => {
|
|
657
|
+
const sid = output.match(/S-1-[0-9-]+/)?.[0];
|
|
658
|
+
if (!sid) throw codeError("host_store_sid_unavailable");
|
|
659
|
+
return sid;
|
|
660
|
+
}).catch(error => { currentUserSidPromise = undefined; throw error; });
|
|
661
|
+
return currentUserSidPromise;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function applyCurrentUserAcl(path, directory) {
|
|
665
|
+
if (process.platform !== "win32") return;
|
|
666
|
+
const sid = await currentWindowsUserSid();
|
|
667
|
+
const permission = directory ? `(OI)(CI)F` : "F";
|
|
668
|
+
try {
|
|
669
|
+
await spawnText(join(WINDOWS_SYSTEM_DIR, "icacls.exe"), [path, "/setowner", `*${sid}`]);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (await readWindowsOwnerSid(path) !== sid) throw error;
|
|
672
|
+
}
|
|
673
|
+
const grants = directory
|
|
674
|
+
? [`*${sid}:${permission}`, "*S-1-5-18:(OI)(CI)F", "*S-1-5-32-544:(OI)(CI)F"]
|
|
675
|
+
: [`*${sid}:${permission}`];
|
|
676
|
+
await spawnText(join(WINDOWS_SYSTEM_DIR, "icacls.exe"), [path, "/inheritance:r", "/grant:r", ...grants]);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function readWindowsOwnerSid(path) {
|
|
680
|
+
const script = "$ErrorActionPreference='Stop'; $p=$env:HELLOLYNK_ACL_TARGET; "
|
|
681
|
+
+ "if([IO.Directory]::Exists($p)){$acl=[IO.Directory]::GetAccessControl($p)}else{$acl=[IO.File]::GetAccessControl($p)}; "
|
|
682
|
+
+ "$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value";
|
|
683
|
+
return (await spawnText(join(WINDOWS_SYSTEM_DIR, "WindowsPowerShell", "v1.0", "powershell.exe"), [
|
|
684
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script,
|
|
685
|
+
], { HELLOLYNK_ACL_TARGET: path })).trim();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function spawnText(file, args, environment = {}) {
|
|
689
|
+
return new Promise((resolvePromise, reject) => {
|
|
690
|
+
const child = spawn(file, args, {
|
|
691
|
+
windowsHide: true,
|
|
692
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
693
|
+
env: sanitizeHelloLynkChildEnvironment(process.env, environment),
|
|
694
|
+
});
|
|
695
|
+
const stdout = [];
|
|
696
|
+
const stderr = [];
|
|
697
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
698
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
699
|
+
child.on("error", reject);
|
|
700
|
+
child.on("close", (code) => {
|
|
701
|
+
if (code === 0) resolvePromise(Buffer.concat(stdout).toString("utf8"));
|
|
702
|
+
else reject(codeError(Buffer.concat(stderr).toString("utf8").trim() || "host_acl_failed"));
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function publicInstallation(value) {
|
|
708
|
+
return Object.freeze({
|
|
709
|
+
region: value.region,
|
|
710
|
+
principalId: value.principalId,
|
|
711
|
+
hostInstallationId: value.hostInstallationId,
|
|
712
|
+
canonicalProfileId: value.canonicalProfileId,
|
|
713
|
+
state: value.state,
|
|
714
|
+
delegateBindings: Object.freeze(value.delegateBindings.map(cloneBinding)),
|
|
715
|
+
createdAt: value.createdAt,
|
|
716
|
+
updatedAt: value.updatedAt,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function cloneBinding(value) {
|
|
721
|
+
return {
|
|
722
|
+
profileId: value.profileId,
|
|
723
|
+
delegateId: value.delegateId,
|
|
724
|
+
authorizationVersion: value.authorizationVersion,
|
|
725
|
+
adapters: [...value.adapters],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function assertExactKeys(value, expected, code) {
|
|
730
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw codeError(code);
|
|
731
|
+
const actual = Object.keys(value).sort();
|
|
732
|
+
const wanted = [...expected].sort();
|
|
733
|
+
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) throw codeError(code);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function assertAbsoluteRoot(value) {
|
|
737
|
+
const root = resolve(String(value || ""));
|
|
738
|
+
if (!isAbsolute(root) || root.length < 4) throw codeError("host_store_root_invalid");
|
|
739
|
+
return root;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function assertExplicitProfileFile(value) {
|
|
743
|
+
const text = String(value || "");
|
|
744
|
+
if (!text || !isAbsolute(text)) throw codeError("host_profile_file_required");
|
|
745
|
+
return resolve(text);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function safeTimestamp(value) {
|
|
749
|
+
const result = Number(value);
|
|
750
|
+
if (!Number.isSafeInteger(result) || result < 0) throw codeError("host_timestamp_invalid");
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function normalizeClock(value) {
|
|
755
|
+
if (value == null) return () => Date.now();
|
|
756
|
+
if (typeof value === "function") return value;
|
|
757
|
+
if (typeof value?.now === "function") return () => value.now();
|
|
758
|
+
throw codeError("host_clock_invalid");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function codeError(code) {
|
|
762
|
+
const error = new Error(code);
|
|
763
|
+
error.code = code;
|
|
764
|
+
return error;
|
|
765
|
+
}
|