@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100

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.
Files changed (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +101 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,600 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createClientUpdateManager, extractClientUpdateArchive, compareClientUpdateVersions, CLIENT_UPDATE_CLIENTS } from "./hellolynk-client-update.mjs";
8
+ import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-store.mjs";
9
+ import { readClientAccount } from "./hellolynk-client-account.mjs";
10
+ import runtimeSafety from "./hellolynk-runtime-safety.cjs";
11
+ import { pruneInstalledReleases } from "./hellolynk-release-retention.mjs";
12
+
13
+ const SELF = fileURLToPath(import.meta.url);
14
+ const HASH = /^[a-f0-9]{64}$/;
15
+ const VERSION = /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.+-]+)?$/;
16
+ const NAMES = { codex: "@hellolynk-ai/codex-plugin", openclaw: "@hellolynk-ai/openclaw-channel", "trae-work": "@hellolynk-ai/trae-work-mcp", workbuddy: "@hellolynk-ai/workbuddy-mcp", dsh: "hellolynk-dsh-local-connect", "claude-code": "@hellolynk-ai/claude-code-mcp" };
17
+ const CONTRACT = "hellolynk-client-update-runtime-v1";
18
+ const STAGES = ["idle", "applying", "applied", "failed"];
19
+ const MAX_LEASES = 128;
20
+ // A version gets one automatic retry, and only for failures the machine caused rather than the package.
21
+ const RETRYABLE = new Set(["client_update_apply_interrupted", "client_update_setup_failed_previous_preserved", "client_update_native_install_failed_rolled_back", "client_update_installer_timeout", "client_update_lock_timeout"]);
22
+ // The retry waits this long after the failure, so a cause that lasts a few minutes (a held file, a virus scan) does not use it up.
23
+ const RETRY_AFTER_MS = 10 * 60_000;
24
+
25
+ // Call after a client's initialize response. Nothing here installs: the promise
26
+ // waits only for DSH's lease, and a downloaded update is applied by a worker in
27
+ // the background. Checking the network is always detached.
28
+ export async function startClientUpdateRuntime(options = {}) {
29
+ if ((options.platform || process.platform) !== "win32") return disabled();
30
+ let config;
31
+ try { config = normalize(options); await verifyInstalledContext(config); }
32
+ catch { return disabled(); }
33
+ const io = createRuntime(config, options);
34
+ const token = crypto.randomBytes(24).toString("hex");
35
+ let stopped = false, timer;
36
+ // DSH replaces files its running Harness has loaded, so its worker waits for every lease. Other clients have none.
37
+ if (config.client === "dsh") {
38
+ await io.gate(async () => {
39
+ const leases = await io.leases();
40
+ leases.push({ pid: process.pid, token, writtenAt: Date.now() });
41
+ await io.writeLeases(leases);
42
+ });
43
+ }
44
+ await io.sweepLockCandidates().catch(() => {});
45
+ async function check({ force = false } = {}) {
46
+ if (stopped) return io.readState();
47
+ const downloaded = await io.manager.check({ force });
48
+ if (downloaded.stage === "downloaded" && downloaded.sha256 && !downloaded.error) await io.ensureWorker();
49
+ return io.readState();
50
+ }
51
+ // Network failures and a pending update must never reject host startup.
52
+ queueMicrotask(() => { void io.ensureWorker().catch(() => {}).then(() => check()).catch(() => {}); });
53
+ timer = setInterval(() => { void check().catch(() => {}); }, 24 * 3_600_000);
54
+ timer.unref?.();
55
+ return Object.freeze({
56
+ disabled: false,
57
+ check,
58
+ readState: io.readState,
59
+ async stop() {
60
+ if (stopped) return;
61
+ stopped = true; clearInterval(timer);
62
+ if (config.client !== "dsh") return;
63
+ await io.gate(async () => { await io.writeLeases((await io.leases()).filter((lease) => lease.token !== token)); });
64
+ },
65
+ });
66
+ }
67
+
68
+ // Exported for bounded integration tests and the hidden worker entry point.
69
+ export async function runClientUpdateWorker(options = {}) {
70
+ const config = normalize(options);
71
+ await verifyInstalledContext(config);
72
+ const io = createRuntime(config, options);
73
+ // Only DSH waits: its files are replaced under a running Harness. Every other client installs at once.
74
+ const waits = config.client === "dsh";
75
+ const deadline = Date.now() + (options.workerLifetimeMs || 24 * 3_600_000);
76
+ while (Date.now() < deadline) {
77
+ let result;
78
+ try {
79
+ result = await io.gate(async () => {
80
+ if (waits && (await io.leases()).length) return null;
81
+ return (await io.applyPending()) || { done: true };
82
+ });
83
+ } catch (error) {
84
+ if (error?.code !== "client_update_lock_timeout") throw error;
85
+ await io.recordLockTimeout().catch(() => {});
86
+ return { ok: false };
87
+ }
88
+ if (result) return result;
89
+ await new Promise((resolve) => setTimeout(resolve, options.workerPollMs || 2_000));
90
+ }
91
+ return { deferred: true };
92
+ }
93
+
94
+ function createRuntime(config, options) {
95
+ const { root, client, currentVersion, installContext } = config;
96
+ const directory = path.join(root, "client-updates", client);
97
+ const statePath = path.join(directory, "runtime.json"), leasePath = path.join(directory, "leases.json"), installedPath = path.join(directory, "installed.json");
98
+ const manager = createClientUpdateManager({ ...options, root, client, currentVersion });
99
+ const alive = options.isProcessAlive || processAlive;
100
+ async function gate(operation) {
101
+ await safeDirectory(directory);
102
+ // An installation can take longer than the shared lock's single wait. Keep
103
+ // waiting locally instead of starting an old binding during its commit.
104
+ const attempts = options.lockAttempts || 36;
105
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
106
+ try { return await withNeutralHostStoreLock(directory, "update-apply", operation); }
107
+ catch (error) {
108
+ if (error?.code !== "host_registry_lock_timeout") throw error;
109
+ if (attempt === attempts - 1) throw failure("lock_timeout");
110
+ }
111
+ }
112
+ }
113
+ async function readRuntime() {
114
+ const idle = { contract: CONTRACT, client, stage: "idle" };
115
+ let value;
116
+ try { value = await readJson(statePath, null); }
117
+ catch (error) { if (!damaged(error)) throw error; return idle; }
118
+ // A damaged record starts over; the next attempt overwrites it.
119
+ if (value == null || value.contract !== CONTRACT || value.client !== client || !STAGES.includes(value.stage)
120
+ || (value.sha256 != null && !HASH.test(value.sha256)) || (value.retried != null && typeof value.retried !== "boolean")) return idle;
121
+ return value;
122
+ }
123
+ async function readState() {
124
+ const [downloaded, runtime] = await Promise.all([manager.readState(), readRuntime()]);
125
+ const relevant = runtime.sha256 && runtime.sha256 === downloaded.sha256;
126
+ return Object.freeze({ ...downloaded, ...(relevant ? runtime : {}), client, downloadStage: downloaded.stage });
127
+ }
128
+ // Leases exist for DSH alone. Dead processes drop out first, a lease written
129
+ // before the machine booted is stale, and the file never holds more than
130
+ // MAX_LEASES. A file in any other shape is discarded and written afresh.
131
+ async function leases() {
132
+ let value;
133
+ try { value = await readJson(leasePath, null); }
134
+ catch (error) { if (!damaged(error)) throw error; return []; }
135
+ if (value?.contract !== CONTRACT || value.client !== client || !Array.isArray(value.leases)
136
+ || value.leases.some((lease) => !Number.isSafeInteger(lease?.pid) || lease.pid <= 0 || !/^[a-f0-9]{48}$/.test(lease.token)
137
+ || !Number.isFinite(lease.writtenAt))) return [];
138
+ const booted = Date.now() - os.uptime() * 1000;
139
+ return value.leases.filter((lease) => lease.writtenAt >= booted && alive(lease.pid)).slice(-MAX_LEASES);
140
+ }
141
+ async function writeLeases(value) { await atomicJson(leasePath, { contract: CONTRACT, client, leases: value.slice(-MAX_LEASES) }); }
142
+ function isReleaseRoot(value) {
143
+ return typeof value === "string" && path.dirname(value) === path.join(path.dirname(root), "releases")
144
+ && new RegExp(`^${client}-[a-f0-9]{64}$`).test(path.basename(value));
145
+ }
146
+ // A later run for an artifact already installed verifies that release and reports it again.
147
+ async function installedRelease(previous, downloaded) {
148
+ const next = previous.nextPackageRoot;
149
+ if (!isReleaseRoot(next)) throw failure("state_invalid");
150
+ if (!options.runInstaller) await verifyPackage(next, client, downloaded.version);
151
+ return { ok: true, nextPackageRoot: next };
152
+ }
153
+ // A process that stays open keeps the code it started with while its updater installs newer
154
+ // releases. Newest first: the release this updater installed last, when it is newer than the
155
+ // startup one and older than the next, then the startup one.
156
+ async function knownInstalls(nextVersion) {
157
+ const startup = { currentVersion, installContext };
158
+ const record = await readJson(installedPath, null).catch(() => null);
159
+ if (record?.contract !== CONTRACT || record.client !== client || !VERSION.test(String(record.version)) || !isReleaseRoot(record.packageRoot)
160
+ || compareClientUpdateVersions(record.version, currentVersion) <= 0 || compareClientUpdateVersions(nextVersion, record.version) <= 0) return [startup];
161
+ return [{ currentVersion: record.version, installContext: { ...installContext, packageRoot: record.packageRoot } }, startup];
162
+ }
163
+ // DSH installs only after every DSH process has exited, so its next worker always starts from
164
+ // the current install. A record that could not be written only means the startup one is tried.
165
+ async function recordInstalled(version, packageRoot) {
166
+ if (client === "dsh") return;
167
+ await atomicJson(installedPath, { contract: CONTRACT, client, version, packageRoot }).catch(() => {});
168
+ }
169
+ // A crashed lock attempt leaves its candidate file behind. Sweep only this
170
+ // updater's two locks, only after an hour, and only once its process is gone.
171
+ async function sweepLockCandidates() {
172
+ await safePath(directory);
173
+ let names;
174
+ try { names = await fs.readdir(directory); } catch (error) { if (error.code === "ENOENT") return; throw error; }
175
+ for (const name of names) {
176
+ const owner = /^\.(?:update-apply|client-update)\.lock\.candidate\.(\d+)\.[a-f0-9]{16}$/.exec(name);
177
+ if (!owner || alive(Number(owner[1]))) continue;
178
+ const file = path.join(directory, name), stat = await fs.lstat(file);
179
+ if (stat.isFile() && Date.now() - stat.mtimeMs > 3_600_000) await fs.rm(file, { force: true });
180
+ }
181
+ }
182
+ // Giving up on the lock is an environment failure and counts as an attempt,
183
+ // unless another process is still installing this very package.
184
+ async function recordLockTimeout() {
185
+ const downloaded = await manager.readState(), previous = await readRuntime();
186
+ if (downloaded.stage !== "downloaded" || downloaded.error || !HASH.test(String(downloaded.sha256 || ""))) return;
187
+ const same = previous.sha256 === downloaded.sha256;
188
+ if (same && !(previous.stage === "failed" && previous.retried !== true && RETRYABLE.has(previous.error))) return;
189
+ await atomicJson(statePath, { contract: CONTRACT, client, stage: "failed", version: downloaded.version, sha256: downloaded.sha256,
190
+ error: "client_update_lock_timeout", attemptedAt: Date.now(), ...(same ? { retried: true } : {}) });
191
+ }
192
+ // After a good install the update cache keeps only the downloaded package. Cleanup never undoes an install.
193
+ async function tidyCache() { try { await manager.prune(); } catch { /* best effort */ } }
194
+ async function ensureWorker() {
195
+ return gate(async () => {
196
+ const downloaded = await manager.readState(), runtime = await readRuntime();
197
+ // One recorded attempt per version, plus one retry of a failure the machine caused.
198
+ const retryable = runtime.stage === "failed" && runtime.retried !== true && RETRYABLE.has(runtime.error)
199
+ && !(Number.isFinite(runtime.attemptedAt) && Date.now() >= runtime.attemptedAt && Date.now() - runtime.attemptedAt < RETRY_AFTER_MS);
200
+ const attempted = runtime.sha256 === downloaded.sha256 && !(runtime.stage === "applying" || retryable);
201
+ if (downloaded.stage !== "downloaded" || downloaded.error || attempted) return;
202
+ // A damaged worker record means no known worker.
203
+ const worker = await readJson(path.join(directory, "worker.json"), null).catch((error) => { if (!damaged(error)) throw error; return null; });
204
+ if (worker?.client === client && Number.isSafeInteger(worker.pid) && alive(worker.pid)) return;
205
+ const launch = options.spawnWorker || launchWorker;
206
+ let pid;
207
+ try {
208
+ pid = await launch(config);
209
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw failure("worker_launch_failed");
210
+ } catch (error) {
211
+ // Retain only this rejected launch attempt, not raw errors or proof of
212
+ // worker absence. Keep an earlier PID's conservative liveness check.
213
+ await atomicJson(path.join(directory, "worker.json"), { client,
214
+ ...(worker?.client === client && Number.isSafeInteger(worker.pid) ? { pid: worker.pid } : {}),
215
+ sha256: downloaded.sha256, error: "client_update_worker_launch_failed", failedAt: Date.now(),
216
+ }).catch(() => {});
217
+ throw error;
218
+ }
219
+ await atomicJson(path.join(directory, "worker.json"), { client, pid });
220
+ });
221
+ }
222
+ async function applyPending() {
223
+ const downloaded = await manager.readState();
224
+ if (!["downloaded", "current"].includes(downloaded.stage) || !downloaded.version || !HASH.test(String(downloaded.sha256 || "")) || downloaded.error) return null;
225
+ let previous = await readRuntime();
226
+ // The startup install until the doctor below proves which install this attempt starts from.
227
+ let { currentVersion, installContext } = config;
228
+ const versionOrder = compareClientUpdateVersions(downloaded.version, currentVersion);
229
+ if (versionOrder < 0) return null;
230
+ if (versionOrder === 0) {
231
+ if (previous.sha256 === downloaded.sha256 && ["applying", "failed"].includes(previous.stage)) {
232
+ let proof;
233
+ try {
234
+ proof = await prepareCandidate(config, downloaded);
235
+ await verifyPackage(installContext.packageRoot, client, currentVersion);
236
+ if (sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))
237
+ !== sha(await safeFile(path.join(installContext.packageRoot, "hellolynk-client-pack-manifest.json")))) throw failure("adoption_invalid");
238
+ const doctor = await (options.runInstaller || runInstaller)({ executable: installContext.nodePath || process.execPath,
239
+ args: [path.join(installContext.packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor"], cwd: installContext.packageRoot, root, timeoutMs: 180_000 });
240
+ if (!doctorProves(doctor, client, currentVersion)) throw failure("adoption_invalid");
241
+ const adoptedRoot = path.join(path.dirname(root), "releases", `${client}-${sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))}`);
242
+ if (!options.runInstaller) await verifyPackage(adoptedRoot, client, currentVersion);
243
+ await recordInstalled(currentVersion, adoptedRoot);
244
+ await atomicJson(statePath, { ...previous, stage: "applied", nextPackageRoot: adoptedRoot, appliedAt: Date.now(), error: null });
245
+ await pruneInstalledReleases({ releasesRoot: path.dirname(adoptedRoot), client,
246
+ currentRelease: adoptedRoot, previousRelease: previous.previousPackageRoot || null });
247
+ await tidyCache();
248
+ } catch { /* Unproven commits remain unavailable, never reported as success. */ }
249
+ finally { if (proof) await fs.rm(proof.temporaryRoot, { recursive: true, force: true }); }
250
+ }
251
+ return null;
252
+ }
253
+ const retrying = previous.sha256 === downloaded.sha256;
254
+ if (retrying) {
255
+ if (previous.stage === "applied") return installedRelease(previous, downloaded);
256
+ if (previous.stage === "applying") {
257
+ previous = { ...previous, stage: "failed", error: "client_update_apply_interrupted" };
258
+ await atomicJson(statePath, previous);
259
+ }
260
+ // One more attempt for a failure the machine caused; never for the package itself.
261
+ if (previous.stage !== "failed" || previous.retried === true || !RETRYABLE.has(previous.error)) return null;
262
+ if (Number.isFinite(previous.attemptedAt) && Date.now() >= previous.attemptedAt && Date.now() - previous.attemptedAt < RETRY_AFTER_MS) return null;
263
+ }
264
+ if (client === "dsh" && (!installContext.dshProfile || !installContext.dshRoot || !installContext.expectedInstalledManifestSha256)) return null;
265
+ let expectedAccountRevision;
266
+ try {
267
+ const account = readClientAccount(client, root);
268
+ if (account.phase !== "ready") return null;
269
+ expectedAccountRevision = account.revision;
270
+ } catch { return null; }
271
+ let candidate, rollbackRoot = null, previousImmutableRoot = null, bindingCommitted = false, nativeCommitted = false, nativeRecoveryAllowed = true;
272
+ const run = options.runInstaller || runInstaller;
273
+ const doctorAt = async (packageRoot, version) => {
274
+ try {
275
+ await verifyPackage(packageRoot, client, version);
276
+ const inspected = await run({ executable: installContext.nodePath || process.execPath,
277
+ args: [path.join(packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor"], cwd: packageRoot, root, timeoutMs: 180_000 });
278
+ return doctorProves(inspected, client, version);
279
+ } catch { return false; }
280
+ };
281
+ // Packages can be loaded before first enrollment. Do not consume this
282
+ // artifact's one installation attempt until the existing account/binding
283
+ // and client configuration are independently ready. The binding names the
284
+ // release installed last, or the startup one after a reinstall by hand.
285
+ let proven = null;
286
+ for (const install of await knownInstalls(downloaded.version)) {
287
+ if (await doctorAt(install.installContext.packageRoot, install.currentVersion)) { proven = install; break; }
288
+ }
289
+ if (!proven) return null;
290
+ ({ currentVersion, installContext } = proven);
291
+ try {
292
+ candidate = await prepareCandidate(config, downloaded);
293
+ previousImmutableRoot = path.join(path.dirname(root), "releases", `${client}-${sha(await safeFile(path.join(installContext.packageRoot, "hellolynk-client-pack-manifest.json")))}`);
294
+ if (client === "dsh") {
295
+ await verifyPackage(installContext.packageRoot, client, currentVersion);
296
+ rollbackRoot = path.join(candidate.temporaryRoot, "rollback");
297
+ await fs.cp(installContext.packageRoot, rollbackRoot, { recursive: true, force: false, errorOnExist: true });
298
+ await verifyPackage(rollbackRoot, client, currentVersion);
299
+ previousImmutableRoot = path.join(path.dirname(root), "releases", `${client}-${sha(await safeFile(path.join(rollbackRoot, "hellolynk-client-pack-manifest.json")))}`);
300
+ if (!options.runInstaller) await verifyPackage(previousImmutableRoot, client, currentVersion);
301
+ }
302
+ await atomicJson(statePath, { contract: CONTRACT, client, stage: "applying", version: downloaded.version, sha256: downloaded.sha256, previousPackageRoot: previousImmutableRoot, attemptedAt: Date.now(), ...(retrying ? { retried: true } : {}) });
303
+ const packManifest = await safeFile(path.join(candidate.packageRoot, "hellolynk-client-pack-manifest.json"));
304
+ const nextPackageRoot = path.join(path.dirname(root), "releases", `${client}-${sha(packManifest)}`);
305
+ try {
306
+ const installed = await run({ executable: installContext.nodePath || process.execPath,
307
+ args: [candidate.setupPath, "setup", "--defer-release-cleanup"], cwd: candidate.packageRoot, root, timeoutMs: 180_000 });
308
+ if (installed?.ok !== true || installed.client !== client || installed.packageVersion !== downloaded.version) throw failure("install_result_invalid");
309
+ if (!options.runInstaller) await verifyPackage(nextPackageRoot, client, downloaded.version);
310
+ } catch {
311
+ // An exit code, missing stdout or timeout is not evidence of rollback.
312
+ // Inspect actual binding AND client configuration before deciding. These
313
+ // reads never overwrite a concurrent human change to a third release.
314
+ if (await doctorAt(candidate.packageRoot, downloaded.version)) {
315
+ if (!options.runInstaller) await verifyPackage(nextPackageRoot, client, downloaded.version);
316
+ } else if (await doctorAt(rollbackRoot || installContext.packageRoot, currentVersion)) {
317
+ throw failure("setup_failed_previous_preserved");
318
+ } else {
319
+ throw failure("setup_recovery_required");
320
+ }
321
+ }
322
+ bindingCommitted = true;
323
+ if (client === "dsh") {
324
+ const request = { executable: installContext.nodePath || process.execPath,
325
+ args: [path.join(candidate.extractedRoot, "scripts", "install.mjs"), "--upgrade", "--profile", installContext.dshProfile,
326
+ "--dsh-root", installContext.dshRoot, "--expected-installed-manifest-sha256", installContext.expectedInstalledManifestSha256, "--live-profile"],
327
+ cwd: candidate.extractedRoot, root, timeoutMs: 180_000 };
328
+ nativeRecoveryAllowed = false;
329
+ try {
330
+ const result = await run({ ...request, args: [...request.args, "--apply"] });
331
+ if (result?.ok !== true || (result.upgraded !== true && result.alreadyCurrent !== true)) throw failure("native_install_failed");
332
+ nativeCommitted = true;
333
+ } catch {
334
+ // DSH may commit its outer tree before stdout is lost. Its existing
335
+ // read-only upgrade preflight verifies the installed tree and journal.
336
+ // Only a proven old outer tree permits reverting the new binding.
337
+ try {
338
+ const inspected = await run(request);
339
+ if (inspected?.ok !== true || inspected.plannedMutation !== "upgrade"
340
+ || !await samePath(inspected.profile, installContext.dshProfile) || !await samePath(inspected.dshRoot, installContext.dshRoot)
341
+ || typeof inspected.alreadyCurrent !== "boolean") throw failure("native_recovery_required");
342
+ if (inspected.alreadyCurrent) nativeCommitted = true;
343
+ else {
344
+ const installedManifest = await safeFile(path.join(installContext.dshProfile, "node_modules", "hellolynk-dsh-nav", "hellolynk-dsh-nav-build.json"));
345
+ if (sha(installedManifest) !== installContext.expectedInstalledManifestSha256) throw failure("native_recovery_required");
346
+ nativeRecoveryAllowed = true;
347
+ }
348
+ } catch { throw failure("native_recovery_required"); }
349
+ if (!nativeCommitted) throw failure("native_install_failed");
350
+ }
351
+ }
352
+ // setup owns the immutable install and protected binding. Never report
353
+ // applied on the strength of an installer stdout string alone.
354
+ await recordInstalled(downloaded.version, nextPackageRoot);
355
+ const state = { contract: CONTRACT, client, stage: "applied", version: downloaded.version,
356
+ sha256: downloaded.sha256, appliedAt: Date.now(), nextPackageRoot, previousPackageRoot: previousImmutableRoot, error: null };
357
+ await atomicJson(statePath, state);
358
+ const releaseCleanup = await pruneInstalledReleases({ releasesRoot: path.dirname(nextPackageRoot), client,
359
+ currentRelease: nextPackageRoot, previousRelease: previousImmutableRoot });
360
+ await tidyCache();
361
+ return { ok: true, nextPackageRoot, releaseCleanup };
362
+ } catch (error) {
363
+ if (rollbackRoot && bindingCommitted && !nativeCommitted && nativeRecoveryAllowed) {
364
+ try {
365
+ const releasePin = await readJson(path.join(candidate.packageRoot, "runtime", "neutral-host-release-pin.json"));
366
+ if (!HASH.test(String(releasePin.manifestSha256 || ""))) throw failure("rollback_failed");
367
+ // Recovery is performed by the NEW trusted setup under its account
368
+ // lock. Its exact-current binding CAS must reject a human switch to
369
+ // any third release between native installation and recovery.
370
+ const restored = await run({ executable: installContext.nodePath || process.execPath,
371
+ args: [candidate.setupPath, "setup", "--recover-previous-root", previousImmutableRoot,
372
+ "--expected-current-release-manifest-sha256", releasePin.manifestSha256,
373
+ "--expected-account-revision", expectedAccountRevision], cwd: candidate.packageRoot, root, timeoutMs: 180_000 });
374
+ if (restored?.ok !== true || restored.client !== client || restored.recovered !== true
375
+ || restored.restoredPackageVersion !== currentVersion) throw failure("rollback_failed");
376
+ error = failure("native_install_failed_rolled_back");
377
+ } catch {
378
+ // Recovery itself may have committed before losing stdout. Adopt a
379
+ // fully verified old pair; otherwise retain the explicit uncertainty.
380
+ error = failure(await doctorAt(rollbackRoot, currentVersion)
381
+ ? "native_install_failed_rolled_back" : "native_recovery_required");
382
+ }
383
+ }
384
+ await atomicJson(statePath, { contract: CONTRACT, client, stage: "failed", version: downloaded.version, sha256: downloaded.sha256,
385
+ error: /^client_update_[a-z_]+$/.test(error?.code || "") ? error.code : "client_update_apply_failed", attemptedAt: Date.now(),
386
+ ...(retrying ? { retried: true } : {}) });
387
+ return { ok: false };
388
+ } finally { if (candidate?.temporaryRoot) await fs.rm(candidate.temporaryRoot, { recursive: true, force: true }); }
389
+ }
390
+ return { gate, manager, readState, leases, writeLeases, sweepLockCandidates, recordLockTimeout, ensureWorker, applyPending };
391
+ }
392
+
393
+ export async function prepareClientUpdateCandidate(config, record) { return prepareCandidate(normalize(config), record); }
394
+ // An installer checks its release the way this updater will before recording it.
395
+ export async function verifyClientUpdatePackage(root, client, version) { return verifyPackage(root, client, version); }
396
+ async function prepareCandidate(config, record) {
397
+ const directory = path.join(config.root, "client-updates", config.client);
398
+ if (!HASH.test(record.sha256) || typeof record.version !== "string" || !/^[0-9A-Za-z.+-]{1,100}$/.test(record.version)
399
+ || typeof record.filename !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.zip$/.test(record.filename)) throw failure("candidate_invalid");
400
+ const artifactPath = path.join(directory, "releases", `${record.version}-${record.sha256}`, record.filename);
401
+ if (artifactPath !== record.artifactPath) throw failure("candidate_path_invalid");
402
+ const archive = await safeFile(artifactPath, 64 * 1024 * 1024);
403
+ if (archive.length !== record.bytes || sha(archive) !== record.sha256) throw failure("artifact_integrity_invalid");
404
+ const temporaryRoot = path.join(directory, `.apply-${crypto.randomUUID()}`);
405
+ await fs.mkdir(temporaryRoot, { mode: 0o700 });
406
+ const extractedRoot = path.join(temporaryRoot, "extracted");
407
+ try {
408
+ // Re-extract the hash-verified archive; never execute a mutable earlier
409
+ // extractedRoot or an executable path supplied by state.json.
410
+ const sealed = path.join(temporaryRoot, record.filename);
411
+ await fs.writeFile(sealed, archive, { flag: "wx", mode: 0o600 });
412
+ await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot });
413
+ let packageRoot;
414
+ if (config.client === "dsh") {
415
+ const descriptor = await readJson(path.join(extractedRoot, "trusted-release.json"));
416
+ const manifestBytes = await safeFile(path.join(extractedRoot, "dist", "package", "hellolynk-dsh-nav-build.json"));
417
+ if (descriptor?.contract !== "hellolynk-dsh-nav-release-v1" || descriptor.manifestSha256 !== sha(manifestBytes)) throw failure("package_pin_invalid");
418
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
419
+ if (manifest.contract !== "hellolynk-dsh-nav-build-v3") throw failure("package_pin_invalid");
420
+ await verifyRecords(path.join(extractedRoot, "dist", "package"), manifest.packageFiles, ["hellolynk-dsh-nav-build.json"]);
421
+ packageRoot = path.join(extractedRoot, "dist", "package", "runtime", "connect");
422
+ await safeFile(path.join(extractedRoot, "scripts", "install.mjs"));
423
+ } else {
424
+ const candidates = [extractedRoot, path.join(extractedRoot, "package"), path.join(extractedRoot, `hellolynk-${config.client}-${record.version}`, "package")];
425
+ const matches = [];
426
+ for (const candidate of candidates) if (await exists(path.join(candidate, "package.json"))) matches.push(candidate);
427
+ if (matches.length !== 1) throw failure("package_identity_invalid");
428
+ packageRoot = matches[0];
429
+ }
430
+ await verifyPackage(packageRoot, config.client, record.version);
431
+ return { temporaryRoot, extractedRoot, artifactPath: sealed, packageRoot, setupPath: path.join(packageRoot, "bin", "hellolynk-client-setup.mjs") };
432
+ } catch (error) { await fs.rm(temporaryRoot, { recursive: true, force: true }); throw error; }
433
+ }
434
+
435
+ async function verifyPackage(root, client, version) {
436
+ const pkg = await readJson(path.join(root, "package.json"));
437
+ const manifestBytes = await safeFile(path.join(root, "hellolynk-client-pack-manifest.json"));
438
+ const pin = await readJson(path.join(root, "hellolynk-client-pack-pin.json"));
439
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
440
+ if (pkg.version !== version || pkg.hellolynkClient?.client !== client || pkg.name !== NAMES[client]
441
+ || manifest.contract !== "hellolynk-client-npm-pack-manifest-v1" || manifest.client !== client
442
+ || manifest.packageName !== pkg.name || manifest.version !== version
443
+ || pin.contract !== "hellolynk-client-npm-pack-pin-v1" || pin.manifestSha256 !== sha(manifestBytes)) throw failure("package_identity_invalid");
444
+ await verifyRecords(root, manifest.files, ["hellolynk-client-pack-manifest.json", "hellolynk-client-pack-pin.json"]);
445
+ if (!manifest.files.some((file) => file.path === "bin/hellolynk-client-setup.mjs")) throw failure("package_pin_invalid");
446
+ }
447
+ async function verifyRecords(root, records, allowedExtra) {
448
+ if (!Array.isArray(records) || records.length < 1 || records.length > 5000) throw failure("package_pin_invalid");
449
+ const expected = new Set(allowedExtra);
450
+ for (const record of records) {
451
+ if (!record || typeof record.path !== "string" || !/^(?:[A-Za-z0-9_.@-]+\/)*[A-Za-z0-9_.@-]+$/.test(record.path)
452
+ || record.path.split("/").some((part) => part === "." || part === "..") || !HASH.test(record.sha256) || expected.has(record.path)) throw failure("package_pin_invalid");
453
+ const body = await safeFile(path.join(root, ...record.path.split("/")), 32 * 1024 * 1024);
454
+ if (sha(body) !== record.sha256 || (record.bytes != null && body.length !== record.bytes)) throw failure("package_pin_invalid");
455
+ expected.add(record.path);
456
+ }
457
+ async function visit(directory, relative = "") {
458
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
459
+ const name = relative ? `${relative}/${entry.name}` : entry.name;
460
+ if (entry.isSymbolicLink() || (!entry.isFile() && !entry.isDirectory())) throw failure("package_pin_invalid");
461
+ if (entry.isDirectory()) await visit(path.join(directory, entry.name), name);
462
+ else if (!expected.delete(name)) throw failure("package_pin_invalid");
463
+ }
464
+ }
465
+ await visit(root);
466
+ if (expected.size) throw failure("package_pin_invalid");
467
+ }
468
+
469
+ async function verifyInstalledContext(config) {
470
+ const context = config.installContext;
471
+ await verifyPackage(context.packageRoot, config.client, config.currentVersion);
472
+ }
473
+ function normalize(value) {
474
+ if (!CLIENT_UPDATE_CLIENTS.includes(value.client) || typeof value.currentVersion !== "string" || !VERSION.test(value.currentVersion)
475
+ || !path.isAbsolute(String(value.root || "")) || !path.isAbsolute(String(value.installContext?.packageRoot || ""))) throw failure("configuration_invalid");
476
+ const context = { packageRoot: path.resolve(value.installContext.packageRoot) };
477
+ for (const name of ["nodePath", "dshProfile", "dshRoot"]) {
478
+ if (value.installContext[name] != null) {
479
+ if (!path.isAbsolute(value.installContext[name])) throw failure("configuration_invalid");
480
+ context[name] = path.resolve(value.installContext[name]);
481
+ }
482
+ }
483
+ if (value.installContext.expectedInstalledManifestSha256 != null) {
484
+ if (!HASH.test(value.installContext.expectedInstalledManifestSha256)) throw failure("configuration_invalid");
485
+ context.expectedInstalledManifestSha256 = value.installContext.expectedInstalledManifestSha256;
486
+ }
487
+ return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, installContext: context };
488
+ }
489
+ async function launchWorker(config) {
490
+ // A host may execute this module from an ephemeral captured import graph.
491
+ // The detached updater must outlive that graph and use the pinned release.
492
+ const packageRoot = config.installContext.packageRoot;
493
+ const metadata = await readJson(path.join(packageRoot, "package.json"));
494
+ const coreRoot = metadata.hellolynkClient?.coreRoot;
495
+ if (typeof coreRoot !== "string" || !/^(?:[A-Za-z0-9_.@-]+\/)*[A-Za-z0-9_.@-]+$/.test(coreRoot)
496
+ || coreRoot.split("/").some((part) => part === "." || part === "..")) throw failure("configuration_invalid");
497
+ const workerEntry = path.join(packageRoot, ...coreRoot.split("/"), "hellolynk-client-update-runtime.mjs");
498
+ await safeFile(workerEntry);
499
+ const executable = await runtimeSafety.resolveHelloLynkStandaloneNode({ preferredExecutable: config.installContext.nodePath });
500
+ const workerConfig = { ...config, installContext: { ...config.installContext, nodePath: executable } };
501
+ const child = spawn(executable, [workerEntry, "--worker", JSON.stringify(workerConfig)], {
502
+ // Keep the detached worker out of the client's installation directory.
503
+ cwd: config.root,
504
+ detached: true, windowsHide: true, stdio: "ignore", shell: false,
505
+ env: runtimeSafety.standaloneHelloLynkNodeEnvironment(process.env),
506
+ });
507
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
508
+ child.unref(); return child.pid;
509
+ }
510
+ async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
511
+ executable = await runtimeSafety.resolveHelloLynkStandaloneNode({ preferredExecutable: executable });
512
+ const env = { ...runtimeSafety.standaloneHelloLynkNodeEnvironment(process.env), HELLOLYNK_NEUTRAL_STORE_ROOT: root };
513
+ return new Promise((resolve, reject) => {
514
+ const child = spawn(executable, args, { cwd, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
515
+ let stdout = "", stderr = "", finished = false, terminalError = null;
516
+ const timer = setTimeout(() => terminate(failure("installer_timeout")), timeoutMs);
517
+ function finish(error, value) { if (finished) return; finished = true; clearTimeout(timer); error ? reject(error) : resolve(value); }
518
+ function terminate(error) {
519
+ if (finished || terminalError) return;
520
+ terminalError = error;
521
+ // A setup process owns synchronous configurator children. Stop that tree
522
+ // and wait for exit before a doctor can classify the resulting state.
523
+ const killChild = () => { try { child.kill(); } catch {} };
524
+ if (process.platform === "win32" && child.pid) {
525
+ try {
526
+ const killer = spawn(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"),
527
+ ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, shell: false, stdio: "ignore" });
528
+ const killTimer = setTimeout(() => { try { killer.kill(); } catch {} if (!finished) killChild(); }, 10_000);
529
+ killer.on("error", () => { clearTimeout(killTimer); if (!finished) killChild(); });
530
+ killer.once("close", () => { clearTimeout(killTimer); if (!finished) killChild(); });
531
+ } catch { killChild(); }
532
+ } else killChild();
533
+ }
534
+ child.on("error", () => {
535
+ // An error raised while terminating is not an exit receipt. Keep the
536
+ // ownership fence until close, and retain the original failure reason.
537
+ if (!terminalError) finish(failure("installer_timeout"));
538
+ });
539
+ child.stdout.on("data", (data) => {
540
+ if (finished || terminalError) return;
541
+ if (stdout.length + data.length > 64 * 1024) { terminate(failure("install_output_invalid")); return; }
542
+ stdout += data;
543
+ });
544
+ child.stderr.on("data", (data) => { stderr = (stderr + data).slice(-16 * 1024); });
545
+ child.once("close", (code) => {
546
+ if (terminalError) { finish(terminalError); return; }
547
+ if (code !== 0) { finish(failure("install_failed")); return; }
548
+ try { finish(null, JSON.parse(stdout)); } catch { finish(failure("install_output_invalid")); }
549
+ });
550
+ });
551
+ }
552
+ async function readJson(file, fallback) {
553
+ try { return JSON.parse((await safeFile(file)).toString("utf8")); }
554
+ catch (error) { if (error.code === "ENOENT" && arguments.length > 1) return fallback; throw error; }
555
+ }
556
+ async function safeFile(file, maxBytes = 2 * 1024 * 1024) {
557
+ await safePath(file);
558
+ const stat = await fs.lstat(file);
559
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > maxBytes) throw failure("path_invalid");
560
+ return fs.readFile(file);
561
+ }
562
+ async function safePath(target) {
563
+ let current = path.resolve(target);
564
+ while (current !== path.dirname(current)) {
565
+ try { if ((await fs.lstat(current)).isSymbolicLink()) throw failure("path_invalid"); }
566
+ catch (error) { if (error.code !== "ENOENT") throw error; }
567
+ current = path.dirname(current);
568
+ }
569
+ }
570
+ async function safeDirectory(directory) { await safePath(directory); await fs.mkdir(directory, { recursive: true, mode: 0o700 }); await safePath(directory); }
571
+ async function atomicJson(file, value) {
572
+ await safePath(file);
573
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
574
+ try { await fs.writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: "wx", mode: 0o600 }); await fs.rename(temporary, file); }
575
+ finally { await fs.rm(temporary, { force: true }); }
576
+ }
577
+ function processAlive(pid) { try { process.kill(pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; } }
578
+ // A record in a shape this version cannot read is replaced, not repaired.
579
+ function damaged(error) { return error instanceof SyntaxError || error?.code === "client_update_path_invalid"; }
580
+ // Windows paths compare case-insensitively and may differ by a link or short name.
581
+ async function samePath(left, right) {
582
+ if (typeof left !== "string" || typeof right !== "string" || !path.isAbsolute(left) || !path.isAbsolute(right)) return false;
583
+ const same = (a, b) => path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
584
+ if (same(left, right)) return true;
585
+ try { return same(await fs.realpath(left), await fs.realpath(right)); } catch { return false; }
586
+ }
587
+ async function exists(file) { try { await fs.lstat(file); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } }
588
+ function sha(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
589
+ function doctorProves(value, client, version) {
590
+ return value?.ok === true && value.client === client && value.packageVersion === version
591
+ && value.activated === true && value.stableReleaseVerified === true
592
+ && value.exactBindingVerified === true && value.clientConfigurationVerified === true;
593
+ }
594
+ function failure(code) { return Object.assign(new Error(`client_update_${code}`), { code: `client_update_${code}` }); }
595
+ function disabled() { return Object.freeze({ disabled: true, check: async () => ({ stage: "disabled" }), readState: async () => ({ stage: "disabled" }), stop: async () => {} }); }
596
+
597
+ if (process.argv[1] && path.resolve(process.argv[1]) === SELF && process.argv[2] === "--worker") {
598
+ Promise.resolve().then(() => JSON.parse(process.argv[3] || "{}"))
599
+ .then((options) => runClientUpdateWorker(options)).catch(() => { process.exitCode = 1; });
600
+ }