@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,773 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import runtimeSafety from "./hellolynk-runtime-safety.cjs";
|
|
9
|
+
import { inspectProcessIdentity } from "./hellolynk-local-process-lock.mjs";
|
|
10
|
+
|
|
11
|
+
const { sanitizeHelloLynkChildEnvironment } = runtimeSafety;
|
|
12
|
+
|
|
13
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const CLIENTS_DIR = dirname(MODULE_DIR);
|
|
15
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
16
|
+
const RUNTIME_SESSION_LOCK_WAIT_MS = 25;
|
|
17
|
+
const RUNTIME_SESSION_LOCK_STALE_MS = 15_000;
|
|
18
|
+
// A holder can load a persisted session, make two bounded recovery requests,
|
|
19
|
+
// then save it. The client request cap is 30 seconds per request.
|
|
20
|
+
const RUNTIME_SESSION_RECOVERY_REQUEST_TIMEOUT_MS = 30_000;
|
|
21
|
+
const MAX_E2EE_PRIVATE_KEY_MATERIAL_BYTES = 96 * 1024;
|
|
22
|
+
// A Host start reads every profile's keys in one helper process. Its output
|
|
23
|
+
// may exceed one read's cap by at most the base64 of one capped read per profile.
|
|
24
|
+
const MAX_E2EE_BATCH_PROFILES = 64;
|
|
25
|
+
const MAX_E2EE_BATCH_ITEM_OUTPUT_BYTES = Math.ceil(MAX_OUTPUT_BYTES / 3) * 4 + 256;
|
|
26
|
+
const MAX_E2EE_BATCH_OUTPUT_BYTES = MAX_E2EE_BATCH_PROFILES * MAX_E2EE_BATCH_ITEM_OUTPUT_BYTES + 2;
|
|
27
|
+
export const E2EE_PRIVATE_KEY_MATERIAL_SCHEMA_VERSION = 2;
|
|
28
|
+
export const E2EE_PRIVATE_KEY_MATERIAL_FORMAT = "hellolynk_direct_text_e2ee_private_keys_v2";
|
|
29
|
+
|
|
30
|
+
export class HelloLynkCredentialStoreError extends Error {
|
|
31
|
+
constructor(code, options = {}) {
|
|
32
|
+
super(code, options.cause ? { cause: options.cause } : undefined);
|
|
33
|
+
this.name = "HelloLynkCredentialStoreError";
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertCredentialProfileId(value) {
|
|
39
|
+
const profileId = String(value || "").trim();
|
|
40
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$/.test(profileId)) {
|
|
41
|
+
throw new HelloLynkCredentialStoreError("invalid_credential_profile_id");
|
|
42
|
+
}
|
|
43
|
+
return profileId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertCredential(value) {
|
|
47
|
+
const credential = String(value || "");
|
|
48
|
+
if (!/^hellolynk_device_v1_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{32,}$/.test(credential)) {
|
|
49
|
+
throw new HelloLynkCredentialStoreError("invalid_device_credential_format");
|
|
50
|
+
}
|
|
51
|
+
return credential;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertRuntimeSession(value) {
|
|
55
|
+
const session = value && typeof value === "object" ? value : null;
|
|
56
|
+
const safeId = (candidate) => /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$/.test(String(candidate || ""));
|
|
57
|
+
const allowedScopes = Array.isArray(session?.allowedScopes) ? session.allowedScopes.map(String) : [];
|
|
58
|
+
const delegateId = session?.delegateId == null ? null : String(session.delegateId);
|
|
59
|
+
const hostId = session?.hostId == null ? null : String(session.hostId);
|
|
60
|
+
const authorizationVersion = session?.authorizationVersion == null
|
|
61
|
+
? null : Number(session.authorizationVersion);
|
|
62
|
+
const routeFieldCount = [delegateId, hostId, authorizationVersion]
|
|
63
|
+
.filter((item) => item != null).length;
|
|
64
|
+
if (
|
|
65
|
+
!session ||
|
|
66
|
+
!safeId(session.sessionId) ||
|
|
67
|
+
!safeId(session.runtimeId) ||
|
|
68
|
+
!safeId(session.principalId) ||
|
|
69
|
+
!safeId(session.agentId) ||
|
|
70
|
+
!safeId(session.deviceId) ||
|
|
71
|
+
!/^runtime_[a-f0-9]{48}$/.test(String(session.sessionSecret || "")) ||
|
|
72
|
+
(routeFieldCount !== 0 && routeFieldCount !== 3) ||
|
|
73
|
+
(routeFieldCount === 3 && (
|
|
74
|
+
!safeId(delegateId) || !safeId(hostId)
|
|
75
|
+
|| !Number.isSafeInteger(authorizationVersion) || authorizationVersion < 1
|
|
76
|
+
)) ||
|
|
77
|
+
!Number.isFinite(Number(session.expiresAtMs))
|
|
78
|
+
) throw new HelloLynkCredentialStoreError("invalid_runtime_session_handle");
|
|
79
|
+
return {
|
|
80
|
+
sessionId: String(session.sessionId),
|
|
81
|
+
sessionSecret: String(session.sessionSecret),
|
|
82
|
+
runtimeId: String(session.runtimeId),
|
|
83
|
+
principalId: String(session.principalId),
|
|
84
|
+
agentId: String(session.agentId),
|
|
85
|
+
...(routeFieldCount === 3 ? { delegateId, hostId, authorizationVersion } : {}),
|
|
86
|
+
deviceId: String(session.deviceId),
|
|
87
|
+
allowedScopes,
|
|
88
|
+
expiresAtMs: Number(session.expiresAtMs),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function encodeDirectTextPrivateKeyMaterial(value) {
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
94
|
+
throw new HelloLynkCredentialStoreError("invalid_e2ee_private_key_material");
|
|
95
|
+
}
|
|
96
|
+
let privateKeyMaterial;
|
|
97
|
+
try {
|
|
98
|
+
privateKeyMaterial = JSON.stringify(value);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new HelloLynkCredentialStoreError("invalid_e2ee_private_key_material");
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
!privateKeyMaterial
|
|
104
|
+
|| Buffer.byteLength(privateKeyMaterial, "utf8") > MAX_E2EE_PRIVATE_KEY_MATERIAL_BYTES
|
|
105
|
+
) throw new HelloLynkCredentialStoreError("invalid_e2ee_private_key_material");
|
|
106
|
+
const encoded = JSON.stringify({
|
|
107
|
+
schemaVersion: E2EE_PRIVATE_KEY_MATERIAL_SCHEMA_VERSION,
|
|
108
|
+
format: E2EE_PRIVATE_KEY_MATERIAL_FORMAT,
|
|
109
|
+
privateKeyMaterial,
|
|
110
|
+
});
|
|
111
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_OUTPUT_BYTES) {
|
|
112
|
+
throw new HelloLynkCredentialStoreError("invalid_e2ee_private_key_material");
|
|
113
|
+
}
|
|
114
|
+
return encoded;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function decodeDirectTextPrivateKeyMaterial(value) {
|
|
118
|
+
try {
|
|
119
|
+
const wrapper = JSON.parse(String(value || ""));
|
|
120
|
+
const fields = Object.keys(wrapper || {});
|
|
121
|
+
if (
|
|
122
|
+
fields.length !== 3
|
|
123
|
+
|| !fields.includes("schemaVersion")
|
|
124
|
+
|| !fields.includes("format")
|
|
125
|
+
|| !fields.includes("privateKeyMaterial")
|
|
126
|
+
|| wrapper.schemaVersion !== E2EE_PRIVATE_KEY_MATERIAL_SCHEMA_VERSION
|
|
127
|
+
|| wrapper.format !== E2EE_PRIVATE_KEY_MATERIAL_FORMAT
|
|
128
|
+
|| typeof wrapper.privateKeyMaterial !== "string"
|
|
129
|
+
|| Buffer.byteLength(wrapper.privateKeyMaterial, "utf8") > MAX_E2EE_PRIVATE_KEY_MATERIAL_BYTES
|
|
130
|
+
) throw new Error("schema");
|
|
131
|
+
const decoded = JSON.parse(wrapper.privateKeyMaterial);
|
|
132
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) throw new Error("material");
|
|
133
|
+
return decoded;
|
|
134
|
+
} catch {
|
|
135
|
+
throw new HelloLynkCredentialStoreError("invalid_e2ee_private_key_material");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Entries come back in request order; a present value is checked exactly as
|
|
140
|
+
// a single read would check it, so a damaged file fails instead of looking absent.
|
|
141
|
+
function decodeDirectTextPrivateKeyBatch(output, profileIds) {
|
|
142
|
+
let entries = null;
|
|
143
|
+
try { entries = JSON.parse(String(output || "")); } catch {}
|
|
144
|
+
if (!Array.isArray(entries) || entries.length !== profileIds.length) {
|
|
145
|
+
throw new HelloLynkCredentialStoreError("e2ee_private_key_batch_invalid");
|
|
146
|
+
}
|
|
147
|
+
return entries.map((entry, index) => {
|
|
148
|
+
const fields = entry && typeof entry === "object" && !Array.isArray(entry) ? Object.keys(entry) : [];
|
|
149
|
+
if (entry?.profileId !== profileIds[index]) throw new HelloLynkCredentialStoreError("e2ee_private_key_batch_invalid");
|
|
150
|
+
if (entry.state === "missing" && fields.length === 2) return { profileId: entry.profileId, output: null };
|
|
151
|
+
if (entry.state !== "present" || fields.length !== 3 || typeof entry.value !== "string"
|
|
152
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(entry.value)) throw new HelloLynkCredentialStoreError("e2ee_private_key_batch_invalid");
|
|
153
|
+
const value = Buffer.from(entry.value, "base64").toString("utf8");
|
|
154
|
+
decodeDirectTextPrivateKeyMaterial(value);
|
|
155
|
+
return { profileId: entry.profileId, output: value };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function runCredentialHelper(file, args, options = {}) {
|
|
160
|
+
const spawnImpl = options.spawnImpl || nodeSpawn;
|
|
161
|
+
const stdin = options.stdin == null ? "" : String(options.stdin);
|
|
162
|
+
const timeoutMs = Math.min(Math.max(Number(options.timeoutMs) || 10_000, 1_000), 30_000);
|
|
163
|
+
const maxStdoutBytes = Math.min(Math.max(Number(options.maxOutputBytes) || MAX_OUTPUT_BYTES, MAX_OUTPUT_BYTES),
|
|
164
|
+
MAX_E2EE_BATCH_OUTPUT_BYTES);
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
let child;
|
|
167
|
+
try {
|
|
168
|
+
child = spawnImpl(file, args, {
|
|
169
|
+
shell: false,
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
172
|
+
env: {
|
|
173
|
+
...sanitizeHelloLynkChildEnvironment(process.env, options.env),
|
|
174
|
+
...(options.trustedHelperEnvironment || {}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
} catch (error) {
|
|
178
|
+
reject(new HelloLynkCredentialStoreError("credential_helper_unavailable", { cause: error }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let stdout = Buffer.alloc(0);
|
|
182
|
+
let stderr = Buffer.alloc(0);
|
|
183
|
+
let settled = false;
|
|
184
|
+
let terminalCode = null;
|
|
185
|
+
const finish = (callback) => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
callback();
|
|
190
|
+
};
|
|
191
|
+
// Timeout/overflow is an unconfirmed operation, never evidence that the
|
|
192
|
+
// native write was cancelled. Its first cause survives errors from kill.
|
|
193
|
+
const stop = (code) => {
|
|
194
|
+
if (settled || terminalCode) return;
|
|
195
|
+
terminalCode = code;
|
|
196
|
+
try { child.kill(); } catch {}
|
|
197
|
+
finish(() => reject(new HelloLynkCredentialStoreError(terminalCode)));
|
|
198
|
+
};
|
|
199
|
+
const timer = setTimeout(() => stop("credential_helper_timeout"), timeoutMs);
|
|
200
|
+
child.on("error", (error) => finish(() => reject(
|
|
201
|
+
new HelloLynkCredentialStoreError(terminalCode || "credential_helper_unavailable", { cause: error }),
|
|
202
|
+
)));
|
|
203
|
+
child.stdout.on("data", (chunk) => {
|
|
204
|
+
if (settled || terminalCode) return;
|
|
205
|
+
if (stdout.length + chunk.length > maxStdoutBytes) { stop("credential_helper_output_too_large"); return; }
|
|
206
|
+
stdout = Buffer.concat([stdout, chunk]);
|
|
207
|
+
});
|
|
208
|
+
child.stderr.on("data", (chunk) => {
|
|
209
|
+
if (settled || terminalCode) return;
|
|
210
|
+
if (stderr.length + chunk.length > MAX_OUTPUT_BYTES) { stop("credential_helper_output_too_large"); return; }
|
|
211
|
+
stderr = Buffer.concat([stderr, chunk]);
|
|
212
|
+
});
|
|
213
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
214
|
+
stream.on("error", () => stop("credential_helper_failed"));
|
|
215
|
+
}
|
|
216
|
+
child.on("close", (code) => finish(() => {
|
|
217
|
+
if (terminalCode) {
|
|
218
|
+
reject(new HelloLynkCredentialStoreError(terminalCode));
|
|
219
|
+
} else if (code !== 0) {
|
|
220
|
+
const helperCode = stderr.toString("utf8").trim();
|
|
221
|
+
reject(new HelloLynkCredentialStoreError(
|
|
222
|
+
/^[a-z][a-z0-9_]{0,127}$/.test(helperCode) ? helperCode : "credential_helper_failed",
|
|
223
|
+
));
|
|
224
|
+
} else {
|
|
225
|
+
resolve(stdout.toString("utf8").replace(/[\r\n]+$/, ""));
|
|
226
|
+
}
|
|
227
|
+
}));
|
|
228
|
+
try { child.stdin.end(stdin); } catch { stop("credential_helper_failed"); }
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function windowsClientRootForStore(storeRoot) {
|
|
233
|
+
const absolute = resolve(String(storeRoot || ""));
|
|
234
|
+
return basename(absolute).toLowerCase() === "credentials" ? dirname(absolute) : absolute;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function runWindowsClientTrustCommand(command, options = {}) {
|
|
238
|
+
if ((options.platform || process.platform) !== "win32") {
|
|
239
|
+
throw new HelloLynkCredentialStoreError("windows_client_trust_unavailable");
|
|
240
|
+
}
|
|
241
|
+
const shell = options.powerShellPath || "pwsh";
|
|
242
|
+
const script = options.helperPath || join(CLIENTS_DIR, "platform", "windows", "credential-store.ps1");
|
|
243
|
+
const profileId = assertCredentialProfileId(options.profileId);
|
|
244
|
+
const clientRoot = String(options.clientRoot || "");
|
|
245
|
+
if (!clientRoot) throw new HelloLynkCredentialStoreError("client_root_unavailable");
|
|
246
|
+
const args = [
|
|
247
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script,
|
|
248
|
+
"-Command", command,
|
|
249
|
+
"-ProfileId", profileId,
|
|
250
|
+
"-ClientRoot", clientRoot,
|
|
251
|
+
];
|
|
252
|
+
if (options.managedRelativePath != null) {
|
|
253
|
+
args.push("-ManagedRelativePath", String(options.managedRelativePath));
|
|
254
|
+
}
|
|
255
|
+
return (options.runHelper || runCredentialHelper)(shell, args, {
|
|
256
|
+
spawnImpl: options.spawnImpl,
|
|
257
|
+
timeoutMs: options.timeoutMs,
|
|
258
|
+
stdin: options.stdin == null ? "" : String(options.stdin),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function prepareWindowsClientRoot(clientRoot, options = {}) {
|
|
263
|
+
return runWindowsClientTrustCommand("PrepareRoot", {
|
|
264
|
+
...options,
|
|
265
|
+
clientRoot,
|
|
266
|
+
profileId: options.profileId || "root-validation",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function secureWindowsClientReleaseTree(clientRoot, releaseId, options = {}) {
|
|
271
|
+
const acceptedReleaseId = String(releaseId || "");
|
|
272
|
+
if (!/^(?!\.{1,2}$)[A-Za-z0-9.][A-Za-z0-9_.-]{2,191}$/.test(acceptedReleaseId)) {
|
|
273
|
+
throw new HelloLynkCredentialStoreError("client_release_path_invalid");
|
|
274
|
+
}
|
|
275
|
+
return runWindowsClientTrustCommand(options.verifyOnly === true ? "VerifyReleaseTree" : "SecureReleaseTree", {
|
|
276
|
+
...options,
|
|
277
|
+
clientRoot,
|
|
278
|
+
profileId: options.profileId || "release-validation",
|
|
279
|
+
managedRelativePath: `releases\\${acceptedReleaseId}`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function createPlatformCredentialStore(options = {}) {
|
|
284
|
+
const platform = options.platform || process.platform;
|
|
285
|
+
const run = options.runHelper || runCredentialHelper;
|
|
286
|
+
const runnerOptions = {
|
|
287
|
+
spawnImpl: options.spawnImpl,
|
|
288
|
+
timeoutMs: options.timeoutMs,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (platform === "win32") {
|
|
292
|
+
const shell = options.powerShellPath || "pwsh";
|
|
293
|
+
const script = options.helperPath || join(CLIENTS_DIR, "platform", "windows", "credential-store.ps1");
|
|
294
|
+
const runtimeStoreRoot = options.storeRoot || (
|
|
295
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "HelloLynk", "credentials") : null
|
|
296
|
+
);
|
|
297
|
+
if (!runtimeStoreRoot) throw new HelloLynkCredentialStoreError("credential_store_root_unavailable");
|
|
298
|
+
const clientRoot = options.clientRoot || windowsClientRootForStore(runtimeStoreRoot);
|
|
299
|
+
const e2eeStoreRoot = options.e2eeStoreRoot || join(clientRoot, "e2ee-private-keys");
|
|
300
|
+
const storeRootArgs = [
|
|
301
|
+
"-ClientRoot", clientRoot,
|
|
302
|
+
"-StoreRoot", runtimeStoreRoot,
|
|
303
|
+
];
|
|
304
|
+
if (
|
|
305
|
+
runtimeStoreRoot
|
|
306
|
+
&& e2eeStoreRoot
|
|
307
|
+
&& resolve(runtimeStoreRoot).toLowerCase() === resolve(e2eeStoreRoot).toLowerCase()
|
|
308
|
+
) throw new HelloLynkCredentialStoreError("e2ee_private_key_store_not_separated");
|
|
309
|
+
const invoke = (command, profileId, stdin) => run(shell, [
|
|
310
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script,
|
|
311
|
+
"-Command", command, "-ProfileId", profileId, ...storeRootArgs,
|
|
312
|
+
], { ...runnerOptions, stdin });
|
|
313
|
+
const invokeE2ee = (command, profileId, stdin, extra = {}) => {
|
|
314
|
+
if (!e2eeStoreRoot) throw new HelloLynkCredentialStoreError("e2ee_private_key_store_root_unavailable");
|
|
315
|
+
return run(shell, [
|
|
316
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script,
|
|
317
|
+
"-Command", command,
|
|
318
|
+
"-ProfileId", profileId,
|
|
319
|
+
...storeRootArgs,
|
|
320
|
+
"-E2eeStoreRoot", e2eeStoreRoot,
|
|
321
|
+
], { ...runnerOptions, stdin, ...extra });
|
|
322
|
+
};
|
|
323
|
+
// A helper read is reused only while the file, the helper and every
|
|
324
|
+
// ancestor of both keep the identity seen just before that read. Saves
|
|
325
|
+
// replace the file; ACL, reparse-point, directory or helper changes alter
|
|
326
|
+
// the identity, so the helper checks them again. Own saves and removes
|
|
327
|
+
// clear it; a save confirmed under this instance's lock re-seeds it.
|
|
328
|
+
const e2eeReads = new Map();
|
|
329
|
+
// Bumped when an own save or remove starts and ends. A read that began
|
|
330
|
+
// before then cannot seed the cache, even with a matching identity.
|
|
331
|
+
const e2eeWrites = new Map();
|
|
332
|
+
const e2eeWriteMark = (profileId) => e2eeWrites.set(profileId, (e2eeWrites.get(profileId) || 0) + 1);
|
|
333
|
+
const e2eeReadIdentity = (profileId) => protectedReadIdentity([
|
|
334
|
+
join(resolve(e2eeStoreRoot), `direct-text-private-keys-${profileId}.dpapi.clixml`),
|
|
335
|
+
resolve(script),
|
|
336
|
+
]);
|
|
337
|
+
// What a save leaves alone: the helper chain and the key directory's
|
|
338
|
+
// parent with its ancestors (renaming or replacing the key directory
|
|
339
|
+
// changes that parent too).
|
|
340
|
+
const e2eeOuterIdentity = () => protectedReadIdentity([resolve(script)], [dirname(resolve(e2eeStoreRoot))]);
|
|
341
|
+
// Set while this instance's lock callback for a profile runs. Every writer
|
|
342
|
+
// of a profile's key file holds that cross-process lock.
|
|
343
|
+
const lockScopes = new AsyncLocalStorage();
|
|
344
|
+
return {
|
|
345
|
+
platform,
|
|
346
|
+
async save(profile, credential) {
|
|
347
|
+
await invoke("Store", assertCredentialProfileId(profile), assertCredential(credential));
|
|
348
|
+
},
|
|
349
|
+
async load(profile) {
|
|
350
|
+
return assertCredential(await invoke("Load", assertCredentialProfileId(profile), ""));
|
|
351
|
+
},
|
|
352
|
+
async remove(profile) {
|
|
353
|
+
await invoke("Remove", assertCredentialProfileId(profile), "");
|
|
354
|
+
},
|
|
355
|
+
runtimeSessions: {
|
|
356
|
+
async save(profile, session) {
|
|
357
|
+
const body = JSON.stringify({ schemaVersion: 1, ...assertRuntimeSession(session) });
|
|
358
|
+
await invoke("StoreRuntimeSession", assertCredentialProfileId(profile), body);
|
|
359
|
+
},
|
|
360
|
+
async load(profile) {
|
|
361
|
+
const output = await invoke("LoadRuntimeSession", assertCredentialProfileId(profile), "");
|
|
362
|
+
if (output === "missing") return null;
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(output);
|
|
365
|
+
if (parsed?.schemaVersion !== 1) throw new Error("schema");
|
|
366
|
+
return assertRuntimeSession(parsed);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof HelloLynkCredentialStoreError) throw error;
|
|
369
|
+
throw new HelloLynkCredentialStoreError("invalid_runtime_session_handle", { cause: error });
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
async remove(profile) {
|
|
373
|
+
await invoke("RemoveRuntimeSession", assertCredentialProfileId(profile), "");
|
|
374
|
+
},
|
|
375
|
+
async withLock(profile, operation) {
|
|
376
|
+
if (!runtimeStoreRoot) throw new HelloLynkCredentialStoreError("credential_store_root_unavailable");
|
|
377
|
+
if (typeof operation !== "function") throw new HelloLynkCredentialStoreError("runtime_session_lock_operation_invalid");
|
|
378
|
+
const profileId = assertCredentialProfileId(profile);
|
|
379
|
+
return withRuntimeSessionFileLock(
|
|
380
|
+
runtimeStoreRoot,
|
|
381
|
+
profileId,
|
|
382
|
+
() => {
|
|
383
|
+
// Inactive before the lock is released, so work that outlives
|
|
384
|
+
// the callback counts as unlocked.
|
|
385
|
+
const scope = { profileId, active: true };
|
|
386
|
+
return lockScopes.run(scope, async () => {
|
|
387
|
+
try { return await operation(); } finally { scope.active = false; }
|
|
388
|
+
});
|
|
389
|
+
},
|
|
390
|
+
options.runtimeSessionLockDependencies,
|
|
391
|
+
runnerOptions.timeoutMs,
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
directTextPrivateKeys: {
|
|
396
|
+
async save(profile, privateKeyMaterial) {
|
|
397
|
+
const profileId = assertCredentialProfileId(profile);
|
|
398
|
+
const scope = lockScopes.getStore();
|
|
399
|
+
const locked = scope?.active === true && scope.profileId === profileId;
|
|
400
|
+
let written = null;
|
|
401
|
+
e2eeReads.delete(profileId);
|
|
402
|
+
e2eeWriteMark(profileId);
|
|
403
|
+
try {
|
|
404
|
+
const encoded = encodeDirectTextPrivateKeyMaterial(privateKeyMaterial);
|
|
405
|
+
const outer = locked ? await e2eeOuterIdentity() : null;
|
|
406
|
+
const output = await invokeE2ee("StoreE2eePrivateKeys", profileId, encoded);
|
|
407
|
+
// Under the lock no other writer can replace the file, so a
|
|
408
|
+
// confirmed save is what the next read would return, unless
|
|
409
|
+
// something the save leaves alone changed meanwhile.
|
|
410
|
+
if (outer && output === "stored") {
|
|
411
|
+
const [identity, outerAfter] = await Promise.all([e2eeReadIdentity(profileId), e2eeOuterIdentity()]);
|
|
412
|
+
if (identity && outerAfter === outer && scope.active) written = { identity, output: encoded };
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
e2eeWriteMark(profileId);
|
|
416
|
+
if (written) e2eeReads.set(profileId, written);
|
|
417
|
+
else e2eeReads.delete(profileId);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
async load(profile) {
|
|
421
|
+
const profileId = assertCredentialProfileId(profile);
|
|
422
|
+
const writes = e2eeWrites.get(profileId);
|
|
423
|
+
const before = await e2eeReadIdentity(profileId);
|
|
424
|
+
const cached = e2eeReads.get(profileId);
|
|
425
|
+
// Decode again on every hit: callers mutate state before saving.
|
|
426
|
+
if (before && cached?.identity === before) return decodeDirectTextPrivateKeyMaterial(cached.output);
|
|
427
|
+
e2eeReads.delete(profileId);
|
|
428
|
+
const output = await invokeE2ee("LoadE2eePrivateKeys", profileId, "");
|
|
429
|
+
if (output === "missing") return null;
|
|
430
|
+
const material = decodeDirectTextPrivateKeyMaterial(output);
|
|
431
|
+
if (before && e2eeWrites.get(profileId) === writes) e2eeReads.set(profileId, { identity: before, output });
|
|
432
|
+
return material;
|
|
433
|
+
},
|
|
434
|
+
// Seeds the read cache for many profiles with one helper process; each
|
|
435
|
+
// later load still checks its file identity. Missing keys seed nothing.
|
|
436
|
+
async preload(profiles) {
|
|
437
|
+
const profileIds = [...new Set((Array.isArray(profiles) ? profiles : []).map(assertCredentialProfileId))];
|
|
438
|
+
if (profileIds.length === 0) return;
|
|
439
|
+
if (profileIds.length > MAX_E2EE_BATCH_PROFILES) throw new HelloLynkCredentialStoreError("e2ee_private_key_batch_invalid");
|
|
440
|
+
const writes = profileIds.map((profileId) => e2eeWrites.get(profileId));
|
|
441
|
+
const before = await Promise.all(profileIds.map(e2eeReadIdentity));
|
|
442
|
+
// Up to 64 reads share one process, so it gets the helper's longest wait.
|
|
443
|
+
const output = await invokeE2ee("LoadE2eePrivateKeysBatch", "e2ee-key-batch", JSON.stringify(profileIds),
|
|
444
|
+
{ maxOutputBytes: profileIds.length * MAX_E2EE_BATCH_ITEM_OUTPUT_BYTES + 2, timeoutMs: 30_000 });
|
|
445
|
+
decodeDirectTextPrivateKeyBatch(output, profileIds).forEach((entry, index) => {
|
|
446
|
+
if (entry.output == null || !before[index] || e2eeWrites.get(entry.profileId) !== writes[index]) return;
|
|
447
|
+
e2eeReads.set(entry.profileId, { identity: before[index], output: entry.output });
|
|
448
|
+
});
|
|
449
|
+
},
|
|
450
|
+
async remove(profile) {
|
|
451
|
+
const profileId = assertCredentialProfileId(profile);
|
|
452
|
+
e2eeReads.delete(profileId);
|
|
453
|
+
e2eeWriteMark(profileId);
|
|
454
|
+
try {
|
|
455
|
+
await invokeE2ee("RemoveE2eePrivateKeys", profileId, "");
|
|
456
|
+
} finally { e2eeWriteMark(profileId); e2eeReads.delete(profileId); }
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (platform === "darwin") {
|
|
463
|
+
const helper = options.helperPath || join(
|
|
464
|
+
homedir(), "Library", "Application Support", "HelloLynk", "bin", "hellolynk-keychain-helper",
|
|
465
|
+
);
|
|
466
|
+
const invoke = (command, profileId, stdin) => run(
|
|
467
|
+
helper,
|
|
468
|
+
[command.toLowerCase(), profileId],
|
|
469
|
+
{ ...runnerOptions, stdin },
|
|
470
|
+
);
|
|
471
|
+
return {
|
|
472
|
+
platform,
|
|
473
|
+
async save(profile, credential) {
|
|
474
|
+
await invoke("Store", assertCredentialProfileId(profile), assertCredential(credential));
|
|
475
|
+
},
|
|
476
|
+
async load(profile) {
|
|
477
|
+
return assertCredential(await invoke("Load", assertCredentialProfileId(profile), ""));
|
|
478
|
+
},
|
|
479
|
+
async remove(profile) {
|
|
480
|
+
await invoke("Remove", assertCredentialProfileId(profile), "");
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (platform === "linux") {
|
|
486
|
+
const helper = options.helperPath || join(CLIENTS_DIR, "platform", "linux", "credential-store.sh");
|
|
487
|
+
// The bundled script can be copied/unpacked without an executable bit.
|
|
488
|
+
// An explicitly supplied helper may be a binary, so keep its direct invocation.
|
|
489
|
+
const executable = options.helperPath || "/bin/sh";
|
|
490
|
+
const helperArgs = options.helperPath ? [] : [helper];
|
|
491
|
+
const trustedHelperEnvironment = options.secretToolPath
|
|
492
|
+
? { HELLOLYNK_SECRET_TOOL_BIN: options.secretToolPath }
|
|
493
|
+
: {};
|
|
494
|
+
const invoke = (command, profileId, stdin) => run(
|
|
495
|
+
executable,
|
|
496
|
+
[...helperArgs, command.toLowerCase(), profileId],
|
|
497
|
+
{ ...runnerOptions, env: trustedHelperEnvironment, trustedHelperEnvironment, stdin },
|
|
498
|
+
);
|
|
499
|
+
return {
|
|
500
|
+
platform,
|
|
501
|
+
async save(profile, credential) {
|
|
502
|
+
await invoke("Store", assertCredentialProfileId(profile), assertCredential(credential));
|
|
503
|
+
},
|
|
504
|
+
async load(profile) {
|
|
505
|
+
return assertCredential(await invoke("Load", assertCredentialProfileId(profile), ""));
|
|
506
|
+
},
|
|
507
|
+
async remove(profile) {
|
|
508
|
+
await invoke("Remove", assertCredentialProfileId(profile), "");
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
throw new HelloLynkCredentialStoreError("unsupported_client_platform");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Type, file id, size and times (including ctime, which ACL changes update)
|
|
517
|
+
// of each given file or directory and of every ancestor directory. A missing,
|
|
518
|
+
// linked, id-less or wrongly typed component returns null: such reads are
|
|
519
|
+
// never cached and the helper decides.
|
|
520
|
+
async function protectedReadIdentity(files, directories = []) {
|
|
521
|
+
const components = new Map(files.map((file) => [file, "file"]));
|
|
522
|
+
for (const directory of directories) components.set(directory, "directory");
|
|
523
|
+
for (const path of [...components.keys()]) {
|
|
524
|
+
for (let directory = dirname(path); ; directory = dirname(directory)) {
|
|
525
|
+
components.set(directory, "directory");
|
|
526
|
+
if (dirname(directory) === directory) break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const entries = [...components];
|
|
531
|
+
const stats = await Promise.all(entries.map(([path]) => lstat(path, { bigint: true })));
|
|
532
|
+
return entries.map(([path, kind], index) => {
|
|
533
|
+
const metadata = stats[index];
|
|
534
|
+
if (metadata.isSymbolicLink() || metadata.ino === 0n
|
|
535
|
+
|| (kind === "file" ? !metadata.isFile() : !metadata.isDirectory())) throw new Error("uncacheable");
|
|
536
|
+
return [path, metadata.dev, metadata.ino, metadata.mode, metadata.size,
|
|
537
|
+
metadata.mtimeNs, metadata.ctimeNs, metadata.birthtimeNs].join(":");
|
|
538
|
+
}).join("\n");
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function withRuntimeSessionFileLock(storeRoot, profileId, operation, dependencies = {}, helperTimeoutOption) {
|
|
545
|
+
await mkdir(storeRoot, { recursive: true, mode: 0o700 });
|
|
546
|
+
const rootMetadata = await lstat(storeRoot);
|
|
547
|
+
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
|
548
|
+
throw new HelloLynkCredentialStoreError("credential_store_root_invalid");
|
|
549
|
+
}
|
|
550
|
+
const lockDir = join(storeRoot, `.runtime-session-${profileId}.lock`);
|
|
551
|
+
const helperTimeoutMs = Math.min(Math.max(Number(helperTimeoutOption) || 10_000, 1_000), 30_000);
|
|
552
|
+
// Invalid saved data can instead take one request and three helper calls
|
|
553
|
+
// (load, remove, save); the helper cap is no greater than the request cap.
|
|
554
|
+
const recoveryWaitMs = 2 * RUNTIME_SESSION_RECOVERY_REQUEST_TIMEOUT_MS
|
|
555
|
+
+ 2 * helperTimeoutMs + 2 * RUNTIME_SESSION_LOCK_WAIT_MS;
|
|
556
|
+
if (dependencies?.waitTimeoutMs !== undefined && (
|
|
557
|
+
!Number.isSafeInteger(dependencies.waitTimeoutMs)
|
|
558
|
+
|| dependencies.waitTimeoutMs < 1
|
|
559
|
+
|| dependencies.waitTimeoutMs > recoveryWaitMs
|
|
560
|
+
)) throw new HelloLynkCredentialStoreError("runtime_session_lock_wait_timeout_invalid");
|
|
561
|
+
const waitTimeoutMs = dependencies?.waitTimeoutMs ?? recoveryWaitMs;
|
|
562
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
563
|
+
const ownerProcess = await inspectProcessIdentity(process.pid);
|
|
564
|
+
const ownerIdentityCache = {};
|
|
565
|
+
while (true) {
|
|
566
|
+
const owner = {
|
|
567
|
+
pid: process.pid,
|
|
568
|
+
ownerIdentity: ownerProcess?.identity || null,
|
|
569
|
+
generation: randomBytes(24).toString("hex"),
|
|
570
|
+
createdAtMs: Date.now(),
|
|
571
|
+
};
|
|
572
|
+
if (!(await publishRuntimeSessionLockCandidate(lockDir, owner))) {
|
|
573
|
+
await reclaimDeadRuntimeSessionLock(lockDir, dependencies, ownerIdentityCache).catch((reclaimError) => {
|
|
574
|
+
if (!["ENOENT", "EACCES", "EPERM"].includes(reclaimError?.code)) throw reclaimError;
|
|
575
|
+
});
|
|
576
|
+
if (Date.now() >= deadline) throw new HelloLynkCredentialStoreError("runtime_session_lock_timeout");
|
|
577
|
+
await new Promise((resolve) => setTimeout(resolve, RUNTIME_SESSION_LOCK_WAIT_MS));
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
return await operation();
|
|
582
|
+
} finally {
|
|
583
|
+
await releaseRuntimeSessionLock(lockDir, owner);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function publishRuntimeSessionLockCandidate(lockDir, owner) {
|
|
589
|
+
const candidateDir = `${lockDir}.candidate.${process.pid}.${owner.generation}`;
|
|
590
|
+
try {
|
|
591
|
+
await mkdir(candidateDir, { mode: 0o700 });
|
|
592
|
+
await writeFile(join(candidateDir, "owner.json"), `${JSON.stringify(owner)}\n`, {
|
|
593
|
+
encoding: "utf8",
|
|
594
|
+
flag: "wx",
|
|
595
|
+
mode: 0o600,
|
|
596
|
+
});
|
|
597
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
598
|
+
try {
|
|
599
|
+
await rename(candidateDir, lockDir);
|
|
600
|
+
return true;
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const current = await lstat(lockDir).catch(() => null);
|
|
603
|
+
if (current) return false;
|
|
604
|
+
const transient = ["EACCES", "EBUSY", "EPERM"].includes(error?.code);
|
|
605
|
+
if (!transient || attempt >= 5) throw error;
|
|
606
|
+
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
await rm(candidateDir, { recursive: true, force: true }).catch(() => {});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function reclaimDeadRuntimeSessionLock(lockDir, dependencies, ownerIdentityCache) {
|
|
615
|
+
const observed = await observeRuntimeSessionLock(lockDir);
|
|
616
|
+
if (!(await runtimeSessionLockObservationIsStale(observed, dependencies, ownerIdentityCache))) return false;
|
|
617
|
+
await dependencies?.afterStaleObservation?.({ lockDir, observed });
|
|
618
|
+
const takeover = await claimRuntimeSessionLockTakeover(lockDir, observed);
|
|
619
|
+
if (!takeover) return false;
|
|
620
|
+
const stalePath = `${lockDir}.stale.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
621
|
+
let renamed = false;
|
|
622
|
+
try {
|
|
623
|
+
const verified = await observeRuntimeSessionLock(lockDir);
|
|
624
|
+
const sameGeneration = sameRuntimeSessionLockGeneration(observed, verified);
|
|
625
|
+
await dependencies?.afterTakeoverRevalidation?.({
|
|
626
|
+
lockDir,
|
|
627
|
+
observed,
|
|
628
|
+
verified,
|
|
629
|
+
sameGeneration,
|
|
630
|
+
});
|
|
631
|
+
if (
|
|
632
|
+
!sameGeneration
|
|
633
|
+
|| !(await runtimeSessionLockObservationIsStale(observed.owner ? verified : observed, dependencies, ownerIdentityCache, true))
|
|
634
|
+
) return false;
|
|
635
|
+
await rename(lockDir, stalePath);
|
|
636
|
+
renamed = true;
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (["ENOENT", "EACCES", "EPERM"].includes(error?.code)) return false;
|
|
639
|
+
throw error;
|
|
640
|
+
} finally {
|
|
641
|
+
if (!renamed) await releaseRuntimeSessionLockTakeover(takeover).catch(() => {});
|
|
642
|
+
}
|
|
643
|
+
await rm(stalePath, { recursive: true, force: true });
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function observeRuntimeSessionLock(lockDir) {
|
|
648
|
+
const metadata = await lstat(lockDir);
|
|
649
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
650
|
+
throw new HelloLynkCredentialStoreError("runtime_session_lock_path_invalid");
|
|
651
|
+
}
|
|
652
|
+
let owner = null;
|
|
653
|
+
try {
|
|
654
|
+
owner = JSON.parse(await readFile(join(lockDir, "owner.json"), "utf8"));
|
|
655
|
+
} catch {
|
|
656
|
+
// A stale incomplete lock remains reclaimable after the full stale window.
|
|
657
|
+
}
|
|
658
|
+
return { metadata, owner };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function runtimeSessionLockObservationIsStale(observation, dependencies, cache, fresh = false) {
|
|
662
|
+
const ownerCreatedAtMs = Number(observation.owner?.createdAtMs);
|
|
663
|
+
const createdAtMs = Number.isFinite(ownerCreatedAtMs)
|
|
664
|
+
? ownerCreatedAtMs
|
|
665
|
+
: observation.metadata.mtimeMs;
|
|
666
|
+
if (Date.now() - createdAtMs < RUNTIME_SESSION_LOCK_STALE_MS) return false;
|
|
667
|
+
const pid = Number(observation.owner?.pid);
|
|
668
|
+
if (!processAlive(pid)) return true;
|
|
669
|
+
// Avoid spawning a system probe every 25 ms while a slow owner holds the
|
|
670
|
+
// same lock. Death is still checked above; takeover always probes afresh.
|
|
671
|
+
const key = `${pid}:${observation.owner?.generation || ""}`;
|
|
672
|
+
if (fresh || cache.key !== key || performance.now() - cache.at >= 5_000) {
|
|
673
|
+
const value = await (dependencies?.inspectProcessIdentity || inspectProcessIdentity)(pid);
|
|
674
|
+
Object.assign(cache, { key, at: performance.now(), value });
|
|
675
|
+
}
|
|
676
|
+
const current = cache.value;
|
|
677
|
+
if (!current) return false;
|
|
678
|
+
const recorded = observation.owner?.ownerIdentity;
|
|
679
|
+
// Portable self-only fallbacks and native identities are not comparable.
|
|
680
|
+
// An unavailable probe never gives permission to evict a live owner.
|
|
681
|
+
if (typeof recorded === "string" && current.identity
|
|
682
|
+
&& recorded.split(":", 1)[0] === current.identity.split(":", 1)[0]) {
|
|
683
|
+
return recorded !== current.identity;
|
|
684
|
+
}
|
|
685
|
+
// Locks left by the installed version have no birth identity. A process
|
|
686
|
+
// born after the lock cannot own it, even if Windows reused its PID.
|
|
687
|
+
return Number.isFinite(current.startedAtMs) && current.startedAtMs > createdAtMs + 1_000;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function sameRuntimeSessionLockGeneration(left, right) {
|
|
691
|
+
if (left.owner?.generation || right.owner?.generation) {
|
|
692
|
+
return Boolean(
|
|
693
|
+
left.owner
|
|
694
|
+
&& right.owner
|
|
695
|
+
&& left.owner.generation === right.owner.generation
|
|
696
|
+
&& left.owner.pid === right.owner.pid,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
const sameDirectory = left.metadata.dev === right.metadata.dev
|
|
700
|
+
&& left.metadata.ino === right.metadata.ino
|
|
701
|
+
&& left.metadata.birthtimeMs === right.metadata.birthtimeMs;
|
|
702
|
+
if (!sameDirectory) return false;
|
|
703
|
+
if (!left.owner && !right.owner) return true;
|
|
704
|
+
return Boolean(
|
|
705
|
+
left.owner
|
|
706
|
+
&& right.owner
|
|
707
|
+
&& left.owner.pid === right.owner.pid
|
|
708
|
+
&& left.owner.createdAtMs === right.owner.createdAtMs,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function claimRuntimeSessionLockTakeover(lockDir, observed) {
|
|
713
|
+
const claimPath = join(lockDir, "takeover.json");
|
|
714
|
+
const claim = {
|
|
715
|
+
pid: process.pid,
|
|
716
|
+
token: randomBytes(24).toString("hex"),
|
|
717
|
+
observedGeneration: observed.owner?.generation || null,
|
|
718
|
+
createdAtMs: Date.now(),
|
|
719
|
+
};
|
|
720
|
+
try {
|
|
721
|
+
await writeFile(claimPath, `${JSON.stringify(claim)}\n`, {
|
|
722
|
+
encoding: "utf8",
|
|
723
|
+
flag: "wx",
|
|
724
|
+
mode: 0o600,
|
|
725
|
+
});
|
|
726
|
+
return { claimPath, claim };
|
|
727
|
+
} catch (error) {
|
|
728
|
+
if (["EEXIST", "ENOENT"].includes(error?.code)) return null;
|
|
729
|
+
throw error;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function releaseRuntimeSessionLockTakeover(takeover) {
|
|
734
|
+
const current = JSON.parse(await readFile(takeover.claimPath, "utf8"));
|
|
735
|
+
if (current?.token !== takeover.claim.token || current?.pid !== takeover.claim.pid) return false;
|
|
736
|
+
await rm(takeover.claimPath);
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function releaseRuntimeSessionLock(lockDir, owner) {
|
|
741
|
+
const releasedPath = `${lockDir}.released.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
742
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
743
|
+
const observed = await observeRuntimeSessionLock(lockDir).catch((error) => {
|
|
744
|
+
if (error?.code === "ENOENT") return null;
|
|
745
|
+
throw error;
|
|
746
|
+
});
|
|
747
|
+
if (!observed) return false;
|
|
748
|
+
if (
|
|
749
|
+
observed.owner?.generation !== owner.generation
|
|
750
|
+
|| observed.owner?.pid !== owner.pid
|
|
751
|
+
) return false;
|
|
752
|
+
try {
|
|
753
|
+
await rename(lockDir, releasedPath);
|
|
754
|
+
break;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
const transient = ["EACCES", "EBUSY", "EPERM"].includes(error?.code);
|
|
757
|
+
if (!transient || attempt >= 20) throw error;
|
|
758
|
+
await new Promise((resolve) => setTimeout(resolve, 5 * (attempt + 1)));
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
await rm(releasedPath, { recursive: true, force: true });
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function processAlive(pid) {
|
|
766
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
767
|
+
try {
|
|
768
|
+
process.kill(pid, 0);
|
|
769
|
+
return true;
|
|
770
|
+
} catch (error) {
|
|
771
|
+
return error?.code === "EPERM";
|
|
772
|
+
}
|
|
773
|
+
}
|