@hellolynk-ai/workbuddy-mcp 0.3.99 → 0.3.101

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 (49) hide show
  1. package/bin/hellolynk-client-setup.mjs +70 -10
  2. package/clients/core/hellolynk-client-account.mjs +1 -1
  3. package/clients/core/hellolynk-client-update-entry.mjs +7 -3
  4. package/clients/core/hellolynk-client-update-runtime.mjs +62 -33
  5. package/clients/core/hellolynk-client-update.mjs +62 -18
  6. package/clients/core/hellolynk-darwin-host.mjs +269 -0
  7. package/clients/core/hellolynk-darwin-vault.mjs +153 -0
  8. package/clients/core/hellolynk-host-protected-store.mjs +20 -8
  9. package/clients/core/hellolynk-human-confirmation-ledger.mjs +53 -6
  10. package/clients/core/hellolynk-local-data-protection.mjs +22 -7
  11. package/clients/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
  12. package/clients/core/hellolynk-neutral-host-installation-store.mjs +1 -1
  13. package/clients/core/hellolynk-neutral-host-launcher.mjs +9 -3
  14. package/clients/core/hellolynk-neutral-host-process.mjs +1 -1
  15. package/clients/core/hellolynk-platform-credential-store.mjs +68 -14
  16. package/clients/core/hellolynk-release-retention.mjs +101 -2
  17. package/clients/core/hellolynk-workbench-connection.mjs +5 -3
  18. package/clients/platform/windows/credential-store.ps1 +46 -10
  19. package/hellolynk-client-pack-manifest.json +103 -83
  20. package/hellolynk-client-pack-pin.json +1 -1
  21. package/integrations/workbuddy/package.json +2 -2
  22. package/integrations/workbuddy/server/workbench-process.mjs +58 -8
  23. package/package.json +1 -1
  24. package/runtime/connect-activate.mjs +5 -1
  25. package/runtime/core/hellolynk-client-account.mjs +1 -1
  26. package/runtime/core/hellolynk-client-update-entry.mjs +7 -3
  27. package/runtime/core/hellolynk-client-update-runtime.mjs +62 -33
  28. package/runtime/core/hellolynk-client-update.mjs +62 -18
  29. package/runtime/core/hellolynk-darwin-host.mjs +269 -0
  30. package/runtime/core/hellolynk-darwin-vault.mjs +153 -0
  31. package/runtime/core/hellolynk-host-protected-store.mjs +20 -8
  32. package/runtime/core/hellolynk-human-confirmation-ledger.mjs +53 -6
  33. package/runtime/core/hellolynk-local-data-protection.mjs +22 -7
  34. package/runtime/core/hellolynk-native-human-confirmation-broker.mjs +7 -5
  35. package/runtime/core/hellolynk-neutral-host-installation-store.mjs +1 -1
  36. package/runtime/core/hellolynk-neutral-host-launcher.mjs +9 -3
  37. package/runtime/core/hellolynk-neutral-host-process.mjs +1 -1
  38. package/runtime/core/hellolynk-platform-credential-store.mjs +68 -14
  39. package/runtime/core/hellolynk-release-retention.mjs +101 -2
  40. package/runtime/core/hellolynk-workbench-connection.mjs +5 -3
  41. package/runtime/neutral-host-release-loader.mjs +1 -1
  42. package/runtime/neutral-host-release-manifest.json +25 -17
  43. package/runtime/neutral-host-release-pin.json +1 -1
  44. package/runtime/platform/windows/credential-store.ps1 +46 -10
  45. package/runtime/workbench/hellolynk-workbench-build.json +2 -2
  46. package/runtime/workbench/ui/styles.css +2 -2
  47. package/tools/hellolynk-activate-neutral-host-clients.mjs +5 -1
  48. package/workbuddy-package-manifest.json +1 -1
  49. package/workbuddy-package-pin.json +1 -1
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import crypto from "node:crypto";
4
- import { lstatSync, realpathSync, writeSync } from "node:fs";
4
+ import { constants, lstatSync, realpathSync, writeSync } from "node:fs";
5
5
  import fs from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
@@ -16,11 +16,14 @@ const PACK_PIN = "hellolynk-client-pack-pin.json";
16
16
 
17
17
  // A person at a terminal reads sentences in the system language; the updater, tests and other
18
18
  // programs read one JSON line.
19
- const jsonOutput = process.argv.includes("--json") || process.stdout.isTTY !== true;
19
+ const explicitJson = process.argv.includes("--json");
20
+ const jsonOutput = explicitJson || process.stdout.isTTY !== true;
20
21
  const CHINESE = /^zh\b/i.test(Intl.DateTimeFormat().resolvedOptions().locale);
21
22
  const CLIENT_NAMES = Object.freeze({ codex: "Codex", openclaw: "OpenClaw", "trae-work": "TRAE Work",
22
23
  workbuddy: "WorkBuddy", cursor: "Cursor", dsh: "DSH" });
23
24
  const { packageJson, metadata } = await readPackage();
25
+ const macPackage = metadata.client === "codex" && packageJson.os?.[0] === "darwin"
26
+ && packageJson.cpu?.[0] === "arm64";
24
27
  const commandArguments = process.argv.slice(2).filter((value) => value !== "--json");
25
28
  const command = String(commandArguments.shift() || "help").toLowerCase();
26
29
  const waitForPending = command === "--wait-for-pending";
@@ -63,6 +66,12 @@ try {
63
66
  } else {
64
67
  const accountModule = await importStableCore(PACKAGE_ROOT, "hellolynk-client-account.mjs", "hellolynk_npm_account_runtime_invalid");
65
68
  const originalLocations = resolveLocations(accountModule.clientAccountBaseRoot);
69
+ if (macPackage && command !== "uninstall") {
70
+ const config = await readJson(path.join(sourceReleaseRoot, ".mcp.json"), "hellolynk_npm_release_invalid", true);
71
+ const commandPath = config.mcpServers?.["hellolynk-codex"]?.command;
72
+ if (typeof commandPath !== "string" || !path.isAbsolute(commandPath)) fail("hellolynk_npm_release_invalid");
73
+ await fs.access(commandPath, constants.X_OK).catch(() => fail("hellolynk_npm_codex_mac_node_required"));
74
+ }
66
75
  const baseRoot = accountModule.clientAccountBaseRoot(originalLocations.hostRoot);
67
76
  const packageLocations = { ...originalLocations, hostRoot: baseRoot, finalCurrentRoot: path.dirname(baseRoot) };
68
77
  const accounts = accountModule.createClientAccountActions({ client: metadata.client, storeRoot: baseRoot });
@@ -250,6 +259,10 @@ try {
250
259
  await verifyExactBinding({ ...installed, locations, activation });
251
260
  await verifyClientConfiguration({ ...installed, locations });
252
261
  }
262
+ // A running unsigned updater asks the stable package for implicit JSON. A
263
+ // staged package may still prove its own first install after lost stdout.
264
+ const legacyUpdateProof = installed && !existingDoctor && !explicitJson && jsonOutput
265
+ && process.env.HELLOLYNK_NEUTRAL_STORE_ROOT && samePath(PACKAGE_ROOT, installed.packageRoot);
253
266
  print({
254
267
  ok: true,
255
268
  client: metadata.client,
@@ -260,7 +273,7 @@ try {
260
273
  activated: activation.ready,
261
274
  activationState: activation.state,
262
275
  stableReleaseVerified: installed != null,
263
- exactBindingVerified: installed != null,
276
+ ...(legacyUpdateProof ? {} : { exactBindingVerified: installed != null }),
264
277
  clientConfigurationVerified: installed != null,
265
278
  installsNode: false,
266
279
  installsGit: false,
@@ -429,7 +442,8 @@ async function uninstallClient(locations) {
429
442
  if (receiverConfigured) await receiver.configureTraeNativeTaskWorker({ storeRoot: locations.hostRoot, enabled: false });
430
443
  }
431
444
  // A binding names the release it starts, so it cannot outlive the releases removed below.
432
- if (await exists(path.join(locations.hostRoot, "capabilities-v1.dpapi.clixml"))) {
445
+ const protectedFile = macPackage ? "protected-v1.sqlite" : "capabilities-v1.dpapi.clixml";
446
+ if (await exists(path.join(locations.hostRoot, protectedFile))) {
433
447
  const store = await importStableCore(PACKAGE_ROOT, "hellolynk-host-protected-store.mjs", "hellolynk_npm_binding_invalid");
434
448
  await store.createHostProtectedStore({ root: locations.hostRoot, followClientAccount: false }).disconnectLocalClient(metadata.client);
435
449
  }
@@ -839,10 +853,13 @@ async function verifyRelease(releaseRoot, packageRoot, expectedMetadata = metada
839
853
  }
840
854
 
841
855
  function resolveLocations(getDefaultHostRoot) {
842
- if (process.platform !== "win32") fail("hellolynk_npm_windows_only");
843
- const home = path.resolve(process.env.USERPROFILE || os.homedir());
844
- const localAppData = path.resolve(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"));
845
- const appData = path.resolve(process.env.APPDATA || path.join(home, "AppData", "Roaming"));
856
+ if (macPackage) {
857
+ if (process.platform !== "darwin" || process.arch !== "arm64") fail("hellolynk_npm_platform_unsupported");
858
+ } else if (process.platform !== "win32") fail("hellolynk_npm_windows_only");
859
+ const home = path.resolve((macPackage ? process.env.HOME : process.env.USERPROFILE) || os.homedir());
860
+ const localAppData = macPackage ? path.join(home, "Library", "Application Support")
861
+ : path.resolve(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"));
862
+ const appData = macPackage ? localAppData : path.resolve(process.env.APPDATA || path.join(home, "AppData", "Roaming"));
846
863
  const configuredCodexHome = String(process.env.CODEX_HOME || "");
847
864
  if (configuredCodexHome && !path.isAbsolute(configuredCodexHome)) fail("hellolynk_npm_codex_home_invalid");
848
865
  const codexHome = path.resolve(configuredCodexHome || path.join(home, ".codex"));
@@ -2252,6 +2269,13 @@ function runCodexJson(args, code, codexHome) {
2252
2269
  }
2253
2270
 
2254
2271
  function minimalEnv({ codexHome = null } = {}) {
2272
+ if (macPackage) return Object.freeze({
2273
+ PATH: process.env.PATH || "/usr/bin:/bin",
2274
+ HOME: process.env.HOME || os.homedir(),
2275
+ TMPDIR: process.env.TMPDIR || os.tmpdir(),
2276
+ LANG: process.env.LANG || "en_US.UTF-8",
2277
+ ...(codexHome ? { CODEX_HOME: codexHome } : {}),
2278
+ });
2255
2279
  return Object.freeze({
2256
2280
  PATH: process.env.Path || process.env.PATH || "",
2257
2281
  PATHEXT: process.env.PATHEXT || "",
@@ -2267,13 +2291,18 @@ function minimalEnv({ codexHome = null } = {}) {
2267
2291
 
2268
2292
  function resolveCodexRunner(codexHome) {
2269
2293
  if (resolvedCodexRunner) return resolvedCodexRunner;
2270
- const searchPath = String(process.env.Path || process.env.PATH || "");
2294
+ const searchPath = String((macPackage ? process.env.PATH : process.env.Path || process.env.PATH) || "");
2271
2295
  let sawLoaderCandidate = false;
2272
2296
  let sawNativeCandidate = false;
2273
2297
  for (const rawEntry of searchPath.split(path.delimiter)) {
2274
2298
  try {
2275
2299
  const logical = rawEntry.trim().replace(/^"(.*)"$/, "$1");
2276
2300
  if (!logical || !path.isAbsolute(logical)) continue;
2301
+ if (macPackage) {
2302
+ const runner = probeMacCodex(path.join(logical, "codex"), codexHome);
2303
+ if (runner) return (resolvedCodexRunner = runner);
2304
+ continue;
2305
+ }
2277
2306
  // nvm/fnm and user profile mappings can put a junction on PATH. Resolve
2278
2307
  // that directory once, then retain the ordinary-file checks below it.
2279
2308
  const entry = realpathSync(path.resolve(logical));
@@ -2327,6 +2356,15 @@ function resolveCodexRunner(codexHome) {
2327
2356
  }
2328
2357
 
2329
2358
  function resolveCodexDesktopRunner(codexHome) {
2359
+ if (macPackage) {
2360
+ for (const directory of ["/Applications", path.join(os.homedir(), "Applications")]) {
2361
+ for (const app of ["ChatGPT.app", "Codex.app"]) {
2362
+ const runner = probeMacCodex(path.join(directory, app, "Contents", "Resources", "codex"), codexHome);
2363
+ if (runner) return runner;
2364
+ }
2365
+ }
2366
+ return null;
2367
+ }
2330
2368
  if (process.platform !== "win32") return null;
2331
2369
  const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT;
2332
2370
  if (!systemRoot || !path.isAbsolute(systemRoot)) return null;
@@ -2354,6 +2392,22 @@ function resolveCodexDesktopRunner(codexHome) {
2354
2392
  return null;
2355
2393
  }
2356
2394
 
2395
+ function probeMacCodex(logicalPath, codexHome) {
2396
+ // npm and Homebrew commonly expose a symlink. Execute the canonical file directly.
2397
+ try {
2398
+ const command = realpathSync(logicalPath);
2399
+ const metadata = lstatSync(command);
2400
+ if (!metadata.isFile() || !(metadata.mode & 0o111)) return null;
2401
+ const result = spawnSync(command, ["--version"], {
2402
+ encoding: "utf8", shell: false, timeout: 5000, env: minimalEnv({ codexHome }),
2403
+ });
2404
+ if (result.status === 0 && /^codex-cli\s+\d+\.\d+\.\d+/u.test(String(result.stdout || "").trim())) {
2405
+ return Object.freeze({ command, argsPrefix: Object.freeze([]), kind: "native" });
2406
+ }
2407
+ } catch { /* Missing or unusable CLI. */ }
2408
+ return null;
2409
+ }
2410
+
2357
2411
  function resolveRegularDescendantSync(anchor, anchorRealPath, segments) {
2358
2412
  let observed = anchor;
2359
2413
  for (let index = 0; index < segments.length; index += 1) {
@@ -2533,6 +2587,7 @@ function samePath(left, right) {
2533
2587
  const resolved = normalizeWindowsNamespacePath(value);
2534
2588
  let canonical = resolved;
2535
2589
  try { canonical = realpathSync(resolved); } catch { /* comparison can still reject a missing path */ }
2590
+ if (macPackage) return canonical;
2536
2591
  return normalizeWindowsNamespacePath(canonical).replace(/[\\/]+$/u, "").toLowerCase();
2537
2592
  };
2538
2593
  return normalize(left) === normalize(right);
@@ -2652,7 +2707,9 @@ function helpText() {
2652
2707
  : [[say("安装", "Install"), "init"], [say("连接账号", "Connect an account"), connect],
2653
2708
  [say("更新或修复", "Update or repair"), "setup"], [say("检查", "Check"), "doctor"],
2654
2709
  [say("卸载", "Uninstall"), "uninstall"]];
2655
- return `${[`HelloLynk for ${CLIENT_NAMES[metadata.client]}${say("(Windows,Node.js 22.16.0+)", " (Windows, Node.js 22.16.0+)")}`,
2710
+ const platform = macPackage ? " (macOS arm64, Node.js 22.16.0+)"
2711
+ : say("(Windows,Node.js 22.16.0+)", " (Windows, Node.js 22.16.0+)");
2712
+ return `${[`HelloLynk for ${CLIENT_NAMES[metadata.client]}${platform}`,
2656
2713
  ...rows.map(([label, args]) => `${label}${say(":", ": ")}${run} ${args}`),
2657
2714
  say("加 --json 输出一行 JSON。", "Add --json for one line of JSON.")].join("\n")}\n`;
2658
2715
  }
@@ -2726,6 +2783,9 @@ function failureText(failure) {
2726
2783
  function failureReason(code, name) {
2727
2784
  if (code === "hellolynk_node_22_16_required") return say("需要 Node.js 22.16 或更高版本。", "Node.js 22.16 or later is required.");
2728
2785
  if (code === "hellolynk_npm_windows_only") return say("只支持 Windows。", "Windows only.");
2786
+ if (code === "hellolynk_npm_platform_unsupported") return say("此安装包适用于 Apple 芯片的 Mac。", "This package requires an Apple silicon Mac.");
2787
+ if (code === "hellolynk_npm_codex_mac_node_required") return say("请将 ChatGPT 安装到「应用程序」,打开 Codex 后再试。",
2788
+ "Install ChatGPT in Applications, open Codex, and try again.");
2729
2789
  if (code === "hellolynk_npm_connect_id_invalid") {
2730
2790
  return say("灵信号不对。请从官网复制从 hellolynk: 开始的完整灵信号。",
2731
2791
  "This HelloLynk ID is not valid. Copy the complete ID, starting with hellolynk:, from the website.");
@@ -68,7 +68,7 @@ export function withClientAccountLock(client, root, operation) {
68
68
 
69
69
  export function createClientAccountActions(options) {
70
70
  const client = options?.client;
71
- const root = options?.storeRoot || defaultNeutralHostStoreRoot("win32", options?.env || process.env);
71
+ const root = options?.storeRoot || defaultNeutralHostStoreRoot(process.platform, options?.env || process.env);
72
72
  statePath(client, clientAccountBaseRoot(root));
73
73
  const readAccountState = () => {
74
74
  const state = readClientAccount(client, root);
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { clientAccountBaseRoot } from "./hellolynk-client-account.mjs";
4
4
  import { startClientUpdateRuntime } from "./hellolynk-client-update-runtime.mjs";
5
+ import { clientUpdateCatalogId, MAC_CLIENT_VERSION } from "./hellolynk-client-update.mjs";
5
6
 
6
7
  const running = new Map();
7
8
 
@@ -9,7 +10,9 @@ const running = new Map();
9
10
  // to the Host. A checkout or an uninstalled test fixture never self-updates.
10
11
  export function startInstalledClientUpdate(options = {}) {
11
12
  const { client, packageRoot, storeRoot } = options;
12
- if (process.platform !== "win32" || !path.isAbsolute(String(packageRoot || ""))) return Promise.resolve(null);
13
+ const catalogId = clientUpdateCatalogId(client, process.platform);
14
+ const mac = process.platform === "darwin";
15
+ if (!catalogId || (mac && process.arch !== "arm64") || !path.isAbsolute(String(packageRoot || ""))) return Promise.resolve(null);
13
16
  let baseRoot;
14
17
  try { baseRoot = clientAccountBaseRoot(storeRoot); } catch { return Promise.resolve(null); }
15
18
  const key = `${client}\0${path.resolve(packageRoot)}\0${baseRoot}`;
@@ -21,8 +24,9 @@ export function startInstalledClientUpdate(options = {}) {
21
24
  const metadata = await fs.lstat(packageFile);
22
25
  if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || metadata.size > 64 * 1024) return null;
23
26
  const manifest = JSON.parse(await fs.readFile(packageFile, "utf8"));
24
- if (manifest.hellolynkClient?.client !== client
25
- || !/^\d+\.\d+\.\d+$/.test(manifest.version || "")) return null;
27
+ const version = mac ? MAC_CLIENT_VERSION : /^\d+\.\d+\.\d+$/u;
28
+ if (manifest.hellolynkClient?.client !== client || !version.test(manifest.version || "")
29
+ || (mac && (manifest.os?.[0] !== "darwin" || manifest.cpu?.[0] !== "arm64"))) return null;
26
30
  return startClientUpdateRuntime({
27
31
  root: baseRoot, client, currentVersion: manifest.version,
28
32
  installContext: { ...options.installContext, packageRoot: path.resolve(packageRoot) },
@@ -4,7 +4,8 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { createClientUpdateManager, extractClientUpdateArchive, compareClientUpdateVersions, CLIENT_UPDATE_CLIENTS } from "./hellolynk-client-update.mjs";
7
+ import { createClientUpdateManager, extractClientUpdateArchive, compareClientUpdateVersions, clientUpdateCatalogId,
8
+ CLIENT_UPDATE_CLIENTS, CLIENT_UPDATE_RELEASES, MAC_CLIENT_VERSION } from "./hellolynk-client-update.mjs";
8
9
  import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-store.mjs";
9
10
  import { readClientAccount } from "./hellolynk-client-account.mjs";
10
11
  import runtimeSafety from "./hellolynk-runtime-safety.cjs";
@@ -26,7 +27,7 @@ const RETRY_AFTER_MS = 10 * 60_000;
26
27
  // waits only for DSH's lease, and a downloaded update is applied by a worker in
27
28
  // the background. Checking the network is always detached.
28
29
  export async function startClientUpdateRuntime(options = {}) {
29
- if ((options.platform || process.platform) !== "win32") return disabled();
30
+ if (!clientUpdateCatalogId(options.client, options.platform || process.platform)) return disabled();
30
31
  let config;
31
32
  try { config = normalize(options); await verifyInstalledContext(config); }
32
33
  catch { return disabled(); }
@@ -94,8 +95,8 @@ export async function runClientUpdateWorker(options = {}) {
94
95
  function createRuntime(config, options) {
95
96
  const { root, client, currentVersion, installContext } = config;
96
97
  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 });
98
+ const statePath = path.join(directory, "runtime-v2.json"), leasePath = path.join(directory, "leases.json"), installedPath = path.join(directory, "installed.json");
99
+ const manager = createClientUpdateManager({ ...options, root, client, currentVersion, platform: config.platform });
99
100
  const alive = options.isProcessAlive || processAlive;
100
101
  async function gate(operation) {
101
102
  await safeDirectory(directory);
@@ -200,7 +201,8 @@ function createRuntime(config, options) {
200
201
  const attempted = runtime.sha256 === downloaded.sha256 && !(runtime.stage === "applying" || retryable);
201
202
  if (downloaded.stage !== "downloaded" || downloaded.error || attempted) return;
202
203
  // 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
+ const workerPath = path.join(directory, "worker-v2.json");
205
+ const worker = await readJson(workerPath, null).catch((error) => { if (!damaged(error)) throw error; return null; });
204
206
  if (worker?.client === client && Number.isSafeInteger(worker.pid) && alive(worker.pid)) return;
205
207
  const launch = options.spawnWorker || launchWorker;
206
208
  let pid;
@@ -210,13 +212,13 @@ function createRuntime(config, options) {
210
212
  } catch (error) {
211
213
  // Retain only this rejected launch attempt, not raw errors or proof of
212
214
  // worker absence. Keep an earlier PID's conservative liveness check.
213
- await atomicJson(path.join(directory, "worker.json"), { client,
215
+ await atomicJson(workerPath, { client,
214
216
  ...(worker?.client === client && Number.isSafeInteger(worker.pid) ? { pid: worker.pid } : {}),
215
217
  sha256: downloaded.sha256, error: "client_update_worker_launch_failed", failedAt: Date.now(),
216
218
  }).catch(() => {});
217
219
  throw error;
218
220
  }
219
- await atomicJson(path.join(directory, "worker.json"), { client, pid });
221
+ await atomicJson(workerPath, { client, pid });
220
222
  });
221
223
  }
222
224
  async function applyPending() {
@@ -236,7 +238,7 @@ function createRuntime(config, options) {
236
238
  if (sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))
237
239
  !== sha(await safeFile(path.join(installContext.packageRoot, "hellolynk-client-pack-manifest.json")))) throw failure("adoption_invalid");
238
240
  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 });
241
+ args: [path.join(installContext.packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor", "--json"], cwd: installContext.packageRoot, root, timeoutMs: 180_000 });
240
242
  if (!doctorProves(doctor, client, currentVersion)) throw failure("adoption_invalid");
241
243
  const adoptedRoot = path.join(path.dirname(root), "releases", `${client}-${sha(await safeFile(path.join(proof.packageRoot, "hellolynk-client-pack-manifest.json")))}`);
242
244
  if (!options.runInstaller) await verifyPackage(adoptedRoot, client, currentVersion);
@@ -274,7 +276,7 @@ function createRuntime(config, options) {
274
276
  try {
275
277
  await verifyPackage(packageRoot, client, version);
276
278
  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 });
279
+ args: [path.join(packageRoot, "bin", "hellolynk-client-setup.mjs"), "doctor", "--json"], cwd: packageRoot, root, timeoutMs: 180_000 });
278
280
  return doctorProves(inspected, client, version);
279
281
  } catch { return false; }
280
282
  };
@@ -397,7 +399,7 @@ async function prepareCandidate(config, record) {
397
399
  const directory = path.join(config.root, "client-updates", config.client);
398
400
  if (!HASH.test(record.sha256) || typeof record.version !== "string" || !/^[0-9A-Za-z.+-]{1,100}$/.test(record.version)
399
401
  || 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);
402
+ const artifactPath = path.join(directory, CLIENT_UPDATE_RELEASES, `${record.version}-${record.sha256}`, record.filename);
401
403
  if (artifactPath !== record.artifactPath) throw failure("candidate_path_invalid");
402
404
  const archive = await safeFile(artifactPath, 64 * 1024 * 1024);
403
405
  if (archive.length !== record.bytes || sha(archive) !== record.sha256) throw failure("artifact_integrity_invalid");
@@ -406,43 +408,63 @@ async function prepareCandidate(config, record) {
406
408
  const extractedRoot = path.join(temporaryRoot, "extracted");
407
409
  try {
408
410
  // Re-extract the hash-verified archive; never execute a mutable earlier
409
- // extractedRoot or an executable path supplied by state.json.
411
+ // extractedRoot or an executable path supplied by update state.
410
412
  const sealed = path.join(temporaryRoot, record.filename);
411
413
  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);
414
+ await extractClientUpdateArchive({ archivePath: sealed, destination: extractedRoot, platform: config.platform });
415
+ const { packageRoot } = await verifyExtractedClientUpdatePackage(extractedRoot,
416
+ { client: config.client, platform: config.platform, version: record.version });
431
417
  return { temporaryRoot, extractedRoot, artifactPath: sealed, packageRoot, setupPath: path.join(packageRoot, "bin", "hellolynk-client-setup.mjs") };
432
418
  } catch (error) { await fs.rm(temporaryRoot, { recursive: true, force: true }); throw error; }
433
419
  }
434
420
 
421
+ export async function verifyExtractedClientUpdatePackage(extractedRoot, { client, platform, version }) {
422
+ if (!CLIENT_UPDATE_CLIENTS.includes(client) || typeof version !== "string" || !VERSION.test(version)) throw failure("package_identity_invalid");
423
+ let packageRoot;
424
+ if (client === "dsh") {
425
+ const descriptor = await readJson(path.join(extractedRoot, "trusted-release.json"));
426
+ const manifestBytes = await safeFile(path.join(extractedRoot, "dist", "package", "hellolynk-dsh-nav-build.json"));
427
+ if (descriptor?.contract !== "hellolynk-dsh-nav-release-v1" || descriptor.manifestSha256 !== sha(manifestBytes)) throw failure("package_pin_invalid");
428
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
429
+ if (manifest.contract !== "hellolynk-dsh-nav-build-v3") throw failure("package_pin_invalid");
430
+ await verifyRecords(path.join(extractedRoot, "dist", "package"), manifest.packageFiles, ["hellolynk-dsh-nav-build.json"]);
431
+ packageRoot = path.join(extractedRoot, "dist", "package", "runtime", "connect");
432
+ await safeFile(path.join(extractedRoot, "scripts", "install.mjs"));
433
+ } else {
434
+ const id = clientUpdateCatalogId(client, platform) || client;
435
+ const candidates = [extractedRoot, path.join(extractedRoot, "package"), path.join(extractedRoot, `hellolynk-${id}-${version}`, "package")];
436
+ const matches = [];
437
+ for (const candidate of candidates) if (await exists(path.join(candidate, "package.json"))) matches.push(candidate);
438
+ if (matches.length !== 1) throw failure("package_identity_invalid");
439
+ packageRoot = matches[0];
440
+ }
441
+ const checked = await verifyPackage(packageRoot, client, version);
442
+ return { packageRoot, ...checked };
443
+ }
444
+
435
445
  async function verifyPackage(root, client, version) {
436
446
  const pkg = await readJson(path.join(root, "package.json"));
437
447
  const manifestBytes = await safeFile(path.join(root, "hellolynk-client-pack-manifest.json"));
438
448
  const pin = await readJson(path.join(root, "hellolynk-client-pack-pin.json"));
439
449
  const manifest = JSON.parse(manifestBytes.toString("utf8"));
440
450
  if (pkg.version !== version || pkg.hellolynkClient?.client !== client || pkg.name !== NAMES[client]
451
+ || (MAC_CLIENT_VERSION.test(version) && (!clientUpdateCatalogId(client, "darwin") || pkg.os?.[0] !== "darwin" || pkg.cpu?.[0] !== "arm64"))
441
452
  || manifest.contract !== "hellolynk-client-npm-pack-manifest-v1" || manifest.client !== client
442
453
  || manifest.packageName !== pkg.name || manifest.version !== version
443
454
  || pin.contract !== "hellolynk-client-npm-pack-pin-v1" || pin.manifestSha256 !== sha(manifestBytes)) throw failure("package_identity_invalid");
444
455
  await verifyRecords(root, manifest.files, ["hellolynk-client-pack-manifest.json", "hellolynk-client-pack-pin.json"]);
445
456
  if (!manifest.files.some((file) => file.path === "bin/hellolynk-client-setup.mjs")) throw failure("package_pin_invalid");
457
+ if (MAC_CLIENT_VERSION.test(version)) {
458
+ const helpers = client === "codex"
459
+ ? ["plugins/hellolynk-codex/runtime/platform/darwin", "plugins/hellolynk-codex/runtime/bootstrap/platform/darwin"]
460
+ : ["runtime/platform/darwin"];
461
+ for (const relative of helpers) {
462
+ const file = `${relative}/hellolynk-keychain-helper.bin`;
463
+ if (!manifest.files.some(record => record.path === file)
464
+ || (process.platform !== "win32" && !((await fs.lstat(path.join(root, file))).mode & 0o100))) throw failure("package_pin_invalid");
465
+ }
466
+ }
467
+ return { coreRoot: pkg.hellolynkClient.coreRoot, files: manifest.files };
446
468
  }
447
469
  async function verifyRecords(root, records, allowedExtra) {
448
470
  if (!Array.isArray(records) || records.length < 1 || records.length > 5000) throw failure("package_pin_invalid");
@@ -471,7 +493,9 @@ async function verifyInstalledContext(config) {
471
493
  await verifyPackage(context.packageRoot, config.client, config.currentVersion);
472
494
  }
473
495
  function normalize(value) {
496
+ const platform = value.platform || process.platform;
474
497
  if (!CLIENT_UPDATE_CLIENTS.includes(value.client) || typeof value.currentVersion !== "string" || !VERSION.test(value.currentVersion)
498
+ || (platform === "darwin" && !MAC_CLIENT_VERSION.test(value.currentVersion))
475
499
  || !path.isAbsolute(String(value.root || "")) || !path.isAbsolute(String(value.installContext?.packageRoot || ""))) throw failure("configuration_invalid");
476
500
  const context = { packageRoot: path.resolve(value.installContext.packageRoot) };
477
501
  for (const name of ["nodePath", "dshProfile", "dshRoot"]) {
@@ -484,7 +508,7 @@ function normalize(value) {
484
508
  if (!HASH.test(value.installContext.expectedInstalledManifestSha256)) throw failure("configuration_invalid");
485
509
  context.expectedInstalledManifestSha256 = value.installContext.expectedInstalledManifestSha256;
486
510
  }
487
- return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, installContext: context };
511
+ return { root: path.resolve(value.root), client: value.client, currentVersion: value.currentVersion, platform, installContext: context };
488
512
  }
489
513
  async function launchWorker(config) {
490
514
  // A host may execute this module from an ephemeral captured import graph.
@@ -510,8 +534,9 @@ async function launchWorker(config) {
510
534
  async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
511
535
  executable = await runtimeSafety.resolveHelloLynkStandaloneNode({ preferredExecutable: executable });
512
536
  const env = { ...runtimeSafety.standaloneHelloLynkNodeEnvironment(process.env), HELLOLYNK_NEUTRAL_STORE_ROOT: root };
537
+ const mac = process.platform === "darwin";
513
538
  return new Promise((resolve, reject) => {
514
- const child = spawn(executable, args, { cwd, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
539
+ const child = spawn(executable, args, { cwd, env, detached: mac, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], shell: false });
515
540
  let stdout = "", stderr = "", finished = false, terminalError = null;
516
541
  const timer = setTimeout(() => terminate(failure("installer_timeout")), timeoutMs);
517
542
  function finish(error, value) { if (finished) return; finished = true; clearTimeout(timer); error ? reject(error) : resolve(value); }
@@ -529,6 +554,9 @@ async function runInstaller({ executable, args, cwd, root, timeoutMs }) {
529
554
  killer.on("error", () => { clearTimeout(killTimer); if (!finished) killChild(); });
530
555
  killer.once("close", () => { clearTimeout(killTimer); if (!finished) killChild(); });
531
556
  } catch { killChild(); }
557
+ } else if (mac && child.pid) {
558
+ // The installer owns this process group, including a synchronous Codex CLI.
559
+ try { process.kill(-child.pid, "SIGKILL"); } catch { killChild(); }
532
560
  } else killChild();
533
561
  }
534
562
  child.on("error", () => {
@@ -580,7 +608,8 @@ function damaged(error) { return error instanceof SyntaxError || error?.code ===
580
608
  // Windows paths compare case-insensitively and may differ by a link or short name.
581
609
  async function samePath(left, right) {
582
610
  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();
611
+ const canonical = (value) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
612
+ const same = (a, b) => canonical(a) === canonical(b);
584
613
  if (same(left, right)) return true;
585
614
  try { return same(await fs.realpath(left), await fs.realpath(right)); } catch { return false; }
586
615
  }
@@ -6,20 +6,39 @@ import { withNeutralHostStoreLock } from "./hellolynk-neutral-host-installation-
6
6
 
7
7
  const CATALOG = "https://hellolynk.com/downloads/clients.json";
8
8
  export const CLIENT_UPDATE_CLIENTS = Object.freeze(["codex", "openclaw", "trae-work", "workbuddy", "dsh", "claude-code"]);
9
+ export const MAC_CLIENT_VERSION = /^\d+\.\d+\.\d+-macos\.[1-9]\d*$/u;
9
10
  const HOUR = 3_600_000;
10
11
  const MAX_ARTIFACT = 64 * 1024 * 1024;
11
12
  const HASH = /^[a-f0-9]{64}$/;
12
- const CONTRACT = "hellolynk-client-update-v1";
13
+ const CONTRACT = "hellolynk-client-update-v2";
14
+ export const CLIENT_UPDATE_RELEASES = "releases-v2";
15
+ // The release freeze pins only public Ed25519 keys here. An empty set refuses all updates.
16
+ export const CLIENT_UPDATE_CATALOG_KEYS = Object.freeze({
17
+ "ed25519-20261109-primary-d4fd7f46475dac48": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAucQohi+be+ksjQhRzYD8ZiyZMUz6Vi/DSphQ3iVOWDM=\n-----END PUBLIC KEY-----\n",
18
+ "ed25519-20261109-backup-5e3d08a775320304": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA2vxHpcgLZQc1cXqxq3dcmZRmE0fAFyeRC3umMVGAosA=\n-----END PUBLIC KEY-----\n"
19
+ });
20
+ export const CLIENT_UPDATE_CATALOG_LIMIT = 256 * 1024;
13
21
  // A failed integrity or path check keeps a downloaded package from installing. Every other failure is transient.
14
- const BLOCKING = /^client_update_(?:same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
22
+ const BLOCKING = /^client_update_(?:catalog_signature_invalid|same_version_changed|version_rollback|artifact_integrity_invalid|archive_[a-z_]+|path_invalid|url_invalid|redirect_invalid)$/;
23
+
24
+ export function clientUpdateCatalogId(client, platform) {
25
+ if (platform === "win32") return client;
26
+ if (platform === "darwin" && (client === "codex" || client === "claude-code")) return `${client}-macos`;
27
+ return null;
28
+ }
15
29
 
16
30
  // This component only stages verified files. Host lifecycle and client-native
17
31
  // installation remain the responsibility of the caller.
18
32
  export function createClientUpdateManager(options = {}) {
19
33
  const { client, currentVersion, platform = process.platform } = options;
20
34
  if (!CLIENT_UPDATE_CLIENTS.includes(client) || !semver(currentVersion) || !path.isAbsolute(String(options.root || ""))) fail("configuration_invalid");
35
+ const catalogId = clientUpdateCatalogId(client, platform);
36
+ const mac = platform === "darwin";
37
+ if (mac && !MAC_CLIENT_VERSION.test(currentVersion)) fail("configuration_invalid");
21
38
  const root = path.join(path.resolve(options.root), "client-updates", client);
22
- const stateFile = path.join(root, "state.json");
39
+ // A still-running unsigned updater may read, rewrite and prune its own cache.
40
+ const stateFile = path.join(root, "state-v2.json");
41
+ const releases = path.join(root, CLIENT_UPDATE_RELEASES);
23
42
  const fetcher = options.fetchImpl || globalThis.fetch;
24
43
  const clock = options.clock || Date.now;
25
44
  const timeoutMs = bounded(options.timeoutMs, 30_000, 50, 120_000);
@@ -60,7 +79,7 @@ export function createClientUpdateManager(options = {}) {
60
79
  }
61
80
  }
62
81
 
63
- function releasePath(record) { return path.join(root, "releases", `${record.version}-${record.sha256}`); }
82
+ function releasePath(record) { return path.join(releases, `${record.version}-${record.sha256}`); }
64
83
  async function save(record) {
65
84
  await safeDirectory(root);
66
85
  const temporary = path.join(root, `.state-${crypto.randomUUID()}.tmp`);
@@ -80,12 +99,13 @@ export function createClientUpdateManager(options = {}) {
80
99
  let old = await readState();
81
100
  const now = clock();
82
101
  if (!force && old.checkedAt > 0 && now >= old.checkedAt && now - old.checkedAt < 23 * HOUR) return old;
83
- if (platform !== "win32") return save({ ...old, stage: "unsupported", checkedAt: now });
102
+ if (!catalogId) return save({ ...old, stage: "unsupported", checkedAt: now });
84
103
  try {
85
- const metadata = await request(CATALOG, { limit: 256 * 1024, timeout: timeoutMs, kind: "catalog" });
104
+ const metadata = await request(CATALOG, { limit: CLIENT_UPDATE_CATALOG_LIMIT, timeout: timeoutMs, kind: "catalog" });
86
105
  let catalog;
87
106
  try { catalog = JSON.parse(metadata.toString("utf8")); } catch { fail("catalog_invalid"); }
88
- const artifact = validateCatalog(catalog, client);
107
+ const verified = verifyClientUpdateCatalog(catalog, options.catalogKeys);
108
+ const artifact = validateClientUpdateCatalogEntry(verified, catalogId, mac);
89
109
  // The catalog has no build for this client: there is nothing to install.
90
110
  if (!artifact) {
91
111
  // A downloaded package keeps its stage and any integrity block; only an empty cache is "current".
@@ -134,12 +154,12 @@ export function createClientUpdateManager(options = {}) {
134
154
  if (!bytes) fail("source_unavailable");
135
155
  const release = releasePath(artifact);
136
156
  await safeDirectory(path.dirname(release));
137
- const stage = path.join(root, "releases", `.stage-${crypto.randomUUID()}`);
157
+ const stage = path.join(releases, `.stage-${crypto.randomUUID()}`);
138
158
  await fs.mkdir(stage, { mode: 0o700 });
139
159
  try {
140
160
  const archivePath = path.join(stage, artifact.filename);
141
161
  await fs.writeFile(archivePath, bytes, { flag: "wx", mode: 0o600 });
142
- await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted") });
162
+ await extractClientUpdateArchive({ archivePath, destination: path.join(stage, "extracted"), platform });
143
163
  await fs.rename(stage, release).catch(async (error) => {
144
164
  if (!["EEXIST", "ENOTEMPTY", "EPERM"].includes(error.code)) throw error;
145
165
  try {
@@ -147,7 +167,7 @@ export function createClientUpdateManager(options = {}) {
147
167
  return;
148
168
  } catch { /* Preserve damaged evidence without reusing it. */ }
149
169
  await safeAncestors(release, true);
150
- const quarantine = path.join(root, "releases", `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
170
+ const quarantine = path.join(releases, `.corrupt-${artifact.sha256}-${crypto.randomUUID()}`);
151
171
  await fs.rename(release, quarantine);
152
172
  try { await fs.rename(stage, release); }
153
173
  catch (replacement) { await fs.rename(quarantine, release).catch(() => {}); throw replacement; }
@@ -172,7 +192,7 @@ export function createClientUpdateManager(options = {}) {
172
192
  });
173
193
  }
174
194
 
175
- // After an install only the package state.json names stays cached; older
195
+ // After an install only the package the signed state names stays cached; older
176
196
  // versions and quarantined copies go. Holding the check lock means no
177
197
  // download is in flight to be mistaken for leftovers.
178
198
  async function prune() {
@@ -180,7 +200,7 @@ export function createClientUpdateManager(options = {}) {
180
200
  return withNeutralHostStoreLock(root, "client-update", async () => {
181
201
  const state = await readState();
182
202
  if (!semver(state.version) || !HASH.test(String(state.sha256 || ""))) return [];
183
- const keep = path.basename(releasePath(state)), releases = path.join(root, "releases"), removed = [];
203
+ const keep = path.basename(releasePath(state)), removed = [];
184
204
  await safeAncestors(releases, true);
185
205
  let entries;
186
206
  try { entries = await fs.readdir(releases, { withFileTypes: true }); }
@@ -267,15 +287,36 @@ export function createClientUpdateManager(options = {}) {
267
287
  });
268
288
  }
269
289
 
270
- function validateCatalog(catalog, client) {
290
+ export function verifyClientUpdateCatalog(catalog, keys = CLIENT_UPDATE_CATALOG_KEYS) {
291
+ const signed = catalog?.signed;
292
+ if (!signed || typeof signed.keyId !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(signed.keyId)
293
+ || !Object.hasOwn(keys, signed.keyId) || typeof signed.payload !== "string" || !signed.payload
294
+ || signed.payload.length > CLIENT_UPDATE_CATALOG_LIMIT || typeof signed.signature !== "string"
295
+ || !/^[A-Za-z0-9_-]{86}$/.test(signed.signature)) fail("catalog_signature_invalid");
296
+ const payload = Buffer.from(signed.payload, "base64");
297
+ const signature = Buffer.from(signed.signature, "base64url");
298
+ if (payload.toString("base64") !== signed.payload || signature.toString("base64url") !== signed.signature) fail("catalog_signature_invalid");
299
+ let valid = false;
300
+ try {
301
+ const key = crypto.createPublicKey(keys[signed.keyId]);
302
+ valid = key.asymmetricKeyType === "ed25519" && crypto.verify(null, payload, key, signature);
303
+ } catch { /* Invalid pins and invalid signatures both fail closed. */ }
304
+ if (!valid) fail("catalog_signature_invalid");
305
+ const json = payload.toString("utf8");
306
+ if (!Buffer.from(json, "utf8").equals(payload)) fail("catalog_invalid");
307
+ try { return JSON.parse(json); } catch { fail("catalog_invalid"); }
308
+ }
309
+
310
+ export function validateClientUpdateCatalogEntry(catalog, client, mac) {
271
311
  if (!catalog || !Array.isArray(catalog.clients) || catalog.clients.length > 32) fail("catalog_invalid");
272
312
  const matches = catalog.clients.filter((item) => item?.client === client);
273
313
  if (matches.length === 0) return null;
274
314
  if (matches.length > 1) fail("catalog_invalid");
275
315
  const item = matches[0];
276
- if (!semver(item.version) || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
316
+ if (!semver(item.version) || (mac && !MAC_CLIENT_VERSION.test(item.version))
317
+ || !HASH.test(String(item.sha256 || "")) || !safeFilename(item.filename)
277
318
  || !Number.isSafeInteger(item.bytes) || item.bytes < 4 || item.bytes > MAX_ARTIFACT
278
- || !Array.isArray(item.platforms) || !item.platforms.includes("Windows")) fail("catalog_invalid");
319
+ || !Array.isArray(item.platforms) || !item.platforms.includes(mac ? "macOS" : "Windows")) fail("catalog_invalid");
279
320
  const official = new URL(String(item.url || ""), CATALOG);
280
321
  if (official.origin !== "https://hellolynk.com" || !official.pathname.startsWith("/downloads/")) fail("catalog_invalid");
281
322
  validateUrl(official, "artifact");
@@ -309,7 +350,7 @@ function validateUrl(url, kind, redirected = false, initial = url) {
309
350
  if (!direct && !githubCdn) fail("url_invalid");
310
351
  }
311
352
 
312
- export async function extractClientUpdateArchive({ archivePath, destination, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
353
+ export async function extractClientUpdateArchive({ archivePath, destination, platform = process.platform, maxTotalBytes = 128 * 1024 * 1024, maxFileBytes = 32 * 1024 * 1024, maxEntries = 4096 }) {
313
354
  if (!path.isAbsolute(archivePath) || !path.isAbsolute(destination)) fail("archive_path_invalid");
314
355
  await safeAncestors(archivePath, false);
315
356
  const stat = await fs.lstat(archivePath);
@@ -360,7 +401,7 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
360
401
  total += size;
361
402
  if (total > maxTotalBytes) fail("archive_too_large");
362
403
  spans.push([local, start + packed]);
363
- entries.push({ segments, directory, start, packed, size, method, crc });
404
+ entries.push({ segments, directory, start, packed, size, method, crc, mode });
364
405
  cursor += 46 + nameLength + extraLength + commentLength;
365
406
  }
366
407
  if (cursor !== eocd) fail("archive_invalid");
@@ -377,7 +418,10 @@ export async function extractClientUpdateArchive({ archivePath, destination, max
377
418
  try { body = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.size) }); }
378
419
  catch { fail("archive_integrity_invalid"); }
379
420
  if (body.length !== entry.size || crc32(body) !== entry.crc) fail("archive_integrity_invalid");
380
- await fs.writeFile(target, body, { flag: "wx", mode: 0o600 });
421
+ // A Mac update runs a bundled Keychain helper. Keep its execute bit without
422
+ // inheriting group/other access or special permission bits from the ZIP.
423
+ const ownerExecute = platform === "darwin" ? entry.mode & 0o100 : 0;
424
+ await fs.writeFile(target, body, { flag: "wx", mode: 0o600 | ownerExecute });
381
425
  }
382
426
  return Object.freeze({ files: entries.filter((item) => !item.directory).length, bytes: total });
383
427
  }